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:
@@ -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">2–3 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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user