Commit Graph

27 Commits

Author SHA1 Message Date
thejayman77 3afc1ed37e Sources hardening (Codex audit): promote-time dedup, postJSON timeout, host-only feed_key
Three follow-ups from Codex's audit of the deep-preview/search/dedup work:
- Promote-time duplicate guard: promote_candidate() now re-checks
  find_existing_feed() and raises DuplicateFeedError → 409, so an
  old/CLI/direct-DB candidate or a race can't bypass the add-time check and
  silently overwrite a live source's settings via upsert. (sources scanned
  first, so a real source collision wins over the candidate matching itself.)
- postJSON/putJSON/delJSON gain opt-in {timeout} (AbortController, default
  none so other calls are unchanged); deep preview uses 120s and surfaces a
  calm "timed out" message instead of pinning the button on "Deep-checking…"
  if the LAN model stalls.
- feed_key() now lowercases the host only, not the whole URL — paths/queries
  can be case-significant; scheme/www/trailing-slash/host-case still collapse.

Tests: test_candidate_deep_preview_and_dedup extended — promote succeeds once,
then a re-promote of the same candidate is refused 409. 224 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:31:39 -04:00
thejayman77 e1ac19351e Sources: LLM deep-preview, source search, duplicate-add guard
Three admin Sources upgrades:
- Deep preview: a per-candidate "🔬 Deep preview" button runs the REAL
  classifier on an 8-item sample (the same model that judges live articles),
  versus the fast keyword heuristic the add/Re-preview path uses. Preview now
  carries `classified`, surfaced as a "model-checked" vs "quick estimate"
  badge — so the acceptance % is no longer ambiguously heuristic. conn is
  released during the ~30-60s model pass; postJSON has no client timeout.
- Search: free-text box over the sources table (name / category / feed URL /
  homepage), folded into the existing status filter, with a live match count
  and empty state. Makes "is this already added?" a glance.
- Duplicate-add guard: sources.find_existing_feed() + feed_key() normalize
  scheme/www/trailing-slash/case, so re-adding a feed that's already a live
  source or a queued candidate is refused with a 409 naming where it lives
  (DB already enforced exact-URL uniqueness; this catches the near-miss
  variants and overwrite-on-promote footgun).

Tests: test_candidate_deep_preview_and_dedup (deep flag wires the model +
uses the small sample; exact/www/slash/case variants all 409). 224 pytest +
11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:19:15 -04:00
thejayman77 d558c99b8e SW: mutable no-cache files bypass the SW cache (Codex audit) + beacon build id
Codex's finding: cache-as-you-go would pin files Caddy deliberately serves
no-cache (version.json, manifest, word lists, icons) in the SW cache until the
next SW version — silently defeating the revalidate policy for controlled
clients. version.json is the critical one (it's how the app detects a fresh
deploy); stale word lists could drift from the server's validated answer pool.
New isMutablePath() exclusion: the SW steps aside and the browser HTTP cache
revalidates these per their headers.

Telemetry polish (also Codex): the boot beacon now fills the app_version
column with the entry chunk's hashed filename scraped from the shell's own
modulepreload link (no extra fetch) — deploy-correlated load errors become
obvious. Admin list returns + shows it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 20:26:27 -04:00
thejayman77 628cc5722c Reliability: slow ≠ failed — SW nav timeout, slow-boot telemetry, de-bot stats
Root cause of the intermittent white screen: the shell HTML is no-cache
(cf-cache-status: DYNAMIC), so every page-open does a synchronous round-trip
to the residential origin before any pixel renders — and the SW's network-first
navigation only fell back to the cached shell on REJECTION, never on slowness.
A stalled fetch meant staring at white with a perfectly good shell in cache.
The boot seatbelt couldn't see it either: it lives inside the HTML that hadn't
arrived yet, so slow boots left no telemetry.

- service-worker: race navigation fetch vs 2.5s grace timer. Network wins →
  fresh HTML as before; timer/5xx/failure → cached shell instantly, network
  response still refreshes the cache in the background. Safe due to the 14-day
  immutable-chunk grace window. Caps the white screen at ~2.5s for repeat
  visitors on any network.
- app.html: beacon `boot-slow: Nms (html Nms) on 4g` when mount takes >4s —
  the "white screen, then it loaded" glitches finally leave a trace, with
  HTML-arrival timing to separate slow-origin from slow-JS.
- admin: bot UAs (HeadlessChrome/bot/spider/crawl/…) excluded from the
  headline "Load errors today" count — throttled crawlers trip the 10s boot
  check routinely (the one recorded error was HeadlessChrome on X11, not a
  phone). Bots stay visible in the list, tagged + dimmed.

