Initial build: personal annual letter + podcast year-end email generator

- Monthly journal: 6 prompts per month, saved to localStorage
- Year-end reflections: 6 closing prompts compiled into a letter
- Podcast module: 9 prompts → ready-to-send listener email with preview tab
- Home dashboard: month grid, progress bar, year-end task checklist
- Letter view: formatted narrative with Copy and Download
- Dockerfile + nginx.conf for containerized deployment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-17 13:06:14 -04:00
commit c446546964
31 files changed
+3159

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import { useState, useCallback } from "react";
import { Sidebar } from "./components/Sidebar";
import type { View } from "./components/Sidebar";
import { MonthlyForm, YearEndForm } from "./components/PromptForm";
import { LetterView } from "./components/LetterView";
import { Welcome } from "./components/Welcome";
import { PodcastYearEndForm } from "./components/PodcastYearEnd";
import { Home } from "./components/Home";
import {
load,
save,
getMonthlyEntry,
upsertMonthlyEntry,
getYearEndEntry,
upsertYearEndEntry,
getPodcastEntry,
upsertPodcastEntry,
} from "./utils/storage";
import type { AppData } from "./types";
const now = new Date();
const TODAY_YEAR = now.getFullYear();
const TODAY_MONTH = now.getMonth();
export default function App() {
const [data, setData] = useState<AppData>(load);
const [view, setView] = useState<View>({ kind: "home" });
const persist = useCallback((next: AppData) => {
setData(next);
save(next);
}, []);
function handleStart(name: string) {
persist({ ...data, name });
}
if (!data.name) {
return (
<Welcome
currentMonth={TODAY_MONTH}
currentYear={TODAY_YEAR}
onStart={handleStart}
/>
);
}
return (
<div className="layout">
<Sidebar
data={data}
view={view}
onViewChange={setView}
currentYear={TODAY_YEAR}
currentMonth={TODAY_MONTH}
/>
<main className="main">
{view.kind === "home" && (
<Home
data={data}
currentYear={TODAY_YEAR}
currentMonth={TODAY_MONTH}
onNavigate={setView}
/>
)}
{view.kind === "month" && (
<MonthlyForm
key={`${view.year}-${view.month}`}
entry={getMonthlyEntry(data, view.year, view.month)}
year={view.year}
month={view.month}
onSave={(answers) => {
persist(
upsertMonthlyEntry(data, {
year: view.year,
month: view.month,
answers,
completedAt: new Date().toISOString(),
})
);
}}
/>
)}
{view.kind === "yearEnd" && (
<YearEndForm
key={view.year}
entry={getYearEndEntry(data, view.year)}
year={view.year}
onSave={(answers) => {
persist(
upsertYearEndEntry(data, {
year: view.year,
answers,
completedAt: new Date().toISOString(),
})
);
}}
/>
)}
{view.kind === "letter" && (
<LetterView data={data} year={view.year} />
)}
{view.kind === "podcast" && (
<PodcastYearEndForm
key={view.year}
entry={getPodcastEntry(data, view.year)}
year={view.year}
onSave={(podcastName, answers) => {
persist(
upsertPodcastEntry(data, {
year: view.year,
podcastName,
answers,
completedAt: new Date().toISOString(),
})
);
}}
/>
)}
</main>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+173
View File
@@ -0,0 +1,173 @@
import { MONTH_NAMES, MONTHLY_PROMPTS, PODCAST_PROMPTS, YEAR_END_PROMPTS } from "../types";
import type { AppData } from "../types";
import type { View } from "./Sidebar";
interface HomeProps {
data: AppData;
currentYear: number;
currentMonth: number;
onNavigate: (v: View) => void;
}
export function Home({ data, currentYear, currentMonth, onNavigate }: HomeProps) {
const monthEntries = data.monthlyEntries.filter((e) => e.year === currentYear);
const yearEnd = data.yearEndEntries.find((e) => e.year === currentYear);
const podcast = data.podcastEntries?.find((e) => e.year === currentYear);
const accessibleMonths = currentMonth + 1; // 0-indexed, months up to today
const completedMonths = monthEntries.length;
const pct = Math.round((completedMonths / 12) * 100);
const yearEndFilled = yearEnd
? YEAR_END_PROMPTS.filter((p) => yearEnd.answers[p.id]?.trim()).length
: 0;
const podcastFilled = podcast
? PODCAST_PROMPTS.filter((p) => podcast.answers[p.id]?.trim()).length
: 0;
const pendingMonths = MONTH_NAMES.slice(0, currentMonth + 1).filter(
(_, i) => !monthEntries.some((e) => e.month === i)
);
return (
<div className="home">
<div className="home-header">
<div>
<h1 className="home-year">{currentYear}</h1>
<p className="home-sub">Your year, in progress.</p>
</div>
<button
className="btn-primary"
onClick={() => onNavigate({ kind: "month", year: currentYear, month: currentMonth })}
>
{monthEntries.some((e) => e.month === currentMonth)
? `Edit ${MONTH_NAMES[currentMonth]}`
: `Start ${MONTH_NAMES[currentMonth]}`}
</button>
</div>
{/* Progress bar */}
<div className="home-section">
<div className="progress-row">
<span className="section-label">Monthly entries</span>
<span className="progress-count">{completedMonths} / 12 months</span>
</div>
<div className="progress-bar-track">
<div className="progress-bar-fill" style={{ width: `${pct}%` }} />
</div>
</div>
{/* Month grid */}
<div className="month-grid">
{MONTH_NAMES.map((name, idx) => {
const done = monthEntries.some((e) => e.month === idx);
const isCurrent = idx === currentMonth;
const isFuture = idx > currentMonth;
const entry = monthEntries.find((e) => e.month === idx);
const answeredCount = entry
? MONTHLY_PROMPTS.filter((p) => entry.answers[p.id]?.trim()).length
: 0;
return (
<button
key={idx}
className={`month-card ${done ? "done" : ""} ${isCurrent ? "current" : ""} ${isFuture ? "future" : ""}`}
disabled={isFuture}
onClick={() => !isFuture && onNavigate({ kind: "month", year: currentYear, month: idx })}
>
<span className="month-card-name">{name.slice(0, 3)}</span>
{done && (
<span className="month-card-count">{answeredCount}/{MONTHLY_PROMPTS.length}</span>
)}
{!done && !isFuture && (
<span className="month-card-pending">pending</span>
)}
{isFuture && <span className="month-card-future">—</span>}
</button>
);
})}
</div>
{/* Checklist */}
<div className="home-section">
<div className="section-label" style={{ marginBottom: 14 }}>Year-end tasks</div>
<div className="checklist">
<ChecklistItem
label="Year-End Reflections"
detail={yearEnd ? `${yearEndFilled}/${YEAR_END_PROMPTS.length} answered` : "Not started"}
done={yearEndFilled === YEAR_END_PROMPTS.length}
partial={yearEndFilled > 0 && yearEndFilled < YEAR_END_PROMPTS.length}
onClick={() => onNavigate({ kind: "yearEnd", year: currentYear })}
/>
<ChecklistItem
label="Podcast Year-End Email"
detail={podcast ? `${podcastFilled}/${PODCAST_PROMPTS.length} answered` : "Not started"}
done={podcastFilled === PODCAST_PROMPTS.length}
partial={podcastFilled > 0 && podcastFilled < PODCAST_PROMPTS.length}
onClick={() => onNavigate({ kind: "podcast", year: currentYear })}
/>
<ChecklistItem
label="Read Your Letter"
detail={completedMonths > 0 ? `${completedMonths} month${completedMonths !== 1 ? "s" : ""} compiled` : "Fill in some months first"}
done={false}
partial={completedMonths > 0}
onClick={() => onNavigate({ kind: "letter", year: currentYear })}
isLink
/>
</div>
</div>
{/* What's missing */}
{pendingMonths.length > 0 && (
<div className="home-section">
<div className="section-label" style={{ marginBottom: 10 }}>Still to fill in</div>
<div className="missing-months">
{pendingMonths.map((name, i) => {
const monthIdx = MONTH_NAMES.indexOf(name);
return (
<button
key={i}
className="missing-tag"
onClick={() => onNavigate({ kind: "month", year: currentYear, month: monthIdx })}
>
{name}
</button>
);
})}
</div>
</div>
)}
{pendingMonths.length === 0 && completedMonths > 0 && (
<div className="home-all-done">
All months up to {MONTH_NAMES[currentMonth]} are filled in.
</div>
)}
</div>
);
}
interface ChecklistItemProps {
label: string;
detail: string;
done: boolean;
partial: boolean;
onClick: () => void;
isLink?: boolean;
}
function ChecklistItem({ label, detail, done, partial, onClick, isLink }: ChecklistItemProps) {
return (
<button className={`checklist-item ${done ? "done" : partial ? "partial" : ""}`} onClick={onClick}>
<span className={`check-icon ${done ? "done" : partial ? "partial" : ""}`}>
{done ? "✓" : partial ? "◑" : "○"}
</span>
<span className="check-body">
<span className="check-label">{label}</span>
<span className="check-detail">{detail}</span>
</span>
<span className="check-arrow">{isLink ? "→" : done ? "" : "→"}</span>
</button>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import { generateLetter } from "../utils/letterTemplate";
import type { AppData } from "../types";
interface LetterViewProps {
data: AppData;
year: number;
}
export function LetterView({ data, year }: LetterViewProps) {
const [copied, setCopied] = useState(false);
const letter = generateLetter(data, year);
function handleCopy() {
navigator.clipboard.writeText(letter).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
function handleDownload() {
const blob = new Blob([letter], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${year}-annual-letter.txt`;
a.click();
URL.revokeObjectURL(url);
}
const rendered = renderMarkdown(letter);
return (
<div className="letter-view">
<div className="letter-toolbar">
<h2>{year} Annual Letter</h2>
<div className="toolbar-actions">
<button className="btn-secondary" onClick={handleCopy}>
{copied ? "Copied ✓" : "Copy"}
</button>
<button className="btn-secondary" onClick={handleDownload}>
Download
</button>
</div>
</div>
<div
className="letter-content"
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>
);
}
function renderMarkdown(text: string): string {
// Split on double newlines into paragraphs
return text
.split(/\n\n+/)
.map((para) => {
const inner = para
.split("\n")
.map((line) => {
line = line.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
line = line.replace(/\*(.+?)\*/g, "<em>$1</em>");
return line;
})
.join("<br/>");
return `<p>${inner}</p>`;
})
.join("");
}
+148
View File
@@ -0,0 +1,148 @@
import { useState, useEffect } from "react";
import { PODCAST_PROMPTS } from "../types";
import { generatePodcastEmail } from "../utils/podcastEmailTemplate";
import type { PodcastYearEndEntry } from "../types";
interface PodcastFormProps {
entry?: PodcastYearEndEntry;
year: number;
onSave: (podcastName: string, answers: Record<string, string>) => void;
}
export function PodcastYearEndForm({ entry, year, onSave }: PodcastFormProps) {
const [podcastName, setPodcastName] = useState(entry?.podcastName ?? "");
const [answers, setAnswers] = useState<Record<string, string>>(
entry?.answers ?? {}
);
const [view, setView] = useState<"form" | "email">("form");
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
setPodcastName(entry?.podcastName ?? "");
setAnswers(entry?.answers ?? {});
setView("form");
setSaved(false);
}, [year, entry]);
function handleSave() {
onSave(podcastName, answers);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
const filled = PODCAST_PROMPTS.filter((p) => answers[p.id]?.trim()).length;
const previewEntry: PodcastYearEndEntry = {
year,
podcastName,
answers,
};
const email = generatePodcastEmail(previewEntry);
function handleCopy() {
navigator.clipboard.writeText(email).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
function renderMarkdown(text: string): string {
return text
.split(/\n\n+/)
.map((para) => {
const inner = para
.split("\n")
.map((line) => line.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"))
.join("<br/>");
return `<p>${inner}</p>`;
})
.join("");
}
return (
<div className="prompt-form">
<div className="form-header">
<h2>Podcast Year-End Email — {year}</h2>
{view === "form" && (
<span className="progress-badge podcast">
{filled}/{PODCAST_PROMPTS.length} answered
</span>
)}
</div>
<p className="form-intro">
Answer a few questions about your podcast year. The app will compile them into a ready-to-send listener email.
</p>
<div className="tab-bar">
<button
className={`tab ${view === "form" ? "active" : ""}`}
onClick={() => setView("form")}
>
Prompts
</button>
<button
className={`tab ${view === "email" ? "active" : ""}`}
onClick={() => setView("email")}
>
Preview Email
</button>
</div>
{view === "form" && (
<>
<div className="prompts" style={{ marginTop: 24 }}>
<div className="prompt-item">
<label className="prompt-question">What's your podcast called?</label>
<input
type="text"
className="prompt-textarea"
style={{ height: 44, resize: "none" }}
placeholder="Show name"
value={podcastName}
onChange={(e) => setPodcastName(e.target.value)}
/>
</div>
{PODCAST_PROMPTS.map((p) => (
<div key={p.id} className="prompt-item">
<label className="prompt-question">{p.question}</label>
<textarea
className="prompt-textarea"
rows={p.id === "episodes" ? 1 : 3}
placeholder={p.placeholder}
value={answers[p.id] ?? ""}
onChange={(e) =>
setAnswers((prev) => ({ ...prev, [p.id]: e.target.value }))
}
/>
</div>
))}
</div>
<div className="form-footer">
<button className="btn-primary" onClick={handleSave}>
{saved ? "Saved ✓" : "Save"}
</button>
</div>
</>
)}
{view === "email" && (
<div style={{ marginTop: 24 }}>
<div className="letter-toolbar" style={{ marginBottom: 20 }}>
<span style={{ fontSize: ".9rem", color: "var(--text-muted)", fontStyle: "italic" }}>
Ready to copy into your email platform
</span>
<div className="toolbar-actions">
<button className="btn-secondary" onClick={handleCopy}>
{copied ? "Copied ✓" : "Copy"}
</button>
</div>
</div>
<div
className="letter-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(email) }}
/>
</div>
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useEffect } from "react";
import type { MonthlyEntry, YearEndEntry } from "../types";
import { MONTHLY_PROMPTS, YEAR_END_PROMPTS, MONTH_NAMES } from "../types";
interface MonthlyFormProps {
entry?: MonthlyEntry;
year: number;
month: number;
onSave: (answers: Record<string, string>) => void;
}
export function MonthlyForm({ entry, year, month, onSave }: MonthlyFormProps) {
const [answers, setAnswers] = useState<Record<string, string>>(
entry?.answers ?? {}
);
const [saved, setSaved] = useState(false);
useEffect(() => {
setAnswers(entry?.answers ?? {});
setSaved(false);
}, [year, month, entry]);
function handleSave() {
onSave(answers);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
const filled = MONTHLY_PROMPTS.filter((p) => answers[p.id]?.trim()).length;
return (
<div className="prompt-form">
<div className="form-header">
<h2>
{MONTH_NAMES[month]} {year}
</h2>
<span className="progress-badge">
{filled}/{MONTHLY_PROMPTS.length} answered
</span>
</div>
<div className="prompts">
{MONTHLY_PROMPTS.map((p) => (
<div key={p.id} className="prompt-item">
<label className="prompt-question">{p.question}</label>
<textarea
className="prompt-textarea"
rows={3}
placeholder={p.placeholder}
value={answers[p.id] ?? ""}
onChange={(e) =>
setAnswers((prev) => ({ ...prev, [p.id]: e.target.value }))
}
/>
</div>
))}
</div>
<div className="form-footer">
<button className="btn-primary" onClick={handleSave}>
{saved ? "Saved ✓" : "Save"}
</button>
</div>
</div>
);
}
interface YearEndFormProps {
entry?: YearEndEntry;
year: number;
onSave: (answers: Record<string, string>) => void;
}
export function YearEndForm({ entry, year, onSave }: YearEndFormProps) {
const [answers, setAnswers] = useState<Record<string, string>>(
entry?.answers ?? {}
);
const [saved, setSaved] = useState(false);
useEffect(() => {
setAnswers(entry?.answers ?? {});
setSaved(false);
}, [year, entry]);
function handleSave() {
onSave(answers);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
const filled = YEAR_END_PROMPTS.filter((p) => answers[p.id]?.trim()).length;
return (
<div className="prompt-form year-end-form">
<div className="form-header">
<h2>Year-End Reflection — {year}</h2>
<span className="progress-badge year-end">
{filled}/{YEAR_END_PROMPTS.length} answered
</span>
</div>
<p className="form-intro">
The prompts that close the year and anchor the letter.
</p>
<div className="prompts">
{YEAR_END_PROMPTS.map((p) => (
<div key={p.id} className="prompt-item">
<label className="prompt-question">{p.question}</label>
<textarea
className="prompt-textarea"
rows={p.id === "word" ? 1 : 3}
placeholder={p.placeholder}
value={answers[p.id] ?? ""}
onChange={(e) =>
setAnswers((prev) => ({ ...prev, [p.id]: e.target.value }))
}
/>
</div>
))}
</div>
<div className="form-footer">
<button className="btn-primary" onClick={handleSave}>
{saved ? "Saved ✓" : "Save"}
</button>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { MONTH_NAMES } from "../types";
import type { AppData } from "../types";
export type View =
| { kind: "home" }
| { kind: "month"; year: number; month: number }
| { kind: "yearEnd"; year: number }
| { kind: "letter"; year: number }
| { kind: "podcast"; year: number };
interface SidebarProps {
data: AppData;
view: View;
onViewChange: (v: View) => void;
currentYear: number;
currentMonth: number;
}
export function Sidebar({
data,
view,
onViewChange,
currentYear,
currentMonth,
}: SidebarProps) {
const years = getRelevantYears(currentYear);
return (
<nav className="sidebar">
<button
className={`sidebar-brand ${view.kind === "home" ? "sidebar-brand-active" : ""}`}
onClick={() => onViewChange({ kind: "home" })}
style={{ cursor: "pointer", background: "none", border: "none", width: "100%", textAlign: "left" }}
>
<span className="brand-icon">✉</span>
<span className="brand-name">Annual Letter</span>
</button>
{years.map((year) => {
const monthEntries = data.monthlyEntries.filter((e) => e.year === year);
const yearEndEntry = data.yearEndEntries.find((e) => e.year === year);
const podcastEntry = data.podcastEntries?.find((e) => e.year === year);
return (
<div key={year} className="sidebar-year-section">
<div className="sidebar-year-label">{year}</div>
<ul className="sidebar-months">
{MONTH_NAMES.map((name, idx) => {
const isFuture = year === currentYear && idx > currentMonth;
if (isFuture) return null;
const hasEntry = monthEntries.some((e) => e.month === idx);
const isActive =
view.kind === "month" &&
view.year === year &&
view.month === idx;
const isCurrent = year === currentYear && idx === currentMonth;
return (
<li key={idx}>
<button
className={`sidebar-item ${isActive ? "active" : ""} ${isCurrent ? "current" : ""}`}
onClick={() =>
onViewChange({ kind: "month", year, month: idx })
}
>
<span className="sidebar-item-name">{name}</span>
{hasEntry && <span className="dot" />}
</button>
</li>
);
})}
<li>
<button
className={`sidebar-item sidebar-yearend ${view.kind === "yearEnd" && view.year === year ? "active" : ""}`}
onClick={() => onViewChange({ kind: "yearEnd", year })}
>
<span className="sidebar-item-name">Year-End</span>
{yearEndEntry && <span className="dot" />}
</button>
</li>
<li>
<button
className={`sidebar-item sidebar-letter ${view.kind === "letter" && view.year === year ? "active" : ""}`}
onClick={() => onViewChange({ kind: "letter", year })}
>
<span className="sidebar-item-name">📄 Read Letter</span>
</button>
</li>
<div className="sidebar-section-header">Podcast</div>
<li>
<button
className={`sidebar-item sidebar-podcast ${view.kind === "podcast" && view.year === year ? "active" : ""}`}
onClick={() => onViewChange({ kind: "podcast", year })}
>
<span className="sidebar-item-name">🎙 Year-End Email</span>
{podcastEntry && <span className="dot" />}
</button>
</li>
</ul>
</div>
);
})}
</nav>
);
}
function getRelevantYears(currentYear: number): number[] {
const years = [currentYear];
for (let y = currentYear - 1; y >= currentYear - 3; y--) {
years.push(y);
}
return years;
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react";
import { MONTH_NAMES } from "../types";
interface WelcomeProps {
currentMonth: number;
currentYear: number;
onStart: (name: string) => void;
}
export function Welcome({ currentMonth, currentYear, onStart }: WelcomeProps) {
const [name, setName] = useState("");
return (
<div className="welcome">
<div className="welcome-inner">
<span className="welcome-icon">✉</span>
<h1>Your Annual Letter</h1>
<p className="welcome-tagline">
Answer a handful of prompts each month. At year's end, they become a
letter you'll want to keep.
</p>
<div className="welcome-how">
<div className="how-step">
<span className="how-num">01</span>
<span>Answer 6 prompts each month — takes about 10 minutes</span>
</div>
<div className="how-step">
<span className="how-num">02</span>
<span>Add year-end reflections in December</span>
</div>
<div className="how-step">
<span className="how-num">03</span>
<span>Read (and keep) your compiled letter</span>
</div>
</div>
<div className="welcome-form">
<label className="welcome-label">What should the letter call you?</label>
<input
type="text"
className="welcome-input"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && name.trim() && onStart(name.trim())}
/>
<button
className="btn-primary btn-large"
disabled={!name.trim()}
onClick={() => onStart(name.trim())}
>
Start — {MONTH_NAMES[currentMonth]} {currentYear}
</button>
</div>
</div>
</div>
);
}
+401
View File
@@ -0,0 +1,401 @@
/* ─── Reset ─────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; }
body { font-family: "Georgia", serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* ─── Tokens ─────────────────────────────────────────────────────────────── */
:root {
--bg: #faf9f7;
--bg-sidebar: #1c1917;
--bg-card: #ffffff;
--text: #1c1917;
--text-muted: #78716c;
--text-sidebar: #d6d3d1;
--text-sidebar-muted: #78716c;
--accent: #b45309;
--accent-light: #fef3c7;
--accent-dark: #92400e;
--border: #e7e5e4;
--border-sidebar: #292524;
--radius: 10px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
}
/* ─── Layout ─────────────────────────────────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
/* ─── Sidebar ────────────────────────────────────────────────────────────── */
.sidebar {
background: var(--bg-sidebar);
padding: 24px 0 40px;
overflow-y: auto;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px 24px;
border-bottom: 1px solid var(--border-sidebar);
margin-bottom: 20px;
}
.brand-icon { font-size: 1.2rem; }
.brand-name { font-size: .95rem; font-weight: 600; color: var(--text-sidebar); letter-spacing: .02em; }
.sidebar-year-section { margin-bottom: 8px; }
.sidebar-year-label {
font-size: .7rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--text-sidebar-muted);
padding: 0 20px 6px;
}
.sidebar-months { list-style: none; }
.sidebar-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 20px;
background: none;
border: none;
cursor: pointer;
color: var(--text-sidebar-muted);
font-size: .88rem;
font-family: inherit;
text-align: left;
border-radius: 0;
transition: background .12s, color .12s;
}
.sidebar-item:hover { background: rgba(255,255,255,.06); color: var(--text-sidebar); }
.sidebar-item.active { background: rgba(255,255,255,.1); color: #fff; }
.sidebar-item.current .sidebar-item-name::after { content: " •"; color: var(--accent); }
.sidebar-yearend, .sidebar-letter, .sidebar-podcast { margin-top: 2px; }
.sidebar-letter .sidebar-item-name, .sidebar-podcast .sidebar-item-name {
font-style: italic;
}
.sidebar-podcast { border-top: 1px solid var(--border-sidebar); margin-top: 8px; padding-top: 4px; }
.sidebar-section-header {
font-size: .7rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--text-sidebar-muted);
padding: 12px 20px 6px;
border-top: 1px solid var(--border-sidebar);
margin-top: 12px;
}
.dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
/* ─── Main ───────────────────────────────────────────────────────────────── */
.main {
padding: 48px 56px;
max-width: 780px;
}
/* ─── Prompt form ────────────────────────────────────────────────────────── */
.prompt-form { }
.form-header {
display: flex;
align-items: baseline;
gap: 16px;
margin-bottom: 8px;
}
.form-header h2 { font-size: 1.7rem; font-weight: 700; color: var(--text); }
.progress-badge {
font-size: .75rem;
font-family: "ui-monospace", monospace;
background: var(--accent-light);
color: var(--accent-dark);
border-radius: 99px;
padding: 2px 10px;
}
.progress-badge.year-end { background: #f0fdf4; color: #166534; }
.progress-badge.podcast { background: #eff6ff; color: #1e40af; }
.form-intro {
font-size: .9rem;
color: var(--text-muted);
margin-bottom: 32px;
font-style: italic;
}
.prompts { display: flex; flex-direction: column; gap: 28px; margin-top: 32px; }
.prompt-item { display: flex; flex-direction: column; gap: 8px; }
.prompt-question {
font-size: .95rem;
font-weight: 600;
color: var(--text);
line-height: 1.4;
}
.prompt-textarea, .prompt-input {
width: 100%;
border: 1.5px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
font-size: .93rem;
font-family: inherit;
color: var(--text);
background: var(--bg-card);
resize: vertical;
transition: border-color .15s, box-shadow .15s;
line-height: 1.6;
}
.prompt-input { resize: none; height: 40px; }
.prompt-textarea:focus, .prompt-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180,83,9,.12);
}
.prompt-textarea::placeholder, .prompt-input::placeholder { color: #c4bdb8; }
.form-footer { margin-top: 40px; }
/* ─── Buttons ────────────────────────────────────────────────────────────── */
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 10px 24px;
font-size: .93rem;
font-family: inherit;
font-weight: 600;
cursor: pointer;
transition: background .15s;
}
.btn-primary:hover { background: var(--accent-dark); }
.btn-primary:disabled { opacity: .45; cursor: not-allowed; }
.btn-large { padding: 14px 32px; font-size: 1rem; }
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1.5px solid var(--border);
border-radius: var(--radius);
padding: 8px 18px;
font-size: .88rem;
font-family: inherit;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.btn-secondary:hover { border-color: var(--text-muted); color: var(--text); }
/* ─── Letter view ────────────────────────────────────────────────────────── */
.letter-view { }
.letter-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 36px;
}
.letter-toolbar h2 { font-size: 1.5rem; font-weight: 700; }
.toolbar-actions { display: flex; gap: 10px; }
.letter-content {
font-size: 1.02rem;
line-height: 1.85;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 40px 48px;
box-shadow: var(--shadow);
max-width: 660px;
}
.letter-content p {
margin: 0 0 1.2em;
}
.letter-content p:last-child { margin-bottom: 0; }
/* ─── Welcome ────────────────────────────────────────────────────────────── */
.welcome {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
background: var(--bg);
}
.welcome-inner {
max-width: 480px;
text-align: center;
}
.welcome-icon { font-size: 2.4rem; display: block; margin-bottom: 16px; }
.welcome-inner h1 { font-size: 2rem; font-weight: 700; margin-bottom: 12px; }
.welcome-tagline { font-size: 1.05rem; color: var(--text-muted); line-height: 1.6; margin-bottom: 36px; }
.welcome-how {
text-align: left;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 36px;
display: flex;
flex-direction: column;
gap: 14px;
}
.how-step { display: flex; gap: 14px; align-items: flex-start; font-size: .93rem; }
.how-num { font-family: monospace; font-size: .78rem; color: var(--accent); font-weight: 700; padding-top: 2px; flex-shrink: 0; }
.welcome-form { display: flex; flex-direction: column; gap: 12px; }
.welcome-label { font-size: .88rem; font-weight: 600; text-align: left; }
.welcome-input {
border: 1.5px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
font-size: 1rem;
font-family: inherit;
width: 100%;
transition: border-color .15s;
}
.welcome-input:focus { outline: none; border-color: var(--accent); }
/* ─── Sidebar brand active ───────────────────────────────────────────────── */
.sidebar-brand-active { background: rgba(255,255,255,.1) !important; border-radius: 0; }
/* ─── Home page ──────────────────────────────────────────────────────────── */
.home { max-width: 680px; }
.home-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 40px;
gap: 20px;
}
.home-year { font-size: 2.8rem; font-weight: 800; line-height: 1; letter-spacing: -.02em; }
.home-sub { font-size: 1rem; color: var(--text-muted); margin-top: 4px; font-style: italic; }
.home-section { margin-bottom: 36px; }
.section-label { font-size: .72rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--text-muted); }
.progress-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
.progress-count { font-size: .82rem; font-family: monospace; color: var(--text-muted); }
.progress-bar-track { height: 6px; background: var(--border); border-radius: 99px; overflow: hidden; }
.progress-bar-fill { height: 100%; background: var(--accent); border-radius: 99px; transition: width .4s ease; }
/* Month grid */
.month-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 36px;
}
.month-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 8px;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
font-family: inherit;
}
.month-card:hover:not(:disabled) { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(180,83,9,.1); }
.month-card.done { border-color: #a3cfb8; background: #f0fdf4; }
.month-card.current { border-color: var(--accent); }
.month-card.future { opacity: .35; cursor: default; }
.month-card-name { font-size: .85rem; font-weight: 600; color: var(--text); }
.month-card-count { font-size: .7rem; font-family: monospace; color: #16a34a; }
.month-card-pending { font-size: .68rem; color: var(--text-muted); font-style: italic; }
.month-card-future { font-size: .7rem; color: #ccc; }
/* Checklist */
.checklist { display: flex; flex-direction: column; gap: 8px; }
.checklist-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
font-family: inherit;
text-align: left;
transition: border-color .15s;
width: 100%;
}
.checklist-item:hover { border-color: var(--accent); }
.checklist-item.done { border-color: #a3cfb8; background: #f0fdf4; }
.checklist-item.partial { border-color: #fbbf24; }
.check-icon { font-size: 1.1rem; flex-shrink: 0; width: 22px; text-align: center; }
.check-icon.done { color: #16a34a; }
.check-icon.partial { color: #d97706; }
.check-body { display: flex; flex-direction: column; flex: 1; gap: 2px; }
.check-label { font-size: .92rem; font-weight: 600; color: var(--text); }
.check-detail { font-size: .78rem; color: var(--text-muted); }
.check-arrow { font-size: .85rem; color: var(--text-muted); flex-shrink: 0; }
/* Missing months */
.missing-months { display: flex; flex-wrap: wrap; gap: 8px; }
.missing-tag {
padding: 6px 14px;
border: 1.5px solid var(--border);
border-radius: 99px;
background: var(--bg-card);
font-size: .83rem;
font-family: inherit;
color: var(--text-muted);
cursor: pointer;
transition: border-color .15s, color .15s;
}
.missing-tag:hover { border-color: var(--accent); color: var(--accent); }
.home-all-done {
font-size: .9rem;
color: #16a34a;
font-style: italic;
padding: 12px 0;
}
/* ─── Tabs ───────────────────────────────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-top: 20px;
}
.tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 8px 20px;
font-size: .9rem;
font-family: inherit;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: color .15s, border-color .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+145
View File
@@ -0,0 +1,145 @@
export interface MonthlyEntry {
year: number;
month: number; // 0-indexed
answers: Record<string, string>;
completedAt?: string;
}
export interface YearEndEntry {
year: number;
answers: Record<string, string>;
completedAt?: string;
}
export interface AppData {
name: string;
monthlyEntries: MonthlyEntry[];
yearEndEntries: YearEndEntry[];
podcastEntries: PodcastYearEndEntry[];
}
export const MONTHLY_PROMPTS: { id: string; question: string; placeholder: string }[] = [
{
id: "win",
question: "What was your biggest win or achievement this month?",
placeholder: "A project wrapped up, a goal reached, a moment of pride…",
},
{
id: "challenge",
question: "What challenged or surprised you most?",
placeholder: "Something harder than expected, or that caught you off guard…",
},
{
id: "learned",
question: "What did you learn — about yourself, others, or the world?",
placeholder: "A skill, an insight, something that shifted your perspective…",
},
{
id: "memory",
question: "What one moment do you most want to remember?",
placeholder: "A conversation, a place, a feeling you don't want to forget…",
},
{
id: "people",
question: "Who showed up for you, or who did you show up for?",
placeholder: "A friend, colleague, stranger — a connection that mattered…",
},
{
id: "forward",
question: "What are you carrying into next month?",
placeholder: "An intention, a question, something left unfinished…",
},
];
export const YEAR_END_PROMPTS: { id: string; question: string; placeholder: string }[] = [
{
id: "word",
question: "What single word best captures your year?",
placeholder: "Growth, chaos, turning, arrival…",
},
{
id: "change",
question: "What is the most important thing that changed this year?",
placeholder: "In your life, your work, your relationships, yourself…",
},
{
id: "identity",
question: "What did this year teach you about who you are?",
placeholder: "A strength discovered, a limit found, a value clarified…",
},
{
id: "gratitude",
question: "What are you most grateful for from this year?",
placeholder: "A person, an opportunity, a difficulty that made you stronger…",
},
{
id: "release",
question: "What are you ready to leave behind?",
placeholder: "A habit, a belief, a version of yourself you've outgrown…",
},
{
id: "carry",
question: "What are you carrying forward into next year?",
placeholder: "A goal, a promise to yourself, something that matters deeply…",
},
];
export interface PodcastYearEndEntry {
year: number;
podcastName: string;
answers: Record<string, string>;
completedAt?: string;
}
export const PODCAST_PROMPTS: { id: string; question: string; placeholder: string }[] = [
{
id: "episodes",
question: "How many episodes did you publish this year?",
placeholder: "52, 24, 10…",
},
{
id: "favorite",
question: "Which episode are you most proud of, and why?",
placeholder: "Ep. 47 — it finally said what I'd been trying to say for months…",
},
{
id: "top",
question: "Which episode resonated most with listeners?",
placeholder: "Most downloads, most replies, most shares…",
},
{
id: "guest",
question: "Who was your most memorable guest or conversation?",
placeholder: "A guest who surprised you, challenged you, or moved you…",
},
{
id: "growth",
question: "How did the show grow or change this year?",
placeholder: "New format, new audience, new confidence, new numbers…",
},
{
id: "behind",
question: "What happened behind the mic that listeners never heard?",
placeholder: "A rough patch, a pivot, a decision made quietly…",
},
{
id: "lesson",
question: "What did podcasting teach you this year?",
placeholder: "About storytelling, about yourself, about your audience…",
},
{
id: "gratitude",
question: "What do you most want to say to your listeners?",
placeholder: "The thing you mean but don't always say out loud…",
},
{
id: "next",
question: "What's the one thing you want to do differently or more of next year?",
placeholder: "A format change, a topic, a frequency, a commitment…",
},
];
export const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
+80
View File
@@ -0,0 +1,80 @@
import { MONTH_NAMES } from "../types";
import type { AppData } from "../types";
export function generateLetter(data: AppData, year: number): string {
const name = data.name || "Friend";
const entries = data.monthlyEntries
.filter((e) => e.year === year)
.sort((a, b) => a.month - b.month);
const yearEnd = data.yearEndEntries.find((e) => e.year === year);
const today = new Date();
const dateStr = today.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
let letter = `${year} Annual Letter\n`;
letter += `Written ${dateStr}\n\n`;
letter += `Dear ${name},\n\n`;
if (yearEnd?.answers.word) {
letter += `If ${year} had a single word, it would be: *${yearEnd.answers.word}*.\n\n`;
}
if (entries.length === 0 && !yearEnd) {
letter +=
"This letter is still being written. Return here at the end of the year to read it.\n";
return letter;
}
// Monthly narrative
if (entries.length > 0) {
letter += `Here is what the months held:\n\n`;
for (const entry of entries) {
const monthName = MONTH_NAMES[entry.month];
letter += `**${monthName}**\n`;
const parts: string[] = [];
if (entry.answers.win)
parts.push(`The month's bright spot: ${entry.answers.win}.`);
if (entry.answers.challenge)
parts.push(`You were tested by: ${entry.answers.challenge}.`);
if (entry.answers.learned)
parts.push(`You learned: ${entry.answers.learned}.`);
if (entry.answers.memory)
parts.push(`The moment to hold onto: ${entry.answers.memory}.`);
if (entry.answers.people)
parts.push(`The people in the picture: ${entry.answers.people}.`);
if (entry.answers.forward)
parts.push(`Carrying forward: ${entry.answers.forward}.`);
letter += parts.join("\n") + "\n\n";
}
}
// Year-end reflections
if (yearEnd) {
letter += `**Looking back at the full year:**\n\n`;
if (yearEnd.answers.change)
letter += `The most important thing that changed: ${yearEnd.answers.change}\n\n`;
if (yearEnd.answers.identity)
letter += `What the year taught you about yourself: ${yearEnd.answers.identity}\n\n`;
if (yearEnd.answers.gratitude)
letter += `What you were most grateful for: ${yearEnd.answers.gratitude}\n\n`;
if (yearEnd.answers.release)
letter += `What you're ready to leave behind: ${yearEnd.answers.release}\n\n`;
if (yearEnd.answers.carry)
letter += `What you're carrying forward: ${yearEnd.answers.carry}\n\n`;
}
const completedMonths = entries.length;
if (completedMonths < 12) {
const remaining = 12 - completedMonths;
letter += `*${remaining} month${remaining !== 1 ? "s" : ""} of this year are still being written.*\n\n`;
}
letter += `With love,\n${name} — ${year}`;
return letter;
}
+62
View File
@@ -0,0 +1,62 @@
import type { PodcastYearEndEntry } from "../types";
export function generatePodcastEmail(entry: PodcastYearEndEntry): string {
const { answers, podcastName, year } = entry;
const show = podcastName || "the show";
let email = `Subject: Looking back on ${year} — thank you\n\n`;
email += `Hey everyone,\n\n`;
// Opening
if (answers.episodes) {
email += `${year} was a big year for ${show}. We published ${answers.episodes} episode${Number(answers.episodes) !== 1 ? "s" : ""} together — `;
email += `and I've been sitting with what that means.\n\n`;
} else {
email += `As ${year} winds down, I wanted to take a moment to look back — with you.\n\n`;
}
// Favorite episode
if (answers.favorite) {
email += `The episode I'm most proud of this year: ${answers.favorite}\n\n`;
}
// Top episode
if (answers.top) {
email += `And the one that resonated most with all of you: ${answers.top}\n\n`;
}
// Guest
if (answers.guest) {
email += `My most memorable conversation: ${answers.guest}\n\n`;
}
// Growth / behind the scenes
if (answers.growth || answers.behind) {
email += `**Behind the scenes:**\n\n`;
if (answers.growth) email += `${answers.growth}\n\n`;
if (answers.behind) email += `${answers.behind}\n\n`;
}
// Lesson
if (answers.lesson) {
email += `**What this year taught me:**\n\n${answers.lesson}\n\n`;
}
// Looking ahead
if (answers.next) {
email += `**Looking ahead to ${year + 1}:**\n\n${answers.next}\n\n`;
}
// Gratitude — the close
if (answers.gratitude) {
email += `${answers.gratitude}\n\n`;
} else {
email += `Thank you for listening, for sharing, for reaching out. You're the reason this keeps going.\n\n`;
}
email += `Here's to ${year + 1},\n\n`;
email += `[Your name]\n`;
email += `${show}`;
return email;
}
+84
View File
@@ -0,0 +1,84 @@
import type { AppData, MonthlyEntry, YearEndEntry, PodcastYearEndEntry } from "../types";
const KEY = "annual-letter-data";
const defaults: AppData = {
name: "",
monthlyEntries: [],
yearEndEntries: [],
podcastEntries: [],
};
export function load(): AppData {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...defaults };
return { ...defaults, ...JSON.parse(raw) };
} catch {
return { ...defaults };
}
}
export function save(data: AppData): void {
localStorage.setItem(KEY, JSON.stringify(data));
}
export function getMonthlyEntry(
data: AppData,
year: number,
month: number
): MonthlyEntry | undefined {
return data.monthlyEntries.find((e) => e.year === year && e.month === month);
}
export function upsertMonthlyEntry(
data: AppData,
entry: MonthlyEntry
): AppData {
const idx = data.monthlyEntries.findIndex(
(e) => e.year === entry.year && e.month === entry.month
);
const entries =
idx >= 0
? data.monthlyEntries.map((e, i) => (i === idx ? entry : e))
: [...data.monthlyEntries, entry];
return { ...data, monthlyEntries: entries };
}
export function getYearEndEntry(
data: AppData,
year: number
): YearEndEntry | undefined {
return data.yearEndEntries.find((e) => e.year === year);
}
export function upsertYearEndEntry(
data: AppData,
entry: YearEndEntry
): AppData {
const idx = data.yearEndEntries.findIndex((e) => e.year === entry.year);
const entries =
idx >= 0
? data.yearEndEntries.map((e, i) => (i === idx ? entry : e))
: [...data.yearEndEntries, entry];
return { ...data, yearEndEntries: entries };
}
export function getPodcastEntry(
data: AppData,
year: number
): PodcastYearEndEntry | undefined {
return data.podcastEntries.find((e) => e.year === year);
}
export function upsertPodcastEntry(
data: AppData,
entry: PodcastYearEndEntry
): AppData {
const idx = data.podcastEntries.findIndex((e) => e.year === entry.year);
const entries =
idx >= 0
? data.podcastEntries.map((e, i) => (i === idx ? entry : e))
: [...data.podcastEntries, entry];
return { ...data, podcastEntries: entries };
}