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 feedback = $state([]);
|
||||||
let error = $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 () => {
|
onMount(async () => {
|
||||||
if (!auth.ready) await refresh();
|
if (!auth.ready) await refresh();
|
||||||
if (!auth.user || !auth.user.is_admin) {
|
if (!auth.user || !auth.user.is_admin) {
|
||||||
@@ -16,7 +28,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
stats = await getJSON('/api/admin/stats');
|
await loadStats();
|
||||||
feedback = await getJSON('/api/admin/feedback');
|
feedback = await getJSON('/api/admin/feedback');
|
||||||
} catch {
|
} catch {
|
||||||
error = "Couldn't load stats.";
|
error = "Couldn't load stats.";
|
||||||
@@ -151,6 +163,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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'}
|
{#if section === 'overview'}
|
||||||
<!-- Attention Needed -->
|
<!-- Attention Needed -->
|
||||||
{#if (stats.attention ?? []).length}
|
{#if (stats.attention ?? []).length}
|
||||||
@@ -167,7 +188,7 @@
|
|||||||
<div class="cards">
|
<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.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.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.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.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>
|
<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="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.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.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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Returning visitors (30d)</h2>
|
<h2>Returning visitors ({range}d)</h2>
|
||||||
<div class="cards">
|
<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.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>
|
<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; }
|
.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; }
|
.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) */
|
/* Filter chips (sources + feedback) */
|
||||||
.filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; }
|
.filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; }
|
||||||
.filterchips .chip {
|
.filterchips .chip {
|
||||||
|
|||||||
+3
-2
@@ -848,10 +848,11 @@ def create_app() -> FastAPI:
|
|||||||
return {"ok": True, "flag": body.flag}
|
return {"ok": True, "flag": body.flag}
|
||||||
|
|
||||||
@app.get("/api/admin/stats")
|
@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:
|
with get_conn() as conn:
|
||||||
_require_admin(conn, request)
|
_require_admin(conn, request)
|
||||||
return queries.admin_stats(conn)
|
return queries.admin_stats(conn, days=days)
|
||||||
|
|
||||||
@app.get("/api/summary/{article_id}")
|
@app.get("/api/summary/{article_id}")
|
||||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
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"):
|
"acceptance_rate", "duplicate_rate", "review_reason", "next_due_at"):
|
||||||
assert key in s, f"missing {key}"
|
assert key in s, f"missing {key}"
|
||||||
assert s["served"] == 1 and s["acceptance_rate"] == 100
|
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