Tests: telemetry test extended for bot flag + filtered counts. 223 pytest +
11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:23:33 -04:00
thejayman77 2461584052 Pool admin: delete any word (tombstones + restore) + bulk import
Daily Word pool curation, full add/delete/import — no redeploys to fix tone:
- Remove ANY pool word, curated or admin-added, via a word_pool_removed
  tombstone table. Runtime pool = (static ∪ added) − removed, so even a
  baked-in word can be pulled on negative feedback. Reversible: a "Removed"
  list with one-tap Restore lifts the tombstone. Lookup now surfaces a Remove
  button when in-pool, Restore when removed.
- Import a vetted list (paste or .txt/.csv upload, read client-side): validates
  each word (alpha · 5–6 · in guess dictionary), ignores duplicates, and reports
  rejects with reasons. Re-adding/importing a removed word lifts its tombstone.
- Word Search theme delete already existed (Edit/Remove per theme) — verified.

Pool stays the clean 251/224; today's noisy LLM enrichment is discarded.
Tests: +tests/test_pool_admin.py, extended test_word_pool_admin. 222 pytest +
11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:17:16 -04:00
thejayman77 f71e760847 Admin: Word Search theme authoring + tidy word-pool chips
* New "Word Search themes" panel in the Games tab: enter a theme name + words,
  with live validation (4–8 letters, alpha, deduped) and a count vs the 28 needed
  to fill all three sizes. An " Suggest a word" button asks the LLM for one
  fresh word that fits the theme. Save/edit/remove; authored themes join the daily
  fallback rotation alongside the curated ones (wordsearch_themes table). The
  system still handles word distribution across sizes + placement.
* Daily Word pool's added-word chips now scroll within a bounded area so the
  console stays tidy as the list grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:36:07 -04:00
thejayman77 61f575ba6d Observability + warming guardrails (Codex)
* client_error details, not just a count: new client_errors table + POST
  /api/client-error (reason/path/user-agent/time) + GET /api/admin/client-errors.
  The boot-seatbelt beacon now sends the reason + path (once per page); the admin
  Overview lists the recent errors so we can tell chunk vs SW vs API vs JS — the
  truth meter for the next day as the new SW propagates.
* Deploy warming now also hits the shell, routes (/play /account /admin), SW,
  version.json, word lists, and icons/logo/font — not just immutable chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:31:32 -04:00
thejayman77 903b27fc8d Admin: Daily Word pool curation (lookup + add/remove)
First games admin tool. A "Games" tab in the operator console for the Daily Word
answer pool.
* Lookup: is a word real (in the guess dictionary), the right length (5/6), and
  already in the pool — instant as you type.
* Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the
  guess dict) so the daily answer is always guessable. Remove: drops admin-added
  words (curated static ones stay).
