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:
jay
2026-06-08 13:03:23 -04:00
parent 489c34d2f2
commit 575f562ad5
3 changed files with 375 additions and 179 deletions
+284 -175
View File
@@ -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">23 days</span></div>
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">47 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 &amp; 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">23 days</span></div>
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">47 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 &amp; 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 &nbsp; <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 &nbsp; <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
View File
@@ -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),
+25
View File
@@ -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) == []