From d87347b032bd418d6a635f5615312fdbfb74ed67 Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 6 Jun 2026 19:34:22 +0000 Subject: [PATCH] Dashboard: content + source-health; per-viewer local dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- frontend/src/routes/+page.svelte | 13 ++++- frontend/src/routes/admin/+page.svelte | 60 ++++++++++++++++++++++ goodnews/briefs.py | 4 +- goodnews/cli.py | 6 +-- goodnews/localtime.py | 32 ++++++++++++ goodnews/queries.py | 69 ++++++++++++++++++++++++++ tests/test_dashboard.py | 49 ++++++++++++++++++ 7 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 goodnews/localtime.py create mode 100644 tests/test_dashboard.py diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index db7ab32..c43e413 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -76,6 +76,17 @@ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s); 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 currentMood = $derived(moods.find((m) => m.key === selected)); let currentTopic = $derived(topics.find((t) => t.key === selected)); @@ -89,7 +100,7 @@ : (currentMood?.label ?? cap(currentTopic?.key) ?? '') ); let viewSubtitle = $derived( - selected === 'today' ? (brief?.brief_date ?? '') + selected === 'today' ? localDateLabel(brief) : currentTag ? (tagFamily?.description ?? '') : (currentMood?.description ?? currentTopic?.description ?? '') ); diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index ab6f393..cd15f8e 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -26,6 +26,15 @@ try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } 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 = { share_ub: 'Copied UB link', @@ -62,6 +71,20 @@
{stats.visitors.d30}Last 30 days
+ {#if stats.content} +
+

Content served

+
+
{stats.content.served}Articles live
+
{stats.content.accepted_7d}Fresh (last 7d)
+
{stats.content.added_24h}Ingested (24h)
+
{stats.content.summaries}Summaries
+
{stats.content.active_sources}Active sources
+
{stats.content.latest_brief_size}In today's brief
+
+
+ {/if} +

Accounts

@@ -177,6 +200,29 @@ {:else}

No data yet.

{/if}
+ {#if stats.sources?.length} +
+

Source health

+

{healthy} healthy · {ailing} backing off · {stats.sources.length} active

+
    + {#each stats.sources as s (s.id)} +
  • 0}> + {s.name} + {s.served} + + {#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} + +
  • + {/each} +
+

“served” = accepted articles live from that source · times in your local zone

+
+ {/if} +

Feedback {#if feedback.length}({feedback.length}){/if}

{#if feedback.length} @@ -243,6 +289,20 @@ .legend .sw.visits { background: var(--accent-soft); } .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; } 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; } diff --git a/goodnews/briefs.py b/goodnews/briefs.py index f09842c..f000bbb 100644 --- a/goodnews/briefs.py +++ b/goodnews/briefs.py @@ -1,8 +1,8 @@ from __future__ import annotations import sqlite3 -from datetime import date +from .localtime import local_today from .paywall import is_paywalled @@ -13,7 +13,7 @@ def build_daily_brief( replace: bool = False, window_days: int = 3, ) -> 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 # changed. A calm daily brief shouldn't repeatedly hand the reader a locked diff --git a/goodnews/cli.py b/goodnews/cli.py index 991b5ed..3d935cd 100644 --- a/goodnews/cli.py +++ b/goodnews/cli.py @@ -3,11 +3,11 @@ from __future__ import annotations import argparse import os import sqlite3 -from datetime import date from pathlib import Path from .briefs import build_daily_brief, show_brief from .db import connect, init_db +from .localtime import local_today from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup from .enrich import enrich_brief_images from .summarize import generate_summary, get_summary @@ -300,7 +300,7 @@ def main() -> None: replace=args.replace, ) 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) if found: 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})") if not args.no_brief: - today = date.today().isoformat() + today = local_today() try: brief_id = build_daily_brief(conn, brief_date=today, limit=7, replace=True) found = enrich_brief_images(conn, today) diff --git a/goodnews/localtime.py b/goodnews/localtime.py new file mode 100644 index 0000000..853d1d1 --- /dev/null +++ b/goodnews/localtime.py @@ -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() diff --git a/goodnews/queries.py b/goodnews/queries.py index 6262f9a..8ab8465 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -9,6 +9,8 @@ from __future__ import annotations import sqlite3 +from .feeds import MAX_BACKOFF_MINUTES + # Composite ranking used everywhere a "best first" order is needed. Kept as one # expression so brief, category feeds, and the API all rank identically. 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] +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: """Aggregate, non-personal usage stats for the admin dashboard.""" since = f"-{days} days" @@ -296,6 +363,8 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: return { "days": days, + "content": content_stats(conn), + "sources": source_health(conn), "visitors": visitors, "returning": loyalty.get("returning", 0), "once": loyalty.get("once", 0), diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..b9fe091 --- /dev/null +++ b/tests/test_dashboard.py @@ -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