From 7f38c038fb37d85da3ea441dc16f82f9966c6987 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:13:05 -0400 Subject: [PATCH] Fix Docker build error; add multi-year view, PDF export, ntfy notifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: remove unused 'accessibleMonths' variable (tsc exit code 2) Features: - AllYearsLetter view: all years in chronological order, reachable from "All Years" at the top of the sidebar - PDF export: window.print() with @media print CSS — sidebar, toolbar, and chrome hidden; each year starts on a new page via page-break - "Download all" exports a plain-text file of every year's letter - ntfy notifier: standalone Node.js service (notifier/) that fires a push notification via ntfy on the 1st of each month; configurable via NTFY_TOPIC, NTFY_URL, CRON_SCHEDULE, APP_URL env vars - docker-compose: adds `notifier` service alongside `app`; reads from .env (example in .env.example); NTFY_TEST_ON_START=true sends a test ping on container start Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 17 ++++++ .gitignore | 1 + docker-compose.yml | 15 +++++ notifier/Dockerfile | 6 ++ notifier/index.mjs | 63 ++++++++++++++++++++ notifier/package.json | 9 +++ src/App.tsx | 4 ++ src/components/AllYearsLetter.tsx | 97 +++++++++++++++++++++++++++++++ src/components/Home.tsx | 1 - src/components/Sidebar.tsx | 14 ++++- src/index.css | 50 ++++++++++++++++ 11 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 notifier/Dockerfile create mode 100644 notifier/index.mjs create mode 100644 notifier/package.json create mode 100644 src/components/AllYearsLetter.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..232be16 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Copy to .env and fill in your values + +# ntfy topic — make it unguessable (anyone who knows it can receive/send) +# Subscribe at https://ntfy.sh/YOUR_TOPIC or in the ntfy app +NTFY_TOPIC=your-unguessable-topic-here + +# Optional: self-hosted ntfy server +# NTFY_URL=https://ntfy.sh + +# Optional: URL the notification links back to (your deployed app) +# APP_URL=http://localhost:8080 + +# Optional: cron schedule (default: 9 AM on the 1st of every month) +# CRON_SCHEDULE=0 9 1 * * + +# Set to "true" to get a test ping the moment the notifier starts +# NTFY_TEST_ON_START=false diff --git a/.gitignore b/.gitignore index a547bf3..438657a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/docker-compose.yml b/docker-compose.yml index de556bf..08f0ba6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,21 @@ services: - "8080:80" restart: unless-stopped + notifier: + build: ./notifier + restart: unless-stopped + environment: + # Required: set your ntfy topic (keep it unguessable, e.g. "nate-letter-k7x2") + NTFY_TOPIC: ${NTFY_TOPIC} + # Optional overrides + NTFY_URL: ${NTFY_URL:-https://ntfy.sh} + APP_URL: ${APP_URL:-http://localhost:8080} + CRON_SCHEDULE: ${CRON_SCHEDULE:-0 9 1 * *} # 9 AM on the 1st of every month + # Set to "true" to get a test ping immediately on container start + NTFY_TEST_ON_START: ${NTFY_TEST_ON_START:-false} + depends_on: + - app + dev: image: node:20-alpine working_dir: /app diff --git a/notifier/Dockerfile b/notifier/Dockerfile new file mode 100644 index 0000000..b960638 --- /dev/null +++ b/notifier/Dockerfile @@ -0,0 +1,6 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY index.mjs ./ +CMD ["node", "index.mjs"] diff --git a/notifier/index.mjs b/notifier/index.mjs new file mode 100644 index 0000000..4d41c5d --- /dev/null +++ b/notifier/index.mjs @@ -0,0 +1,63 @@ +import cron from "node-cron"; + +const NTFY_URL = process.env.NTFY_URL || "https://ntfy.sh"; +const NTFY_TOPIC = process.env.NTFY_TOPIC; +const CRON_SCHEDULE = process.env.CRON_SCHEDULE || "0 9 1 * *"; // 9 AM on the 1st of each month +const APP_URL = process.env.APP_URL || "http://localhost:8080"; + +if (!NTFY_TOPIC) { + console.error("NTFY_TOPIC is required. Set it in your environment or docker-compose."); + process.exit(1); +} + +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +async function sendReminder() { + const now = new Date(); + // We fire on the 1st — remind for the month that just ended + const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const monthName = MONTH_NAMES[prevMonth.getMonth()]; + const year = prevMonth.getFullYear(); + + const title = `Time to write your ${monthName} letter`; + const message = `${monthName} ${year} is done. Take 10 minutes to capture the month before it fades. Open your letter app to answer this month's 6 prompts.`; + + try { + const res = await fetch(`${NTFY_URL}/${NTFY_TOPIC}`, { + method: "POST", + headers: { + Title: title, + Priority: "default", + Tags: "pencil,calendar", + Click: APP_URL, + "Content-Type": "text/plain", + }, + body: message, + }); + + if (res.ok) { + console.log(`[${new Date().toISOString()}] Reminder sent for ${monthName} ${year}`); + } else { + console.error(`[${new Date().toISOString()}] ntfy returned ${res.status}: ${await res.text()}`); + } + } catch (err) { + console.error(`[${new Date().toISOString()}] Failed to send reminder:`, err.message); + } +} + +console.log(`[${new Date().toISOString()}] Notifier started`); +console.log(` Topic: ${NTFY_URL}/${NTFY_TOPIC}`); +console.log(` Schedule: ${CRON_SCHEDULE}`); +console.log(` App URL: ${APP_URL}`); + +// Run on schedule +cron.schedule(CRON_SCHEDULE, sendReminder, { timezone: "America/Chicago" }); + +// Send a startup test ping in dev mode +if (process.env.NTFY_TEST_ON_START === "true") { + console.log("Sending test notification on startup..."); + sendReminder(); +} diff --git a/notifier/package.json b/notifier/package.json new file mode 100644 index 0000000..e9b1956 --- /dev/null +++ b/notifier/package.json @@ -0,0 +1,9 @@ +{ + "name": "letter-notifier", + "version": "1.0.0", + "type": "module", + "main": "index.mjs", + "dependencies": { + "node-cron": "^3.0.3" + } +} diff --git a/src/App.tsx b/src/App.tsx index 3133945..da3819c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { LetterView } from "./components/LetterView"; import { Welcome } from "./components/Welcome"; import { PodcastYearEndForm } from "./components/PodcastYearEnd"; import { Home } from "./components/Home"; +import { AllYearsLetter } from "./components/AllYearsLetter"; import { load, save, @@ -100,6 +101,9 @@ export default function App() { {view.kind === "letter" && ( )} + {view.kind === "allYears" && ( + + )} {view.kind === "podcast" && ( (null); + + const years = Array.from( + new Set([ + ...data.monthlyEntries.map((e) => e.year), + ...data.yearEndEntries.map((e) => e.year), + ]) + ).sort((a, b) => b - a); // newest first + + if (years.length === 0) { + return ( +
+

All Years

+

+ No entries yet. Fill in some months to see your letters here. +

+
+ ); + } + + function handlePrint() { + window.print(); + } + + function handleDownload() { + const lines: string[] = []; + for (const year of [...years].reverse()) { + lines.push(generateLetter(data, year)); + lines.push("\n\n" + "─".repeat(60) + "\n\n"); + } + const blob = new Blob([lines.join("")], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${data.name || "my"}-letters-all-years.txt`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+
+

