Admin: source Articles inspector (verify metrics against real evidence)

New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.

- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
  read-only). queries.source_articles + source_articles_summary — per article:
  title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
  (domain rule), has_image, duplicate. Summary = counts + source-level paywall
  rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
  · … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
  image/Duplicates), compact rows with title→link + badges + reason, Load more.

So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-12 21:37:51 -04:00
parent 64339aafb0
commit ddcfab3a11
9 changed files with 445 additions and 61 deletions
+38 -7
View File
@@ -193,7 +193,7 @@
{#each Array(length) as _, c (c)}
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
style={cs ? `animation-delay:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
style={cs ? `--d:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
{/each}
</div>
{/each}
@@ -253,22 +253,53 @@
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
background: var(--surface);
background: var(--surface); position: relative; overflow: hidden;
}
.tile.filled { border-color: #b7c0cb; }
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
/* Judged tiles set like glazed enamel: a soft top-light gradient over the
colour, an inner bevel, and a little lift off the board. Pending tiles stay
flat on purpose — depth marks the moment a letter is settled. */
.tile.correct, .tile.present, .tile.absent { color: #fff; text-shadow: 0 1px 2px rgba(20, 30, 25, 0.22); }
.tile.correct {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #56ac7c, #4a9d6e 55%, #3e8a5e);
border-color: #3e8a5e;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -3px 5px rgba(22, 64, 42, 0.2),
0 2px 5px rgba(58, 125, 86, 0.28);
}
.tile.present {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #e2c163, #d8b24a 55%, #c29c38);
border-color: #c29c38;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(122, 92, 22, 0.2),
0 2px 5px rgba(184, 148, 58, 0.28);
}
.tile.absent {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #a7b2bd, #9aa6b2 55%, #87939f);
border-color: #87939f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26), inset 0 -3px 5px rgba(50, 60, 70, 0.16),
0 2px 4px rgba(110, 122, 134, 0.24);
}
/* One quiet glint sweeps each tile just after its flip lands — once, not a loop. */
.tile.correct::after, .tile.present::after, .tile.absent::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
transform: translateX(-130%);
animation: sheen 0.65s ease-out both;
animation-delay: calc(var(--d, 0s) + 0.32s);
}
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when
you submit; the row shakes on an invalid word. */
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; }
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; animation-delay: var(--d, 0s); }
.row.shake { animation: shake 0.4s ease; }
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
@keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } }
@keyframes sheen { to { transform: translateX(130%); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } }
@media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } }
@media (prefers-reduced-motion: reduce) { .tile, .tile::after, .row { animation: none !important; } }
.flash {
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
@@ -16,7 +16,8 @@
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
let sel = $state([]); // current selection cells
let selecting = false;
let startTime = 0;
let playedMs = 0; // accumulated ACTIVE play time (closed segments)
let segStart = 0; // wall-clock start of the open segment (0 = paused)
let resultMs = $state(0);
let best = $state(0);
let loading = $state(true);
@@ -63,16 +64,44 @@
return m;
});
// --- the clock counts ACTIVE play only -----------------------------------
// Wall-clock timing made "finish in one sitting" feel mandatory — the
// opposite of calm. The clock runs only while the puzzle is on screen
// (tab visible, window focused, game unfinished); stepping away pauses it,
// coming back resumes it, and several sittings simply add up.
function playedNow() { return playedMs + (segStart ? Date.now() - segStart : 0); }
function pauseClock(save = true) {
if (!segStart) return;
playedMs += Date.now() - segStart; segStart = 0;
if (save) persist(); // don't lose the segment if the tab dies
}
function resumeClock() {
if (!segStart && !loading && status === 'playing' && !document.hidden) segStart = Date.now();
}
$effect(() => {
const onVis = () => (document.hidden ? pauseClock() : resumeClock());
const onAway = () => pauseClock();
document.addEventListener('visibilitychange', onVis);
window.addEventListener('pagehide', onAway);
window.addEventListener('blur', onAway);
window.addEventListener('focus', onVis);
return () => {
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('pagehide', onAway);
window.removeEventListener('blur', onAway);
window.removeEventListener('focus', onVis);
};
});
async function load() {
const seq = ++loadSeq; // stale-load guard for rapid size switches
loading = true; ready = false;
foundWords = []; sel = []; resultMs = 0; startTime = 0;
foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0;
try {
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
if (seq !== loadSeq) return; // a newer size was selected — abandon
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
restore();
if (!startTime) startTime = Date.now();
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
} catch {
if (seq !== loadSeq) return;
@@ -80,6 +109,7 @@
}
if (seq !== loadSeq) return;
loading = false;
resumeClock();
requestAnimationFrame(() => (ready = true));
// Reconcile with the server in the background (signed-in only): pull any
// progress from another device, and pull the cross-device best time.
@@ -103,14 +133,14 @@
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.foundWords)) {
foundWords = validFinds(s.foundWords);
startTime = s.startTime || 0;
playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction
resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, played: playedNow(), ms: resultMs, status })); }
catch { /* ignore */ }
onstatus?.(summary());
}
@@ -125,13 +155,17 @@
}
// renumber colours by find order so overlap blends stay consistent
foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length }));
if (merged.startTime && (!startTime || merged.startTime < startTime)) startTime = merged.startTime;
// another device may have accumulated more active time — credit the larger
if ((merged.played || 0) > playedNow()) {
playedMs = merged.played;
if (segStart) segStart = Date.now();
}
if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms);
persist();
}
async function syncNow() {
const d = date, sz = size; // pin against a size switch mid-flight
const merged = await pushGameState('wordsearch', sz, d, { foundWords, startTime, ms: resultMs });
const merged = await pushGameState('wordsearch', sz, d, { foundWords, played: playedNow(), ms: resultMs });
if (d === date && sz === size) adopt(merged); // ignore if the user switched away
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); }
@@ -144,7 +178,7 @@
function down(e) {
if (status === 'done') return;
selecting = true;
if (!startTime) startTime = Date.now();
resumeClock(); // safety net if a focus event was missed
sel = [cellAt(e)];
gridEl.setPointerCapture?.(e.pointerId);
e.preventDefault();
@@ -171,7 +205,8 @@
}
function finish() {
resultMs = startTime ? Date.now() - startTime : 0;
pauseClock(false); // close the open segment; persist follows in evaluate()
resultMs = playedMs;
if (resultMs && (!best || resultMs < best)) {
best = resultMs;
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
+87
View File
@@ -263,6 +263,27 @@
}
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
// --- Source article inspector: the real articles behind the metrics ---
async function toggleArticles(s) {
if (s._showArts) { s._showArts = false; return; }
s._showArts = true;
if (!s._arts) await loadArticles(s, 'all', true);
}
async function loadArticles(s, filter, reset) {
s._artBusy = true; s._artErr = '';
if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
try {
const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
if (r.summary) s._artSummary = r.summary;
s._artMore = r.has_more;
s._artOffset += r.articles.length;
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
finally { s._artBusy = false; }
}
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
// --- Source candidates: supervised "add a source" pipeline ---
let candidates = $state([]);
let newFeedUrl = $state('');
@@ -677,6 +698,7 @@
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
<button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</button>
</td>
</tr>
{#if s._checking || s._check || s._checkErr}
@@ -698,6 +720,49 @@
</td>
</tr>
{/if}
{#if s._showArts}
<tr class="artrow">
<td colspan="10">
{#if s._artSummary}
<div class="artsum">
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image ·
{s._artSummary.duplicates} dup ·
paywall rule: <span class="pwrule" class:on={s._artSummary.paywalled}>{s._artSummary.paywalled ? 'ON (domain)' : 'off'}</span>
</div>
{/if}
<div class="artfilters">
{#each ART_FILTERS as [key, label] (key)}
<button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
{/each}
<button class="act" onclick={() => (s._showArts = false)}>close</button>
</div>
{#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
{#if s._arts?.length}
<ul class="artlist">
{#each s._arts as a (a.id)}
<li>
<div class="art-row">
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
{#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
<span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
</div>
{#if a.reason}<div class="art-reason">{a.reason}</div>{/if}
</li>
{/each}
</ul>
{#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
{:else if !s._artBusy}
<p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
{/if}
{#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
</td>
</tr>
{/if}
{:else}
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}.` : 'No sources in this view.'}</td></tr>
{/each}
@@ -1188,6 +1253,28 @@
.chkex { margin-top: 5px; color: var(--ink); }
.chkex .chklbl { color: var(--muted); }
.chkex.chkrej { color: var(--muted); }
/* Source article inspector */
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
.artsum { color: var(--ink); margin-bottom: 8px; }
.artsum .pwrule { color: var(--muted); font-weight: 600; }
.artsum .pwrule.on { color: #9a3b3b; }
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
.artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
.art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
.art-title:hover { text-decoration: underline; }
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
.badge.ok { background: #e3efe4; color: #3f7048; }
.badge.no { background: #f3e0e0; color: #9a3b3b; }
.art-row .pw { font-size: 0.78rem; }
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
.art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
.art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px; }
.act.more { margin-top: 8px; }
.srctable .rowactions { white-space: nowrap; }
.srctable .rowactions .act {
background: none; border: 1px solid var(--line); color: var(--accent-deep);