The theme was floating between the title and the size options; give it its own
soft accent-tinted card so it reads as the day's headline, distinct from the
size choices.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
* In-app Back arrow is now deterministic on deep links: if there's in-app history
it pops (history.back); otherwise it navigates to the parent screen (game →
selection → hub) instead of leaving the site. Device Back stays native.
* Canonicalize ?game/?v: unknown game → hub; an invalid v for the game (e.g.
word&v=large or wordsearch&v=5) → the game's default, via replaceState so the
URL is clean and local-storage keys/status match. Derived variant/size are also
clamped so a bad URL can never crash the game with an invalid variant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Hub → Game Selection → Game screens were internal $state with no history
entries, so the device/browser Back button skipped straight out of /play. Now
the screen is derived from the URL (?game=…&v=…) and forward moves use goto, so
each screen is a real history entry: Back goes Game → Selection → Hub → site,
matching the rest of the app. The in-app Back button uses history.back() so it
mirrors the device button. Statuses refresh on every navigation (incl. Back).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the matched SVG icons, but restore the full-height split control column
(each key ~1.5 rows) — they read as prominent and set apart, which is the look
that was landing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ⌫ and ↵ glyphs never matched (different font metrics → different size +
baseline). Replaced both with matching 25px line-icon SVGs, perfectly centered.
Also made each control key one row tall — Backspace aligned to the top row,
Enter pushed to the bottom row — so they line up with the letters and look
proportional instead of two oversized half-height blocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The return arrow renders smaller than the backspace glyph at the same font size;
bump it so the two controls look balanced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final mobile keyboard pass per field feedback:
* Back to QWERTY (natural muscle memory) — uniform key width, with the shorter
rows auto-centered under the top row.
* Slimmer control column + Enter is now the ↵ glyph (not "ENTER"), so the letter
keys get a little extra width.
* Keyboard floats up and sits centered in the space between the bottom of the
board and the bottom of the screen (kbzone flex-centers it), instead of being
jammed against the bottom edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Keyboard was shrinking to content + centering (dead horizontal space, squished
letters): auto side-margins on a flex item in the mobile flex-column don't
stretch. Now full width (align-self: stretch, margin sides 0), so the 9 letter
keys spread across the screen and the controls look proportional.
* Board tile width budget bumped (56→64) to cover page padding + the 5px gaps, so
Long Word (6 wide) can't trigger the few-px horizontal scroll.
* Hid the play-area scrollbar (scrollbar-width: none) so no stray "divider" shows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The keyboard read as a timid little afterthought. Now it's a confident game board:
* Bigger, bolder keys (taller, larger font) with a tactile press — a soft bottom
edge + shadow that compresses on tap (translateY). Enter is a solid accent key
with its own depth; feedback keys keep matching depth.
* Board tiles a touch larger to fill the screen better.
* Real game feedback animations: tiles POP as you type, the row REVEALS with a
staggered bounce when you submit, and shakes on an invalid word. Respects
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From field testing.
* Keyboard redesigned to fill its area like a real phone game: a left block of
larger, square, alphabetical letter keys (9 per row) + a right column with
Backspace (top) / Enter (bottom) tucked in. Still flat, warm, on-brand.
(Alphabetical per request; QWERTY is a one-line swap if it feels off to type.)
* Fixed the few-mm horizontal scroll on Long Word: the tile width budget now
accounts for the inter-tile gaps + page padding, so the board can never exceed
the viewport width.
* Board sits up toward the top (flex-start) instead of vertically centered, so
the taller 6-letter board no longer crowds the keyboard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make Daily Word feel like a focused mobile app screen, not a page with a keyboard.
* True viewport: while view==='play' && game==='word', a $effect locks body scroll
and hides the site footer (mobile only), so the keyboard is genuinely pinned, not
riding the document scroll. Effect cleanup ALWAYS removes the class on re-run or
unmount, so leaving /play (back button OR any navigation) can never strand it.
* Keyboard restyled on-brand + modern: flat off-white (--surface) keys with a
hairline border, soft 11px radius, no heavy raised shadow, ~46px tall, ↵ / ⌫
glyphs, centered (max-width 430) instead of a full-bleed beige slab.
* Tiles now size to fit BOTH width and the height left above the keyboard
(--tile = min(cap, width/cols, (100dvh-budget)/rows), gap 4px), so the active row
and keyboard are always visible — Long Word's 6×7 gets slightly smaller tiles.
Real-device Safari/Chrome is the final check (100dvh + safe-area handling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intermittent blank screens / long "Gathering the good news…" — two fixes:
Origin cache headers (Caddyfile, deployed separately): content-hashed
/_app/immutable/* → max-age=31536000, immutable; everything else (HTML shell,
service worker, version manifest, webmanifest, word lists, icons) → no-cache,
so a deploy can't leave a stale shell/SW pinned. (Cloudflare's 4h Browser Cache
TTL still overrides this until its dashboard setting is switched to "Respect
Existing Headers" — that's the actual root cause.)
App startup hardening:
* getJSON now has a 10s AbortController timeout — a stuck request can never hang
the loading state forever.
* Home onMount loads moods+categories in parallel then the view, with loading
ALWAYS cleared in finally; lanes/families dropped to non-blocking decoration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mobile polish for the games.
* New flow: Play Hub → Game Selection → Game. The 5/6 (word) and S/M/L (search)
pickers move OFF the game screen onto a selection screen that shows each
option's today-status. Back-button reads "Game Selection" in a game and
"Play Hub" on the selection screen — buys vertical room for the keyboard.
* Daily Word on mobile now fills the height: the board scrolls in the middle and
the keyboard is pinned at the bottom, always reachable (no scrolling down to
type). Desktop stays inline.
* Keyboard restyled on-brand: warm cream keys (was cool generic grey), the label
font, an accent-tinted Enter, and the same green/gold/grey feedback as the
tiles; full-bleed, tactile press, safe-area aware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pulled the pointer→cell math out of cellAt() into a pure cellFromPoint(rect, x,
y, n) in $lib/wordsearch.js (only getBoundingClientRect stays in the component),
and covered it with vitest — including the last-column case that was drifting
under the old overflowing layout, plus clamping and a scrolled-origin rect.
11 vitest tests now; real-device testing remains the final validator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Playtesting fixes:
* Constant cell size (~32px) — the board GROWS with the grid instead of shrinking
letters into a fixed box. Fixes Small's oversized spacing; on a narrow phone the
largest grid gently scales to fit (the standard word-search compromise).
* Themes now gather ~28 words (LLM asked for 28; curated fallbacks ~22 each), and
each size samples its OWN subset — so every tier is a distinct puzzle. Large is
now reliably full (14 words on 14×14), fixing the "13 words / 11 listed" mismatch.
* Tiers: small 8×8/6, med 11×11/10, large 14×14/14.
* Word list is now a framed "Find these · n/total" palette panel (pill chips that
take on each found word's colour) instead of loose text under the grid.
* Size chips use qualitative labels (cosy / balanced / a longer sit) so no count
can ever contradict the actual puzzle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Per Codex's Phase 2 audit notes. Moved the drag-snap (lineFrom) and find-match
(matchWord) logic into $lib/wordsearch.js and added vitest coverage:
- lineFrom always yields a straight, in-bounds path — a non-straight drag snaps,
never returns bent; single cell and edge-clamping covered.
- matchWord matches forward + reversed selections, is a harmless no-op on an
already-found word (so completion/best-time can't double-record), and returns
null for non-words / too-short selections.
Restore behaviour audited: finish() (which records best-time) only runs when the
final word is found mid-play; on refresh, restore() repopulates found cells +
time and the derived status flips to done WITHOUT calling finish(), so best-time
never re-records. First JS test runner for the frontend (npm test → vitest run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The variant-watch $effect read `loading`, but load() flips `loading` false at
the end — which re-fired the effect, which called load() again, forever. The
board never rendered. Effect now tracks ONLY `variant`, so it loads once on
mount and once per variant toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex. Pool grown 51/44 → 115/104 hopeful answers (5/6 letter) via the
agreed workflow: LLM proposes themed candidates → code filters to the bundled
guess dictionary (length/alpha/dedup) → human spot-check prunes tone-drift
("growl", "plain", "color"…). ~3.5-month runway before repeats per variant.
test_wordpool.py locks the invariant in CI: every answer must be lowercase
alpha, correct length, unique, and present in words-5/6.json — so no future
addition can become an unguessable puzzle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Per Codex: generate_summary treated why_belongs alone as a complete explanation,
but get_explanation requires all three — so a partial older row (e.g. only
why_belongs) would never top up and the page would fall back forever. Now the
fully-cached check requires summary + what_happened + why_matters + why_belongs.
Test covers the partial-row top-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — make /a/<id> feel like Upbeat Bytes has editorial judgment, not just
a summary wrapper. Trust-building, short, not an essay.
* article_summaries gains what_happened / why_matters / why_belongs (+ migration).
* summarize.explain_article: a separate, fallback-able LLM pass producing three
short notes (parsed from a labelled WHAT/MATTERS/BELONGS format). generate_summary
now stores them alongside the summary, and tops up older summaries on next view.
get_explanation returns them only when all three are present.
* API: share_page + /api/summary expose the explanation.
* share.py: renders the three-part section (accent rule) when complete; otherwise
the single "Why it's here" reason line is the calm fallback. The page polls and
swaps in both the summary and the section as they cache.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — guard against the static logo silently going missing (which would
break the newsletter masthead). Non-fatal curl check after Caddy reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user — a heavier, darker divider (2px #9aa6b2) marks the brief→personal
section change, distinct from the light #e8e3d8 item separators above.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user — separate the heading a touch more from the first followed entry
(bottom margin 18px → 28px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It was 11px uppercase — smaller than the 14px links, backwards for a heading.
Now 20px bold ink, clearly a section header above its items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — give follows an immediate payoff without making the email feel
algorithmic. The editorial brief stays the star: subject + brief count unchanged.
* followed_digest_items(conn, user_id, exclude_ids, limit=3): recent items from
the user's followed sources/tags, same accepted/non-dup/content-visible gate,
excludes anything in the brief, capped to one per source so a single follow
can't dominate. Empty → section omitted (no empty state in email).
* build_digest gains an optional `followed` list → a small "From what you follow"
section AFTER the brief, only when there are items. Item rendering factored into
shared _item_html / _item_text_lines helpers.
* send_due_digests computes the followed items per user (excluding the brief).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: saveLanes() bounced to Highlights if the current view wasn't in the
customizable keys, but Following is a special pinned lane (like today/latest)
that's never in keys. Add it to the special-lanes exclusion so editing the lane
picker while viewing Following no longer navigates away.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Per Codex — cleanest, most brand-consistent option. Rasterized logo.svg to a
small transparent logo-email.png (360px wide, shown at 180px for retina), served
at /logo-email.png. alt="Upbeat Bytes" keeps the brand if a client blocks
images. SVG isn't email-safe; a hosted PNG is the standard approach.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user — make the text masthead read as the brand: lowercase 'upbeat bytes'
with a space but tight letter-spacing (-0.045em, not standard), in the logo's
two colours (azure #0083ad + navy #002772). Closest email-safe echo of the logo
short of a hosted PNG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user: lowercase masthead to echo the logo's character (kept as text, not an
image — email clients strip SVG and block remote images, so text always renders
and degrades gracefully). More breathing room before the 'Good morning' intro,
and a horizontal divider after it to match the item separators. (Real logo PNG
remains an option if wanted.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user feedback: rename from 'Today's good news' (which implied the site was
done for the day) to a publication-style 'Upbeat Bytes — Daily Highlights'
masthead with a warm morning intro and a sign-off that links back to the site
('more good news is always waiting'). Adds 'why it's here' to the plain-text
part too. No images by design (lightweight, mail-client-safe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex polish: if someone taps 'Get tomorrow's brief' then dismisses sign-in
without authing, clear pendingDigestOptIn (guarded by !auth.user so a successful
sign-in still auto-enables via the $effect).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
On phones the feed is a single column, so the banner no longer needs to keep a
uniform grid height. Below 540px: trim photo banners (16/9 → 2/1) and shrink
image-less placeholders to a slim 54px topic-label band. Desktop/2-column
unchanged (uniform heights still matter there).
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: outgoing reply now sets Reply-To = GOODNEWS_REPLY_TO_EMAIL, falling
back to the From address. Never the reader's own address (they're the recipient).
send_email gained an optional reply_to param. Failed-send stays UI-only (draft
kept) — no schema change, per Codex's lean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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: a paused/retired source with a future retry_after_at shouldn't nag
'rate-limited for 12h+' — it's intentionally out of polling. Scope long_rest to
active (matching the other operational items). Test: paused/retired rate-limited
sources stay quiet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — make the Overview strip diagnostic without making the operator hunt
through tables. Aggregated (one calm line per condition with a count), volume-
gated, conservative thresholds:
* Stale: active+visible source, last success > 10 days ago (warn).
* High rejection: >=20 ingested, acceptance < 25% (info).
* High duplicate: >=10 accepted, accepted-dup > 50% (info).
* Thin images: >=10 served, per-source image coverage < 25% (info).
* Long rate-limit: retry_after_at more than 12h out (info).
source_health gains a per-source images count + image_coverage. _attention takes
an optional now (for tests). Existing site-wide items (global image coverage,
thin brief, unread feedback) unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>