* Additions persist in a new word_pool table (survives redeploys, unlike the
  baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts
  shipped with the package (goodnews/data/words-5/6.json) for server-side
  validation. Admin-gated endpoints + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:42:52 -04:00
thejayman77 6ef58e3915 Word Search: pin per-size counts in test + fix stale .playing-word comment
Per Codex audit. test_wordsearch_endpoint now asserts the exact promise — small 6,
med 9, large 13, pairwise-disjoint. app.css comment updated to .playing-game (the
class was renamed when the focused viewport was generalized to both games).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:26:19 -04:00
thejayman77 52a8bc5326 Word Search mobile: focused viewport, theme placement, unique-per-size words
Per field feedback.
* Each day is now THREE distinct puzzles: the three sizes draw DISJOINT word
  slices from a date-shuffled pool (small/med/large = 6/9/13, sum 28 unique).
  Curated fallback themes expanded to 30 words each; LLM proposals accepted only
  if they supply >= 28 unique words, else fall back. No more repeats across sizes.
* Word Search is now a focused game screen on mobile (same as Daily Word): body
  scroll locked + footer hidden (generalized .playing-game), and the grid sizes
  to the largest square that fits between the theme and the palette (container
  query) — the whole puzzle is on screen, no page scroll.
* Theme placement: full "Today's theme · <name>" on the size-selection screen;
  just the theme name on the puzzle itself, saving vertical space for Large.
* cosy → cozy. 🇺🇸

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:15:06 -04:00
thejayman77 b909b7e64b Word Search bug-fixes + Codex polish
Two reported bugs, same root cause: the fixed-cell grid overflowed its wrapper
on Large, so (a) the last column spilled past the border and (b) the pointer→cell
math drifted across the row, recording finds "off by a letter".
* Grid now uses 1fr columns with max-width = n·32px: the board grows with the
  grid and can never overflow (shrinks to fit a narrow phone instead).
* cellAt() accounts for the grid padding/border, so selection is exact edge-to-edge.
* restore() now validates each saved find against the CURRENT grid and drops any
  whose cells no longer spell the word — clears stale highlights if the day's
  puzzle changed.

Codex follow-ups:
* _ws_propose now requires >= large.count + 4 valid words before accepting an LLM
  proposal (else falls back to a curated theme), so a thin LLM result can't
  underfill Large. Added a thin-LLM fallback test.
* Cleaned Svelte warnings: removed the now-unused .gamecard.soon CSS, added an
  ARIA role/label to the grid, declared gridEl with $state. Build is warning-clean.
* Added a stale-load guard in WordSearchGame.load() so rapid size switches can't
  let an older request overwrite the newer selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:09:33 -04:00
thejayman77 f43f645d69 Games batch: neutral words/themes, Word Search sizes + per-word colours
From playtesting findings:
* Pools nearly doubled (115/104 → 228/201) with calm/neutral everyday words
  (claps, dance, drench, beach…), not just strictly-upbeat ones — more variety,
  ~7-month runway. The post-solve "why" prompt reworded to fit neutral words.
* Word Search now stores one theme + word list per day; the grid is built per
  request for three SIZE tiers — Small (8×8, 6 words), Medium (11×11, 9),
  Large (14×14, 13). Large packs more words = a longer sit ("too fast" fix).
  All sizes share the day's theme; every size still code-placed + solvable.
* Word Search themes can now be neutral everyday scenes ("Around the house",
  "At the beach", "In the kitchen", "A walk outdoors", "Making music"…), not
  only hopeful — same shape as the articles.
* Each found word gets its own colour from a calm palette, in the grid and its
  word-list chip. Per-size local progress + best time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:32:53 -04:00
thejayman77 90cd0291a3 Play hub Phase 2: Word Search (LLM theme/words, code places the grid)
A calm second daily game, same philosophy as Daily Word — LLM proposes, code
disposes.

* LLM proposes a hopeful theme + ~8 words; code validates (alpha/length/dedup)
  and PLACES every word in a date-seeded grid, so the puzzle is always solvable.
  Curated fallback themes if the LLM is thin. Only placed words are returned;
  the solution cells (placements) are never sent to the client.
* GET /api/puzzle/wordsearch → {theme, words, grid, size}. No answer to hide:
  the grid and word list are meant to be seen — the play is finding them, which
  the client validates by reading the selected line off the grid.
* WordSearchGame.svelte: pointer-drag selection snapped to the 8 straight
  directions (mouse + touch), found-word highlighting, no-fail, no pressure
  timer — time is recorded quietly and shown at the end with a personal best.
  Spoiler-free share. localStorage progress (restores found cells + timer).
* Hub's Word Search card is now live with today's status; cycle pre-generates
  both games with the LLM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:15:19 -04:00
thejayman77 1bc9925e40 Daily Word: server-adjudicate guesses (answer no longer in the response)
Per Codex's v2 hardening. The GET /api/puzzle/word response no longer carries
the answer at all — guesses POST to /api/puzzle/word/guess and the server
returns the colour pattern, computed against the day's answer. The answer (and
the "why") are revealed only once solved or the guesses are spent. This removes
the "open DevTools, read the answer" issue without pretending to be a fortress
(a deliberate crafted request can still peek; there's no leaderboard or prize,
so that's fine). Client keeps local progress/stats; dict validation stays
client-side. Trade-off accepted: each guess needs the API (the site already
depends on it for today's content).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:48:47 -04:00
thejayman77 215a5c4d64 Play hub + Daily Word game (Phase 1 of the games feature)
A calm /play space — "after the brief, a small thing to enjoy." Framework-ready
for more games (Word Search next; zen/coloring later).

* Daily Word (5 letters / 6 guesses) + Long Word (6 / 7) — same Wordle mechanic,
  Upbeat Bytes flavor (no "Wordle" in the UI). Hopeful answers; after solving, a
  one-line "why this word matters."
* LLM proposes, code disposes: answers are picked deterministically by date-seed
  from a hand-curated hopeful pool that's pre-validated ⊆ the guess dictionary
  (always typeable), avoiding recent repeats; the LLM only adds the optional
  "why" (with fallback). daily_puzzles(date, game, variant, payload) stores them
  so everyone gets the same daily; the cycle pre-generates with the "why".
* Bundled guess dictionaries (words-5/6.json, ~12.6k/22.4k) for client-side guess
  validation — never the LLM. Answer lightly obfuscated (base64) in the payload.
* Private, gentle stats (played/solved/streak, guess distribution); spoiler-free
  emoji-grid share. No leaderboard, no timer, no streak-loss drama.
* Play in the bottom nav (replacing Browse, still on the lane rail) + the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:06:20 -04:00
thejayman77 d0fb153e46 "Since you last visited" cue + PWA install (add to home screen)
Two calm returning-reader features.

Since-last-visit (Highlights companion, not a nav lane — per Codex):
* queries.feed gains a `since` filter; GET /api/since?ts= returns the count +
  a few accepted/non-dup/visible articles discovered since the reader's last
  visit (boundary-respecting; invalid/future ts → 0, no error).
* Home stores last_seen in localStorage (reads prev, then stamps now); on
  Highlights, a gentle "Since you were last here, N new calm reads came in"
  note with a "See what's new" reveal of a compact inline section. Dismissible.
  No badges, no unread counts, no "missed" language.

PWA:
* Real PNG icons (192/512 + full-bleed maskable) rasterized from favicon.svg;
  manifest fixed (azure theme to match the brand, PNG icons); apple-touch-icon.
* Minimal service worker: precache the app shell, always-fresh API + /a/ pages.
* Gentle, dismissible install banner (beforeinstallprompt → Install; iOS → the
  Share → Add to Home Screen hint). Never nags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:38:12 -04:00
thejayman77 d8e246b4ff Follow source/topic — account-backed personalization (v1)
Per Codex — turn accounts into a real reason to return, without an algorithmic
feed. Durable interests (sources + tags), not moods.

* DB: user_follows (user_id, kind source|tag, value, unique).
* queries.feed gains follow_sources/follow_tags → the Following feed is
  "articles from a followed source OR carrying a followed tag", still respecting
  calm filters/boundaries.
* API: GET/POST/DELETE /api/follows (sign-in required; source ids validated);
  /api/feed?following=true resolves the user's follows (anon → empty, not error).
* Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a
  Follow button on source + tag/topic views; a "Following" lane in the nav with
  a tailored empty state; a Following management section in Account (unfollow).

Digest "From what you follow" deferred to v2 (brief stays first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:34:46 -04:00
thejayman77 1956d7fd23 Digest polish: honest on-site wording, one-tap opt-in after sign-in, List-Unsubscribe
* On-site end-cap now says "You're caught up for now." — honest, since Highlights
  refreshes through the day (the email keeps the daily "see you tomorrow").
* Anonymous "Get tomorrow's brief by email" now honors the one-tap promise:
  sets a pending flag, opens sign-in, and auto-enables once auth resolves.
* Email compliance (RFC 2369/8058): send_email takes optional headers; the digest
  sets List-Unsubscribe + List-Unsubscribe-Post=One-Click, and a POST
  /api/digest/unsubscribe handles native one-click (GET still serves the page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:35:05 -04:00
thejayman77 cf5cbb33c0 Daily digest (opt-in) + finite "you're caught up" ending
Reader-retention as ritual, not capture (Codex's framing). Opt-in calm morning
email of today's brief; the on-site twin is the finite end-of-feed nudge.

* Schema: users.digest_enabled + digest_unsub_token; digest_sends (dedupe +
  visibility). auth.get_user now returns the digest fields.
* goodnews/digest.py: build (dated calm subject, items w/ summary + "why it's
  here" + UB/source links + one-click unsubscribe, "you're caught up" sign-off)
  and send_due_digests (morning-window gated, >=4-item floor or skip quietly,
  deduped, reuses SMTP). No streaks/urgency/"you missed".
* API: /auth/me exposes digest_enabled; POST /api/account/digest toggle;
  GET /api/digest/unsubscribe (token, no login, calm confirmation page).
* CLI: cycle gains a morning-gated digest step (--no-digest) + a send-digests
  command (--force).
* Frontend: digest toggle on the Account profile; the Highlights end-cap now
  says "you're caught up — see you tomorrow" with a one-tap "Get tomorrow's
  brief by email" (signed-in → enable; anon → sign in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:17:46 -04:00
thejayman77 ee00d8e89b Sources: "Check source" read-only spot-check action
Per Codex — a per-row Check button that previews a LIVE source on demand,
intentionally read-only and ephemeral.

* POST /api/admin/sources/{id}/preview — admin-gated, safe-fetch + heuristic
  preview (reuses the candidate preview path), returns the result. Mutates
  NOTHING: no DB write, no poll attempt, no health/state change. 404 on missing.
* UI: per-row Check button with a Checking… state; results in an inline row
  under the source (sampled, would-pass %, recent-7d, example accept/skip
  headlines) with dismiss; inline error on failure. "Checked just now" is
  local UI state only. Heuristic v1 — model deep-check left for later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:11:08 -04:00
thejayman77 6bfee767d0 CSV export: defuse formula injection in cells
Per Codex: source-controlled strings (name, feed_url, last_error, review_reason)
could be read as formulas by spreadsheet apps if they start with = + - @. Add
_csv_cell — prefixes such strings with an apostrophe; numbers pass through
untouched (no risk, and avoids mangling negatives). Routed every exported cell
through it. Test: a =HYPERLINK(...) source name is escaped, never bare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:14:59 -04:00
thejayman77 1cd7f1d89a Admin CSV export (sources snapshot + audience time-series)
Per Codex v1 — boring-in-the-best-way: inspect/archive operational data outside
the app. Admin-gated, Python csv module, text/csv + attachment disposition.

* GET /api/admin/export/sources.csv — current-state snapshot per source: name,
  feed/homepage, status, visible, served/accepted/total, acceptance/duplicate/
  accepted-dup/image-coverage %, last success/error, retry-after, review.
* GET /api/admin/export/audience.csv?days= — summary block (visitors, returning,
  accounts, feedback, shares) + a blank line + the daily visits/opens series;
  range applies to audience, sources is a snapshot.
* source_health now also returns feed_url/homepage. Small download links on the
  Sources + Audience tabs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:05:09 -04:00
thejayman77 1a8d1b3bf1 Promote-candidate UI: add-a-source pipeline in the admin console
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.

* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
  http(s) only, every redirect hop re-validated via enrich._host_is_public,
  body size-capped, bounded redirects, no cookies. preview_feed gains a
  `fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
  used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
  before the outbound fetch, no DB conn held during network); /{id}/preview
  (explicit re-preview); /{id}/promote (paused by default, returns the new
  source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
  preview (pass rate, recent count, example headlines) with Promote (as paused,
  or Activate immediately) / Re-preview / Reject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:28:00 -04:00
thejayman77 9ed817c051 Source Retire lifecycle (Phase 1: status + content_visible, active mirrored)
Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.

* Schema: sources.status (active|paused|retired) + content_visible; migration
  backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
  so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
  separate, reversible lever: content_visible=0 drops a source's articles from
  the public feed + brief (read AND build), behind a confirm. Personal saved/
  history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
  /active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
  Pause/Resume · Retire/Restore · Hide/Show actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:58:15 -04:00
thejayman77 8cce3a2165 Admin: analytics date-range toggle (7d / 30d / 90d)
/api/admin/stats accepts ?days= (clamped to 7/30/90, default 30) → passed to
admin_stats, which already windows visitors, retention, funnel, sharing, daily
trend, and the top lists by that span. Frontend adds a Window picker on the
analytics tabs (Overview/Content/Audience); changing it refetches and the
windowed labels ("Visitors (Nd)", "Last N days", "Returning visitors (Nd)")
follow. Corpus totals + source health are unaffected (not time-windowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:14:47 -04:00
thejayman77 84bc5b0267 Source management console: pause/resume, flag/clear, decision metrics
Turn the Sources tab into a real management console (per Codex):

* source_health now lists ALL sources (active + paused) with backing metrics:
  served / accepted_total / total_articles / duplicates + acceptance & duplicate
  rates + review_reason, alongside last success/attempt, next poll, failures.
* Admin endpoints (gated, 404 on missing): POST sources/{id}/active (pause/
  resume) and /review (flag/clear with reason).
* Pausing only stops future polling — the feed query has no active filter, so a
  paused source's accepted articles stay live.
* Frontend: metric table + Paused filter + per-row Pause/Resume & Flag/Clear
  (optimistic, revert on failure). Attention 'resting' now scoped to active.

Retire/Delete intentionally deferred (distinct lifecycle state, later).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:04:40 -04:00
thejayman77 762f121320 Admin step B: stats endpoint + /admin dashboard
- users.is_admin (+ migration); admin = is_admin OR email in GOODNEWS_ADMIN_EMAILS
  (normalized). is_admin exposed on /api/auth/me. Server-authorized GET
  /api/admin/stats (403 for non-admins).
- queries.admin_stats: visitors (today/7d/30d), returning vs one-and-done, top
  opened articles, popular groupings + topics (derived from article_id at query
  time), share breakdown, daily opens/visits trend — all aggregate, no PII.
- /admin page (gated, redirects non-admins): stat cards, CSS bar lists, a daily
  trend; "Admin dashboard" link on /account for admins. 129 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:25:46 +00:00