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:
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user