From cf5cbb33c011415edc50da2bc8d1ea62199f4696 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 9 Jun 2026 16:17:46 -0400 Subject: [PATCH] Daily digest (opt-in) + finite "you're caught up" ending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/routes/+page.svelte | 32 ++++- frontend/src/routes/account/+page.svelte | 33 ++++- goodnews/api.py | 49 +++++++ goodnews/auth.py | 3 +- goodnews/cli.py | 17 +++ goodnews/db.py | 15 +++ goodnews/digest.py | 162 +++++++++++++++++++++++ tests/test_admin.py | 18 +++ tests/test_digest.py | 60 +++++++++ 9 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 goodnews/digest.py create mode 100644 tests/test_digest.py diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index dde7ee2..e015b7a 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -303,6 +303,17 @@ } } + // The finite-ending's gentle nudge: one tap to get tomorrow's brief by email. + let digestBusy = $state(false); + async function subscribeDigest() { + if (!auth.user) { showSignIn = true; return; } // sign in, then enable below + if (auth.user.digest_enabled || digestBusy) return; + digestBusy = true; + try { await postJSON('/api/account/digest', { enabled: true }); await refreshAuth(); } + catch { /* leave as-is */ } + finally { digestBusy = false; } + } + const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' }; function applyAction(kind, value) { applyPrefAction(kind, value); // updates + persists + syncs to account @@ -430,7 +441,17 @@ {/if} -

✦ that's the good news for today ✦

+
+

✦ that's the good news for today ✦

+

You're caught up — see you tomorrow.

+ {#if auth.user?.digest_enabled} +

Tomorrow's brief is headed to your inbox ☕

+ {:else} + + {/if} +
{:else}

No highlights yet today — try a calmer filter, or check back soon.

{/if} @@ -532,6 +553,15 @@ text-align: center; color: var(--muted); font-family: var(--serif); font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em; } + .endcap .endmark { margin: 0; } + .endcap .endsub { margin: 4px 0 0; font-size: 0.92rem; } + .endcap .digestnote { margin: 14px 0 0; font-style: normal; font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep); } + .endcap .digestcta { + margin-top: 16px; font-family: var(--label); font-style: normal; font-size: 0.9rem; cursor: pointer; + background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 10px 22px; + } + .endcap .digestcta:hover { background: var(--accent-deep); } + .endcap .digestcta:disabled { opacity: 0.6; cursor: default; } .loadmore { display: flex; justify-content: center; margin: 30px 0 6px; } .loadmore button { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep); diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index a05fda4..a3715ca 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -1,7 +1,7 @@