news-digest: add world + local headlines sections

Two new dense headline rails above the existing reddit/tech cards.
Designed for high-volume "what happened" coverage where the title
is the deliverable — no LLM summarization, ~15 items per section,
6-column-collapsing grid (title / source / time).

Digest pipeline:
  * fetch_miniflux_headlines(category) — flat list per category, dedup
    by lowercased title (different feeds syndicate the same wire stories)
  * 8h look-back window (vs 12h for tech/reddit) since headlines move
    faster
  * cap of 15 per section (DIGEST_MINIFLUX_HEADLINES_MAX)

Frontend:
  * .headline element parallels .item for the hide-button machinery
    (both have data-id, both honored by app.js)
  * dense 3-col layout collapses to 1-col on narrow screens
  * jumpnav now numbers world=01, local=02, reddit=03, tech=04

Setup:
  * seed-headlines.py — one-shot script (lives in the image at
    /app/seed-headlines.py). Creates the World + Local categories in
    miniflux, subscribes a curated feed list, and renames each feed
    to a short display title (BBC vs "BBC News", "LA Times" vs "California").
    Idempotent — reruns only add new feeds.
  * Default world: BBC, NPR, Al Jazeera. Default local: LA Times Local,
    LA Times CA, Voice of OC. (OC Register blocks miniflux; left out.)
  * entrypoint.sh now syncs templates/{style.css,app.js,favicon.svg}
    to /output on container start so frontend asset updates land
    without a manual copy after rebuild.
This commit is contained in:
vh
2026-04-28 11:14:15 -07:00
parent d2ed7671d7
commit 9bdb41ea6a
9 changed files with 391 additions and 13 deletions
+6 -5
View File
@@ -30,9 +30,10 @@
const TRAY_COUNT = document.getElementById("hidden-tray-count");
const TRAY_TOGGLE = TRAY && TRAY.querySelector(".hidden-tray-toggle");
/** Map<id, HTMLElement> — every .item on this page, keyed by data-id */
/** Map<id, HTMLElement> — every hideable element on this page, keyed by
* data-id. Both .item (cards) and .headline (compact rows) qualify. */
const itemsById = new Map();
document.querySelectorAll(".item[data-id]").forEach((el) => {
document.querySelectorAll(".item[data-id], .headline[data-id]").forEach((el) => {
itemsById.set(el.dataset.id, el);
});
@@ -76,7 +77,7 @@
src.classList.toggle("is-empty", visible === 0);
});
document.querySelectorAll(".desk").forEach((desk) => {
const visibleItems = desk.querySelectorAll(".item:not(.is-hidden)").length;
const visibleItems = desk.querySelectorAll(".item:not(.is-hidden), .headline:not(.is-hidden)").length;
const badge = desk.querySelector(".desk-count");
if (badge) badge.textContent = `${visibleItems} items`;
desk.classList.toggle("is-empty", visibleItems === 0);
@@ -130,7 +131,7 @@
}
function titleOf(el) {
const t = el.querySelector(".item-title");
const t = el.querySelector(".item-title, .headline-title");
return (t && t.textContent.trim()) || "(untitled)";
}
@@ -187,7 +188,7 @@
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".item-hide");
if (!btn) return;
const item = btn.closest(".item[data-id]");
const item = btn.closest("[data-id]");
if (!item) return;
ev.preventDefault();
hideItem(item.dataset.id);