news-digest: real article summaries + per-desk collapse
Two upgrades to make the digest actually readable:
1) Article-grounded 2-3 sentence summaries (everywhere)
The old prompt got just the title + miniflux's content excerpt,
which for HN/Lobsters/wire feeds is barely more than the title
itself — so summaries paraphrased the title and added nothing.
Now every URL gets fetched and main-content-extracted via
trafilatura on a parallel pre-pass (10 workers, ~15s for ~50
URLs). Extracted text caches to /output/.article-cache.json with
a 7-day TTL so repeat runs in the same window don't re-pull.
Headlines also get summarized now — one batched LLM call per
category (world / local). Rendered as a paragraph below the
title with source + time on the right rail.
Prompt rewrites tell the model to pull names/numbers/places
from the body and explicitly forbid restating the title.
Result: real specifics ("71% saw no pay increase globally",
"third time in less than two weeks", "Islamabad and Moscow
intermediaries") instead of title paraphrase.
2) Per-desk collapse buttons
Chevron next to .desk-count toggles a .is-collapsed class.
Collapsed state is per-device (localStorage by section id) since
collapse is a viewing preference, not content state.
This commit is contained in:
@@ -201,6 +201,38 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── desk collapse (per-device, localStorage) ───────────────────── */
|
||||
// Stored as a JSON object {deskId: true} of collapsed desks. Per
|
||||
// device by design — collapse is a viewing preference, not content
|
||||
// state, so no server roundtrip.
|
||||
const COLLAPSE_KEY = "digest:collapsed-desks";
|
||||
function readCollapsed() {
|
||||
try { return JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "{}"); }
|
||||
catch (_) { return {}; }
|
||||
}
|
||||
function writeCollapsed(obj) {
|
||||
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(obj)); } catch (_) {}
|
||||
}
|
||||
function applyCollapseState() {
|
||||
const state = readCollapsed();
|
||||
document.querySelectorAll(".desk[id]").forEach((desk) => {
|
||||
desk.classList.toggle("is-collapsed", !!state[desk.id]);
|
||||
});
|
||||
}
|
||||
applyCollapseState();
|
||||
document.addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest(".desk-collapse");
|
||||
if (!btn) return;
|
||||
const desk = btn.closest(".desk[id]");
|
||||
if (!desk) return;
|
||||
ev.preventDefault();
|
||||
desk.classList.toggle("is-collapsed");
|
||||
const state = readCollapsed();
|
||||
if (desk.classList.contains("is-collapsed")) state[desk.id] = true;
|
||||
else delete state[desk.id];
|
||||
writeCollapsed(state);
|
||||
});
|
||||
|
||||
/* ── initial paint ──────────────────────────────────────────────── */
|
||||
|
||||
apiGetHidden().then((hidden) => {
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<h2 class="desk-title">World</h2>
|
||||
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "8" or "8" }} hours · headlines</span>
|
||||
<span class="desk-count">{{ world_total }} items</span>
|
||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
||||
</header>
|
||||
<ol class="headlines-list">
|
||||
{% for h in world_headlines %}
|
||||
@@ -82,6 +83,9 @@
|
||||
</h3>
|
||||
<span class="headline-source">{{ h.source }}</span>
|
||||
<span class="headline-time">{{ h.posted_at|humanago }}</span>
|
||||
{% if h.tldr %}
|
||||
<p class="headline-tldr">{{ h.tldr }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -96,6 +100,7 @@
|
||||
<h2 class="desk-title">Local</h2>
|
||||
<span class="desk-sub">orange county · greater LA · last 8 hours</span>
|
||||
<span class="desk-count">{{ local_total }} items</span>
|
||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
||||
</header>
|
||||
<ol class="headlines-list">
|
||||
{% for h in local_headlines %}
|
||||
@@ -106,6 +111,9 @@
|
||||
</h3>
|
||||
<span class="headline-source">{{ h.source }}</span>
|
||||
<span class="headline-time">{{ h.posted_at|humanago }}</span>
|
||||
{% if h.tldr %}
|
||||
<p class="headline-tldr">{{ h.tldr }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -120,6 +128,7 @@
|
||||
<h2 class="desk-title">Reddit</h2>
|
||||
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered</span>
|
||||
<span class="desk-count">{{ reddit_total }} items</span>
|
||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
||||
</header>
|
||||
|
||||
{% for src in reddit_sources %}
|
||||
@@ -189,6 +198,7 @@
|
||||
<h2 class="desk-title">Tech Feeds</h2>
|
||||
<span class="desk-sub">non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours</span>
|
||||
<span class="desk-count">{{ tech_total }} items</span>
|
||||
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
|
||||
</header>
|
||||
|
||||
{% for src in tech_sources %}
|
||||
|
||||
@@ -305,6 +305,47 @@ a:hover { color: var(--accent); }
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Per-desk collapse toggle. Sits on the right edge of the desk header,
|
||||
clicking collapses the body to a single-line summary. State is
|
||||
persisted in localStorage by section id (#world, #local, etc), so
|
||||
each device remembers per-user preference. */
|
||||
.desk-collapse {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
margin-left: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--fg-dim);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.18s ease;
|
||||
}
|
||||
.desk-collapse:hover,
|
||||
.desk-collapse:focus-visible {
|
||||
color: var(--accent);
|
||||
border-color: var(--rule-bright);
|
||||
background: var(--bg-elev);
|
||||
outline: none;
|
||||
}
|
||||
.desk.is-collapsed .desk-collapse {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
/* When collapsed, hide everything inside the desk EXCEPT the header.
|
||||
The .desk-head is direct child; .source / .headlines-list etc are
|
||||
the rest of the body. */
|
||||
.desk.is-collapsed > :not(.desk-head) {
|
||||
display: none;
|
||||
}
|
||||
/* Tighten the header bottom-border in collapsed state — no items below
|
||||
means the heavy underline looks weird floating alone. */
|
||||
.desk.is-collapsed .desk-head {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--rule);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
per-source block
|
||||
------------------------------------------------------------------------- */
|
||||
@@ -509,11 +550,25 @@ a:hover { color: var(--accent); }
|
||||
position: relative; /* anchors the absolute-positioned × button */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
column-gap: 16px;
|
||||
row-gap: 4px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.headline-title { grid-column: 1 / 2; grid-row: 1; }
|
||||
.headline-source { grid-column: 2 / 3; grid-row: 1; }
|
||||
.headline-time { grid-column: 3 / 4; grid-row: 1; }
|
||||
.headline-tldr {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 2;
|
||||
margin: 4px 0 2px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.headline:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -855,16 +910,20 @@ a:hover { color: var(--accent); }
|
||||
.item-hide { opacity: 1; width: 28px; height: 28px; font-size: 20px; }
|
||||
.hidden-tray { padding: 12px 14px; }
|
||||
|
||||
/* Headlines: stack source + time below title on narrow screens so
|
||||
the title gets the full width. */
|
||||
/* Headlines: stack title / source / time / tldr vertically on narrow
|
||||
screens so the title gets the full width. */
|
||||
.headline {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 10px 0;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.headline-title,
|
||||
.headline-source,
|
||||
.headline-time {
|
||||
.headline-time,
|
||||
.headline-tldr {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: auto;
|
||||
}
|
||||
.headline-time { text-align: left; }
|
||||
.headline-tldr { font-size: 13.5px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user