Add News Briefs source; weather day-parts; paper layout for both
1d3c8f7f0f36ff08bfabdac57cc7082aa102b1da
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-09 10:47
parent cff9c267
Add News Briefs source; weather day-parts; paper layout for both - New keyless "briefs" source: pulls Screamer category briefs from screamer.humdrum.one (politics/tech/sports/culture), walking back through dates per category. Wired through schema, module, barrel, seed (enabled), CardBodies, and SOURCE_KINDS. - Weather: fetch hourly and derive four day-parts (morning/noon/ evening/night) for a printed weather hero strip; WeatherPart schema. - /print: briefs render as the trailing full-page spread (replaces raw headlines in print order); globals.css + page.tsx layout updates. - Fix stale absolute path in settings.local.json after repo move to ~/Developer/Personal-Dashboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 files changed
.claude/settings.local.json +27 −1
@@ -53,7 +53,33 @@ "Bash(ls .next.nosync/server/app/print/page.js)",
"Bash(ls -ld data editions)",
"Bash(file data *)",
"Bash(ls -la data/*.db)",
- "Bash(git commit -q -m ' *)"
+ "Bash(git commit -q -m ' *)",
+ "Bash(ls ~/Library/LaunchAgents/)",
+ "Bash(ls \"/Users/kortum/Documents/30-39 Interwebs/37 Apps/Scripts/\")",
+ "Bash(ls ~/bin ~/.local/bin)",
+ "Bash(curl -s -o /dev/null -w \"http://192.168.1.94 → %{http_code}\\\\n\" --max-time 6 http://192.168.1.94/)",
+ "Bash(curl -s --max-time 6 http://192.168.1.94/)",
+ "Bash(curl -s -o /dev/null -w \"ipp 631 → %{http_code}\\\\n\" --max-time 6 http://192.168.1.94:631/)",
+ "Bash(lpr -P Brother_HL_L2350DW_series_2 '/Users/kortum/Documents/10-19 Personal/10 Personal Info/10.06 Morning-Paper/2026/06/2026-06-08.pdf')",
+ "Bash(lpstat -W not-completed -o Brother_HL_L2350DW_series_2)",
+ "Bash(lpstat -p Brother_HL_L2350DW_series_2)",
+ "Bash(lpoptions -p Brother_HL_L2350DW_series_2 -l)",
+ "Bash(lpoptions -p Brother_HL_L2350DW_series_2)",
+ "Bash(pdfinfo paper-mock-A.pdf)",
+ "Bash(echo \"A pages: $\\(pdfinfo paper-mock-A.pdf)",
+ "Bash(break)",
+ "Bash(lpr -P Brother_HL_L2350DW_series_2 -o sides=two-sided-long-edge -o media=Letter '/Users/kortum/Documents/10-19 Personal/10 Personal Info/10.05 Daily-Notes/2026/06/2026-06-08.pdf')",
+ "Bash(lpr -P Brother_HL_L2350DW_series_2 -o sides=two-sided-long-edge -o media=Letter '/Users/kortum/Developer/Personal-Dashboard/editions.nosync/edition-2026-06-09-13-00-13.pdf')",
+ "Bash(lpstat -o)",
+ "Bash(killall bird *)",
+ "Bash(lsof -t -iTCP:4317 -sTCP:LISTEN)",
+ "Bash(xargs -r kill)",
+ "WebFetch(domain:github.com)",
+ "Bash(brew uninstall *)",
+ "Bash(command -v zplug)",
+ "Read(//opt/homebrew/Cellar/zplug/2.4.2/**)",
+ "Bash(zsh -i -c 'whence -v zplug; echo \"exit=$?\"')",
+ "Bash(zsh -i -c 'print -l $fpath | grep -i zplug || echo \"no zplug in fpath\"')"
]
}
}
app/globals.css +74 −30
@@ -193,43 +193,87 @@ border-bottom: 1px solid #111;
padding: 4px 0;
margin-top: var(--space-2);
}
-.paper-lead {
- text-align: center;
- font-family: var(--font-display);
- font-size: 18px;
- margin: 0 0 var(--space-5);
+/* ── Weather hero ── full width: current block + today's day-part strip ── */
+.paper-wx { border: 1.5px solid #111; display: flex; margin-bottom: var(--space-4); }
+.paper-wx-now {
+ display: flex; align-items: center; gap: 16px;
+ padding: 12px 18px; border-right: 1.5px solid #111; min-width: 300px;
}
-.paper-body { column-count: 2; column-gap: var(--space-6); }
-@media (max-width: 640px) { .paper-body { column-count: 1; } }
-/* Sections flow and balance across columns/pages instead of jumping whole —
- that's what left big empty gaps. Headings stay glued to their first lines,
- and individual items never split mid-entry. */
-.paper-section { margin-bottom: var(--space-5); }
-/* Short sections (e.g. From the Vault) stay intact in a single column instead
- of splitting; long sections still flow column-to-column. */
-.paper-section--whole { break-inside: avoid; }
-.paper-section > h2 {
- font-family: var(--font-display);
- font-size: 20px;
- border-bottom: 2px solid #111;
- margin: 0 0 var(--space-2);
- padding-bottom: 2px;
+.paper-wx-temp { font-family: var(--font-display); font-weight: 700; font-size: 56px; line-height: 0.86; }
+.paper-wx-loc {
+ font-family: var(--font-mono); font-weight: 700; font-size: 11px;
+ letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 2px;
+}
+.paper-wx-cond { font-family: var(--font-display); font-size: 19px; }
+.paper-wx-hl { font-family: var(--font-mono); font-size: 10px; color: #555; margin-top: 4px; }
+.paper-wx-strip { flex: 1; display: flex; }
+.paper-wx-seg { flex: 1; text-align: center; padding: 10px 4px; border-left: 1px solid #ddd; }
+.paper-wx-seg:first-child { border-left: none; }
+.paper-wx-seg-t {
+ font-family: var(--font-mono); font-weight: 700; font-size: 9px;
+ letter-spacing: 0.12em; text-transform: uppercase; color: #555;
+}
+.paper-wx-seg-g { font-size: 20px; line-height: 1.3; margin: 3px 0; }
+.paper-wx-seg-d { font-family: var(--font-display); font-weight: 700; font-size: 21px; line-height: 1; }
+.paper-wx-seg-p { font-family: var(--font-mono); font-size: 9px; color: #555; margin-top: 3px; }
+
+/* ── Personal bento grid ── 6 cols, dynamic spans, dense flow ── */
+.paper-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 11px; grid-auto-flow: row dense; }
+.paper-card {
+ border: 1px solid #ddd; border-left: 4px solid #111; border-radius: 7px;
+ padding: 9px 12px 10px; break-inside: avoid;
+}
+.paper-card > h2 {
+ font-family: var(--font-display); font-weight: 700; font-size: 16px;
+ display: flex; align-items: baseline; gap: 7px;
+ border-bottom: 1.5px solid #111; padding-bottom: 4px; margin: 0 0 6px;
break-after: avoid;
}
-.paper-section h3 {
- font-family: var(--font-mono);
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- margin: var(--space-3) 0 var(--space-1);
+.paper-card > h2 .kick {
+ margin-left: auto; font-family: var(--font-mono); font-weight: 500; font-size: 8px;
+ letter-spacing: 0.13em; text-transform: uppercase; color: #777;
}
-.paper-item { margin-bottom: var(--space-2); font-size: 13px; line-height: 1.45; break-inside: avoid; }
-.paper-item .src { color: #555; font-size: 11px; }
+.paper-card h3 {
+ font-family: var(--font-mono); font-size: 9px; text-transform: uppercase;
+ letter-spacing: 0.1em; margin: 6px 0 3px; color: #555;
+}
+.paper-item { margin-bottom: 4px; font-size: 12px; line-height: 1.4; break-inside: avoid; }
+.paper-item .src { color: #555; font-size: 10px; }
.paper-empty { color: #777; font-style: italic; }
.paper-item .yr { font-family: var(--font-mono); font-weight: 700; }
-.paper-weather { font-size: 14px; }
-.paper-todo { font-size: 13px; }
.paper-todo.done { text-decoration: line-through; color: #777; }
+
+/* ── News briefs spread ── trailing full page, 2-column magazine flow ── */
+.paper-news { break-before: page; }
+.paper-news-band {
+ border-top: 2px solid #111; border-bottom: 2px solid #111;
+ padding: 5px 2px; margin-bottom: var(--space-3);
+ display: flex; justify-content: space-between; align-items: baseline;
+}
+.paper-news-band h1 { font-family: var(--font-display); font-weight: 700; font-size: 26px; margin: 0; }
+.paper-news-band .meta {
+ font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
+ letter-spacing: 0.14em; color: #777;
+}
+.paper-news-flow { column-count: 2; column-gap: 22px; }
+.paper-brief { break-inside: avoid-column; margin-bottom: 15px; }
+.paper-brief > h2 {
+ font-family: var(--font-display); font-weight: 700; font-size: 19px;
+ display: flex; align-items: baseline; gap: 7px;
+ border-bottom: 2px solid #111; padding-bottom: 4px; margin: 0 0 8px;
+ break-after: avoid;
+}
+.paper-brief > h2 .kick {
+ margin-left: auto; font-family: var(--font-mono); font-weight: 500; font-size: 8px;
+ letter-spacing: 0.13em; text-transform: uppercase; color: #777;
+}
+.paper-story { margin-bottom: 10px; break-inside: avoid; }
+.paper-story .t { font-weight: 700; font-size: 12.5px; line-height: 1.28; margin-bottom: 3px; }
+.paper-story .s { font-size: 11px; line-height: 1.4; color: #222; margin-bottom: 3px; }
+.paper-story .src {
+ font-family: var(--font-mono); font-weight: 500; font-size: 8.5px;
+ letter-spacing: 0.04em; text-transform: uppercase; color: #777;
+}
@media print {
nav, [role="status"], nextjs-portal { display: none !important; }
app/print/page.tsx +132 −30
@@ -1,12 +1,16 @@
// /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.
+// Layout: masthead → full-width weather hero (with today's day-part strip) →
+// a dynamic bento grid of personal sections → a trailing full-page news spread
+// (the four Screamer briefs). 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";
@@ -22,6 +26,7 @@
// Classic newspaper heading per kind; falls back to the source's own label.
const SECTION_TITLE: Partial<Record<SourceKind, string>> = {
news: "Headlines",
+ briefs: "News Briefs",
feeds: "My Feeds",
onthisday: "On This Day",
obsidian: "From the Vault",
@@ -35,6 +40,26 @@ bluesky: "Bluesky",
todos: "Agenda",
};
+// Small mono kicker shown at the right of each card header.
+const SECTION_KICK: Partial<Record<SourceKind, string>> = {
+ calendar: "Today",
+ todos: "Things",
+ sports: "Scores",
+ feeds: "RSS",
+ onthisday: "History",
+ obsidian: "Obsidian",
+};
+
+// Bento spans on a 6-column grid — content weight, not source order.
+const SECTION_SPAN: Partial<Record<SourceKind, number>> = {
+ calendar: 2,
+ sports: 2,
+ feeds: 2,
+ obsidian: 2,
+ todos: 3,
+ onthisday: 3,
+};
+
function shortDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
@@ -52,6 +77,61 @@ 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 WeatherHero({ w }: { w: WeatherPayload }) {
+ const deg = w.units === "metric" ? "°C" : "°F";
+ const wind = w.units === "metric" ? "km/h" : "mph";
+ const today = w.daily[0];
+ return (
+ <div className="paper-wx">
+ <div className="paper-wx-now">
+ <div className="paper-wx-temp">
+ {w.current.temp}
+ {deg}
+ </div>
+ <div>
+ <div className="paper-wx-loc">{w.location}</div>
+ <div className="paper-wx-cond">{w.current.text}</div>
+ {today && (
+ <div className="paper-wx-hl">
+ H {today.max}
+ {deg} · L {today.min}
+ {deg} · Wind {w.current.wind} {wind}
+ </div>
+ )}
+ </div>
+ </div>
+ {w.parts.length > 0 && (
+ <div className="paper-wx-strip">
+ {w.parts.map((p) => (
+ <div className="paper-wx-seg" key={p.label}>
+ <div className="paper-wx-seg-t">{p.label}</div>
+ <div className="paper-wx-seg-g">{wmoGlyph(p.code)}</div>
+ <div className="paper-wx-seg-d">
+ {p.temp}
+ {deg}
+ </div>
+ <div className="paper-wx-seg-p">{p.precipProb != null ? `${p.precipProb}%` : "—"}</div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
// 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) {
@@ -59,8 +139,6 @@ case "news":
case "feeds": {
const p = payload as NewsPayload;
if (p.items.length === 0) return null;
- // Cap headlines so they don't crowd out higher-priority sections; feeds
- // ("My Feeds") run a touch longer since they're hand-picked.
const cap = kind === "news" ? 9 : 12;
return (
<>
@@ -225,25 +303,60 @@ return null;
}
}
-// Short trailing/list sections that look broken when split mid-section across
-// columns. Long sections (headlines, on this day) still flow column-to-column.
-const KEEP_WHOLE: Partial<Record<SourceKind, true>> = { obsidian: true };
-
-function PaperSection({ section }: { section: EditionSection }) {
+// 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 cls = KEEP_WHOLE[section.kind] ? "paper-section paper-section--whole" : "paper-section";
+ const span = SECTION_SPAN[section.kind] ?? 2;
+ const kick = SECTION_KICK[section.kind];
return (
- <section className={cls}>
- <h2>{SECTION_TITLE[section.kind] ?? section.label}</h2>
+ <section className="paper-card" style={{ gridColumn: `span ${span}` }}>
+ <h2>
+ {SECTION_TITLE[section.kind] ?? section.label}
+ {kick && <span className="kick">{kick}</span>}
+ </h2>
{body}
</section>
);
}
+// The trailing full-page news spread: the four Screamer briefs in a 2-column
+// magazine flow. Title (bold) → summary → source publications.
+function BriefsSpread({ section }: { section: EditionSection }) {
+ const p = section.payload as BriefsPayload;
+ const live = p.categories.filter((c) => c.stories.length > 0);
+ if (live.length === 0) return null;
+ return (
+ <div className="paper-news">
+ <div className="paper-news-band">
+ <h1>News Briefs</h1>
+ <span className="meta">Screamer</span>
+ </div>
+ <div className="paper-news-flow">
+ {live.map((c) => (
+ <div className="paper-brief" key={c.key}>
+ <h2>
+ {c.name}
+ <span className="kick">Screamer</span>
+ </h2>
+ {c.stories.map((s, i) => (
+ <div className="paper-story" key={i}>
+ <div className="t">{s.title}</div>
+ {s.summary && <div className="s">{s.summary}</div>}
+ {s.sources.length > 0 && <div className="src">{s.sources.join(", ")}</div>}
+ </div>
+ ))}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
export default function PrintPage() {
const d = gatherEdition();
- const deg = d.weather?.units === "metric" ? "°C" : "°F";
+ const briefs = d.sections.find((s) => s.kind === "briefs");
+ const grid = d.sections.filter((s) => s.kind !== "briefs");
return (
<div className="paper">
@@ -255,26 +368,15 @@ <span>Personal Edition</span>
</div>
</div>
- {d.weather && (
- <p className="paper-lead">
- {d.weather.location}: {d.weather.current.temp}
- {deg}, {d.weather.current.text.toLowerCase()}.{" "}
- {d.weather.daily[0] && (
- <>
- High {d.weather.daily[0].max}
- {deg} · Low {d.weather.daily[0].min}
- {deg}
- {d.weather.daily[0].precipProb ? `, ${d.weather.daily[0].precipProb}% precip` : ""}.
- </>
- )}
- </p>
- )}
+ {d.weather && <WeatherHero w={d.weather} />}
- <div className="paper-body">
- {d.sections.map((s) => (
- <PaperSection key={s.id} section={s} />
+ <div className="paper-grid">
+ {grid.map((s) => (
+ <PaperCard key={s.id} section={s} />
))}
</div>
+
+ {briefs && <BriefsSpread section={briefs} />}
</div>
);
}
components/cards/CardBodies.tsx +29 −0
@@ -5,6 +5,7 @@ // and renders a compact card body. Unknown kinds fall back to JSON.
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 { GitHubPayload } from "@/lib/schemas/sources/github";
@@ -82,6 +83,32 @@ <span className="ml-1 text-xs" style={faint}>· {it.source}</span>
</li>
))}
</ul>
+ );
+}
+
+function BriefsBody({ p }: { p: BriefsPayload }) {
+ const live = p.categories.filter((c) => c.stories.length > 0);
+ if (live.length === 0) return <p className="text-sm" style={faint}>No briefs yet.</p>;
+ return (
+ <div className="space-y-3">
+ {live.map((c) => (
+ <div key={c.key}>
+ <h4 className="text-xs uppercase tracking-widest mb-1" style={muted}>
+ {c.name}
+ </h4>
+ <ul className="space-y-1">
+ {c.stories.map((s, i) => (
+ <li key={i} className="text-sm leading-snug">
+ <span className="font-medium">{s.title}</span>
+ {s.sources.length > 0 && (
+ <span className="ml-1 text-xs" style={faint}>· {s.sources[0]}</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
);
}
@@ -309,6 +336,8 @@ return <WeatherBody p={payload as WeatherPayload} row={shortTile} />;
case "news":
case "feeds":
return <NewsBody p={payload as NewsPayload} />;
+ case "briefs":
+ return <BriefsBody p={payload as BriefsPayload} />;
case "onthisday":
return <OnThisDayBody p={payload as OnThisDayPayload} />;
case "obsidian":
lib/edition-data.ts +1 −1
@@ -109,9 +109,9 @@ "calendar", // today's events
"todos", // agenda / tasks
"sports", // scores
"feeds", // my feeds
- "news", // headlines
"onthisday",
"obsidian", // from the vault
+ "briefs", // news briefs — rendered as the trailing full-page spread
];
function printRank(kind: SourceKind): number {
lib/schemas/source.ts +1 −0
@@ -5,6 +5,7 @@ // "todos" is local data (no fetcher) — the others pull from the network/disk.
export const SOURCE_KINDS = [
"weather",
"news",
+ "briefs",
"feeds",
"onthisday",
"sports",
lib/schemas/sources/briefs.ts +40 −0
@@ -0,0 +1,40 @@
+import { z } from "zod";
+
+// Screamer news briefs (screamer.humdrum.one). Each category is an AI-written
+// digest published as JSON at `${baseUrl}/data/${date}-${category}.json`.
+
+export const BriefCategory = z.object({
+ key: z.string(), // "politics" | "tech" | "sports" | "culture" | …
+ name: z.string(), // display name
+});
+
+export const BriefsConfig = z.object({
+ baseUrl: z.string().url().default("https://screamer.humdrum.one"),
+ categories: z.array(BriefCategory).default([
+ { key: "politics", name: "Politics" },
+ { key: "tech", name: "Technology" },
+ { key: "sports", name: "Sports" },
+ { key: "culture", name: "Culture" },
+ ]),
+ // How many past dates to walk back when today's brief isn't published yet.
+ lookbackDays: z.number().int().min(1).max(14).default(7),
+});
+export type BriefsConfig = z.infer<typeof BriefsConfig>;
+
+export const BriefStory = z.object({
+ title: z.string(),
+ summary: z.string(),
+ sources: z.array(z.string()).default([]),
+});
+
+export const BriefSection = z.object({
+ key: z.string(),
+ name: z.string(),
+ date: z.string().nullable(), // the date actually resolved for this category
+ stories: z.array(BriefStory),
+});
+
+export const BriefsPayload = z.object({
+ categories: z.array(BriefSection),
+});
+export type BriefsPayload = z.infer<typeof BriefsPayload>;
lib/schemas/sources/weather.ts +11 −0
@@ -16,6 +16,16 @@ text: z.string(),
precipProb: z.number().nullable(),
});
+// One of today's four day-parts (morning/noon/evening/night) — drives the
+// printed weather hero strip. Empty array when hourly data is unavailable.
+export const WeatherPart = z.object({
+ label: z.string(), // "Morning" | "Noon" | "Evening" | "Night"
+ temp: z.number(),
+ code: z.number(),
+ text: z.string(),
+ precipProb: z.number().nullable(),
+});
+
export const WeatherPayload = z.object({
location: z.string(),
units: z.enum(["metric", "imperial"]),
@@ -27,5 +37,6 @@ wind: z.number(),
humidity: z.number().nullable(),
}),
daily: z.array(WeatherDay),
+ parts: z.array(WeatherPart).default([]),
});
export type WeatherPayload = z.infer<typeof WeatherPayload>;
lib/sources/modules/briefs.ts +86 −0
@@ -0,0 +1,86 @@
+import type { SourceModule } from "../registry";
+import {
+ BriefsConfig,
+ BriefsPayload,
+ type BriefsConfig as Config,
+ type BriefsPayload as Payload,
+} from "@/lib/schemas/sources/briefs";
+
+// Shape of a single category file on screamer.humdrum.one.
+interface RawBrief {
+ date?: string;
+ category?: string;
+ stories?: Array<{
+ title?: string;
+ summary?: string;
+ articles?: Array<{ publication?: string }>;
+ }>;
+}
+
+// Newest-first list of dates that have at least one published brief.
+async function fetchDates(baseUrl: string, signal?: AbortSignal): Promise<string[]> {
+ try {
+ const res = await fetch(`${baseUrl}/data/index.json`, { signal });
+ if (!res.ok) return [];
+ const j = (await res.json()) as { dates?: string[] };
+ return Array.isArray(j.dates) ? j.dates : [];
+ } catch {
+ return [];
+ }
+}
+
+// Fetch the newest available brief for one category, walking back through dates
+// until one resolves (briefs publish at staggered times overnight, so today's
+// may not exist yet at 6 AM). Returns null if none found in the window.
+async function fetchCategory(
+ baseUrl: string,
+ key: string,
+ dates: string[],
+ signal?: AbortSignal,
+): Promise<{ date: string; raw: RawBrief } | null> {
+ for (const date of dates) {
+ try {
+ const res = await fetch(`${baseUrl}/data/${date}-${key}.json`, { signal });
+ if (!res.ok) continue;
+ const raw = (await res.json()) as RawBrief;
+ if (raw.stories && raw.stories.length > 0) return { date, raw };
+ } catch {
+ // try the next date
+ }
+ }
+ return null;
+}
+
+export const briefsModule: SourceModule<Config, Payload> = {
+ kind: "briefs",
+ label: "News Briefs",
+ keyless: true,
+ defaultRefreshSeconds: 3600,
+ configSchema: BriefsConfig,
+ payloadSchema: BriefsPayload,
+
+ isConfigured: (config) => config.categories.length > 0,
+
+ async fetch({ config, signal }) {
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
+ const allDates = await fetchDates(baseUrl, signal);
+ // index.json is newest-first; cap how far back we'll look per category.
+ const dates = (allDates.length ? allDates : []).slice(0, config.lookbackDays);
+
+ const categories = await Promise.all(
+ config.categories.map(async ({ key, name }) => {
+ const hit = await fetchCategory(baseUrl, key, dates, signal);
+ const stories = (hit?.raw.stories ?? []).map((s) => ({
+ title: s.title ?? "",
+ summary: s.summary ?? "",
+ sources: [
+ ...new Set((s.articles ?? []).map((a) => a.publication).filter((p): p is string => !!p)),
+ ],
+ }));
+ return { key, name, date: hit?.date ?? null, stories };
+ }),
+ );
+
+ return { categories };
+ },
+};
lib/sources/modules/index.ts +2 −0
@@ -2,6 +2,7 @@ import type { SourceModule } from "../registry";
import { weatherModule } from "./weather";
import { onThisDayModule } from "./onthisday";
import { newsModule } from "./news";
+import { briefsModule } from "./briefs";
import { feedsModule } from "./feeds";
import { obsidianModule } from "./obsidian";
import { githubModule } from "./github";
@@ -20,6 +21,7 @@ export const sourceModules: SourceModule[] = [
weatherModule as SourceModule,
onThisDayModule as SourceModule,
newsModule as SourceModule,
+ briefsModule as SourceModule,
feedsModule as SourceModule,
obsidianModule as SourceModule,
githubModule as SourceModule,
lib/sources/modules/weather.ts +40 −0
@@ -61,6 +61,10 @@ url.searchParams.set(
"daily",
"temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max",
);
+ url.searchParams.set(
+ "hourly",
+ "temperature_2m,weather_code,precipitation_probability",
+ );
url.searchParams.set("temperature_unit", tempUnit);
url.searchParams.set("wind_speed_unit", windUnit);
url.searchParams.set("timezone", "auto");
@@ -81,6 +85,12 @@ temperature_2m_max: number[];
temperature_2m_min: number[];
weather_code: number[];
precipitation_probability_max: (number | null)[];
+ };
+ hourly?: {
+ time: string[];
+ temperature_2m: number[];
+ weather_code: number[];
+ precipitation_probability: (number | null)[];
};
};
@@ -93,6 +103,35 @@ text: codeText(j.daily.weather_code[i]),
precipProb: j.daily.precipitation_probability_max[i] ?? null,
}));
+ // Today split into four day-parts for the printed weather strip. Open-Meteo
+ // hourly times are local ("auto" tz), formatted "YYYY-MM-DDTHH:00". Pick the
+ // hour nearest each anchor (8am / 12pm / 6pm / 10pm) within today only.
+ const today = j.daily.time[0];
+ const parts: Payload["parts"] = [];
+ if (j.hourly && today) {
+ const anchors: Array<[string, number]> = [
+ ["Morning", 8], ["Noon", 12], ["Evening", 18], ["Night", 22],
+ ];
+ const hours = j.hourly.time
+ .map((t, i) => ({ t, i, hr: Number(t.slice(11, 13)), day: t.slice(0, 10) }))
+ .filter((h) => h.day === today && Number.isFinite(h.hr));
+ for (const [label, anchor] of anchors) {
+ let best: (typeof hours)[number] | null = null;
+ for (const h of hours) {
+ if (!best || Math.abs(h.hr - anchor) < Math.abs(best.hr - anchor)) best = h;
+ }
+ if (!best) continue;
+ const code = j.hourly.weather_code[best.i];
+ parts.push({
+ label,
+ temp: Math.round(j.hourly.temperature_2m[best.i]),
+ code,
+ text: codeText(code),
+ precipProb: j.hourly.precipitation_probability[best.i] ?? null,
+ });
+ }
+ }
+
return {
location: place.label,
units,
@@ -104,6 +143,7 @@ wind: Math.round(j.current.wind_speed_10m),
humidity: j.current.relative_humidity_2m ?? null,
},
daily,
+ parts,
};
},
};
scripts/seed.ts +18 −0
@@ -43,6 +43,24 @@ ],
limit: 14,
},
},
+ // Screamer news briefs — replaces raw headlines in the paper. Keyless.
+ {
+ kind: "briefs",
+ label: "News Briefs",
+ enabled: true,
+ refreshSeconds: 3600,
+ size: "tall",
+ config: {
+ baseUrl: "https://screamer.humdrum.one",
+ categories: [
+ { key: "politics", name: "Politics" },
+ { key: "tech", name: "Technology" },
+ { key: "sports", name: "Sports" },
+ { key: "culture", name: "Culture" },
+ ],
+ lookbackDays: 7,
+ },
+ },
// Personal RSS — starts empty; add your own feed URLs in Settings → My Feeds.
{ kind: "feeds", label: "My Feeds", enabled: true, refreshSeconds: 1800, size: "tall", config: { feeds: [], limit: 14 } },
{ kind: "sports", label: "Scores", enabled: false, refreshSeconds: 600, size: "md", config: { leagues: ["basketball/nba"], teams: [] } },