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:
@@ -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 tomorrow’s 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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user