diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index ad8cd71..aa5c10f 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -9,6 +9,18 @@ let feedback = $state([]); let error = $state(''); + // Analytics window (days). Windowed metrics — visitors, retention, funnel, + // sharing, daily trend, top lists — rescale to this; corpus totals don't. + let range = $state(30); + async function loadStats() { + stats = await getJSON('/api/admin/stats?days=' + range); + } + async function setRange(n) { + if (n === range) return; + range = n; + try { await loadStats(); } catch { error = "Couldn't load stats."; } + } + onMount(async () => { if (!auth.ready) await refresh(); if (!auth.user || !auth.user.is_admin) { @@ -16,7 +28,7 @@ return; } try { - stats = await getJSON('/api/admin/stats'); + await loadStats(); feedback = await getJSON('/api/admin/feedback'); } catch { error = "Couldn't load stats."; @@ -151,6 +163,15 @@ {/each} + {#if section === 'overview' || section === 'content' || section === 'audience'} +
+ Window + {#each [7, 30, 90] as d (d)} + + {/each} +
+ {/if} + {#if section === 'overview'} {#if (stats.attention ?? []).length} @@ -167,7 +188,7 @@
{stats.visitors.today}Visitors today
{stats.visitors.d7}Visitors (7d)
-
{stats.visitors.d30}Visitors (30d)
+
{stats.visitors.d30}Visitors ({range}d)
{stats.content.served}Articles live
{stats.content.accepted_7d}Fresh (7d)
{stats.content.latest_brief_size}In today's brief
@@ -293,11 +314,11 @@
{stats.visitors.today}Today
{stats.visitors.d7}Last 7 days
-
{stats.visitors.d30}Last 30 days
+
{stats.visitors.d30}Last {range} days
-

Returning visitors (30d)

+

Returning visitors ({range}d)

{stats.retention.new}New (1 day)
{stats.retention['2-3']}2–3 days
@@ -461,6 +482,16 @@ .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; } + /* Analytics window picker */ + .rangepick { display: flex; align-items: center; gap: 7px; margin: 0 0 18px; } + .rangepick .rl { font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 2px; } + .rangepick .chip { + border: 1px solid var(--line); background: var(--surface); color: var(--ink); + border-radius: 999px; padding: 4px 12px; font-size: 0.8rem; cursor: pointer; + } + .rangepick .chip:hover { border-color: var(--accent); } + .rangepick .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; } + /* Filter chips (sources + feedback) */ .filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; } .filterchips .chip { diff --git a/goodnews/api.py b/goodnews/api.py index 89a980f..ac242a3 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -848,10 +848,11 @@ def create_app() -> FastAPI: return {"ok": True, "flag": body.flag} @app.get("/api/admin/stats") - def admin_stats(request: Request) -> dict: + def admin_stats(request: Request, days: int = Query(30)) -> dict: + days = days if days in (7, 30, 90) else 30 # clamp to the offered windows with get_conn() as conn: _require_admin(conn, request) - return queries.admin_stats(conn) + return queries.admin_stats(conn, days=days) @app.get("/api/summary/{article_id}") def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict: diff --git a/tests/test_admin.py b/tests/test_admin.py index 385eff6..71077ca 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -97,3 +97,12 @@ def test_source_health_includes_metrics(tmp_path, monkeypatch): "acceptance_rate", "duplicate_rate", "review_reason", "next_due_at"): assert key in s, f"missing {key}" assert s["served"] == 1 and s["acceptance_rate"] == 100 + + +def test_admin_stats_days_param_clamped(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + tc = _signin(app, api, "boss@x.com") + assert tc.get("/api/admin/stats?days=7").json()["days"] == 7 + assert tc.get("/api/admin/stats?days=90").json()["days"] == 90 + assert tc.get("/api/admin/stats?days=999").json()["days"] == 30 # clamped + assert tc.get("/api/admin/stats").json()["days"] == 30 # default