Add opt-in Daily Digest (daily email of last 24h of library recaps)

Multi-mode, off by default. Each new recap is synthesized into a 1-2
paragraph overview via the relay (operator-absorbed) and cached onto the
session JSON; a daily 08:00 scan emails opted-in users their fresh
recaps, deduped by a per-user watermark that never skips a failed or
over-cap recap. One-click tokenized unsubscribe; settings-modal toggle;
admin test trigger. Bumps to 0.2.158.
This commit is contained in:
Keysat
2026-06-15 19:50:48 -05:00
parent 962423ca10
commit b4fa5d7be8
14 changed files with 1144 additions and 17 deletions
+72
View File
@@ -2362,6 +2362,9 @@
// User's own active sessions (lite-settings tenant view). Loaded
// on demand when the Settings modal opens.
mySessions: { rows: null, loading: false, error: null },
// Daily Digest opt-in (lite-settings tenant view). enabled is null
// until loaded from /api/account/digest when the modal opens.
digest: { enabled: null, loading: false, saving: false },
// "Take Recap home" — fetches the tenant's raw license key on
// demand. We don't load this in /api/account/whoami because the
// key is a bearer credential we'd rather not pass through the
@@ -7343,6 +7346,7 @@
${state.account?.user ? renderClaimPurchaseBlock() : ""}
${state.account?.user ? renderPasswordBlock() : ""}
${state.account?.user ? renderMySessionsBlock() : ""}
${state.account?.user ? renderDigestBlock() : ""}
${renderLibraryTransfer()}
${state.account?.user ? renderTenantDangerZone() : ""}
</div>
@@ -7874,6 +7878,33 @@
`;
}
// Daily Digest opt-in toggle. Off by default; a single switch that
// POSTs /api/account/digest. enabled is null until loaded — show a
// muted "Loading…" rather than a misleading unchecked box during the
// round-trip so the user never sees the wrong initial state.
function renderDigestBlock() {
const d = state.digest || {};
const checkboxAttrs =
d.enabled === null || d.loading || d.saving ? "disabled" : "";
return `
<label class="field-label" style="margin-top:14px;">Daily digest</label>
<div style="border:1px solid #1e293b;background:rgba(15,23,42,0.4);border-radius:8px;padding:12px;">
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#cbd5e1;cursor:${checkboxAttrs ? "default" : "pointer"};">
<input type="checkbox" ${d.enabled ? "checked" : ""} ${checkboxAttrs}
onchange="setDigestEnabled(this.checked)" style="margin:2px 0 0;" />
<span>
Email me a daily digest
<span style="color:#64748b;display:block;font-size:10px;margin-top:2px;line-height:1.5;">
${d.enabled === null
? "Loading…"
: "A once-a-day email summarizing the recaps you added to your library in the last 24 hours. Off by default; skipped on days with nothing new."}
</span>
</span>
</label>
</div>
`;
}
// Danger Zone — sits at the very bottom of the tenant lite-settings
// modal. One action for now (Delete Account); future destructive
// self-actions land here. Visually muted by default to avoid being
@@ -10264,6 +10295,7 @@
loadAdminActivity(state.ops.activityHours || 24);
} else if (state.account?.user) {
loadMySessions();
loadMyDigest();
}
}
render();
@@ -12012,6 +12044,46 @@
}
}
async function loadMyDigest() {
state.digest.loading = true;
render();
try {
const res = await fetch(`${API_BASE}/api/account/digest`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.digest.enabled = !!data.enabled;
} catch (e) {
// Leave enabled null; the block keeps showing "Loading…" rather
// than asserting a state we couldn't confirm.
} finally {
state.digest.loading = false;
render();
}
}
async function setDigestEnabled(enabled) {
const prev = state.digest.enabled;
// Optimistic flip so the switch responds instantly; revert on error.
state.digest.enabled = enabled;
state.digest.saving = true;
render();
try {
const res = await fetch(`${API_BASE}/api/account/digest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
showToast(enabled ? "Daily digest on" : "Daily digest off", "✓");
} catch (e) {
state.digest.enabled = prev;
showToast("Couldn't save that — try again", "!");
} finally {
state.digest.saving = false;
render();
}
}
async function revokeMySession(sessionId) {
try {
const res = await fetch(