1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
|
// 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,
};
}
|