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: 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 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 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>
Codex's remaining caveat: appNavDepth decremented on every popstate, so a
browser Back then Forward undercounted (in-page Back would jump to Highlights
early). Use the navigation's signed delta on popstate (Back -1, Forward +1,
±N for jumps) instead of a flat decrement, so the depth stays accurate through
any back/forward dance. Falls back to -1 if delta is unavailable (safe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit follow-ups:
* Track in-app navigation depth (forward goto/link increments, popstate
unwinds, clamped at 0) and base the in-page Back on it instead of
history.length. A direct deep link (email/social/article) now sends the
in-page Back to Highlights rather than out of the site.
* Apply the same stale-load guard to the Today/Highlights path that feed views
have, and only scroll-to-top when the load is still current — avoids stale
error/scroll state during quick navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per audit (user + Codex): the in-page Back and browser Back were two separate
histories, which is confusing — especially for less-technical users. Make the
URL the single source of truth so both traverse one history.
* The view derives from the URL (/?view=latest, /?tag=, /?source=, bare / for
Highlights); `selected` is $derived from $page.url.
* All navigation goes through goto(); afterNavigate is the single loader hook,
so in-app clicks AND browser back/forward reload the same way.
* The in-page Back button now just calls history.back() (fallback to Highlights)
— identical to the browser Back. Removed the private navStack.
* Stop stripping ?source= — the URL stays honest, so source/tag views are
shareable and survive reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Position the in-feed Back button to the right of the view title (inline with
it) instead of stacked above; the accent underline moves under the title text.
* Deep-linking a source feed from an article page (/?source=<id>) now seeds the
back history so the Back button appears (returns to Highlights).
* Strip the ?source= param after consuming it (replaceState) so it can't linger
and make the browser back/forward behave oddly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Back button on feed views: drilling into a tag or source from a card now
remembers where you came from (a small history stack), and a "← Back" appears
in the view header to return there — chains of drill-ins included. Top-level
nav (rail/bottom bar) resets the history.
* Article page: the source name is now a link into that source's in-app feed
(/?source=<id>); the SPA reads the param on load and opens the source view
(label falls back to the loaded feed's source name). Completes the
"cards-only v1" — source is clickable on /a/ too now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Click a source name on any card → a feed of just that source's articles,
newest-first, still accepted / non-duplicate / boundary-filtered (the calm
promise isn't bypassed). A natural way to follow a publication's feel.
* queries.feed + /api/feed: source_id filter; Article output gains source_id.
* Frontend: source label is a button → transient 'source:<id>' view (like
'tag:<slug>'), rendered in the feed grid with Load more, header = source name.
* Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) +
Follow; shareable /source/<slug> route and source_view analytics come then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carry the brief's uniform card language (compact photo banner or flat topic-
colored placeholder) onto every feed grid — Latest, topic/tag/mood views, and
the account Saved grid — so the whole site is visually consistent. Same quality
gate and uniform sizing; the brief hero remains the single large image.
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>
Per review: the summary and the "Why it's here" line were competing in a tight
card and both came up short. Remove "why" from cards (it still appears on the
article page when clicked in) and give the summary the room (image cards 2→4
lines). Also flatten the placeholder banner — solid topic-color tint instead of
a gradient-to-blank, with the topic word in a deep near-black shade of that
color so it reads clearly instead of washed out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, mixed photo/no-photo rows read as ragged ("the ones without feel
lacking"). Make every brief rest card carry a banner so the grid is uniform: a
compact 16:9 photo when available, otherwise a calm placeholder tinted by the
card's topic accent color (the same per-topic hue as the accent line) with the
topic word set faint in serif. A failed/blocked image falls back to the same
placeholder, so cards never look broken and heights stay identical. Hero remains
the single large image. Light desaturation on photos; no heavy tint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure the nav around two permanent lanes, then the reader's chosen ones:
"Highlights" (the curated daily brief — formerly "Today") and "Latest" (the
freshest accepted stories, newest-first). Now that the gate is tight, a
chronological "incoming" feed is safe to expose.
* feed(): new sort="latest" (pure recency) alongside the default best-first
rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only
and boundary-respecting either way.
* lanes.py: two pinned lanes (Highlights + Latest) instead of one.
* Home: "Latest" view + "Load more" pagination for every feed view (offset-
paged, de-duped). Mobile bottom bar gains a Latest tab.
* LanePicker shows both pinned lanes; nav rail renders them first.
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>
Pre-traffic cleanup from an audit:
* Scheduler: poll_due_sources now keys on the last *attempt* (success or
failure), not the last success, and scales the wait by the consecutive-
failure streak (capped at a day). A failing feed (e.g. Phys.org's HTTP 429s)
used to be retried every cycle because it had no successful run; it now backs
off and recovers on its own. Extracted due_source_rows() + tests.
* FK hygiene: deleting a daily_brief is supposed to cascade to its items, but
SQLite enforces foreign keys per-connection — connect() already sets the
pragma, so the cascade is correct going forward; added a regression test.
(Orphaned items + Phys.org settings were cleaned directly on the live DB.)
* a11y: modal/drawer dialogs are now focusable (tabindex), close on Escape
(window) and on backdrop click via a target check (dropping the inner
stopPropagation handlers). Build is warning-free.
* tests: conftest points any un-mocked LLM client at a closed port with a 1s
timeout, so an accidental real call fails fast instead of hanging the suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Lanes" section under Account that reuses LanePicker inline, completing
the round trip with Boundaries. Refactor LanePicker to support an `inline`
variant (bare panel vs modal) and apply changes immediately on toggle — so the
account panel needs no explicit save and the home modal now previews the nav
rail live as you pick. Selection still persists through the shared prefs store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Readers can now choose which quick-access lanes sit above the feed; "Today"
stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source
of truth over three lane kinds the feed already renders: moods, primary topics,
and high-volume Discovery tags. Selection lives in the existing prefs blob
(localStorage + /api/prefs sync); the filter parser ignores the new `lanes`
field, so it rides along harmlessly. Default = today's moods, unchanged.
Food/Space stay grouping tags rather than primary topics (per review): `space`
already existed; added `food` to the Mind & Craft family so the classifier
assigns it, and seeded the Food lane by re-tagging the two food sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the summary pages discoverable so traffic compounds passively:
- /today: a server-rendered, shareable + indexable digest of today's brief —
each item's title (→ /a summary), our summary, and a source link. OG/Twitter
meta + self-canonical.
- /sitemap.xml: dynamic — home, /today, and every accepted non-duplicate /a page
with lastmod. robots.txt allows all and points to it.
- Home (SPA shell) gains canonical + OG/Twitter tags for cleaner unfurls.
- Caddy routes /today + /sitemap.xml to the API. 133 tests pass.
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>
Make summaries the core reading experience (summary-first, source-forward):
- Cycle pre-warms summaries for Today's 7 (idempotent → only new ones hit the LLM).
- /api/brief items carry their cached summary; Today cards (hero + tiles) show it
inline, so Today reads as a calm briefing.
- Card title/image now open the /a summary page (the canonical artifact), with a
visible "Full story" link straight to the source on every card (the escape hatch).
- /a gains related-grouping chips + a Copy-link/share control.
- Tighten the summary prompt: original, factual, no quotations / no close paraphrase.
Long tail stays lazy+cached. No article bodies stored. 129 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inline Boundaries/History panels lived on the home page, so opening them while
scrolled left you stranded. Move everything "yours" behind the account icon:
- Home header slims to: Saved (opens a right-side flyout, signed-in) · shield
(Boundaries indicator — filled when active — linking to the Boundaries section) ·
avatar. The inline panels + the home "saved" view are gone.
- /account is now a sectioned hub (left sidebar on desktop, top tabs on mobile),
OPEN TO EVERYONE with each section self-gating: Profile (sign-in), Saved (sign-in),
History (device/account), Boundaries (device/account), Admin (admins). This keeps
Boundaries/History usable without an account (they're device-local) while
consolidating the UI — and every section loads at the top, fixing the scroll bug.
- Lift Calm Filters and History into shared stores (prefs.svelte.js, history.svelte.js)
so the home feed (applies/records) and the account page (edits/manages) share one
source of truth. New SavedFlyout component. Card boundary actions only render when a
handler is provided.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clearSession only reset the device-local history; for a signed-in user the panel
shows the account history (serverHistory), which was never cleared and never sent
to the server — so it looked like nothing happened. Add DELETE /api/history
(clear all) and have clearSession reset serverHistory + call it when signed in.
- 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>