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>
This commit is contained in:
jay
2026-06-09 16:17:46 -04:00
parent 5d36e6b639
commit cf5cbb33c0
9 changed files with 386 additions and 3 deletions
+31 -1
View File
@@ -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 @@
</div>
{/if}
</section>
<p class="endcap rise">✦ that's the good news for today ✦</p>
<div class="endcap rise">
<p class="endmark">✦ that's the good news for today ✦</p>
<p class="endsub">You're caught up — see you tomorrow.</p>
{#if auth.user?.digest_enabled}
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
{:else}
<button class="digestcta" onclick={subscribeDigest} disabled={digestBusy}>
{digestBusy ? '…' : 'Get tomorrows brief by email'}
</button>
{/if}
</div>
{:else}
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
{/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);