feat(daily): a calm opt-in morning briefing push

Add a once-daily briefing notification that reads the cached daily digest
and never makes a fresh model call. It rides the existing dispatch cascade
(APNs, Telegram, ntfy, webhook, email, web push), so it inherits the
hard-reject classification and the push-attempts ledger unchanged.

- New DAILY_BRIEFING event type, default off: the briefing stays silent
  until the user opts a channel in from the notifications matrix, so the
  channel choice is explicit and a fresh account never gets an unasked-for
  push.
- One decision seam both triggers share: the sleep-arrival morning refresh
  fires it the moment the day finalises, and a fixed local-morning fallback
  slot covers the case where last night's sleep never synced. Whichever
  wins the morning writes the ledger row; the once-per-local-day cap
  suppresses the other.
- The body is the digest line — the cached briefing lead when present, else
  the deterministic floor — so a keyless self-hoster still gets a
  first-class push. Provisional days carry an honest "sleep not in yet"
  note.
- Non-clinical and non-urgent by construction, fired only inside a local
  morning window: it is a nudge, never the anomaly channel, and never
  bypasses Focus. Empty accounts are left alone rather than nudged with a
  hollow all-clear.
- Localised across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 23:23:41 +02:00
1 parent b6ded4d1f9
commit 04cb80da6a
12 files changed
+940

No files matched your search

