Admin: analytics date-range toggle (7d / 30d / 90d)

/api/admin/stats accepts ?days= (clamped to 7/30/90, default 30) → passed to
admin_stats, which already windows visitors, retention, funnel, sharing, daily
trend, and the top lists by that span. Frontend adds a Window picker on the
analytics tabs (Overview/Content/Audience); changing it refetches and the
windowed labels ("Visitors (Nd)", "Last N days", "Returning visitors (Nd)")
follow. Corpus totals + source health are unaffected (not time-windowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 14:14:47 -04:00
parent 84bc5b0267
commit 8cce3a2165
3 changed files with 47 additions and 6 deletions
+35 -4
View File
@@ -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}
</nav>
{#if section === 'overview' || section === 'content' || section === 'audience'}
<div class="rangepick">
<span class="rl">Window</span>
{#each [7, 30, 90] as d (d)}
<button class="chip" class:on={range === d} onclick={() => setRange(d)}>{d}d</button>
{/each}
</div>
{/if}
{#if section === 'overview'}
<!-- Attention Needed -->
{#if (stats.attention ?? []).length}
@@ -167,7 +188,7 @@
<div class="cards">
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visitors (7d)</span></div>
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors (30d)</span></div>
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors ({range}d)</span></div>
<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 (7d)</span></div>
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
@@ -293,11 +314,11 @@
<div class="cards">
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
<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 {range} days</span></div>
</div>
</section>
<section>
<h2>Returning visitors (30d)</h2>
<h2>Returning visitors ({range}d)</h2>
<div class="cards">
<div class="stat"><span class="n">{stats.retention.new}</span><span class="l">New (1 day)</span></div>
<div class="stat"><span class="n">{stats.retention['2-3']}</span><span class="l">23 days</span></div>
@@ -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 {
+3 -2
View File
@@ -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:
+9
View File
@@ -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