// /print — the newspaper edition. Server-rendered from current cached snapshots. // Puppeteer loads this route to produce the PDF (see /api/edition). // // Layout: masthead → full-width weather hero (double-wide, with today's day-part // strip) → personal sections, then the Screamer briefs, all in one classic // two-column newspaper flow (briefs come last and simply spill down the columns // and onto later pages). Each kind has a small paper renderer below; add a case // to support a new kind. import { gatherEdition, type EditionSection } from "@/lib/edition-data"; import type { SourceKind } from "@/lib/schemas/source"; import type { WeatherPayload } from "@/lib/schemas/sources/weather"; import type { NewsPayload } from "@/lib/schemas/sources/news"; import type { BriefsPayload } from "@/lib/schemas/sources/briefs"; 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 { MastodonPayload } from "@/lib/schemas/sources/mastodon"; import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky"; import type { AqiPayload } from "@/lib/schemas/sources/aqi"; import type { MarketsPayload } from "@/lib/schemas/sources/markets"; import type { HackerNewsPayload } from "@/lib/schemas/sources/hackernews"; 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> = { news: "Headlines", briefs: "News Briefs", feeds: "My Feeds", aqi: "Air Quality", markets: "Markets", hackernews: "Hacker News", onthisday: "On This Day", obsidian: "From the Vault", calendar: "Calendar", sports: "Scores", github: "GitHub", vercel: "Deployments", links: "Links", mastodon: "Mastodon", bluesky: "Bluesky", todos: "Agenda", }; // Small mono kicker shown at the right of each card header. const SECTION_KICK: Partial> = { calendar: "Today", todos: "Things", sports: "Scores", feeds: "RSS", onthisday: "History", obsidian: "Obsidian", }; 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(); } // WMO weather code → a grayscale-safe glyph for the weather strip. function wmoGlyph(code: number): string { if (code === 0) return "☀"; if (code <= 2) return "⛅"; if (code === 3) return "☁"; if (code <= 48) return "🌫"; if (code <= 67) return "🌧"; if (code <= 77) return "❄"; if (code <= 82) return "🌦"; if (code <= 86) return "🌨"; return "⛈"; } function fmtTime(iso: string) { return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } function WeatherHero({ w }: { w: WeatherPayload }) { const deg = w.units === "metric" ? "°C" : "°F"; const wind = w.units === "metric" ? "km/h" : "mph"; const today = w.daily[0]; const showFeels = w.current.feelsLike != null && Math.abs(w.current.feelsLike - w.current.temp) >= 2; const highPollen = w.pollen.filter((p) => p.level !== "Low"); return ( <> {w.alerts.map((a, i) => (
⚠ {a.event}{a.headline ? ` — ${a.headline}` : ""}
))}
{w.current.temp} {deg}
{w.location}
{w.current.text}
{today && (
H {today.max}{deg} · L {today.min}{deg} · Wind {w.current.wind} {wind} {showFeels ? ` · Feels ${w.current.feelsLike}${deg}` : ""} {w.current.humidity != null ? ` · ${w.current.humidity}% RH` : ""} {w.current.uvIndex != null ? ` · UV ${w.current.uvIndex}` : ""} {w.aqi != null ? ` · AQI ${w.aqi} ${w.aqiCategory}` : ""}
)} {(w.sunrise || w.sunset || w.moonPhase) && (
{w.sunrise ? `↑ ${fmtTime(w.sunrise)}` : ""} {w.sunset ? ` · ↓ ${fmtTime(w.sunset)}` : ""} {w.moonPhase ? ` · ${w.moonEmoji} ${w.moonPhase}` : ""}
)} {highPollen.length > 0 && (
Pollen: {highPollen.map((p) => `${p.label} ${p.level}`).join(" · ")}
)}
{w.parts.length > 0 && (
{w.parts.map((p) => (
{p.label}
{wmoGlyph(p.code)}
{p.temp} {deg}
{p.precipProb != null ? `${p.precipProb}%` : "—"}
))}
)}
); } // 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": case "feeds": { const p = payload as NewsPayload; if (p.items.length === 0) return null; const cap = kind === "news" ? 9 : 12; return ( <> {p.items.slice(0, cap).map((it, i) => (