All Years

+
+ + +
+
+ +
+ {[...years].reverse().map((year, i) => { + const letter = generateLetter(data, year); + const rendered = renderMarkdown(letter); + return ( +
+ {i > 0 &&
} +
+
+ ); + })} +
+
+ ); +} + +function renderMarkdown(text: string): string { + return text + .split(/\n\n+/) + .map((para) => { + const inner = para + .split("\n") + .map((line) => { + line = line.replace(/\*\*(.+?)\*\*/g, "$1"); + line = line.replace(/\*(.+?)\*/g, "$1"); + return line; + }) + .join("
"); + return `

${inner}

`; + }) + .join(""); +} diff --git a/src/components/Home.tsx b/src/components/Home.tsx index e2096a6..1021813 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -14,7 +14,6 @@ export function Home({ data, currentYear, currentMonth, onNavigate }: HomeProps) 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); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 073e95b..3bec862 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -6,7 +6,8 @@ export type View = | { kind: "month"; year: number; month: number } | { kind: "yearEnd"; year: number } | { kind: "letter"; year: number } - | { kind: "podcast"; year: number }; + | { kind: "podcast"; year: number } + | { kind: "allYears" }; interface SidebarProps { data: AppData; @@ -36,6 +37,17 @@ export function Sidebar({ Annual Letter +
    +
  • + +
  • +
+ {years.map((year) => { const monthEntries = data.monthlyEntries.filter((e) => e.year === year); const yearEndEntry = data.yearEndEntries.find((e) => e.year === year); diff --git a/src/index.css b/src/index.css index 4a3b6cf..b4156d0 100644 --- a/src/index.css +++ b/src/index.css @@ -377,6 +377,56 @@ body { font-family: "Georgia", serif; background: var(--bg); color: var(--text); padding: 12px 0; } +/* ─── All-years view ─────────────────────────────────────────────────────── */ +.all-years-content { display: flex; flex-direction: column; gap: 0; } +.year-letter-block { } +.year-divider { border: none; border-top: 2px dashed var(--border); margin: 48px 0; } + +/* ─── Print / PDF export ─────────────────────────────────────────────────── */ +@media print { + /* Hide everything except the letter content */ + .sidebar, + .letter-toolbar, + .toolbar-actions, + .home-header button, + .tab-bar { + display: none !important; + } + + .layout { + display: block; + } + + .main { + padding: 0; + max-width: 100%; + } + + .letter-content, + .all-years-content .letter-content { + border: none; + box-shadow: none; + padding: 0; + max-width: 100%; + font-size: 11pt; + line-height: 1.7; + } + + .year-divider { + border-top: 1px solid #999; + margin: 32pt 0; + } + + /* Each year starts on a new page */ + .print-page-break + .print-page-break { + page-break-before: always; + } + + @page { + margin: 1.2in 1in; + } +} + /* ─── Tabs ───────────────────────────────────────────────────────────────── */ .tab-bar { display: flex;