+6
View File
@@ -6516,6 +6516,8 @@
"eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.",
"eventMedicationIntakeSync": "Einnahme-Synchronisierung",
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
"eventDailyBriefing": "Täglicher Überblick",
"eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.",
"security": {
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
@@ -8504,6 +8506,10 @@
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
"allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal."
},
"push": {
"title": "Dein Morgenüberblick",
"bodyProvisional": "{line} · Der Schlaf von letzter Nacht fehlt noch."
},
"item": {
"doseWindow": {
"title": "Medikament fällig",
+6
View File
@@ -6516,6 +6516,8 @@
"eventMeasurementReminderDesc": "Sent when a tracked measurement is due.",
"eventMedicationIntakeSync": "Medication intake sync",
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
"eventDailyBriefing": "Daily briefing",
"eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.",
"security": {
"newDeviceTitle": "New sign-in to your account",
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
@@ -8504,6 +8506,10 @@
"score": "Your health score today is {score}.",
"allClear": "Nothing needs your attention today — everything's tracking normally."
},
"push": {
"title": "Your morning briefing",
"bodyProvisional": "{line} · Last night's sleep isn't in yet."
},
"item": {
"doseWindow": {
"title": "Medication due",
+6
View File
@@ -6516,6 +6516,8 @@
"eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.",
"eventMedicationIntakeSync": "Sincronización de tomas",
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
"eventDailyBriefing": "Resumen diario",
"eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.",
"security": {
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
@@ -8504,6 +8506,10 @@
"score": "Tu puntuación de salud hoy es {score}.",
"allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad."
},
"push": {
"title": "Tu resumen matinal",
"bodyProvisional": "{line} · El sueño de anoche aún no está disponible."
},
"item": {
"doseWindow": {
"title": "Medicación pendiente",
+6
View File
@@ -6516,6 +6516,8 @@
"eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.",
"eventMedicationIntakeSync": "Synchronisation des prises",
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
"eventDailyBriefing": "Résumé quotidien",
"eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.",
"security": {
"newDeviceTitle": "Nouvelle connexion à votre compte",
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
@@ -8504,6 +8506,10 @@
"score": "Ton score de santé aujourd'hui est de {score}.",
"allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement."
},
"push": {
"title": "Votre résumé du matin",
"bodyProvisional": "{line} · Le sommeil de la nuit dernière n'est pas encore disponible."
},
"item": {
"doseWindow": {
"title": "Médicament à prendre",
+6
View File
@@ -6516,6 +6516,8 @@
"eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.",
"eventMedicationIntakeSync": "Sincronizzazione assunzioni",
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
"eventDailyBriefing": "Riepilogo quotidiano",
"eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.",
"security": {
"newDeviceTitle": "Nuovo accesso al tuo account",
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
@@ -8504,6 +8506,10 @@
"score": "Il tuo punteggio di salute oggi è {score}.",
"allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente."
},
"push": {
"title": "Il tuo riepilogo mattutino",
"bodyProvisional": "{line} · Il sonno della scorsa notte non è ancora disponibile."
},
"item": {
"doseWindow": {
"title": "Farmaco da assumere",
+6
View File
@@ -6516,6 +6516,8 @@
"eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.",
"eventMedicationIntakeSync": "Synchronizacja przyjęć leku",
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
"eventDailyBriefing": "Codzienne podsumowanie",
"eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.",
"security": {
"newDeviceTitle": "Nowe logowanie do Twojego konta",
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
@@ -8504,6 +8506,10 @@
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
"allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie."
},
"push": {
"title": "Twoje poranne podsumowanie",
"bodyProvisional": "{line} · Brakuje jeszcze snu z zeszłej nocy."
},
"item": {
"doseWindow": {
"title": "Lek do przyjęcia",
@@ -0,0 +1,302 @@
/**
* S5 — daily-briefing push decision seam + composer.
*
* Pins the once-per-day, opt-in-only, morning-window, non-alert contract of
* `maybeDispatchDailyBriefing` and the floor/lead composition of
* `buildDailyBriefingPush`, all without a DB / provider / boss.
*/
import { describe, it, expect, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
maybeDispatchDailyBriefing,
buildDailyBriefingPush,
digestHasSubstance,
type DailyBriefingDispatchDeps,
} from "@/lib/daily/daily-briefing-push";
import type { DailyDigest } from "@/lib/daily/digest";
import { isUrgentPayload } from "@/lib/notifications/types";
import { getServerTranslator } from "@/lib/i18n/server-translator";
// 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the
// fixed fallback hour. The tz-window cases move only this instant.
const IN_WINDOW = new Date("2026-07-16T06:00:00Z");
const BEFORE_WINDOW = new Date("2026-07-16T00:30:00Z"); // 02:30 Berlin
const AFTER_WINDOW = new Date("2026-07-16T12:30:00Z"); // 14:30 Berlin
function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
return {
generatedAt: "2026-07-16T06:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 1 },
topSignal: null,
briefingLead: "Sleep looked solid last night.",
line: "Sleep looked solid last night.",
worthALook: [],
...over,
};
}
function makePrisma(opts: {
user?: unknown;
optIn?: unknown;
lastOk?: { createdAt: Date } | null;
}) {
const user =
opts.user === undefined
? {
id: "u1",
timezone: "Europe/Berlin",
locale: "en",
morningDigestRefreshedOn: null,
}
: opts.user;
return {
user: { findUnique: vi.fn(async () => user) },
notificationPreference: {
findFirst: vi.fn(async () =>
opts.optIn === undefined ? { id: "pref1" } : opts.optIn,
),
},
pushAttempt: { findFirst: vi.fn(async () => opts.lastOk ?? null) },
} as unknown as PrismaClient;
}
function makeDeps(
over: Partial<DailyBriefingDispatchDeps> = {},
): DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> } {
const dispatch = vi.fn(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
return {
dispatch,
loadDigest: async () => makeDigest(),
isModuleEnabled: async () => true,
...over,
} as DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> };
}
describe("buildDailyBriefingPush", () => {
const { t } = getServerTranslator("en");
it("uses the digest line verbatim as the body when the day is final", () => {
const digest = makeDigest({ line: "Pulse ran a touch high yesterday." });
const { title, body } = buildDailyBriefingPush(digest, t);
expect(body).toBe("Pulse ran a touch high yesterday.");
expect(title).toBe(t("daily.push.title"));
});
it("floor: a no-AI user (no briefing lead) still gets the deterministic line", () => {
// composeLine's floor already sits in `digest.line`; the push carries it
// unchanged, so a keyless self-hoster gets a first-class body.
const digest = makeDigest({
briefingLead: null,
topSignal: null,
line: "Your health score today is 82.",
});
const { body } = buildDailyBriefingPush(digest, t);
expect(body).toBe("Your health score today is 82.");
});
it("provisional: appends the honest sleep-pending wording", () => {
const digest = makeDigest({
sleepPending: true,
line: "Trends held steady this week.",
});
const { body } = buildDailyBriefingPush(digest, t);
expect(body).toContain("Trends held steady this week.");
expect(body).not.toBe("Trends held steady this week.");
});
});
describe("digestHasSubstance", () => {
it("is false for an empty account (score/lead/signal all null, no items)", () => {
expect(
digestHasSubstance(
makeDigest({
score: null,
briefingLead: null,
topSignal: null,
worthALook: [],
}),
),
).toBe(false);
});
it("is true when a score is present", () => {
expect(
digestHasSubstance(
makeDigest({ briefingLead: null, topSignal: null, worthALook: [] }),
),
).toBe(true);
});
});
describe("maybeDispatchDailyBriefing", () => {
it("opted-in + substantive digest + in window → exactly one DAILY_BRIEFING dispatch", async () => {
const prisma = makePrisma({});
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("sent");
expect(deps.dispatch).toHaveBeenCalledTimes(1);
const payload = deps.dispatch.mock.calls[0][0];
expect(payload.eventType).toBe("DAILY_BRIEFING");
expect(payload.metadata?.url).toBe("/");
});
it("never routes as an alert: the payload is non-urgent", async () => {
const prisma = makePrisma({});
const deps = makeDeps();
await maybeDispatchDailyBriefing(prisma, "u1", IN_WINDOW, deps);
const payload = deps.dispatch.mock.calls[0][0];
// No urgent flag AND not MEDICATION_REMINDER → the urgency classifier is
// false, so it can never escalate to a time-sensitive / Focus-bypass send.
expect(payload.urgent).toBeUndefined();
expect(isUrgentPayload(payload)).toBe(false);
});
it("opted-out (no enabled preference) → no dispatch", async () => {
const prisma = makePrisma({ optIn: null });
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("opted-out");
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("no digest substance → no dispatch", async () => {
const prisma = makePrisma({});
const deps = makeDeps({
loadDigest: async () =>
makeDigest({
score: null,
briefingLead: null,
topSignal: null,
worthALook: [],
}),
});
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("no-digest");
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("insights module off → no dispatch", async () => {
const prisma = makePrisma({});
const deps = makeDeps({ isModuleEnabled: async () => false });
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("module-off");
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => {
const prisma = makePrisma({
// 04:00Z → 06:00 Berlin, same local day as the 08:00 attempt.
lastOk: { createdAt: new Date("2026-07-16T04:00:00Z") },
});
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("suppressed-frequency");
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("frequency cap: an ok ledger row on the PREVIOUS local day does not suppress", async () => {
const prisma = makePrisma({
// 20:00Z on the 15th → 22:00 Berlin on the 15th → previous local day.
lastOk: { createdAt: new Date("2026-07-15T20:00:00Z") },
});
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("sent");
expect(deps.dispatch).toHaveBeenCalledTimes(1);
});
it("tz timing: before the local morning window → no dispatch", async () => {
const prisma = makePrisma({});
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
BEFORE_WINDOW,
deps,
);
expect(result).toBe("outside-window");
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("tz timing: after the local morning window → no dispatch", async () => {
const prisma = makePrisma({});
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
AFTER_WINDOW,
deps,
);
expect(result).toBe("outside-window");
expect(deps.dispatch).not.toHaveBeenCalled();
});
it("no channel delivered → no-channel (ledger slot left free)", async () => {
const prisma = makePrisma({});
const deps = makeDeps({
dispatch: vi.fn(async () => ({
dispatched: false,
channelsAttempted: 0,
channelsSucceeded: 0,
})) as unknown as DailyBriefingDispatchDeps["dispatch"],
});
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("no-channel");
});
it("missing user → missing-user, never throws", async () => {
const prisma = makePrisma({ user: null });
const deps = makeDeps();
const result = await maybeDispatchDailyBriefing(
prisma,
"u1",
IN_WINDOW,
deps,
);
expect(result).toBe("missing-user");
expect(deps.dispatch).not.toHaveBeenCalled();
});
});
+255
View File
@@ -0,0 +1,255 @@
/**
* S5 — the daily briefing push (§2.2).
*
* A CALM, once-per-day morning nudge that rides the EXISTING notification
* cascade (`dispatchNotification` → APNs → Telegram → ntfy → Webhook → Email →
* Web Push), inheriting the hard-reject classification and the `push_attempts`
* ledger for free. It reads the ALREADY-CACHED daily digest (S1 `buildDailyDigest`
* via `loadDailyDigest`) — it NEVER makes a fresh AI/provider call (the
* warm-on-mount ban extends to warm-on-dispatch), so a keyless self-hoster gets
* a first-class push from the digest's deterministic `line` floor.
*
* This module owns ONE decision seam — `maybeDispatchDailyBriefing` — that both
* triggers funnel through: the S4 morning-refresh finalisation hook (event-
* driven, on sleep arrival) and the fixed local-morning fallback cron
* (`@/lib/jobs/daily-briefing`). Whichever fires first that morning writes the
* `ok` ledger row; the frequency-cap check suppresses the second — one push per
* user per local day, no "you haven't opened the app" second push, ever.
*
* Privacy + calm posture (load-bearing): the body is `digest.line`, which is
* non-clinical by construction (a briefing lead sentence, the top signal's
* headline, a 0–100 score statement, or the honest all-clear — never a raw BP /
* glucose figure). It is dispatched NON-URGENT, so it never escalates to a
* Focus-bypassing / time-sensitive delivery — a concerning reading always stays
* on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning
* window, which is the quiet-hours guarantee for this nudge.
*/
import type { User } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { isModuleEnabled } from "@/lib/modules/gate";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { userDayKey } from "@/lib/tz/format";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import type { DailyDigest } from "@/lib/daily/digest";
/**
* The local-morning window this nudge may fire in, [earliest, latest). Both the
* sleep-arrival finalisation hook and the fixed fallback slot are gated by it,
* so a late-afternoon sleep sync (a full re-sync, a device that reconnected
* hours after waking) can never fire a "morning briefing" at an odd hour —
* outside the window the day simply carries no push (never-nag). 05:00 is the
* earliest a genuine wake-time sleep sync lands; noon is a firm ceiling.
*/
export const DAILY_BRIEFING_MORNING_EARLIEST_HOUR = 5;
export const DAILY_BRIEFING_MORNING_LATEST_HOUR = 12;
/**
* The fixed local hour the fallback cron dispatches at when sleep never
* arrived (so the finalisation hook never fired). Inside the window above; the
* earlier part of the window is left for the event-driven finalisation push, so
* a user whose sleep synced on wake gets the warmer FINAL digest first and the
* ledger suppresses this slot.
*/
export const DAILY_BRIEFING_FALLBACK_HOUR = 8;
/** The dashboard / Today view the push deep-links to (same-origin path). */
const DAILY_BRIEFING_DEEP_LINK = "/";
/**
* The outcome of one dispatch attempt — one value per branch so the wide-event
* annotation (and the tests) can assert the exact decision the seam took.
*/
export type DailyBriefingDispatchResult =
| "sent"
| "suppressed-frequency"
| "no-digest"
| "opted-out"
| "module-off"
| "outside-window"
| "no-channel"
| "missing-user"
| "error";
export interface DailyBriefingDispatchDeps {
dispatch?: typeof dispatchNotification;
loadDigest?: (user: User, now: Date) => Promise<DailyDigest>;
isModuleEnabled?: typeof isModuleEnabled;
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* A digest is "substantive" — worth a morning push — when it carries a score, a
* briefing lead, a top signal, OR a worth-a-look item. A brand-new / empty
* account whose only line would be the generic all-clear is NOT nudged every
* morning (the never-nag rule); it falls through to the `no-digest` skip.
*/
export function digestHasSubstance(digest: DailyDigest): boolean {
return (
digest.score !== null ||
digest.briefingLead !== null ||
digest.topSignal !== null ||
digest.worthALook.length > 0
);
}
/**
* Compose the push title + body from the cached digest. The body IS
* `digest.line` — the cached briefing lead when present, else the deterministic
* floor — so a no-AI self-hoster still gets a meaningful push. When the day is
* still provisional (last night's sleep not yet in), the honest sleep-pending
* wording is appended so the read is never silently presented as final.
*/
export function buildDailyBriefingPush(
digest: DailyDigest,
t: ServerTranslator["t"],
): { title: string; body: string } {
const title = t("daily.push.title");
const body = digest.sleepPending
? t("daily.push.bodyProvisional", { line: digest.line })
: digest.line;
return { title, body };
}
/**
* The ONE dispatch-decision seam. Fully fault-isolated — every failure returns
* a result value rather than throwing, so neither the sleep-arrival hook nor
* the cron tick can be broken by it.
*
* Gates, cheapest first:
* 1. user missing → `missing-user`
* 2. no opt-in (default OFF) → `opted-out` (no enabled DAILY_BRIEFING pref)
* 3. outside morning window → `outside-window`
* 4. insights module off → `module-off`
* 5. already pushed today → `suppressed-frequency` (push_attempts ledger)
* 6. digest not substantive → `no-digest`
* 7. dispatch; no channel → `no-channel`; else `sent`.
*/
export async function maybeDispatchDailyBriefing(
prisma: PrismaClient,
userId: string,
now: Date,
deps: DailyBriefingDispatchDeps = {},
): Promise<DailyBriefingDispatchResult> {
const dispatch = deps.dispatch ?? dispatchNotification;
const loadDigest = deps.loadDigest ?? loadDailyDigest;
const moduleGate = deps.isModuleEnabled ?? isModuleEnabled;
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
annotate({ action: { name: "daily.briefing_push.missing_user" } });
return "missing-user";
}
// Opt-in: DAILY_BRIEFING defaults OFF, so an opted-in user is one with at
// least one enabled per-channel preference row. Silence until then.
const optIn = await prisma.notificationPreference.findFirst({
where: {
eventType: "DAILY_BRIEFING",
enabled: true,
channel: { userId },
},
select: { id: true },
});
if (!optIn) {
annotate({ action: { name: "daily.briefing_push.opted_out" } });
return "opted-out";
}
const tz = user.timezone || "Europe/Berlin";
const { hour } = wallClockInTz(now, tz);
if (
hour < DAILY_BRIEFING_MORNING_EARLIEST_HOUR ||
hour >= DAILY_BRIEFING_MORNING_LATEST_HOUR
) {
annotate({
action: { name: "daily.briefing_push.outside_window" },
meta: { local_hour: hour },
});
return "outside-window";
}
// Insights is the digest's home module; a user who turned it off gets no
// digest surfaces at all, so no morning push either.
if (!(await moduleGate(userId, "insights"))) {
annotate({ action: { name: "daily.briefing_push.module_off" } });
return "module-off";
}
// One per user per LOCAL day. Anchored on the `push_attempts` ledger the
// senders already write (`ok` on a successful delivery), exactly like the
// coach-nudge cap: whichever trigger fired first this morning left an `ok`
// row, so the second is suppressed. Compared by local day-key (DST-safe),
// so the day boundary is the user's, not UTC's.
const lastOk = await prisma.pushAttempt.findFirst({
where: { userId, eventType: "DAILY_BRIEFING", result: "ok" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
});
if (lastOk && userDayKey(lastOk.createdAt, tz) === userDayKey(now, tz)) {
annotate({
action: { name: "daily.briefing_push.suppressed_frequency" },
});
return "suppressed-frequency";
}
const digest = await loadDigest(user, now);
if (!digestHasSubstance(digest)) {
annotate({ action: { name: "daily.briefing_push.no_digest" } });
return "no-digest";
}
const { t } = getServerTranslator(resolveLocale(user.locale));
const { title, body } = buildDailyBriefingPush(digest, t);
const outcome = await dispatch({
// NON-URGENT by construction: no `urgent` flag, and DAILY_BRIEFING is not
// MEDICATION_REMINDER, so `isUrgentPayload` is false — this never
// escalates to a time-sensitive / Focus-bypassing delivery. It is a calm
// nudge, never the anomaly channel.
eventType: "DAILY_BRIEFING",
userId,
title,
message: body,
metadata: {
url: DAILY_BRIEFING_DEEP_LINK,
phase: digest.phase,
scheduledAt: now.toISOString(),
},
});
if (!outcome.dispatched) {
// No channel delivered — leave the ledger slot free (no `ok` row) so a
// later trigger this morning can still succeed once a channel recovers.
annotate({ action: { name: "daily.briefing_push.no_channel" } });
return "no-channel";
}
annotate({
action: { name: "daily.briefing_push.sent" },
meta: {
daily_briefing_phase: digest.phase,
daily_briefing_sleep_pending: digest.sleepPending,
},
});
return "sent";
} catch (err) {
getEvent()?.addWarning(
`daily-briefing push failed for ${userId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return "error";
}
}
@@ -0,0 +1,117 @@
/**
* S5 — daily-briefing fallback cron: tz slot-gating + result accounting, plus
* the dead-queue wiring guard (imported / in allQueues / scheduled / worked).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
runDailyBriefingTick,
DAILY_BRIEFING_QUEUE,
DAILY_BRIEFING_CRON,
} from "@/lib/jobs/daily-briefing";
import type { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push";
// 06:00Z → 08:00 Berlin (the fallback hour) but 02:00 in New York (not the
// slot), so a mixed-tz cohort exercises the per-user local-hour gate.
const NOW = new Date("2026-07-16T06:00:00Z");
function makePrisma(
cohort: Array<{ userId: string; timezone: string }>,
): PrismaClient {
return {
notificationPreference: {
findMany: vi.fn(async () =>
cohort.map((c) => ({
channel: { userId: c.userId, user: { timezone: c.timezone } },
})),
),
},
} as unknown as PrismaClient;
}
describe("runDailyBriefingTick — slot gating", () => {
it("only calls the dispatch seam for users at their local fallback hour", async () => {
const prisma = makePrisma([
{ userId: "berlin", timezone: "Europe/Berlin" }, // 08:00 → in slot
{ userId: "ny", timezone: "America/New_York" }, // 02:00 → not slot
]);
const maybeDispatch = vi.fn<typeof maybeDispatchDailyBriefing>(
async () => "sent",
);
const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch });
expect(summary.candidatesScanned).toBe(2);
expect(summary.inSlot).toBe(1);
expect(summary.sent).toBe(1);
expect(maybeDispatch).toHaveBeenCalledTimes(1);
expect(maybeDispatch.mock.calls[0][1]).toBe("berlin");
});
it("routes each dispatch result into its summary bucket", async () => {
const prisma = makePrisma([
{ userId: "berlin", timezone: "Europe/Berlin" },
]);
const maybeDispatch = vi.fn<typeof maybeDispatchDailyBriefing>(
async () => "suppressed-frequency",
);
const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch });
expect(summary.suppressedFrequency).toBe(1);
expect(summary.sent).toBe(0);
});
it("an empty opted-in cohort is a clean no-op", async () => {
const prisma = makePrisma([]);
const maybeDispatch = vi.fn<typeof maybeDispatchDailyBriefing>(
async () => "sent",
);
const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch });
expect(summary.candidatesScanned).toBe(0);
expect(maybeDispatch).not.toHaveBeenCalled();
});
});
describe("reminder-worker — daily-briefing wiring", () => {
const registrar = readFileSync(
join(__dirname, "..", "reminder", "register-status.ts"),
"utf8",
);
it("imports the queue symbols from the daily-briefing module", () => {
expect(registrar).toMatch(/from\s*["']@\/lib\/jobs\/daily-briefing["']/);
expect(registrar).toMatch(/\bDAILY_BRIEFING_QUEUE\b/);
expect(registrar).toMatch(/\bDAILY_BRIEFING_CRON\b/);
expect(registrar).toMatch(/\brunDailyBriefingTick\b/);
});
it("registers the queue in allQueues and schedules the cron", () => {
const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
expect(allQueues).not.toBeNull();
expect(allQueues![1]).toMatch(/\bDAILY_BRIEFING_QUEUE\b/);
expect(registrar).toMatch(
/\[DAILY_BRIEFING_QUEUE,\s*DAILY_BRIEFING_CRON\]/,
);
});
it("wires a boss.work handler that runs the tick", () => {
expect(registrar).toMatch(
/boss\.work[\s\S]{0,200}DAILY_BRIEFING_QUEUE[\s\S]{0,400}runDailyBriefingTick/,
);
});
it("fires the finalisation-hook push after a finalised morning refresh", () => {
expect(registrar).toMatch(
/result\.status\s*===\s*["']finalised["'][\s\S]{0,300}maybeDispatchDailyBriefing/,
);
});
it("uses an every-15-min cron for the fallback slot", () => {
expect(DAILY_BRIEFING_QUEUE).toBe("daily-briefing");
expect(DAILY_BRIEFING_CRON).toBe("*/15 * * * *");
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* S5 — the daily-briefing fallback cron.
*
* The PRIMARY trigger for the morning push is event-driven: the S4
* morning-refresh worker dispatches it the instant last night's sleep lands and
* the digest finalises (see `register-status.ts`). This cron is the FALLBACK
* slot for the honest-degradation case — a user whose sleep never synced, so no
* finalisation ever fired. It ticks every 15 minutes (the mood- /
* measurement-reminder cadence) and, for each OPTED-IN user whose local wall
* clock is at the fixed fallback hour, funnels through the same
* `maybeDispatchDailyBriefing` decision seam. The `push_attempts` frequency cap
* means a user who already got the finalisation push earlier this morning is
* suppressed here — one push per user per local day, no matter which trigger
* won the race.
*
* The opted-in cohort is small (DAILY_BRIEFING defaults OFF), and the per-user
* work is gated on the exact local fallback hour, so the tick is cheap: it only
* loads a digest for the handful of users at their fallback minute-window this
* tick.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import {
DAILY_BRIEFING_FALLBACK_HOUR,
maybeDispatchDailyBriefing,
type DailyBriefingDispatchDeps,
} from "@/lib/daily/daily-briefing-push";
export const DAILY_BRIEFING_QUEUE = "daily-briefing";
/** Every 15 minutes; the per-user local-hour gate selects the fallback slot. */
export const DAILY_BRIEFING_CRON = "*/15 * * * *";
export interface DailyBriefingTickSummary {
candidatesScanned: number;
inSlot: number;
sent: number;
suppressedFrequency: number;
noDigest: number;
optedOut: number;
moduleOff: number;
noChannel: number;
outsideWindow: number;
failed: number;
}
/**
* Run one fallback-cron tick. Injectable `dispatch` / `maybeDispatch` so the
* unit tests pin the slot-gating + result accounting without a running boss,
* provider, or DB.
*/
export async function runDailyBriefingTick(
prisma: PrismaClient,
now: Date,
options: {
dispatch?: typeof dispatchNotification;
/** Injectable decision seam (defaults to the real one). */
maybeDispatch?: typeof maybeDispatchDailyBriefing;
dispatchDeps?: DailyBriefingDispatchDeps;
} = {},
): Promise<DailyBriefingTickSummary> {
const maybeDispatch = options.maybeDispatch ?? maybeDispatchDailyBriefing;
const dispatchDeps: DailyBriefingDispatchDeps = {
...(options.dispatchDeps ?? {}),
...(options.dispatch ? { dispatch: options.dispatch } : {}),
};
const summary: DailyBriefingTickSummary = {
candidatesScanned: 0,
inSlot: 0,
sent: 0,
suppressedFrequency: 0,
noDigest: 0,
optedOut: 0,
moduleOff: 0,
noChannel: 0,
outsideWindow: 0,
failed: 0,
};
// The opted-in cohort: users with at least one enabled DAILY_BRIEFING
// preference row. Deduped by userId (a user may have opted several channels
// in) and carrying the timezone so the slot gate needs no extra read.
const prefs = await prisma.notificationPreference.findMany({
where: { eventType: "DAILY_BRIEFING", enabled: true },
select: {
channel: {
select: { userId: true, user: { select: { timezone: true } } },
},
},
});
const cohort = new Map<string, string>();
for (const pref of prefs) {
const userId = pref.channel?.userId;
if (!userId) continue;
cohort.set(userId, pref.channel.user?.timezone || "Europe/Berlin");
}
for (const [userId, tz] of cohort) {
summary.candidatesScanned += 1;
try {
// Fire only at the fixed fallback hour; the earlier part of the morning
// window belongs to the event-driven finalisation push.
if (wallClockInTz(now, tz).hour !== DAILY_BRIEFING_FALLBACK_HOUR) {
continue;
}
summary.inSlot += 1;
const result = await maybeDispatch(prisma, userId, now, dispatchDeps);
switch (result) {
case "sent":
summary.sent += 1;
break;
case "suppressed-frequency":
summary.suppressedFrequency += 1;
break;
case "no-digest":
summary.noDigest += 1;
break;
case "opted-out":
summary.optedOut += 1;
break;
case "module-off":
summary.moduleOff += 1;
break;
case "no-channel":
summary.noChannel += 1;
break;
case "outside-window":
summary.outsideWindow += 1;
break;
default:
summary.failed += 1;
}
} catch (err) {
summary.failed += 1;
getEvent()?.addWarning(
`daily-briefing tick failed for ${userId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
return summary;
}
+64
View File
@@ -29,6 +29,12 @@ import {
runMorningDigestRefresh,
type MorningDigestRefreshPayload,
} from "@/lib/jobs/morning-digest-refresh";
import {
DAILY_BRIEFING_QUEUE,
DAILY_BRIEFING_CRON,
runDailyBriefingTick,
} from "@/lib/jobs/daily-briefing";
import { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push";
import {
RECOVERY_SCORE_QUEUE,
RECOVERY_SCORE_CRON,
@@ -188,6 +194,11 @@ const allQueues = [
// v1.16.11 — daily medication low-stock pass. Without this entry the 09:00
// schedule silently no-ops and no low-stock alert ever fires.
MEDICATION_LOW_STOCK_QUEUE,
// S5 — daily-briefing fallback cron. The primary morning push rides the
// sleep-arrival finalisation hook below; this queue is the fixed local-morning
// slot for the no-sleep-arrived case. Without this entry the every-15-min
// schedule silently no-ops and the fallback push never fires.
DAILY_BRIEFING_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -243,6 +254,11 @@ const schedules: ScheduleEntry[] = [
// can act on it. Once daily; the per-medication stamp keeps it at
// one push per threshold crossing.
[MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON],
// S5 — daily-briefing fallback slot. Ticks every 15 min; the per-user
// local-hour gate inside the tick selects each user's fixed morning slot, so
// one UTC cron serves every timezone. The `push_attempts` cap suppresses it
// when the sleep-arrival finalisation push already fired this morning.
[DAILY_BRIEFING_QUEUE, DAILY_BRIEFING_CRON],
];
/**
@@ -326,6 +342,20 @@ export async function registerStatusQueues(
"morning_refresh",
`${result.status}:${result.comprehensive ?? "none"}`,
);
// S5 — the day just finalised (last night's sleep folded in), so
// this is the natural, event-driven moment to fire the calm morning
// push carrying the FINAL digest. Fully fault-isolated inside the
// seam; the opt-in / morning-window / once-per-day gates all live
// there, so a user who never opted in or is outside the window is a
// clean no-op. The fallback cron covers the no-sleep-arrived case.
if (result.status === "finalised") {
const pushResult = await maybeDispatchDailyBriefing(
getWorkerPrisma(),
job.data.userId,
new Date(),
);
evt.addMeta("daily_briefing_push", pushResult);
}
} catch (err) {
recordError();
workerLog("error", "[morning-digest-refresh] pass failed", err);
@@ -429,6 +459,40 @@ export async function registerStatusQueues(
});
},
);
// S5 — daily-briefing fallback slot. Single-flight; the `push_attempts`
// frequency cap makes an overlapping tick a no-op (a second same-day attempt
// is suppressed), and the tick only loads a digest for opted-in users at
// their exact local fallback hour. No provider call — it reads the cached
// digest and dispatches the deterministic line + cached lead.
await boss.work(DAILY_BRIEFING_QUEUE, { localConcurrency: 1 }, async () => {
await withBackgroundEvent("job.daily_briefing", async (evt) => {
try {
const summary = await runDailyBriefingTick(
getWorkerPrisma(),
new Date(),
);
evt.setBackground({
task_name: "job.daily_briefing",
result: {
candidates_scanned: summary.candidatesScanned,
in_slot: summary.inSlot,
sent: summary.sent,
suppressed_frequency: summary.suppressedFrequency,
no_digest: summary.noDigest,
opted_out: summary.optedOut,
module_off: summary.moduleOff,
no_channel: summary.noChannel,
outside_window: summary.outsideWindow,
failed: summary.failed,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
});
// v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived
// proxy) compute + store. Single-flight so two ticks never double-walk
// the cohort. The runner iterates every eligible user and upserts one
+17
View File
@@ -101,6 +101,17 @@ export const EVENT_TYPES = [
// would defeat the feature. An explicit per-channel `NotificationPreference`
// row still wins for a user who opts out.
"SECURITY_ALERT",
// S5 — the once-daily calm morning briefing. Default OFF (see
// EVENT_DEFAULT_ENABLED, the PERSONAL_RECORD precedent): the user opts in
// per-channel from the /notifications matrix, so the channel choice is
// explicit at opt-in. It reads the CACHED daily digest — never a fresh AI
// call — and carries a minimal, non-clinical line (cached briefing lead with
// a deterministic floor). It is a nudge, NEVER the anomaly channel: it is
// dispatched non-urgent, fired at most once per user per local day (the
// `push_attempts` ledger is the frequency anchor), and only inside a local
// morning window, so a concerning reading always stays on
// `MEASUREMENT_ANOMALY` and this never bypasses Focus.
"DAILY_BRIEFING",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -162,6 +173,12 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
// needs surfaced. An explicit per-channel `NotificationPreference` row
// (enabled=false) still suppresses it for a user who opts out.
SECURITY_ALERT: true,
// S5 — OFF by default (the PERSONAL_RECORD precedent). The calm daily
// briefing stays silent until the user explicitly opts in per-channel at
// /notifications; the dispatcher's default-policy gate suppresses every
// channel that has no explicit enabled row, so a fresh account never
// receives a morning push it did not ask for.
DAILY_BRIEFING: false,
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {