Triage cards expand to show every report behind a story

Cards showed a hard-capped 5 members with no way to see the rest. They now
render every report, with the first three inline and the remainder behind a
native <details> disclosure -- so it works without JavaScript and stays keyboard
accessible. Each entry carries its publication time and outlet, and the summary
names how many reports across how many outlets. `e` expands the selected card.

Loaded inline rather than through a lazy endpoint because the data is small:
measured on this corpus the largest cluster is 23 members and multi-item
clusters hold 249 between them.

Also fixes a bug this introduced. The sources-list comprehension was byte
identical in triage() and digest_page(), so a str.replace() intended for one hit
both, and digest_page started reading a published_at column its query does not
select -- four tests went red. The assert only checked that the anchor EXISTS,
not that it is UNIQUE. Edits now assert the occurrence count. The digest query
gained the column properly, since a timestamp is useful there too.
This commit is contained in:
connor committed 2026-09-08 18:10:25 -04:00
1 parent 8ed007a75f
commit 90077aa3bd
3 files changed
+83 -10

No files matched your search

+36 -4
View File
@@ -34,7 +34,18 @@
.tag.lane-alert\:interest { color:var(--accent); border-color:currentColor; }
.tag.demote { color:var(--warn); border-color:currentColor; }
.srcs { list-style:none; padding:0; margin:.35rem 0 0; }
.srcs li { font-size:.83rem; margin:.12rem 0; color:var(--muted); }
.srcs li { font-size:.83rem; margin:.12rem 0; color:var(--muted);
display:flex; gap:.45rem; align-items:baseline; }
.when { font-variant-numeric:tabular-nums; font-size:.75rem; opacity:.75;
min-width:2.6rem; }
.outlet { font-size:.75rem; opacity:.8; white-space:nowrap; }
.more { margin-top:.15rem; }
.more summary { font-size:.78rem; color:var(--accent); cursor:pointer;
list-style:none; padding:.15rem 0; }
.more summary::-webkit-details-marker { display:none; }
.more summary::before { content:"\25B8 "; display:inline-block;
transition:transform .12s; }
.more[open] summary::before { transform:rotate(90deg); }
.srcs a { color:inherit; text-decoration:none; border-bottom:1px solid var(--rule); }
.acts { display:flex; gap:.4rem; margin-top:.5rem; }
button { font:inherit; font-size:.83rem; padding:.22rem .7rem; cursor:pointer;
@@ -79,11 +90,25 @@
{% if r.demote %}<span class="tag demote">demoted: {{ r.demote }}</span>{% endif %}
</div>
<ul class="srcs">
{% for s in r.sources %}
<li><a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.title }}</a>
— {{ s.name }}</li>
{% for s in r.sources[:3] %}
<li><span class="when">{{ s.when }}</span>
<a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.title }}</a>
<span class="outlet">{{ s.name }}</span></li>
{% endfor %}
</ul>
{% if r.sources|length > 3 %}
<details class="more">
<summary>{{ r.sources|length - 3 }} more report{{ '' if r.sources|length == 4 else 's' }}
across {{ r.sources|map(attribute='name')|unique|list|length }} outlets</summary>
<ul class="srcs">
{% for s in r.sources[3:] %}
<li><span class="when">{{ s.when }}</span>
<a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.title }}</a>
<span class="outlet">{{ s.name }}</span></li>
{% endfor %}
</ul>
</details>
{% endif %}
<div class="acts">
<button class="yes" data-kind="interested">Interested <kbd>i</kbd></button>
<button class="no" data-kind="not_interested">Not interested <kbd>n</kbd></button>
@@ -103,6 +128,7 @@
<span><kbd>i</kbd> interested</span>
<span><kbd>n</kbd> not interested</span>
<span><kbd>o</kbd> open</span>
<span><kbd>e</kbd> expand</span>
</div>
<script>
@@ -155,6 +181,12 @@
case 'i': if (card) label(card, 'interested'); break;
case 'n': if (card) label(card, 'not_interested'); break;
case 'o': if (card) card.querySelector('h2 a')?.click(); break;
case 'e': {
// Expand the selected card's other reports without reaching for the mouse.
const d = card?.querySelector('details.more');
if (d) d.open = !d.open;
break;
}
default: return;
}
ev.preventDefault();
+12 -6
View File
@@ -108,11 +108,15 @@ async def triage(request):
if show == "unlabelled" and s.cluster_id in labelled:
continue
d = decisions.get(s.cluster_id)
# Every member, not a slice: the largest cluster on this corpus is 23
# items and multi-item clusters hold 249 between them, so rendering the
# lot inline is cheaper than a lazy-loading endpoint would be to build.
members = con.execute(
"""SELECT i.title, i.url, so.name FROM cluster_members cm
"""SELECT i.title, i.url, i.published_at, so.name FROM cluster_members cm
JOIN items i ON i.id = cm.item_id
JOIN sources so ON so.id = i.source_id
WHERE cm.cluster_id = ? LIMIT 5""", (s.cluster_id,)).fetchall()
WHERE cm.cluster_id = ?
ORDER BY i.published_at DESC""", (s.cluster_id,)).fetchall()
rows.append({
"id": s.cluster_id, "headline": s.lead_title,
"topic": s.interest.block, "importance_topic": s.importance.block,
@@ -127,7 +131,8 @@ async def triage(request):
# nothing was recorded.
"tok_yes": sign(secret, s.cluster_id, "interested") if secret else "",
"tok_no": sign(secret, s.cluster_id, "not_interested") if secret else "",
"sources": [{"name": m["name"], "title": m["title"], "url": m["url"]}
"sources": [{"name": m["name"], "title": m["title"], "url": m["url"],
"when": (m["published_at"] or "")[11:16]}
for m in members],
})
if len(rows) >= limit:
@@ -364,15 +369,16 @@ async def digest_page(request):
stories = []
for r in rows:
members = con.execute(
"""SELECT i.title, i.url, s.name FROM cluster_members cm
"""SELECT i.title, i.url, i.published_at, s.name FROM cluster_members cm
JOIN items i ON i.id = cm.item_id
JOIN sources s ON s.id = i.source_id
WHERE cm.cluster_id = ? LIMIT 6""",
WHERE cm.cluster_id = ? ORDER BY i.published_at DESC LIMIT 6""",
(r["cluster_id"],),
).fetchall()
stories.append({
"id": r["cluster_id"], "headline": r["headline"], "lane": r["lane"],
"sources": [{"name": m["name"], "title": m["title"], "url": m["url"]}
"sources": [{"name": m["name"], "title": m["title"], "url": m["url"],
"when": (m["published_at"] or "")[11:16]}
for m in members],
})
+35
View File
@@ -267,3 +267,38 @@ def test_palette_is_defined_once_from_the_shared_partial(client, path):
html = c.get(path).text
assert html.count(".sitenav {") == 1
assert html.count("--accent:#7c3aed") == 1, "light palette declared more than once"
def test_triage_cards_expand_to_every_report(tmp_path, monkeypatch):
"""A card must show all the reports behind a story, not a truncated slice."""
import numpy as np
from datetime import UTC, datetime
monkeypatch.setenv("NEWS_DB", str(tmp_path / "e.db"))
monkeypatch.setenv("NEWS_FEEDBACK_SECRET", SECRET)
con = store.connect(tmp_path / "e.db")
now = datetime.now(UTC)
for n in range(7):
store.upsert_source(con, id=f"s{n}", name=f"Outlet{n}", url=f"http://s{n}",
kind="rss", bucket=f"b{n}", weight=1.0, prominence=0,
enabled=1, poll_interval_secs=900)
con.execute("""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
published_at,first_seen_at,content_hash)
VALUES (?,?,?,'',?,?,?,?,?)""",
(f"i{n}", f"s{n}", f"Report number {n}", f"http://x/{n}",
f"http://x/{n}", now.isoformat(), now.isoformat(), f"h{n}"))
con.execute("""INSERT INTO clusters (id,centroid,size,first_seen_at,last_seen_at,lead_item_id)
VALUES ('big',?,7,?,?,'i0')""",
(store.to_blob(np.zeros(384, dtype="float32")),
now.isoformat(), now.isoformat()))
for n in range(7):
con.execute("INSERT INTO cluster_members VALUES ('big',?,?)", (f"i{n}", now.isoformat()))
from news import web
web._VECTORS = None
html = TestClient(web.app).get("/").text
for n in range(7):
assert f"Report number {n}" in html, f"report {n} missing from the card"
assert "<details" in html and "4 more reports" in html
assert "across 7 outlets" in html