// 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 } from "@/lib/store"; import type { WeatherPayload } from "@/lib/schemas/sources/weather"; import type { SourceKind } from "@/lib/schemas/source"; import type { NewsPayload } from "@/lib/schemas/sources/news"; import type { CalendarPayload } from "@/lib/schemas/sources/calendar"; import type { GitHubPayload } from "@/lib/schemas/sources/github"; import type { VercelPayload } from "@/lib/schemas/sources/vercel"; import type { MastodonPayload } from "@/lib/schemas/sources/mastodon"; import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky"; import type { ObsidianPayload } from "@/lib/schemas/sources/obsidian"; export interface EditionSection { id: string; kind: SourceKind; label: string; payload: unknown; } export interface EditionData { date: string; weather: WeatherPayload | null; sections: EditionSection[]; } // --- "Today's paper" scoping ------------------------------------------------- // The edition is a morning newspaper: it reports *this day*, not a rolling // digest. So each time-based section is trimmed to a daily window before it // reaches the page — last night through end of today for activity/scores, // today only for the calendar. Evergreen/already-daily kinds (On This Day, // the Things agenda, the static links list) pass through untouched. // // This only affects the PDF/print view. Live dashboard cards read snapshots // directly and still show their full window. To scope a NEW kind, add a case; // the default is passthrough so nothing is silently emptied. function dayBounds() { const now = new Date(); const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const dayMs = 24 * 60 * 60 * 1000; return { startToday, startYesterday: startToday - dayMs, endToday: startToday + dayMs }; } // Is the timestamp within [lo, hi)? Items with no/garbage date fall back to // keepNull — true when we'd rather show an undated item than hide it. function inWindow(iso: string | null | undefined, lo: number, hi: number, keepNull: boolean) { if (!iso) return keepNull; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return keepNull; return t >= lo && t < hi; } function scopeToToday(kind: SourceKind, payload: unknown): unknown { const { startToday, startYesterday, endToday } = dayBounds(); switch (kind) { case "calendar": { const p = payload as CalendarPayload; return { ...p, events: p.events.filter((e) => inWindow(e.start, startToday, endToday, false)) }; } case "sports": { // The sports module already scopes to yesterday + today (+ each favorite's // next game). Pass through so the paper matches the TUI exactly. return payload; } case "news": case "feeds": { // Overnight + today's headlines. Feeds without item dates kept as-is. const p = payload as NewsPayload; return { ...p, items: p.items.filter((it) => inWindow(it.isoDate, startYesterday, endToday, true)) }; } case "obsidian": { const p = payload as ObsidianPayload; return { ...p, notes: p.notes.filter((n) => inWindow(n.modified, startYesterday, endToday, false)) }; } case "github": { const p = payload as GitHubPayload; return { ...p, events: p.events.filter((e) => inWindow(e.createdAt, startYesterday, endToday, false)) }; } case "vercel": { const p = payload as VercelPayload; return { ...p, deployments: p.deployments.filter((d) => inWindow(d.createdAt, startYesterday, endToday, false)) }; } case "mastodon": { const p = payload as MastodonPayload; return { ...p, notifications: p.notifications.filter((n) => inWindow(n.createdAt, startYesterday, endToday, false)) }; } case "bluesky": { const p = payload as BlueskyPayload; return { ...p, notifications: p.notifications.filter((n) => inWindow(n.createdAt, startYesterday, endToday, false)) }; } default: return payload; // onthisday, todos, links — already daily or evergreen } } // Newspaper reading order, independent of the live dashboard's source order: // what matters first thing in the morning. Weather is the lead paragraph and is // not listed here. Kinds not listed fall to the end (in source order). `links` // is intentionally absent — it never belongs in the printed edition. const PRINT_ORDER: SourceKind[] = [ "calendar", // today's events "todos", // agenda / tasks "sports", // scores "feeds", // my feeds "news", // headlines "onthisday", "obsidian", // from the vault "hackernews", // tech reading "markets", // watchlist "briefs", // news briefs — flow last, as trailing column cards ]; function printRank(kind: SourceKind): number { const i = PRINT_ORDER.indexOf(kind); return i === -1 ? PRINT_ORDER.length : i; } export function gatherEdition(): EditionData { let weather: WeatherPayload | null = null; const sections: EditionSection[] = []; for (const s of listSources()) { if (!s.enabled) continue; if (s.kind === "links") continue; // never printed const snap = latestSnapshot(s.id); if (!snap?.ok || !snap.payload) continue; // 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; } const kind = s.kind as SourceKind; sections.push({ id: s.id, kind, label: s.label, payload: scopeToToday(kind, snap.payload) }); } // Stable sort into newspaper reading order. sections.sort((a, b) => printRank(a.kind) - printRank(b.kind)); return { date: new Date().toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", }), weather, sections, }; }