{it.title} — {it.source}

))} ); } case "aqi": { const p = payload as AqiPayload; if (p.aqi == null && p.pollen.length === 0) return null; return ( <>

AQI {p.aqi ?? "—"} {p.category} {p.pm25 != null && — PM2.5 {p.pm25}}

{p.pollen.slice(0, 3).map((pl) => (

{pl.label} — {pl.level}

))} ); } case "markets": { const p = payload as MarketsPayload; if (p.quotes.length === 0) return null; return ( <> {p.quotes.slice(0, 6).map((q) => (

{q.symbol}{" "} {q.price.toLocaleString(undefined, { maximumFractionDigits: 2 })} {q.changePct != null && ( {" "} — {q.changePct >= 0 ? "+" : ""} {q.changePct}% )}

))} ); } case "hackernews": { const p = payload as HackerNewsPayload; if (p.stories.length === 0) return null; return ( <> {p.stories.slice(0, 5).map((s, i) => (

{s.title} — {s.source}

))} ); } case "onthisday": { const p = payload as OnThisDayPayload; if (p.events.length === 0 && p.births.length === 0) return null; return ( <> {p.events.slice(0, 8).map((e, i) => (

{e.year != null && {e.year} } {e.text}

))} {p.births.length > 0 && ( <>

Born

{p.births.slice(0, 5).map((e, i) => (

{e.year != null && {e.year} } {e.text}

))} )} ); } case "obsidian": { const p = payload as ObsidianPayload; if (p.notes.length === 0) return null; return ( <> {p.notes.map((n) => (

{n.title} — {shortDate(n.modified)}

))} ); } case "calendar": { const p = payload as CalendarPayload; // Calendar always prints — an empty day is itself worth stating. if (p.events.length === 0) return

No events scheduled today.

; return ( <> {p.events.map((e, i) => (

{e.summary}{" "} — {e.allDay ? shortDate(e.start) : shortDateTime(e.start)}

))} ); } case "sports": { const p = payload as SportsPayload; if (p.games.length === 0) return null; type G = SportsPayload["games"][number]; // Order within a tier: Active (live), then Finished, then Upcoming. const rank = (g: G): number => { if (g.state === "in") return 0; if (g.state === "post") return 1; return 2; }; const line = (g: G) => g.state === "pre" ? `${g.away} @ ${g.home}` : `${g.away} ${g.awayScore ?? ""} @ ${g.home} ${g.homeScore ?? ""}`.replace(/\s+/g, " ").trim(); // The module already scopes to yesterday + today (+ each favorite's next // game); split into Favorites vs Leagues, matching the TUI. const tiers = ([ { key: "fav", label: "Favorites", games: p.games.filter((g) => g.favorite) }, { key: "lg", label: "Leagues", games: p.games.filter((g) => !g.favorite) }, ] as const).filter((s) => s.games.length > 0); if (tiers.length === 0) return null; return ( <> {tiers.map((s) => { const groups: Record = {}; const ordered = [...s.games].sort( (a, b) => rank(a) - rank(b) || (a.startTime ?? "").localeCompare(b.startTime ?? ""), ); for (const g of ordered) (groups[g.league] ||= []).push(g); return (

{s.label}

{Object.entries(groups).map(([league, games]) => (

{leagueName(league)}

{games.map((g, i) => (

{line(g)} — {g.status}

))}
))}
); })} ); } case "github": { const p = payload as GitHubPayload; if (p.events.length === 0) return null; return ( <> {p.events.slice(0, 10).map((e, i) => (

{e.type} {e.repo}

))} ); } case "vercel": { const p = payload as VercelPayload; if (p.deployments.length === 0) return null; return ( <> {p.deployments.map((d, i) => (

{d.name} {d.target ? ` · ${d.target}` : ""} — {d.state}

))} ); } case "mastodon": { const p = payload as MastodonPayload; if (p.notifications.length === 0) return null; return ( <> {p.notifications.map((n, i) => (

{n.account} {n.type} {n.text ? ` — ${n.text}` : ""}

))} ); } case "bluesky": { const p = payload as BlueskyPayload; if (p.notifications.length === 0) return null; return ( <> {p.notifications.map((n, i) => (

{n.author} {n.reason} {n.text ? ` — ${n.text}` : ""}

))} ); } case "todos": { const p = payload as ThingsPayload; if (p.tasks.length === 0) return

No tasks today.

; return ( <> {p.tasks.map((t) => (

☐ {t.title} {t.project ? — {t.project} : null}

))} ); } default: return null; } } // A personal-section bento card on page one. function PaperCard({ section }: { section: EditionSection }) { const body = SectionBody({ kind: section.kind, payload: section.payload }); if (!body) return null; const kick = SECTION_KICK[section.kind]; return (

{SECTION_TITLE[section.kind] ?? section.label} {kick && {kick}}

{body}
); } // One Screamer brief category as a flowing column card (title → summary → // source publications). These come last, after every personal section, and just // flow down the two columns and onto following pages — no balancing. function BriefCard({ category }: { category: BriefsPayload["categories"][number] }) { if (category.stories.length === 0) return null; return (

{category.name} Screamer

{category.stories.map((s, i) => (
{s.title}
{s.summary &&
{s.summary}
} {s.sources.length > 0 &&
{s.sources.join(", ")}
}
))}
); } export default function PrintPage() { const d = gatherEdition(); const briefs = d.sections.find((s) => s.kind === "briefs"); const grid = d.sections.filter((s) => s.kind !== "briefs"); const briefCats = briefs ? (briefs.payload as BriefsPayload).categories.filter((c) => c.stories.length > 0) : []; return (

The Daily Dashboard

{d.date} Personal Edition
{d.weather && }
{grid.map((s) => ( ))} {briefCats.map((c) => ( ))}
); }