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:
1 parent
8ed007a75f
commit
90077aa3bd
3 files changed
+83
-10
No files matched your search
@@ -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
@@ -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],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user