Admin: tabbed operator console (Overview/Content/Sources/Audience/Feedback)
Reshape the long single-page dashboard into a sectioned console (one route, ?section= tabs, sticky subnav) focused on "what needs my attention" first. * Overview: an "Attention Needed" strip (soft amber/blue, never alarming red) derived from the same data — sources resting/flagged, image coverage <70%, thin brief, recent feedback — plus at-a-glance pulse cards. * Content: corpus health + image/summary coverage (with_image, summaries_with_ image, brief image coverage, 24h image misses) + top opened / topics / tags. * Sources: filterable table (All/Healthy/Resting/Flagged) — served, last success, next poll, failure streak, status — instead of a card pile. * Audience: visitors, retention, accounts, funnel, sharing, daily trend. * Feedback: inbox with category filter, newest first, quick mailto reply. Backend: content_stats gains added_7d + image-coverage fields; source_health gains review_flag; admin_stats adds attention[] + feedback_7d. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
|
||||
@@ -22,6 +23,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
{ key: 'sources', label: 'Sources' },
|
||||
{ key: 'audience', label: 'Audience' },
|
||||
{ key: 'feedback', label: 'Feedback' },
|
||||
];
|
||||
let section = $derived($page.url.searchParams.get('section') || 'overview');
|
||||
|
||||
function fdate(s) {
|
||||
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
|
||||
catch { return s; }
|
||||
@@ -33,8 +43,25 @@
|
||||
if (isNaN(d)) return s;
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
let healthy = $derived((stats?.sources ?? []).filter((s) => !s.failures).length);
|
||||
let ailing = $derived((stats?.sources ?? []).filter((s) => s.failures > 0).length);
|
||||
|
||||
let sources = $derived(stats?.sources ?? []);
|
||||
let healthy = $derived(sources.filter((s) => !s.failures && !s.review_flag).length);
|
||||
let resting = $derived(sources.filter((s) => s.failures > 0).length);
|
||||
let flagged = $derived(sources.filter((s) => s.review_flag).length);
|
||||
|
||||
// Sources table filter.
|
||||
let srcFilter = $state('all');
|
||||
let shownSources = $derived(
|
||||
srcFilter === 'healthy' ? sources.filter((s) => !s.failures && !s.review_flag)
|
||||
: srcFilter === 'resting' ? sources.filter((s) => s.failures > 0)
|
||||
: srcFilter === 'flagged' ? sources.filter((s) => s.review_flag)
|
||||
: sources
|
||||
);
|
||||
|
||||
// Feedback inbox filter.
|
||||
let fbCat = $state('all');
|
||||
let shownFeedback = $derived(fbCat === 'all' ? feedback : feedback.filter((f) => f.category === fbCat));
|
||||
let fbCats = $derived([...new Set(feedback.map((f) => f.category))]);
|
||||
|
||||
const SHARE_LABEL = {
|
||||
share_ub: 'Copied UB link',
|
||||
@@ -42,11 +69,9 @@
|
||||
copy_source: 'Copied source',
|
||||
source_click: 'Source clicks (from /a)',
|
||||
};
|
||||
// Width % for a simple CSS bar, relative to the top value in a list.
|
||||
function pct(v, max) {
|
||||
return max > 0 ? Math.max(4, Math.round((v / max) * 100)) : 0;
|
||||
}
|
||||
function pct(v, m) { return m > 0 ? Math.max(4, Math.round((v / m) * 100)) : 0; }
|
||||
const max = (rows, key) => rows.reduce((m, r) => Math.max(m, r[key]), 0);
|
||||
function coverage(part, whole) { return whole > 0 ? Math.round((100 * part) / whole) : 0; }
|
||||
</script>
|
||||
|
||||
<header class="bar">
|
||||
@@ -63,183 +88,230 @@
|
||||
{:else if !stats}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else}
|
||||
<p class="sub">Aggregate, anonymous — last {stats.days} days. No personal data.</p>
|
||||
<nav class="tabs" aria-label="Dashboard sections">
|
||||
{#each TABS as t (t.key)}
|
||||
<a href={'?section=' + t.key} class:active={section === t.key}>
|
||||
{t.label}{#if t.key === 'feedback' && feedback.length}<span class="badge">{feedback.length}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<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">Last 7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
||||
</div>
|
||||
|
||||
{#if stats.content}
|
||||
<section>
|
||||
<h2>Content served</h2>
|
||||
<div class="cards">
|
||||
<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 (last 7d)</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.summaries}</span><span class="l">Summaries</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.active_sources}</span><span class="l">Active sources</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2>Accounts</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Total</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_today}</span><span class="l">New today</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_7d}</span><span class="l">New this week</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.active_7d}</span><span class="l">Active this week</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Returning visitors (30d)</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>
|
||||
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">4–7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['8+']}</span><span class="l">8+ days</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Reading funnel</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.funnel.summary_viewed}</span><span class="l">Summaries read</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_click}</span><span class="l">→ Source (from summary)</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_rate}%</span><span class="l">Summary → source rate</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Emotional mix & friction</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.not_today}</span><span class="l">Not today</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.less_like_this}</span><span class="l">Less like this</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.hide_topic}</span><span class="l">Hide topic</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.used}</span><span class="l">Replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.none}</span><span class="l">No replacement</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywall_replace}</span><span class="l">Paywall replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywalled_source_open}</span><span class="l">Paywalled opens</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Most-opened articles</h2>
|
||||
{#if stats.top_articles.length}
|
||||
<ul class="bars">
|
||||
{#each stats.top_articles as a (a.id)}
|
||||
<li>
|
||||
<a href={'/a/' + a.id} class="lbl" title={a.title}>{a.title}</a>
|
||||
<span class="bar" style="width:{pct(a.opens, max(stats.top_articles, 'opens'))}%"></span>
|
||||
<span class="v">{a.opens}</span>
|
||||
</li>
|
||||
{#if section === 'overview'}
|
||||
<!-- Attention Needed -->
|
||||
{#if (stats.attention ?? []).length}
|
||||
<div class="attn-strip">
|
||||
{#each stats.attention as a}
|
||||
<div class="attn {a.level}">{a.text}</div>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No opens yet.</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="attn ok">✓ All clear — nothing needs attention right now.</div>
|
||||
{/if}
|
||||
|
||||
<div class="two">
|
||||
<section>
|
||||
<h2>Popular groupings</h2>
|
||||
{#if stats.top_groupings.length}
|
||||
<ul class="bars">
|
||||
{#each stats.top_groupings as g (g.tag)}
|
||||
<li>
|
||||
<span class="lbl">{g.tag.replace('-', ' ')}</span>
|
||||
<span class="bar" style="width:{pct(g.opens, max(stats.top_groupings, 'opens'))}%"></span>
|
||||
<span class="v">{g.opens}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Popular topics</h2>
|
||||
{#if stats.top_topics.length}
|
||||
<ul class="bars">
|
||||
{#each stats.top_topics as t (t.topic)}
|
||||
<li>
|
||||
<span class="lbl">{t.topic}</span>
|
||||
<span class="bar" style="width:{pct(t.opens, max(stats.top_topics, 'opens'))}%"></span>
|
||||
<span class="v">{t.opens}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Sharing</h2>
|
||||
<h2>Pulse</h2>
|
||||
<div class="cards">
|
||||
{#each Object.entries(stats.shares) as [kind, n]}
|
||||
<div class="stat"><span class="n">{n}</span><span class="l">{SHARE_LABEL[kind] ?? kind}</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.d30}</span><span class="l">Visitors (30d)</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>
|
||||
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
||||
</div>
|
||||
|
||||
{:else if section === 'content'}
|
||||
<h2>Corpus</h2>
|
||||
<div class="cards">
|
||||
<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.rejected}</span><span class="l">Filtered out</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.added_24h}</span><span class="l">Ingested (24h)</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.added_7d}</span><span class="l">Ingested (7d)</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh & live (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>
|
||||
|
||||
<h2>Coverage</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{coverage(stats.content.with_image, stats.content.served)}%</span><span class="l">Articles with image ({stats.content.with_image}/{stats.content.served})</span></div>
|
||||
<div class="stat"><span class="n">{coverage(stats.content.summaries, stats.content.served)}%</span><span class="l">Summaries ({stats.content.summaries}/{stats.content.served})</span></div>
|
||||
<div class="stat"><span class="n">{coverage(stats.content.summaries_with_image, stats.content.summaries)}%</span><span class="l">Summary pages w/ image</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.brief_with_image}/{stats.content.latest_brief_size}</span><span class="l">Brief w/ image</span></div>
|
||||
<div class="stat"><span class="n">{stats.content.recent_enrich_fail}</span><span class="l">Image misses (24h)</span></div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Most-opened articles</h2>
|
||||
{#if stats.top_articles.length}
|
||||
<ul class="bars">
|
||||
{#each stats.top_articles as a (a.id)}
|
||||
<li>
|
||||
<a href={'/a/' + a.id} class="lbl" title={a.title}>{a.title}</a>
|
||||
<span class="bar" style="width:{pct(a.opens, max(stats.top_articles, 'opens'))}%"></span>
|
||||
<span class="v">{a.opens}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No opens yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
<div class="two">
|
||||
<section>
|
||||
<h2>Popular groupings</h2>
|
||||
{#if stats.top_groupings.length}
|
||||
<ul class="bars">
|
||||
{#each stats.top_groupings as g (g.tag)}
|
||||
<li>
|
||||
<span class="lbl">{g.tag.replace('-', ' ')}</span>
|
||||
<span class="bar" style="width:{pct(g.opens, max(stats.top_groupings, 'opens'))}%"></span>
|
||||
<span class="v">{g.opens}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
<section>
|
||||
<h2>Popular topics</h2>
|
||||
{#if stats.top_topics.length}
|
||||
<ul class="bars">
|
||||
{#each stats.top_topics as t (t.topic)}
|
||||
<li>
|
||||
<span class="lbl">{t.topic}</span>
|
||||
<span class="bar" style="width:{pct(t.opens, max(stats.top_topics, 'opens'))}%"></span>
|
||||
<span class="v">{t.opens}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{:else if section === 'sources'}
|
||||
<h2>Source health</h2>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {sources.length} active</p>
|
||||
<div class="filterchips">
|
||||
{#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged']] as [key, label] (key)}
|
||||
<button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<div class="tablewrap">
|
||||
<table class="srctable">
|
||||
<thead>
|
||||
<tr><th>Source</th><th class="num">Served</th><th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shownSources as s (s.id)}
|
||||
<tr class:warn={s.failures > 0} class:flag={s.review_flag}>
|
||||
<td class="sname">{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}</td>
|
||||
<td class="num">{s.served}</td>
|
||||
<td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
|
||||
<td class="dim">{fwhen(s.next_due_at)}</td>
|
||||
<td class="num">{s.failures || ''}</td>
|
||||
<td class="status">
|
||||
{#if s.failures > 0}<span class="bad" title={s.last_error || ''}>⚠ resting</span>
|
||||
{:else if s.review_flag}<span class="flagtxt">⚑ review</span>
|
||||
{:else}<span class="good">✓ ok</span>{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
|
||||
|
||||
<section>
|
||||
<h2>Daily trend</h2>
|
||||
{#if stats.daily.length}
|
||||
{@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))}
|
||||
<div class="trend">
|
||||
{#each stats.daily as d (d.day)}
|
||||
<div class="col" title={`${d.day}: ${d.visits} visits, ${d.opens} opens`}>
|
||||
<span class="visits" style="height:{pct(d.visits, dmax)}%"></span>
|
||||
<span class="opens" style="height:{pct(d.opens, dmax)}%"></span>
|
||||
</div>
|
||||
{:else if section === 'audience'}
|
||||
<section>
|
||||
<h2>Visitors</h2>
|
||||
<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>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Returning visitors (30d)</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>
|
||||
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">4–7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['8+']}</span><span class="l">8+ days</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Accounts</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Total</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_today}</span><span class="l">New today</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_7d}</span><span class="l">New this week</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.active_7d}</span><span class="l">Active this week</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Reading funnel</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.funnel.summary_viewed}</span><span class="l">Summaries read</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_click}</span><span class="l">→ Source (from summary)</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_rate}%</span><span class="l">Summary → source rate</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Emotional mix & friction</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.not_today}</span><span class="l">Not today</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.less_like_this}</span><span class="l">Less like this</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.hide_topic}</span><span class="l">Hide topic</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.used}</span><span class="l">Replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywalled_source_open}</span><span class="l">Paywalled opens</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Sharing</h2>
|
||||
<div class="cards">
|
||||
{#each Object.entries(stats.shares) as [kind, n]}
|
||||
<div class="stat"><span class="n">{n}</span><span class="l">{SHARE_LABEL[kind] ?? kind}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="legend"><span class="sw visits"></span> visits <span class="sw opens"></span> opens</p>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
{#if stats.sources?.length}
|
||||
<section>
|
||||
<h2>Source health</h2>
|
||||
<p class="sub2">{healthy} healthy · {ailing} backing off · {stats.sources.length} active</p>
|
||||
<ul class="srclist">
|
||||
{#each stats.sources as s (s.id)}
|
||||
<li class:warn={s.failures > 0}>
|
||||
<span class="sname" title={s.last_error || ''}>{s.name}</span>
|
||||
<span class="sserved">{s.served}</span>
|
||||
<span class="sstatus">
|
||||
{#if s.failures > 0}
|
||||
⚠ {s.failures} fail{s.failures > 1 ? 's' : ''} · resting until {fwhen(s.next_due_at)}
|
||||
{:else}
|
||||
✓ next poll {fwhen(s.next_due_at)}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
<h2>Daily trend</h2>
|
||||
{#if stats.daily.length}
|
||||
{@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))}
|
||||
<div class="trend">
|
||||
{#each stats.daily as d (d.day)}
|
||||
<div class="col" title={`${d.day}: ${d.visits} visits, ${d.opens} opens`}>
|
||||
<span class="visits" style="height:{pct(d.visits, dmax)}%"></span>
|
||||
<span class="opens" style="height:{pct(d.opens, dmax)}%"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="legend"><span class="sw visits"></span> visits <span class="sw opens"></span> opens</p>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{:else if section === 'feedback'}
|
||||
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
||||
{#if feedback.length}
|
||||
<div class="filterchips">
|
||||
<button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
|
||||
{#each fbCats as cat (cat)}
|
||||
<button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<ul class="fb">
|
||||
{#each feedback as f (f.id)}
|
||||
{#each shownFeedback as f (f.id)}
|
||||
<li>
|
||||
<div class="fhead">
|
||||
<span class="cat">{f.category}</span>
|
||||
<span class="when">{fdate(f.created_at)}</span>
|
||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>{f.contact_email}</a>{/if}
|
||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
|
||||
</div>
|
||||
<p class="msg">{f.message}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -250,14 +322,35 @@
|
||||
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.back svg { width: 17px; height: 17px; display: block; }
|
||||
.page { padding: 22px 20px 70px; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 2px; }
|
||||
.sub { color: var(--muted); font-size: 0.9rem; margin: 0 0 22px; }
|
||||
h2 { font-size: 1.1rem; margin: 30px 0 12px; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 14px; }
|
||||
h2 { font-size: 1.1rem; margin: 28px 0 12px; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
/* Sticky section tabs */
|
||||
.tabs {
|
||||
display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 22px;
|
||||
position: sticky; top: 64px; z-index: 15; background: var(--bg);
|
||||
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tabs a {
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||
.tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.tabs .badge { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 0 7px; font-size: 0.74rem; }
|
||||
.tabs a.active .badge { background: rgba(255,255,255,0.25); color: #fff; }
|
||||
|
||||
/* Attention Needed — soft amber/blue, never alarming red unless truly broken */
|
||||
.attn-strip { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||||
.attn { border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; border-left: 3px solid; }
|
||||
.attn.warn { background: #fdf3e3; color: #875a16; border-color: #e0a648; }
|
||||
.attn.info { background: #eef4f8; color: var(--accent-deep); border-color: var(--accent); }
|
||||
.attn.ok { background: #eef5ee; color: #3f7048; border-left: 3px solid #6aa86a; border-radius: 12px; padding: 11px 15px; font-size: 0.9rem; }
|
||||
|
||||
.cards { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.stat {
|
||||
flex: 1 1 120px; background: var(--surface); border: 1px solid var(--line);
|
||||
flex: 1 1 130px; background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
||||
@@ -270,8 +363,7 @@
|
||||
ul.bars li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; position: relative; }
|
||||
ul.bars .lbl {
|
||||
grid-column: 1; z-index: 1; font-size: 0.86rem; color: var(--ink);
|
||||
text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
padding: 4px 0;
|
||||
text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 0;
|
||||
}
|
||||
a.lbl { text-decoration: none; }
|
||||
a.lbl:hover { color: var(--accent-deep); }
|
||||
@@ -292,17 +384,33 @@
|
||||
|
||||
.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; }
|
||||
ul.srclist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
ul.srclist li {
|
||||
display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: 12px;
|
||||
padding: 7px 10px; border-radius: 8px; font-size: 0.86rem;
|
||||
|
||||
/* Filter chips (sources + feedback) */
|
||||
.filterchips { display: flex; gap: 7px; flex-wrap: wrap; margin: 0 0 14px; }
|
||||
.filterchips .chip {
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer; text-transform: capitalize;
|
||||
}
|
||||
ul.srclist li:nth-child(odd) { background: var(--surface); }
|
||||
ul.srclist li.warn { background: #fbeaea; }
|
||||
ul.srclist .sname { color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
ul.srclist .sserved { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
ul.srclist .sstatus { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
|
||||
ul.srclist li.warn .sstatus { color: #9a3b3b; }
|
||||
.filterchips .chip:hover { border-color: var(--accent); }
|
||||
.filterchips .chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* Source health table */
|
||||
.tablewrap { overflow-x: auto; }
|
||||
.srctable { width: 100%; border-collapse: collapse; font-size: 0.86rem; min-width: 560px; }
|
||||
.srctable th {
|
||||
text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--muted); font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.srctable td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: baseline; }
|
||||
.srctable .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.srctable .dim { color: var(--muted); white-space: nowrap; font-size: 0.82rem; }
|
||||
.srctable .sname { font-weight: 600; color: var(--ink); }
|
||||
.srctable .sname .cat { display: block; font-weight: 400; font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||||
.srctable tr.warn { background: #fdf3e3; }
|
||||
.srctable tr.flag { background: #eef4f8; }
|
||||
.srctable .status .bad { color: #875a16; }
|
||||
.srctable .status .flagtxt { color: var(--accent-deep); }
|
||||
.srctable .status .good { color: #3f7048; }
|
||||
|
||||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
||||
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
@@ -311,5 +419,6 @@
|
||||
.fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
|
||||
.fhead .when { color: var(--muted); }
|
||||
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
|
||||
.fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
|
||||
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
|
||||
</style>
|
||||
|
||||
+66
-4
@@ -233,17 +233,45 @@ def content_stats(conn: sqlite3.Connection) -> dict:
|
||||
"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()
|
||||
with_image = 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 a.image_url IS NOT NULL AND a.image_url!=''"
|
||||
)
|
||||
summaries = scalar("SELECT COUNT(*) FROM article_summaries")
|
||||
summaries_with_image = scalar(
|
||||
"SELECT COUNT(*) FROM article_summaries m JOIN articles a ON a.id=m.article_id "
|
||||
"WHERE a.image_url IS NOT NULL AND a.image_url!=''"
|
||||
)
|
||||
brief_with_image = 0
|
||||
if brief:
|
||||
brief_with_image = scalar(
|
||||
"SELECT COUNT(*) FROM daily_brief_items bi JOIN articles a ON a.id=bi.article_id "
|
||||
"JOIN daily_briefs b ON b.id=bi.brief_id "
|
||||
"WHERE b.brief_date=? AND a.image_url IS NOT NULL AND a.image_url!=''",
|
||||
(brief["brief_date"],),
|
||||
)
|
||||
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"),
|
||||
"added_7d": scalar("SELECT COUNT(*) FROM articles WHERE discovered_at >= datetime('now','-7 days')"),
|
||||
"summaries": summaries,
|
||||
"summaries_with_image": summaries_with_image,
|
||||
"with_image": with_image,
|
||||
# Accepted, imageless articles whose enrichment was tried recently and
|
||||
# came up empty (no usable og:image) — the image "misses" to watch.
|
||||
"recent_enrich_fail": 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 (a.image_url IS NULL OR a.image_url='') "
|
||||
"AND a.image_checked_at >= datetime('now','-1 day')"
|
||||
),
|
||||
"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,
|
||||
"brief_with_image": brief_with_image,
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +286,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""
|
||||
SELECT
|
||||
s.id, s.name, s.default_category AS category, s.active,
|
||||
s.consecutive_failures AS failures,
|
||||
s.consecutive_failures AS failures, s.review_flag,
|
||||
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
|
||||
@@ -279,6 +307,34 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _attention(content: dict, sources: list[dict], feedback_7d: int) -> list[dict]:
|
||||
"""The 'Attention Needed' strip: what an operator should look at, soft-toned
|
||||
(warn = act soon, info = worth a glance). Derived from the same data shown
|
||||
elsewhere, so it never disagrees with the detail sections."""
|
||||
items: list[dict] = []
|
||||
n = lambda c: "" if c == 1 else "s" # noqa: E731 — tiny pluralizer
|
||||
|
||||
resting = [s for s in sources if (s.get("failures") or 0) > 0]
|
||||
if resting:
|
||||
items.append({"level": "warn", "text": f"{len(resting)} source{n(len(resting))} backing off after failures"})
|
||||
flagged = [s for s in sources if s.get("review_flag")]
|
||||
if flagged:
|
||||
items.append({"level": "warn", "text": f"{len(flagged)} source{n(len(flagged))} flagged for review"})
|
||||
|
||||
served = content.get("served") or 0
|
||||
with_image = content.get("with_image") or 0
|
||||
if served and (with_image / served) < 0.70:
|
||||
items.append({"level": "info", "text": f"Image coverage at {round(100 * with_image / served)}% (aim for 70%+)"})
|
||||
|
||||
brief_size = content.get("latest_brief_size") or 0
|
||||
if brief_size < 7:
|
||||
items.append({"level": "info", "text": f"Today's brief has only {brief_size} item{n(brief_size)}"})
|
||||
|
||||
if feedback_7d:
|
||||
items.append({"level": "info", "text": f"{feedback_7d} feedback message{n(feedback_7d)} in the last 7 days"})
|
||||
return items
|
||||
|
||||
|
||||
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
||||
since = f"-{days} days"
|
||||
@@ -376,10 +432,16 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"FROM events WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
|
||||
)]
|
||||
|
||||
content = content_stats(conn)
|
||||
sources = source_health(conn)
|
||||
feedback_7d = scalar("SELECT COUNT(*) FROM feedback WHERE created_at >= datetime('now','-7 days')")
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"content": content_stats(conn),
|
||||
"sources": source_health(conn),
|
||||
"content": content,
|
||||
"sources": sources,
|
||||
"attention": _attention(content, sources, feedback_7d),
|
||||
"feedback_7d": feedback_7d,
|
||||
"visitors": visitors,
|
||||
"returning": loyalty.get("returning", 0),
|
||||
"once": loyalty.get("once", 0),
|
||||
|
||||
@@ -47,3 +47,28 @@ def test_source_health_orders_failing_first_and_computes_next_due(tmp_path):
|
||||
assert flaky["next_due_at"] is not None
|
||||
good = next(s for s in sh if s["name"] == "Good")
|
||||
assert good["served"] == 1 and good["next_due_at"] is None # never attempted → due now
|
||||
|
||||
|
||||
def test_attention_flags_resting_flagged_and_brief():
|
||||
from goodnews import queries
|
||||
content = {"served": 100, "with_image": 90, "latest_brief_size": 5}
|
||||
sources = [
|
||||
{"failures": 3, "review_flag": 0},
|
||||
{"failures": 0, "review_flag": 1},
|
||||
{"failures": 0, "review_flag": 0},
|
||||
]
|
||||
items = queries._attention(content, sources, feedback_7d=2)
|
||||
texts = " | ".join(i["text"] for i in items)
|
||||
assert "1 source backing off" in texts # one resting
|
||||
assert "1 source flagged" in texts # one flagged
|
||||
assert "brief has only 5" in texts # brief < 7
|
||||
assert "2 feedback" in texts # recent feedback
|
||||
# 90/100 = 90% image coverage → no coverage warning
|
||||
assert "Image coverage" not in texts
|
||||
|
||||
|
||||
def test_attention_clear_when_healthy():
|
||||
from goodnews import queries
|
||||
content = {"served": 100, "with_image": 95, "latest_brief_size": 7}
|
||||
sources = [{"failures": 0, "review_flag": 0}]
|
||||
assert queries._attention(content, sources, feedback_7d=0) == []
|
||||
|
||||
Reference in New Issue
Block a user