Dashboard: content + source-health; per-viewer local dates

* Date fix: introduce GOODNEWS_TZ (goodnews/localtime.py) so the brief's "today"
  rolls over in a pinned zone (Eastern) instead of UTC — robust to host-clock
  resets. The home page now formats the brief's date in each VISITOR's local
  timezone (from its UTC freshness stamp), so nobody ever sees "tomorrow."

* Admin "Content served": articles live, fresh (7d), ingested (24h), summaries,
  active sources, today's brief size — queries.content_stats().

* Admin "Source health": per active source, the failure streak, last error,
  accepted contribution, and computed next-poll time (so backoff / "resting
  until" is visible), via queries.source_health() reusing the feeds backoff
  math. Failing sources sort to the top; times render in the viewer's zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-06 19:34:22 +00:00
parent 452e5a3fe4
commit d87347b032
7 changed files with 227 additions and 6 deletions
+12 -1
View File
@@ -76,6 +76,17 @@
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
const humanize = (s) => (s || '').replace(/-/g, ' '); const humanize = (s) => (s || '').replace(/-/g, ' ');
// Show the brief's date in the VIEWER's local timezone (from its UTC freshness
// stamp), so everyone sees their own correct date — never "tomorrow."
function localDateLabel(b) {
const ts = b?.generated_at;
if (ts) {
const d = new Date(ts.replace(' ', 'T') + 'Z');
if (!isNaN(d)) return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
}
return b?.brief_date ?? '';
}
let filtersOn = $derived(prefsActive()); let filtersOn = $derived(prefsActive());
let currentMood = $derived(moods.find((m) => m.key === selected)); let currentMood = $derived(moods.find((m) => m.key === selected));
let currentTopic = $derived(topics.find((t) => t.key === selected)); let currentTopic = $derived(topics.find((t) => t.key === selected));
@@ -89,7 +100,7 @@
: (currentMood?.label ?? cap(currentTopic?.key) ?? '') : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
); );
let viewSubtitle = $derived( let viewSubtitle = $derived(
selected === 'today' ? (brief?.brief_date ?? '') selected === 'today' ? localDateLabel(brief)
: currentTag ? (tagFamily?.description ?? '') : currentTag ? (tagFamily?.description ?? '')
: (currentMood?.description ?? currentTopic?.description ?? '') : (currentMood?.description ?? currentTopic?.description ?? '')
); );
+60
View File
@@ -26,6 +26,15 @@
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
catch { return s; } catch { return s; }
} }
// A UTC "YYYY-MM-DD HH:MM:SS" stamp → the viewer's local date + time.
function fwhen(s) {
if (!s) return 'now';
const d = new Date(s.replace(' ', 'T') + 'Z');
if (isNaN(d)) return s;
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
let healthy = $derived((stats?.sources ?? []).filter((s) => !s.failures).length);
let ailing = $derived((stats?.sources ?? []).filter((s) => s.failures > 0).length);
const SHARE_LABEL = { const SHARE_LABEL = {
share_ub: 'Copied UB link', share_ub: 'Copied UB link',
@@ -62,6 +71,20 @@
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div> <div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
</div> </div>
{#if stats.content}
<section>
<h2>Content served</h2>
<div class="cards">
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (last 7d)</span></div>
<div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
<div class="stat"><span class="n">{stats.content.summaries}</span><span class="l">Summaries</span></div>
<div class="stat"><span class="n">{stats.content.active_sources}</span><span class="l">Active sources</span></div>
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
</div>
</section>
{/if}
<section> <section>
<h2>Accounts</h2> <h2>Accounts</h2>
<div class="cards"> <div class="cards">
@@ -177,6 +200,29 @@
{:else}<p class="muted">No data yet.</p>{/if} {:else}<p class="muted">No data yet.</p>{/if}
</section> </section>
{#if stats.sources?.length}
<section>
<h2>Source health</h2>
<p class="sub2">{healthy} healthy · {ailing} backing off · {stats.sources.length} active</p>
<ul class="srclist">
{#each stats.sources as s (s.id)}
<li class:warn={s.failures > 0}>
<span class="sname" title={s.last_error || ''}>{s.name}</span>
<span class="sserved">{s.served}</span>
<span class="sstatus">
{#if s.failures > 0}
{s.failures} fail{s.failures > 1 ? 's' : ''} · resting until {fwhen(s.next_due_at)}
{:else}
✓ next poll {fwhen(s.next_due_at)}
{/if}
</span>
</li>
{/each}
</ul>
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
</section>
{/if}
<section> <section>
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2> <h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
{#if feedback.length} {#if feedback.length}
@@ -243,6 +289,20 @@
.legend .sw.visits { background: var(--accent-soft); } .legend .sw.visits { background: var(--accent-soft); }
.legend .sw.opens { background: var(--accent); } .legend .sw.opens { background: var(--accent); }
.sub2 { color: var(--muted); font-size: 0.84rem; margin: 0 0 12px; }
.legend2 { color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; font-style: italic; }
ul.srclist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
ul.srclist li {
display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: 12px;
padding: 7px 10px; border-radius: 8px; font-size: 0.86rem;
}
ul.srclist li:nth-child(odd) { background: var(--surface); }
ul.srclist li.warn { background: #fbeaea; }
ul.srclist .sname { color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
ul.srclist .sserved { color: var(--muted); font-variant-numeric: tabular-nums; }
ul.srclist .sstatus { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
ul.srclist li.warn .sstatus { color: #9a3b3b; }
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; } .count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
+2 -2
View File
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from datetime import date
from .localtime import local_today
from .paywall import is_paywalled from .paywall import is_paywalled
@@ -13,7 +13,7 @@ def build_daily_brief(
replace: bool = False, replace: bool = False,
window_days: int = 3, window_days: int = 3,
) -> int: ) -> int:
target_date = brief_date or date.today().isoformat() target_date = brief_date or local_today()
# Compose the selection first so we can tell whether anything actually # Compose the selection first so we can tell whether anything actually
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked # changed. A calm daily brief shouldn't repeatedly hand the reader a locked
+3 -3
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import argparse import argparse
import os import os
import sqlite3 import sqlite3
from datetime import date
from pathlib import Path from pathlib import Path
from .briefs import build_daily_brief, show_brief from .briefs import build_daily_brief, show_brief
from .db import connect, init_db from .db import connect, init_db
from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
from .enrich import enrich_brief_images from .enrich import enrich_brief_images
from .summarize import generate_summary, get_summary from .summarize import generate_summary, get_summary
@@ -300,7 +300,7 @@ def main() -> None:
replace=args.replace, replace=args.replace,
) )
print(f"Built brief {brief_id}") print(f"Built brief {brief_id}")
bdate = args.date or date.today().isoformat() bdate = args.date or local_today()
found = enrich_brief_images(conn, bdate) found = enrich_brief_images(conn, bdate)
if found: if found:
print(f"Enriched {found} hero image(s)") print(f"Enriched {found} hero image(s)")
@@ -445,7 +445,7 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
print(f"dedup: skipped ({exc})") print(f"dedup: skipped ({exc})")
if not args.no_brief: if not args.no_brief:
today = date.today().isoformat() today = local_today()
try: try:
brief_id = build_daily_brief(conn, brief_date=today, limit=7, replace=True) brief_id = build_daily_brief(conn, brief_date=today, limit=7, replace=True)
found = enrich_brief_images(conn, today) found = enrich_brief_images(conn, today)
+32
View File
@@ -0,0 +1,32 @@
"""The site's canonical local day.
There is one daily brief, so "today" must resolve to a single timezone — not the
host clock (which is UTC on the server and was silently reset on a rebuild).
GOODNEWS_TZ pins it explicitly (default UTC) so a rebuild can't reintroduce the
"brief shows tomorrow's date in the evening" bug. This governs when the brief
rolls over and the canonical date on the server-rendered /today page; each
visitor's *displayed* date is formatted in their own browser timezone.
"""
from __future__ import annotations
import os
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def site_tz() -> ZoneInfo:
name = os.environ.get("GOODNEWS_TZ", "UTC")
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("UTC")
def local_now() -> datetime:
return datetime.now(site_tz())
def local_today() -> str:
"""The site's current calendar date (ISO yyyy-mm-dd) in GOODNEWS_TZ."""
return local_now().date().isoformat()
+69
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import sqlite3 import sqlite3
from .feeds import MAX_BACKOFF_MINUTES
# Composite ranking used everywhere a "best first" order is needed. Kept as one # Composite ranking used everywhere a "best first" order is needed. Kept as one
# expression so brief, category feeds, and the API all rank identically. # expression so brief, category feeds, and the API all rank identically.
RANK_SCORE_SQL = ( RANK_SCORE_SQL = (
@@ -197,6 +199,71 @@ def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[di
return [dict(row) for row in rows] return [dict(row) for row in rows]
def content_stats(conn: sqlite3.Connection) -> dict:
"""Corpus / serving health for the dashboard: how much good news is live."""
def scalar(sql, params=()):
return conn.execute(sql, params).fetchone()[0] or 0
served = scalar(
"SELECT COUNT(*) FROM article_scores s JOIN articles a ON a.id=s.article_id "
"WHERE s.accepted=1 AND a.duplicate_of IS NULL"
)
accepted_7d = scalar(
"SELECT COUNT(*) FROM article_scores s JOIN articles a ON a.id=s.article_id "
"WHERE s.accepted=1 AND a.duplicate_of IS NULL "
"AND COALESCE(a.published_at, a.discovered_at) >= datetime('now','-7 days')"
)
brief = conn.execute(
"SELECT brief_date, (SELECT COUNT(*) FROM daily_brief_items WHERE brief_id=daily_briefs.id) n "
"FROM daily_briefs ORDER BY brief_date DESC LIMIT 1"
).fetchone()
return {
"served": served,
"total": scalar("SELECT COUNT(*) FROM articles"),
"rejected": scalar("SELECT COUNT(*) FROM article_scores WHERE accepted=0"),
"accepted_7d": accepted_7d,
"added_24h": scalar("SELECT COUNT(*) FROM articles WHERE discovered_at >= datetime('now','-1 day')"),
"summaries": scalar("SELECT COUNT(*) FROM article_summaries"),
"active_sources": scalar("SELECT COUNT(*) FROM sources WHERE active=1"),
"total_sources": scalar("SELECT COUNT(*) FROM sources"),
"latest_brief_date": brief["brief_date"] if brief else None,
"latest_brief_size": brief["n"] if brief else 0,
}
def source_health(conn: sqlite3.Connection) -> list[dict]:
"""Per active source: failure streak, last error, accepted contribution, and
the computed next-poll time (so the backoff/'resting until' state is visible).
next_due_at = last attempt + MIN(cap, interval * (1 + consecutive_failures)),
mirroring feeds.due_source_rows; NULL last attempt means "due now".
"""
rows = conn.execute(
"""
SELECT
s.id, s.name, s.default_category AS category, s.active,
s.consecutive_failures AS failures,
s.poll_interval_minutes AS interval_minutes,
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
(SELECT MAX(r.finished_at) FROM ingest_runs r
WHERE r.source_id = s.id AND r.finished_at IS NOT NULL) AS last_attempt,
(SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id
WHERE a.source_id = s.id AND sc.accepted = 1 AND a.duplicate_of IS NULL) AS served,
datetime(
(SELECT MAX(r.finished_at) FROM ingest_runs r
WHERE r.source_id = s.id AND r.finished_at IS NOT NULL),
'+' || MIN(?, s.poll_interval_minutes * (1 + s.consecutive_failures)) || ' minutes'
) AS next_due_at
FROM sources s
WHERE s.active = 1
ORDER BY s.consecutive_failures DESC, served DESC, s.name
""",
(MAX_BACKOFF_MINUTES,),
).fetchall()
return [dict(r) for r in rows]
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
"""Aggregate, non-personal usage stats for the admin dashboard.""" """Aggregate, non-personal usage stats for the admin dashboard."""
since = f"-{days} days" since = f"-{days} days"
@@ -296,6 +363,8 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
return { return {
"days": days, "days": days,
"content": content_stats(conn),
"sources": source_health(conn),
"visitors": visitors, "visitors": visitors,
"returning": loyalty.get("returning", 0), "returning": loyalty.get("returning", 0),
"once": loyalty.get("once", 0), "once": loyalty.get("once", 0),
+49
View File
@@ -0,0 +1,49 @@
from goodnews.db import connect, init_db
from goodnews import queries, localtime
def test_site_tz_from_env_and_fallback(monkeypatch):
monkeypatch.setenv("GOODNEWS_TZ", "America/New_York")
assert localtime.site_tz().key == "America/New_York"
assert localtime.local_now().utcoffset() is not None # tz-aware
# A bogus zone must not crash — fall back to UTC.
monkeypatch.setenv("GOODNEWS_TZ", "Not/AZone")
assert localtime.site_tz().key == "UTC"
def _seed(c):
c.execute("INSERT INTO sources (id,name,feed_url,active,default_category) VALUES (1,'Good','http://s/1',1,'science')")
c.execute("INSERT INTO sources (id,name,feed_url,active,consecutive_failures,poll_interval_minutes) "
"VALUES (2,'Flaky','http://s/2',1,5,60)")
# source 1: two accepted (one duplicate → not served), one rejected
for aid, dup in [(1, None), (2, None), (3, 1)]:
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,duplicate_of,published_at) "
"VALUES (?,1,?,?,?,?,datetime('now'))", (aid, f"u{aid}", f"T{aid}", f"h{aid}", dup))
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (1,1)")
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (2,0)")
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (3,1)") # duplicate → excluded
c.commit()
def test_content_stats_counts_served_excluding_duplicates(tmp_path):
c = connect(str(tmp_path / "t.db")); init_db(c); _seed(c)
cs = queries.content_stats(c)
assert cs["served"] == 1 # only article 1 (2 is rejected, 3 is a duplicate)
assert cs["total"] == 3
assert cs["rejected"] == 1
assert cs["active_sources"] == 2
def test_source_health_orders_failing_first_and_computes_next_due(tmp_path):
c = connect(str(tmp_path / "t.db")); init_db(c); _seed(c)
c.execute("INSERT INTO ingest_runs (source_id, finished_at, status) "
"VALUES (2, datetime('now','-30 minutes'), 'failed')")
c.commit()
sh = queries.source_health(c)
assert [s["name"] for s in sh][0] == "Flaky" # failing source floats to top
flaky = next(s for s in sh if s["name"] == "Flaky")
assert flaky["failures"] == 5
# next_due = last_attempt + 60*(1+5)=360 min; attempt was 30m ago → still resting
assert flaky["next_due_at"] is not None
good = next(s for s in sh if s["name"] == "Good")
assert good["served"] == 1 and good["next_due_at"] is None # never attempted → due now