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
+32 -1
View File
@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import { getJSON, postJSON } from '$lib/api.js';
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
@@ -43,6 +43,20 @@
persistPrefs();
}
let digestBusy = $state(false);
async function toggleDigest() {
if (!auth.user || digestBusy) return;
digestBusy = true;
try {
await postJSON('/api/account/digest', { enabled: !auth.user.digest_enabled });
await refresh(); // re-pull auth.user with the new digest_enabled
} catch {
/* leave as-is */
} finally {
digestBusy = false;
}
}
// Load the saved grid when entering that section while signed in.
$effect(() => {
if (section === 'saved' && auth.user && !savedReady) {
@@ -128,6 +142,13 @@
<!-- profile -->
{#if auth.user}
<AccountPanel onclose={() => {}} />
<section class="panel digest">
<h2>Daily digest</h2>
<p class="dnote">A calm morning email — today's handful of good things, with a one-tap unsubscribe. No streaks, no noise; just the brief in your inbox.</p>
<button class="dtoggle" class:on={auth.user.digest_enabled} onclick={toggleDigest} disabled={digestBusy} aria-pressed={auth.user.digest_enabled}>
{#if digestBusy}{:else if auth.user.digest_enabled}✓ On — you'll get tomorrow's brief{:else}Get the daily digest{/if}
</button>
</section>
{:else}
<p class="gate">Sign in from the home page to manage your profile, saved articles, and devices.</p>
{/if}
@@ -165,6 +186,16 @@
}
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px; }
.digest { margin-top: 16px; }
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
.dtoggle {
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
}
.dtoggle:hover { border-color: var(--accent-deep); }
.dtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.dtoggle:disabled { opacity: 0.6; cursor: default; }
.phead { display: flex; align-items: baseline; justify-content: space-between; }
.phead h2 { font-size: 1.3rem; }
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }