▍ humdrum codex / soft

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 {