Editions: delete action + order-driven newspaper sections
06821e19fada93c22569f1082e919b64c9797410
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-06 07:40
parent 5e6ea2f0
Editions: delete action + order-driven newspaper sections Issue 1. Add a way to remove generated PDF editions and render all enabled sources in the printed paper instead of a hard-coded subset. - deleteEdition(id) store helper returns pdfPath for unlinking - DELETE /api/editions/[id] removes the row and unlinks the file on disk - ✕ delete button per edition in EditionsView - edition-data + /print are now order-driven: weather is the lead, every other enabled source with cached data renders as a section in the user's configured order. Renderers for calendar, sports, github, vercel, links, mastodon, bluesky, and Things agenda; new kinds appear automatically once they have a SectionBody case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5 files changed
ISSUES.md +9 −11
@@ -4,19 +4,17 @@ Tracking known problems and follow-ups. Checked = done.
---
-## 1. Print editions: delete + more content
+## 1. Print editions: delete + more content ✅ done 2026-06-06
-**Delete.** There's no way to remove a generated PDF edition. Need a delete action.
-- Add `DELETE /api/editions/[id]` → remove the row **and** unlink the PDF file on disk.
-- Add a delete (✕) button per edition in `components/EditionsView.tsx`.
-- Store helper: `deleteEdition(id)` in `lib/store.ts` (return `pdfPath` so the route can unlink it).
+**Delete.** ✅ `DELETE /api/editions/[id]` removes the row + unlinks the PDF;
+`deleteEdition(id)` store helper returns `pdfPath`; ✕ button per edition in
+`EditionsView.tsx`.
-**More sections.** The paper only renders weather, news, On This Day, Agenda (todos), and
-"From the Vault" (obsidian). It should include the other enabled sources too.
-- `app/print/page.tsx` + `lib/edition-data.ts` — add Calendar, Scores, GitHub/Vercel,
- Links, Mastodon/Bluesky sections (render whatever sources are enabled + have data).
-- Consider making edition sections driven by the enabled sources / their order, rather than
- hard-coded, so new sources show up automatically.
+**More sections.** ✅ `/print` + `edition-data.ts` are now order-driven: weather is the
+lead, every other enabled source with cached data renders as a body section in the user's
+configured order. Renderers added for Calendar, Scores, GitHub, Deployments (Vercel),
+Links, Mastodon, Bluesky, and Agenda (Things). New kinds show up automatically once they
+have a `SectionBody` case.
## 2. Personal RSS — dedicated section
app/print/page.tsx +242 −59
@@ -1,10 +1,248 @@
// /print — the newspaper edition. Server-rendered from current cached snapshots.
// Puppeteer loads this route to produce the PDF (see /api/edition).
+//
+// Sections are driven by enabled sources in the user's order (see edition-data).
+// Each kind has a small paper renderer below; add a case to support a new kind.
-import { gatherEdition } from "@/lib/edition-data";
+import { gatherEdition, type EditionSection } from "@/lib/edition-data";
+import type { SourceKind } from "@/lib/schemas/source";
+import type { NewsPayload } from "@/lib/schemas/sources/news";
+import type { OnThisDayPayload } from "@/lib/schemas/sources/onthisday";
+import type { ObsidianPayload } from "@/lib/schemas/sources/obsidian";
+import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
+import type { SportsPayload } from "@/lib/schemas/sources/sports";
+import type { GitHubPayload } from "@/lib/schemas/sources/github";
+import type { VercelPayload } from "@/lib/schemas/sources/vercel";
+import type { LinksPayload } from "@/lib/schemas/sources/links";
+import type { MastodonPayload } from "@/lib/schemas/sources/mastodon";
+import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky";
+import type { ThingsPayload } from "@/lib/schemas/sources/things";
export const dynamic = "force-dynamic";
+// Classic newspaper heading per kind; falls back to the source's own label.
+const SECTION_TITLE: Partial<Record<SourceKind, string>> = {
+ news: "Headlines",
+ onthisday: "On This Day",
+ obsidian: "From the Vault",
+ calendar: "Calendar",
+ sports: "Scores",
+ github: "GitHub",
+ vercel: "Deployments",
+ links: "Links",
+ mastodon: "Mastodon",
+ bluesky: "Bluesky",
+ todos: "Agenda",
+};
+
+function shortDate(iso: string) {
+ return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+function shortDateTime(iso: string) {
+ return new Date(iso).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+function leagueName(path: string) {
+ return (path.split("/").pop() ?? path).toUpperCase();
+}
+
+// Render a section's body for its kind. Returns null when there's nothing to show.
+function SectionBody({ kind, payload }: { kind: SourceKind; payload: unknown }) {
+ switch (kind) {
+ case "news": {
+ const p = payload as NewsPayload;
+ if (p.items.length === 0) return null;
+ return (
+ <>
+ {p.items.map((it, i) => (
+ <p className="paper-item" key={i}>
+ {it.title} <span className="src">— {it.source}</span>
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "onthisday": {
+ const p = payload as OnThisDayPayload;
+ if (p.events.length === 0 && p.births.length === 0) return null;
+ return (
+ <>
+ {p.events.map((e, i) => (
+ <p className="paper-item" key={i}>
+ {e.year != null && <span className="yr">{e.year} </span>}
+ {e.text}
+ </p>
+ ))}
+ {p.births.length > 0 && (
+ <>
+ <h3>Born</h3>
+ {p.births.map((e, i) => (
+ <p className="paper-item" key={i}>
+ {e.year != null && <span className="yr">{e.year} </span>}
+ {e.text}
+ </p>
+ ))}
+ </>
+ )}
+ </>
+ );
+ }
+ case "obsidian": {
+ const p = payload as ObsidianPayload;
+ if (p.notes.length === 0) return null;
+ return (
+ <>
+ {p.notes.map((n) => (
+ <p className="paper-item" key={n.path}>
+ {n.title} <span className="src">— {shortDate(n.modified)}</span>
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "calendar": {
+ const p = payload as CalendarPayload;
+ if (p.events.length === 0) return null;
+ return (
+ <>
+ {p.events.map((e, i) => (
+ <p className="paper-item" key={i}>
+ {e.summary}{" "}
+ <span className="src">
+ — {e.allDay ? shortDate(e.start) : shortDateTime(e.start)}
+ </span>
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "sports": {
+ const p = payload as SportsPayload;
+ if (p.games.length === 0) return null;
+ const groups: Record<string, SportsPayload["games"]> = {};
+ for (const g of p.games) (groups[g.league] ||= []).push(g);
+ return (
+ <>
+ {Object.entries(groups).map(([league, games]) => (
+ <div key={league}>
+ <h3>{leagueName(league)}</h3>
+ {games.map((g, i) => (
+ <p className="paper-item" key={i}>
+ {g.away} {g.awayScore ?? ""} @ {g.home} {g.homeScore ?? ""}{" "}
+ <span className="src">— {g.status}</span>
+ </p>
+ ))}
+ </div>
+ ))}
+ </>
+ );
+ }
+ case "github": {
+ const p = payload as GitHubPayload;
+ if (p.events.length === 0) return null;
+ return (
+ <>
+ {p.events.slice(0, 10).map((e, i) => (
+ <p className="paper-item" key={i}>
+ <span className="src">{e.type}</span> {e.repo}
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "vercel": {
+ const p = payload as VercelPayload;
+ if (p.deployments.length === 0) return null;
+ return (
+ <>
+ {p.deployments.map((d, i) => (
+ <p className="paper-item" key={i}>
+ {d.name}
+ {d.target ? ` · ${d.target}` : ""} <span className="src">— {d.state}</span>
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "links": {
+ const p = payload as LinksPayload;
+ if (p.links.length === 0) return null;
+ return (
+ <>
+ {p.links.map((l, i) => (
+ <p className="paper-item" key={i}>
+ {l.label} <span className="src">— {l.url}</span>
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "mastodon": {
+ const p = payload as MastodonPayload;
+ if (p.notifications.length === 0) return null;
+ return (
+ <>
+ {p.notifications.map((n, i) => (
+ <p className="paper-item" key={i}>
+ <span className="yr">{n.account} </span>
+ <span className="src">{n.type}</span>
+ {n.text ? ` — ${n.text}` : ""}
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "bluesky": {
+ const p = payload as BlueskyPayload;
+ if (p.notifications.length === 0) return null;
+ return (
+ <>
+ {p.notifications.map((n, i) => (
+ <p className="paper-item" key={i}>
+ <span className="yr">{n.author} </span>
+ <span className="src">{n.reason}</span>
+ {n.text ? ` — ${n.text}` : ""}
+ </p>
+ ))}
+ </>
+ );
+ }
+ case "todos": {
+ const p = payload as ThingsPayload;
+ if (p.tasks.length === 0) return null;
+ return (
+ <>
+ {p.tasks.map((t) => (
+ <p className="paper-todo paper-item" key={t.id}>
+ ☐ {t.title}
+ {t.project ? <span className="src"> — {t.project}</span> : null}
+ </p>
+ ))}
+ </>
+ );
+ }
+ default:
+ return null;
+ }
+}
+
+function PaperSection({ section }: { section: EditionSection }) {
+ const body = SectionBody({ kind: section.kind, payload: section.payload });
+ if (!body) return null;
+ return (
+ <section className="paper-section">
+ <h2>{SECTION_TITLE[section.kind] ?? section.label}</h2>
+ {body}
+ </section>
+ );
+}
+
export default function PrintPage() {
const d = gatherEdition();
const deg = d.weather?.units === "metric" ? "°C" : "°F";
@@ -35,64 +273,9 @@ </p>
)}
<div className="paper-body">
- {d.news && d.news.items.length > 0 && (
- <section className="paper-section">
- <h2>Headlines</h2>
- {d.news.items.map((it, i) => (
- <p className="paper-item" key={i}>
- {it.title} <span className="src">— {it.source}</span>
- </p>
- ))}
- </section>
- )}
-
- {d.onthisday && (
- <section className="paper-section">
- <h2>On This Day</h2>
- {d.onthisday.events.map((e, i) => (
- <p className="paper-item" key={i}>
- {e.year != null && <span className="yr">{e.year} </span>}
- {e.text}
- </p>
- ))}
- {d.onthisday.births.length > 0 && (
- <>
- <h3>Born</h3>
- {d.onthisday.births.map((e, i) => (
- <p className="paper-item" key={i}>
- {e.year != null && <span className="yr">{e.year} </span>}
- {e.text}
- </p>
- ))}
- </>
- )}
- </section>
- )}
-
- {d.todos.length > 0 && (
- <section className="paper-section">
- <h2>Agenda</h2>
- {d.todos.map((t) => (
- <p className="paper-todo paper-item" key={t.id}>
- ☐ {t.title}
- </p>
- ))}
- </section>
- )}
-
- {d.obsidian && d.obsidian.notes.length > 0 && (
- <section className="paper-section">
- <h2>From the Vault</h2>
- {d.obsidian.notes.map((n) => (
- <p className="paper-item" key={n.path}>
- {n.title}{" "}
- <span className="src">
- — {new Date(n.modified).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
- </span>
- </p>
- ))}
- </section>
- )}
+ {d.sections.map((s) => (
+ <PaperSection key={s.id} section={s} />
+ ))}
</div>
</div>
);
components/EditionsView.tsx +35 −14
@@ -32,6 +32,11 @@ setGenerating(false);
}
}
+ async function remove(id: string) {
+ const res = await fetch(`/api/editions/${id}`, { method: "DELETE" });
+ if (res.ok) setEditions((prev) => prev.filter((e) => e.id !== id));
+ }
+
return (
<main className="max-w-3xl mx-auto px-6 py-8 space-y-6">
<div className="flex items-center justify-between">
@@ -90,20 +95,36 @@ <div className="text-xs" style={{ color: "var(--text-faint)" }}>
{format(new Date(e.generatedAt), "h:mm a")}
</div>
</div>
- <a
- href={`/api/editions/${e.id}/file`}
- target="_blank"
- rel="noreferrer"
- className="px-3 py-1.5 text-sm"
- style={{
- background: "var(--bg-elevated)",
- color: "var(--text)",
- border: "1px solid var(--border-strong)",
- borderRadius: 6,
- }}
- >
- Open PDF
- </a>
+ <div className="flex gap-2">
+ <a
+ href={`/api/editions/${e.id}/file`}
+ target="_blank"
+ rel="noreferrer"
+ className="px-3 py-1.5 text-sm"
+ style={{
+ background: "var(--bg-elevated)",
+ color: "var(--text)",
+ border: "1px solid var(--border-strong)",
+ borderRadius: 6,
+ }}
+ >
+ Open PDF
+ </a>
+ <button
+ onClick={() => remove(e.id)}
+ aria-label="Delete edition"
+ title="Delete edition"
+ className="px-3 py-1.5 text-sm"
+ style={{
+ background: "var(--bg-elevated)",
+ color: "var(--text-faint)",
+ border: "1px solid var(--border-strong)",
+ borderRadius: 6,
+ }}
+ >
+ ✕
+ </button>
+ </div>
</li>
))}
</ul>
lib/edition-data.ts +32 −21
@@ -1,30 +1,44 @@
// lib/edition-data.ts — server-side: gather the latest cached payloads for the
// newspaper edition. Reads current snapshots only (no network).
+//
+// Order-driven: weather becomes the lead paragraph; every other enabled source
+// that has cached data becomes a body section, in the user's configured order.
+// New source kinds show up automatically once they have a paper renderer.
-import { listSources, latestSnapshot, listTodos } from "@/lib/store";
+import { listSources, latestSnapshot } from "@/lib/store";
import type { WeatherPayload } from "@/lib/schemas/sources/weather";
-import type { NewsPayload } from "@/lib/schemas/sources/news";
-import type { OnThisDayPayload } from "@/lib/schemas/sources/onthisday";
-import type { ObsidianPayload } from "@/lib/schemas/sources/obsidian";
-import type { TodoRow } from "@/db/schema";
+import type { SourceKind } from "@/lib/schemas/source";
+
+export interface EditionSection {
+ id: string;
+ kind: SourceKind;
+ label: string;
+ payload: unknown;
+}
export interface EditionData {
date: string;
weather: WeatherPayload | null;
- onthisday: OnThisDayPayload | null;
- news: NewsPayload | null;
- obsidian: ObsidianPayload | null;
- todos: TodoRow[];
+ sections: EditionSection[];
}
-function payloadFor<T>(kind: string): T | null {
- const source = listSources().find((s) => s.kind === kind && s.enabled);
- if (!source) return null;
- const snap = latestSnapshot(source.id);
- return snap?.ok && snap.payload ? (snap.payload as T) : null;
-}
+export function gatherEdition(): EditionData {
+ let weather: WeatherPayload | null = null;
+ const sections: EditionSection[] = [];
+
+ for (const s of listSources()) {
+ if (!s.enabled) continue;
+ const snap = latestSnapshot(s.id);
+ if (!snap?.ok || !snap.payload) continue;
-export function gatherEdition(): EditionData {
+ // First weather source is the lead; it is not repeated as a body section.
+ if (s.kind === "weather" && !weather) {
+ weather = snap.payload as WeatherPayload;
+ continue;
+ }
+ sections.push({ id: s.id, kind: s.kind as SourceKind, label: s.label, payload: snap.payload });
+ }
+
return {
date: new Date().toLocaleDateString(undefined, {
weekday: "long",
@@ -32,10 +46,7 @@ year: "numeric",
month: "long",
day: "numeric",
}),
- weather: payloadFor<WeatherPayload>("weather"),
- onthisday: payloadFor<OnThisDayPayload>("onthisday"),
- news: payloadFor<NewsPayload>("news"),
- obsidian: payloadFor<ObsidianPayload>("obsidian"),
- todos: listTodos().filter((t) => !t.done),
+ weather,
+ sections,
};
}
lib/store.ts +8 −0
@@ -162,6 +162,14 @@ .run();
return db.select().from(editions).where(eq(editions.id, id)).get()!;
}
+// Remove an edition row. Returns its pdfPath so the caller can unlink the file.
+export function deleteEdition(id: string): string | null {
+ const row = db.select().from(editions).where(eq(editions.id, id)).get();
+ if (!row) return null;
+ db.delete(editions).where(eq(editions.id, id)).run();
+ return row.pdfPath;
+}
+
/* ───────────────── settings ───────────────── */
export function getSettings(): Settings {