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:
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user