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>
Two concrete latency wins found by measuring (server compute is 2-17ms; the time
is in the path, not the box):
- Admin panel fired its 6 API calls SEQUENTIALLY (await chain) — so it paid the
uncached origin round-trip six times back-to-back. Now one Promise.all batch.
This is the admin lag.
- /api/brief (the home "Gathering the good news…" content) wasn't edge-cached, so
a distant anonymous visitor triggered a Cloudflare→residential-origin pull.
Same global/shareable boundary as /api/feed: public s-maxage=45 when no
prefs/exclude, else private,no-store. (Needs /api/brief added to the CF cache
rule path list to take effect at the edge.)
Tests: test_brief_cache_boundary. 228 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A staged candidate could only be renamed by rejecting and re-adding it, which
churns the queue and discards the preview just to fix a typo. Add an inline
Rename on each candidate: a "Rename" pill swaps the name for an input
(Enter saves · Esc cancels), POST /api/admin/candidates/{id}/rename →
sources.rename_candidate(). Empty clears the name (promote then derives one
from the feed host). Preview is preserved; the fixed name carries into promotion.
Tests: test_candidate_rename (rename in place keeps preview, promotes with the
new name, gated + 404). 225 pytest + 11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The sources table scrolled horizontally but had no height cap, so its horizontal
scrollbar sat at the bottom of a 46-row table — you had to page the whole window
down to reach it. Make .tablewrap a self-contained scroll panel (max-height 65vh,
overflow auto, bordered card) so both scrollbars stay on-screen; pin the header
row sticky (box-shadow divider survives position:sticky under border-collapse).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Two hardening fixes from Codex's audit:
- _pick_answer falls back to the curated baseline if the live pool is empty,
so an admin tombstoning every answer in a variant can't divide-by-zero the
daily picker. Test added (test_picker_survives_empty_live_pool). Chosen over
a minimum-count block: robust without refusing legitimate removals.
- Removal copy is now honest — "Removed from future puzzles (today's answer is
already set)" — since a tombstone doesn't rewrite today's generated
daily_puzzles row. Panel intro updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
* 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>
* 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>
Per Codex. A branded recovery card in app.html shows if the app hasn't mounted
in 7s, or on a pre-mount JS error/unhandledrejection — with a "Refresh Upbeat
Bytes" button. A chunk/preload failure (vite:preloadError) reloads once
(sessionStorage-guarded). +layout calls window.__ubBooted() on mount to clear
the card + timer. A pre-mount failure also fires a tiny anonymous client_error
beacon; the admin Overview now shows "Load errors today" (red if >0) so we can
see if blank-risk is happening in the wild.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Per Codex — make the table more decision-ready from data we already have.
Paywall is a domain-level hint, so it's a per-source flag (not a meaningful
rate): show image-coverage % plus a 🔒 marker for subscription domains in one
compact "Media" column (tooltip spells it out). source_health gains a
`paywalled` flag (is_paywalled on homepage/feed); also added to sources.csv.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Per Codex's spec — a publisher saying "slow down" shouldn't make a feed look
broken, but repeated 429s stay visible via last_success_at / stale-source.
* Schema: sources.retry_after_at (nullable) + migration.
* feeds.parse_retry_after: delta-seconds OR HTTP-date → UTC stamp; ignores
invalid/negative/past; caps at now + MAX_BACKOFF_MINUTES.
* fetch_feed raises RateLimited (carrying the parsed time) on a 429.
* poll_source: on 429 set retry_after_at + last_error, status='rate_limited',
and do NOT increment consecutive_failures; on success clear retry_after_at;
non-429 failures unchanged.
* due_source_rows requires BOTH the streak backoff elapsed AND retry_after_at
passed (i.e. the later of the two).
* Admin: source_health returns retry_after_at; status reads
"rate-limited · rests until …" rather than "failed/resting".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex polish note: promote/reject/re-preview now Object.assign the
server-returned candidate onto the local one — keeps status/updated_at/preview
(and any future fields) in sync, while preserving the transient UI fields
(_cat/_activate/_err). Promote uses res.candidate then loadStats().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex
greenlit for this narrow, admin-only surface).
* markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text.
Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and
span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong,
i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles
(content kept as escaped text) and discards script/style content entirely.
* API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html,
derives plain text → stored message + the email text/plain part. Unchanged:
multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422.
* Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List),
execCommand with styleWithCSS=false for semantic tags, font size wraps the
selection in a fixed-px span, paste intercepted as plain text. No links yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's optional note: alongside the ingest-wide duplicate_rate, expose
accepted_dup_rate — of what a source got ACCEPTED, how much was a duplicate of
already-served content (accepted_total − served). Nearly free (derived from
existing counts); surfaced as a tooltip on the Dup column so the table stays
uncluttered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: a constrained Markdown-ish composer rather than contenteditable.
* goodnews/markup.render_reply_html — escapes everything first, then introduces
only a tiny whitelist (**bold**, - bullets, #/##/### headings, paragraphs,
line breaks). No links, attributes, inline styles, or raw HTML passthrough.
* feedback_replies.message_html column (+ live migration); replies store both
the Markdown text and the rendered HTML.
* email_send.send_feedback_reply now sends multipart text/plain + text/html
(the sanitized render, wrapped in a trusted email template).
* Frontend: textarea + a small toolbar (Bold / • List / H) that inserts
Markdown; the reply thread renders the server-sanitized HTML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's polish note: flagging a source now opens a small inline popover for
the optional reason (consistent with the calm admin UI) instead of a native
prompt(). Clearing a flag stays immediate. Backdrop/Escape/Cancel close it;
Enter confirms. Optimistic with revert-on-failure, like the other actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reply to a reader from the admin inbox instead of a mailto. Per Codex: keep v1
plain text (no rich editor — defers the user's bold/bullets ask as a fast-follow).
* DB: feedback_replies table (feedback_id, user_id, message, sent_to, sent_at),
created on the live DB.
* email_send.send_feedback_reply: plain-text "Re: Your Upbeat Bytes feedback"
with a quoted context block, no analytics/account details.
* API: POST /api/admin/feedback/{id}/reply — admin-gated, requires the feedback
exists (404) and has a contact_email (400), trims+caps the message; sends via
SMTP and only records the reply on success (502 on send failure so the UI keeps
the draft); marks the item read. Feedback list now includes each item's replies.
* Frontend: inline composer (Send/Cancel, sending state, error keeps draft) +
reply thread under the message; Reply only shows when there's an address,
else "No reply address".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/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>
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>
Make the admin Feedback section a real inbox.
* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
{read} toggles it; DELETE /api/admin/feedback/{id} removes a message
(both admin-gated). admin_stats gains feedback_unread; the Attention strip
and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
Unread filter joins the category chips; each message has Mark read/unread
and Delete (confirm), with optimistic updates that revert on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit:
* Unknown ?section= values now clamp to Overview, so the page never renders the
tabs with an empty body.
* Summary/image coverage counts join through articles+scores and require
accepted=1 AND duplicate_of IS NULL, so percentages stay ≤100% and honest as
rejected/duplicate rows accrue summaries over time.
* A source that's both resting and flagged now shows "⚠ resting · review"
rather than hiding the review flag behind the resting state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Placeholder: bold, slightly larger initial letter on the topic word; make
health (teal) and environment (leaf green) clearly distinct and show more hue
in the deepened word so they're easy to tell apart.
* Article page: the source name was chopped to its first word ("Read the full
story at The") — use the full publisher name; open the source link in a new
tab so upbeatbytes.com stays put.
* Use the new SVG back arrow on the account and admin top bars (matching the
article page) instead of the old "←" glyph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Date fix: introduce GOODNEWS_TZ (goodnews/localtime.py) so the brief's "today"
rolls over in a pinned zone (Eastern) instead of UTC — robust to host-clock
resets. The home page now formats the brief's date in each VISITOR's local
timezone (from its UTC freshness stamp), so nobody ever sees "tomorrow."
* Admin "Content served": articles live, fresh (7d), ingested (24h), summaries,
active sources, today's brief size — queries.content_stats().
* Admin "Source health": per active source, the failure streak, last error,
accepted contribution, and computed next-poll time (so backoff / "resting
until" is visible), via queries.source_health() reusing the feeds backoff
math. Failing sources sort to the top; times render in the viewer's zone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feedback:
- feedback table; POST /api/feedback (anonymous-ok, optional category/email,
honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback.
- Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop
header, the mobile top bar (logo moves left), the footer, and /account. Feedback
section in /admin.
Stats (additive, same privacy model — no IP/UA/referrer/raw terms):
- Event vocab: summary_viewed (fired on /a load), full_story (card → source),
not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace,
paywalled_source_open. Card title/image opens /a (no double-count); history
records via keepalive so it survives the nav.
- Dashboard: Accounts card (counts only), reading funnel (summary→source rate),
emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics
deferred to a future monitoring dashboard.) 131 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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>