Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle

This commit is contained in:
Marc-André Bombeck committed 2026-07-17 00:02:51 +02:00
commit d470d406a7
35 files changed
+2390 -41

No files matched your search

+19 -1
View File
@@ -2,7 +2,25 @@
## [Unreleased]
## [1.28.54] — 2026-07-16 — One hero on the dashboard
## [1.28.55] — 2026-07-16 — The Today view comes to life
Three pieces turn the Today view into a daily loop:
- **A coach check-in.** When a coach plan is due for a look-back, it surfaces as
a calm card in Today with keep, adjust, or let-go — one tap. Letting go retires
the plan quietly; silence never changes it.
- **A morning that reflects last night.** The score and read are prepared
overnight, but last night's sleep usually hasn't synced by then. Now, the
moment it arrives, the sleep-dependent parts refresh on their own — so by the
time you look, the day is up to date. Until sleep is in, Today says so plainly
instead of showing a stale score.
- **An optional daily nudge.** A once-a-day morning notification with the day's
read, off by default and opt-in per channel under Notifications. It carries the
refreshed digest when sleep is in, never repeats within a day, and is a calm
note — never an alert.
All three read the digest prepared overnight; nothing is generated when you open
the app.
With the Today view now leading the dashboard, the older opt-in hero card it
replaced is retired, so the two never stack. The greeting stays, the tiles and
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.54
version: 1.28.55
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+25
View File
@@ -130,6 +130,31 @@ test.describe("authenticated dashboard render", () => {
},
],
},
// S3 — a coach check-in card (keep / adjust / let-go). Rendered
// only; the mutating taps PATCH the plan route, which this spec
// never clicks, so it stays green without a plan-route stub.
{
kind: "coach_checkin",
title: "Time to check in on a plan",
body: "About a week ago you set: “every morning → weigh in”.",
status: "info",
moduleKey: "coach",
actions: [
{
labelKey: "daily.action.checkinKeep",
intent: "coach.checkin.keep:p1",
},
{
labelKey: "daily.action.checkinAdjust",
intent: "coach.checkin.adjust",
href: "/coach",
},
{
labelKey: "daily.action.checkinLetGo",
intent: "coach.checkin.letGo:p1",
},
],
},
],
},
error: null,
+15 -1
View File
@@ -6536,6 +6536,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.",
@@ -8524,6 +8526,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",
@@ -8538,12 +8544,20 @@
"syncIssue": {
"title": "Synchronisierung braucht Aufmerksamkeit",
"body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten."
},
"coachCheckin": {
"title": "Zeit, einen Plan anzuschauen",
"body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.",
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck."
}
},
"action": {
"logDose": "Dosis erfassen",
"reconnect": "Neu verbinden",
"viewCheckups": "Vorsorge ansehen"
"viewCheckups": "Vorsorge ansehen",
"checkinKeep": "Behalten",
"checkinAdjust": "Anpassen",
"checkinLetGo": "Loslassen"
},
"today": {
"scoreLabel": "Gesundheitsscore",
+15 -1
View File
@@ -6536,6 +6536,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.",
@@ -8524,6 +8526,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",
@@ -8538,12 +8544,20 @@
"syncIssue": {
"title": "Sync needs attention",
"body": "{integration} isn't syncing — reconnect to keep your data current."
},
"coachCheckin": {
"title": "Time to check in on a plan",
"body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way."
}
},
"action": {
"logDose": "Log dose",
"reconnect": "Reconnect",
"viewCheckups": "View check-ups"
"viewCheckups": "View check-ups",
"checkinKeep": "Keep it",
"checkinAdjust": "Adjust",
"checkinLetGo": "Let it go"
},
"today": {
"scoreLabel": "Health score",
+15 -1
View File
@@ -6536,6 +6536,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.",
@@ -8524,6 +8526,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",
@@ -8538,12 +8544,20 @@
"syncIssue": {
"title": "La sincronización necesita atención",
"body": "{integration} no se está sincronizando: vuelve a conectarlo para mantener tus datos al día."
},
"coachCheckin": {
"title": "Es buen momento para revisar un plan",
"body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.",
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión."
}
},
"action": {
"logDose": "Registrar dosis",
"reconnect": "Reconectar",
"viewCheckups": "Ver revisiones"
"viewCheckups": "Ver revisiones",
"checkinKeep": "Conservar",
"checkinAdjust": "Ajustar",
"checkinLetGo": "Dejarlo ir"
},
"today": {
"scoreLabel": "Puntuación de salud",
+15 -1
View File
@@ -6536,6 +6536,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.",
@@ -8524,6 +8526,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",
@@ -8538,12 +8544,20 @@
"syncIssue": {
"title": "La synchronisation demande votre attention",
"body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour."
},
"coachCheckin": {
"title": "Il est temps de revoir un plan",
"body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.",
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression."
}
},
"action": {
"logDose": "Enregistrer la dose",
"reconnect": "Reconnecter",
"viewCheckups": "Voir les examens"
"viewCheckups": "Voir les examens",
"checkinKeep": "Garder",
"checkinAdjust": "Ajuster",
"checkinLetGo": "Laisser tomber"
},
"today": {
"scoreLabel": "Score de santé",
+15 -1
View File
@@ -6536,6 +6536,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.",
@@ -8524,6 +8526,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",
@@ -8538,12 +8544,20 @@
"syncIssue": {
"title": "La sincronizzazione richiede attenzione",
"body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati."
},
"coachCheckin": {
"title": "È il momento di rivedere un piano",
"body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.",
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni."
}
},
"action": {
"logDose": "Registra dose",
"reconnect": "Riconnetti",
"viewCheckups": "Vedi controlli"
"viewCheckups": "Vedi controlli",
"checkinKeep": "Mantieni",
"checkinAdjust": "Modifica",
"checkinLetGo": "Lascia andare"
},
"today": {
"scoreLabel": "Punteggio di salute",
+15 -1
View File
@@ -6536,6 +6536,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.",
@@ -8524,6 +8526,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",
@@ -8538,12 +8544,20 @@
"syncIssue": {
"title": "Synchronizacja wymaga uwagi",
"body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne."
},
"coachCheckin": {
"title": "Czas przyjrzeć się planowi",
"body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.",
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji."
}
},
"action": {
"logDose": "Zapisz dawkę",
"reconnect": "Połącz ponownie",
"viewCheckups": "Zobacz badania"
"viewCheckups": "Zobacz badania",
"checkinKeep": "Zachowaj",
"checkinAdjust": "Dostosuj",
"checkinLetGo": "Odpuść"
},
"today": {
"scoreLabel": "Wynik zdrowia",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.54",
"version": "1.28.55",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,15 @@
-- Morning-freshness marker (S4 of the unified daily-value system).
--
-- The nightly insight pre-pass runs ~04:30, before last night's sleep has
-- synced, so the morning digest/health-score reference the day WITHOUT the
-- current sleep. This column records the user-local YYYY-MM-DD (their profile
-- timezone) for which the event-driven morning refresh has completed: last
-- night's sleep arrived AND the sleep-dependent insight/score cache was
-- regenerated with it. The daily digest reports `final` once this equals the
-- user's current local date, `provisional` otherwise (honest degradation when
-- sleep is late or never arrives). Stamped by the `morning-digest-refresh`
-- pg-boss job the sleep-arrival trigger enqueues, debounced once per local
-- morning. NULL for every existing row = provisional until the next sleep
-- landing, which is the correct pre-feature reading.
ALTER TABLE "users"
ADD COLUMN "morning_digest_refreshed_on" TEXT;
+9
View File
@@ -111,6 +111,15 @@ model User {
// without re-paying for the findings. This stamps the day a re-roll ran so
// the gate stays at most one extra call per day.
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
// timezone) for which the event-driven morning refresh has run — i.e. last
// night's sleep has arrived AND the sleep-dependent insight/score cache was
// regenerated with it. The daily digest reads `final` when this equals the
// user's current local date, `provisional` otherwise. Stamped by the
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
// string form makes the "same local morning" comparison timezone-honest
// without a second timestamp read.
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.54";
/* @sw-version-fallback */ "v1.28.55";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+18
View File
@@ -34,6 +34,7 @@ import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
interface RouteCtx {
@@ -94,6 +95,23 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
: null;
}
// S3 (§2.3.1) — every accepted plan earns a check-in. When the coach
// activates a plan and set no review date of its own, default `reviewDate` to
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
// the current row first so an explicitly-pinned review date is never
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
const current = await prisma.coachPlan.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: { reviewDate: true },
});
if (current && current.reviewDate === null) {
data.reviewDate = new Date(
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
);
}
}
// `updateMany` (not `update`) so an unknown / cross-user / already-deleted id
// is a 0-count no-op rather than a P2025 throw — the existence channel never
// leaks across accounts.
@@ -315,7 +315,68 @@ describe("PATCH /api/coach/plans/[id]", () => {
expect(arg.where.id).toBe("p1");
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
// Only the lifecycle field is written — never metric / encrypted text.
// S3 (§2.3.1): activating a plan the coach never dated defaults a review
// checkpoint ~7d out. Only lifecycle fields are written (status +
// reviewDate) — never metric / encrypted text (no mass assignment).
expect(new Set(Object.keys(arg.data))).toEqual(
new Set(["status", "reviewDate"]),
);
expect(arg.data.status).toBe("active");
expect(arg.data.reviewDate).toBeInstanceOf(Date);
const days =
((arg.data.reviewDate as Date).getTime() - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
});
it("does NOT clobber a review date the caller pinned on activate", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "active",
reviewDate: new Date("2026-08-01T00:00:00Z"),
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
const res = await callPatch({
status: "active",
reviewDate: "2026-08-01T00:00:00.000Z",
});
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
data: { reviewDate: Date };
};
// The pinned date is honoured verbatim, not the +7d default.
expect(arg.data.reviewDate.toISOString()).toBe("2026-08-01T00:00:00.000Z");
});
it("does NOT default a review date when marking a plan met", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "met",
reviewDate: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
await callPatch({ status: "met" });
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
data: Record<string, unknown>;
};
expect(Object.keys(arg.data)).toEqual(["status"]);
});
+17
View File
@@ -64,6 +64,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
@@ -764,6 +765,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION
// row for last night just landed, kick the debounced morning refresh so the
// digest/score finalise with the current sleep. The trigger judges "last
// night" in the user's profile tz, so a historical Apple export replaying
// months of nights never re-triggers for an old night. Fire-and-forget.
void maybeEnqueueMorningRefresh(
user.id,
prepared
.filter((p) => p.row.type === "SLEEP_DURATION")
.map((p) =>
p.row.measuredAt instanceof Date
? p.row.measuredAt
: new Date(p.row.measuredAt as string),
),
).catch(() => {});
}
return apiSuccess({
@@ -1,14 +1,20 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
// The hero now wires the coach check-in card's keep / let-go taps through
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient();
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
+26 -2
View File
@@ -36,9 +36,14 @@ import { ScoreRing } from "@/components/insights/derived/score-ring";
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { PriorityCard } from "@/components/daily/priority-card";
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DailyDigest } from "@/lib/daily/digest";
import {
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
type DailyDigest,
} from "@/lib/daily/digest";
/** Format the server-computed score delta as a signed, muted chip string. */
function formatDelta(
@@ -53,6 +58,21 @@ function formatDelta(
export function TodayHero({ digest }: { digest: DailyDigest }) {
const { t } = useTranslations();
const { keep, letGo } = useCoachCheckinAction();
// The coach check-in card's keep / let-go intents carry the plan id after the
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
// as a <Link>, so it never lands here. Every other rail kind is pure
// navigation (dose / sync / preventive), so their taps never call this.
const handleAction = (intent: string) => {
const keepPrefix = `${COACH_CHECKIN_KEEP_INTENT}:`;
const letGoPrefix = `${COACH_CHECKIN_LETGO_INTENT}:`;
if (intent.startsWith(keepPrefix)) {
keep.mutate(intent.slice(keepPrefix.length));
} else if (intent.startsWith(letGoPrefix)) {
letGo.mutate(intent.slice(letGoPrefix.length));
}
};
const hasScore = digest.score !== null;
const hasItems = digest.worthALook.length > 0;
@@ -174,7 +194,11 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard key={`${item.kind}-${i}`} item={item} />
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={handleAction}
/>
))}
</div>
</div>
+59
View File
@@ -0,0 +1,59 @@
"use client";
/**
* S3 — the coach check-in card's keep / let-go actions (§2.3).
*
* The Today rail surfaces a `coach_checkin` `PriorityItem` whose two mutating
* one-tap actions close the coach loop through the EXISTING plan-lifecycle
* route (`PATCH /api/coach/plans/[id]`) — no new backend surface:
* - keep → re-arm: status `active` + `reviewDate` pushed out one more cycle,
* so the check-in comes back in a week rather than the same day;
* - letGo → guilt-free retirement: status `abandoned`, a terminal, respected
* outcome (the calm inversion of a streak). The card never nags.
*
* "Adjust" is navigation (an `href` into the coach) handled by `PriorityCard`
* as a `<Link>` — it never reaches this hook.
*
* Both invalidate the daily digest (so the acted-on card leaves the rail) and
* every plans-list slot (so the /coach/plans ledger stays in step). Keys route
* through the centralised factory — no bare arrays.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
export function useCoachCheckinAction() {
const queryClient = useQueryClient();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
void queryClient.invalidateQueries({ queryKey: queryKeys.coachPlansAll() });
};
const keep = useMutation({
mutationFn: async (planId: string) => {
const reviewDate = new Date(
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
).toISOString();
await apiPatch(`/api/coach/plans/${encodeURIComponent(planId)}`, {
status: "active",
reviewDate,
});
return planId;
},
onSuccess: invalidate,
});
const letGo = useMutation({
mutationFn: async (planId: string) => {
await apiPatch(`/api/coach/plans/${encodeURIComponent(planId)}`, {
status: "abandoned",
});
return planId;
},
onSuccess: invalidate,
});
return { keep, letGo };
}
@@ -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();
});
});
+180
View File
@@ -5,7 +5,12 @@ import type { DailyBriefing } from "@/lib/ai/schema";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
buildDailyDigest,
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
COACH_CHECKIN_RESURFACE_DAYS,
COACH_CHECKIN_REVIEW_DAYS,
MAX_WORTH_A_LOOK,
type DailyDigestCoachPlan,
type DailyDigestInput,
} from "@/lib/daily/digest";
@@ -48,8 +53,28 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
briefing,
medsToday: meds(),
sleepLastSeenDaysAgo: 0,
morningRefreshedToday: false,
syncIssues: [],
preventiveDue: [],
coachPlans: [],
...over,
};
}
const DAY = 86_400_000;
function plan(over: Partial<DailyDigestCoachPlan> = {}): DailyDigestCoachPlan {
// Default: an active plan whose defaulted review (createdAt + 7d) is due.
const createdAt = new Date(
NOW.getTime() - (COACH_CHECKIN_REVIEW_DAYS + 1) * DAY,
);
return {
id: "p1",
status: "active",
reviewDate: null,
createdAt,
updatedAt: createdAt,
planText: "every morning → weigh in",
...over,
};
}
@@ -127,6 +152,34 @@ describe("buildDailyDigest — freshness (provisional/final)", () => {
expect(d.phase).toBe("final");
expect(d.sleepPending).toBe(false);
});
it("the morning-refresh marker finalises the day even while the snapshot's sleep last-seen is still stale (S4 fast path)", () => {
// Sleep last-seen still lags at 1 day (snapshot cache not yet expired), but
// the sleep-arrival refresh has stamped the marker for today — the digest
// must read `final` immediately off the authoritative marker.
const provisional = buildDailyDigest(
input({ sleepLastSeenDaysAgo: 1, morningRefreshedToday: false }),
t,
);
expect(provisional.phase).toBe("provisional");
expect(provisional.sleepPending).toBe(true);
const finalised = buildDailyDigest(
input({ sleepLastSeenDaysAgo: 1, morningRefreshedToday: true }),
t,
);
expect(finalised.phase).toBe("final");
expect(finalised.sleepPending).toBe(false);
});
it("stays provisional when sleep never arrives (no marker, no reading)", () => {
const d = buildDailyDigest(
input({ sleepLastSeenDaysAgo: null, morningRefreshedToday: false }),
t,
);
expect(d.phase).toBe("provisional");
expect(d.sleepPending).toBe(true);
});
});
describe("buildDailyDigest — worth-a-look rail item builders", () => {
@@ -233,3 +286,130 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
expect(d.worthALook).toEqual([]);
});
});
describe("buildDailyDigest — coach check-in (S3)", () => {
function checkin(d: ReturnType<typeof buildDailyDigest>) {
return d.worthALook.find((i) => i.kind === "coach_checkin");
}
it("emits a check-in when an active plan's defaulted review has come due", () => {
const d = buildDailyDigest(input({ coachPlans: [plan()] }), t);
const item = checkin(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
expect(item?.moduleKey).toBe("coach");
expect(item?.actions).toHaveLength(3);
// The plan's own words are echoed in the body.
expect(item?.body).toContain("every morning → weigh in");
// Keep / let-go carry the plan id; adjust is a plain navigation href.
expect(item?.actions[0].intent).toBe(`${COACH_CHECKIN_KEEP_INTENT}:p1`);
expect(item?.actions[1].href).toBe("/coach");
expect(item?.actions[2].intent).toBe(`${COACH_CHECKIN_LETGO_INTENT}:p1`);
});
it("emits a review-due check-in from a plan's pinned reviewDate", () => {
const reviewDate = new Date(NOW.getTime() - DAY);
const d = buildDailyDigest(
input({ coachPlans: [plan({ reviewDate })] }),
t,
);
expect(checkin(d)).toBeDefined();
});
it("emits a check-in for a reviewed plan (post-sweep read-back state)", () => {
const d = buildDailyDigest(
input({
coachPlans: [
plan({
status: "reviewed",
reviewDate: null,
updatedAt: new Date(NOW.getTime() - DAY),
}),
],
}),
t,
);
expect(checkin(d)).toBeDefined();
});
it("falls back to a generic body when the plan text is undecryptable", () => {
const d = buildDailyDigest(
input({ coachPlans: [plan({ planText: null })] }),
t,
);
expect(checkin(d)?.body).toBe(
"It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
);
});
it("does NOT emit a check-in before the review is due", () => {
const d = buildDailyDigest(
input({
coachPlans: [plan({ reviewDate: new Date(NOW.getTime() + DAY) })],
}),
t,
);
expect(checkin(d)).toBeUndefined();
});
it("does NOT emit a check-in when the coach module is off", () => {
const d = buildDailyDigest(
input({ modules: { coach: false }, coachPlans: [plan()] }),
t,
);
expect(checkin(d)).toBeUndefined();
});
it("emits none when there are no standing plans", () => {
const d = buildDailyDigest(input({ coachPlans: [] }), t);
expect(checkin(d)).toBeUndefined();
});
it("stops resurfacing after the resurface window (quiet retirement)", () => {
const stale = new Date(
NOW.getTime() - (COACH_CHECKIN_RESURFACE_DAYS + 2) * DAY,
);
const d = buildDailyDigest(
input({ coachPlans: [plan({ reviewDate: stale })] }),
t,
);
expect(checkin(d)).toBeUndefined();
});
it("caps at ONE check-in per day, surfacing the earliest-due plan", () => {
const older = new Date(NOW.getTime() - 5 * DAY);
const newer = new Date(NOW.getTime() - 1 * DAY);
const d = buildDailyDigest(
input({
coachPlans: [
plan({ id: "recent", reviewDate: newer }),
plan({ id: "oldest", reviewDate: older }),
],
}),
t,
);
const items = d.worthALook.filter((i) => i.kind === "coach_checkin");
expect(items).toHaveLength(1);
expect(items[0].actions[0].intent).toBe(
`${COACH_CHECKIN_KEEP_INTENT}:oldest`,
);
});
it("does not displace an overdue dose from the bounded rail", () => {
const d = buildDailyDigest(
input({
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
syncIssues: [
{ integration: "withings", state: "error_reauth" },
{ integration: "moodlog", state: "parked" },
],
coachPlans: [plan()],
}),
t,
);
// dose + 2 sync fill the cap; the check-in waits for a following day.
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
expect(checkin(d)).toBeUndefined();
expect(d.worthALook[0].kind).toBe("dose_window");
});
});
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// The queue send is inspected through a fake global boss so the debounce key +
// payload can be asserted without a running worker.
const sendMock = vi.fn();
let bossInstance: { send: typeof sendMock } | null = { send: sendMock };
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => bossInstance,
}));
// Module gate + tz resolver are stubbed; the tz FORMAT helpers stay real so the
// "last night in the profile tz" logic is exercised for real.
const resolveModuleMapMock = vi.fn(
async (): Promise<Record<string, boolean>> => ({}),
);
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: () => resolveModuleMapMock(),
}));
let tz = "America/Los_Angeles";
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: async () => tz,
}));
const userFindUniqueMock = vi.fn(
async (): Promise<{ morningDigestRefreshedOn: string | null } | null> => ({
morningDigestRefreshedOn: null,
}),
);
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: () => userFindUniqueMock() } },
}));
import {
isLastNightLocal,
maybeEnqueueMorningRefresh,
} from "../morning-refresh-trigger";
describe("isLastNightLocal", () => {
it("counts a segment whose LOCAL date is yesterday but whose UTC date is today", () => {
// LA (UTC-7 in July). now = 07:00 local on the 16th.
const now = new Date("2026-07-16T14:00:00Z");
// 06:00Z = 23:00 local on the 15th → yesterday LOCALLY, but the 16th in UTC.
const measuredAt = new Date("2026-07-16T06:00:00Z");
expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(true);
});
it("counts a this-morning segment across the UTC date line (Auckland)", () => {
// Auckland (UTC+12). now = 12:30 local on the 16th; the segment is 06:00
// local on the 16th (this morning) but 15th in UTC — a naive UTC-day check
// would wrongly reject it.
const now = new Date("2026-07-16T00:30:00Z");
const measuredAt = new Date("2026-07-15T18:00:00Z");
expect(isLastNightLocal(measuredAt, now, "Pacific/Auckland")).toBe(true);
});
it("rejects an old backfilled segment", () => {
const now = new Date("2026-07-16T14:00:00Z");
const measuredAt = new Date("2026-07-10T06:00:00Z");
expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(
false,
);
});
it("rejects a future-dated sample", () => {
const now = new Date("2026-07-16T14:00:00Z");
const measuredAt = new Date("2026-07-16T20:00:00Z");
expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(
false,
);
});
});
describe("maybeEnqueueMorningRefresh", () => {
const NOW = new Date("2026-07-16T14:00:00Z"); // 07:00 on the 16th in LA
const lastNight = new Date("2026-07-16T06:00:00Z"); // 23:00 on the 15th, LA
const oldNight = new Date("2026-07-10T06:00:00Z");
beforeEach(() => {
sendMock.mockReset();
bossInstance = { send: sendMock };
tz = "America/Los_Angeles";
resolveModuleMapMock.mockReset();
resolveModuleMapMock.mockResolvedValue({});
userFindUniqueMock.mockReset();
userFindUniqueMock.mockResolvedValue({ morningDigestRefreshedOn: null });
});
it("enqueues exactly one debounced refresh for many last-night samples", async () => {
await maybeEnqueueMorningRefresh(
"u1",
[lastNight, lastNight, lastNight, lastNight],
NOW,
);
expect(sendMock).toHaveBeenCalledTimes(1);
const [queue, payload, opts] = sendMock.mock.calls[0]!;
expect(queue).toBe("morning-digest-refresh");
// The debounce key + payload date are the user's LOCAL date (the 16th),
// not the UTC date of the samples.
expect(payload).toMatchObject({ userId: "u1", localDate: "2026-07-16" });
expect((opts as { singletonKey: string }).singletonKey).toBe(
"morning-refresh:u1:2026-07-16",
);
});
it("does NOT trigger for an old/backfilled sleep only", async () => {
await maybeEnqueueMorningRefresh("u1", [oldNight, oldNight], NOW);
expect(sendMock).not.toHaveBeenCalled();
});
it("no-ops when the sleep module is off", async () => {
resolveModuleMapMock.mockResolvedValue({ sleep: false });
await maybeEnqueueMorningRefresh("u1", [lastNight], NOW);
expect(sendMock).not.toHaveBeenCalled();
});
it("skips the enqueue when the day is already finalised (marker == today)", async () => {
userFindUniqueMock.mockResolvedValue({
morningDigestRefreshedOn: "2026-07-16",
});
await maybeEnqueueMorningRefresh("u1", [lastNight], NOW);
expect(sendMock).not.toHaveBeenCalled();
});
it("no-ops with no sleep samples", async () => {
await maybeEnqueueMorningRefresh("u1", [], NOW);
expect(sendMock).not.toHaveBeenCalled();
});
it("never throws when a dependency fails", async () => {
resolveModuleMapMock.mockRejectedValue(new Error("db down"));
await expect(
maybeEnqueueMorningRefresh("u1", [lastNight], NOW),
).resolves.toBeUndefined();
expect(sendMock).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";
}
}
+165 -5
View File
@@ -34,6 +34,36 @@ export const MAX_WORTH_A_LOOK = 3;
/** Trim a briefing-lead sentence to a lock-screen-friendly length. */
const MAX_LINE_LENGTH = 160;
const MS_PER_DAY = 86_400_000;
/**
* S3 — coach check-in loop (§2.3). Days added to a plan's activation when the
* coach set no review date: every accepted plan earns a check-in, not only the
* ones the coach explicitly dated. The PATCH-to-`active` route defaults
* `reviewDate` to `+COACH_CHECKIN_REVIEW_DAYS`; this constant is the one source.
*/
export const COACH_CHECKIN_REVIEW_DAYS = 7;
/**
* After a check-in comes due, it resurfaces for at most this many days before
* it stops appearing on its own — the calm inversion of a streak (§2.3.3): an
* ignored check-in sits quiet after ~two cycles rather than nagging forever,
* and the plan's status is NEVER changed behind the user's back. Only an
* explicit keep / let-go moves the plan; silence just retires the card.
*/
export const COACH_CHECKIN_RESURFACE_DAYS = 14;
/**
* Closed allowlist of the check-in card's two MUTATING intents (§2.3.2). The
* generic, id-less `PriorityCard` forwards only a single `intent` string, so
* the target plan id is appended after the ":" — the Today handler recovers it
* and PATCHes the existing plan-lifecycle route. "Adjust" is navigation (an
* `href` into the coach), so it carries no plan id and never mutates here.
*/
export const COACH_CHECKIN_KEEP_INTENT = "coach.checkin.keep";
export const COACH_CHECKIN_LETGO_INTENT = "coach.checkin.letGo";
export const COACH_CHECKIN_ADJUST_INTENT = "coach.checkin.adjust";
type Translate = ServerTranslator["t"];
/** Module map in the registry's DISABLED-allowlist shape (missing = enabled). */
@@ -62,6 +92,25 @@ export interface DailyDigestPreventiveDue {
label: string;
}
/**
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
* builder computes due-ness deterministically from the plain columns — it never
* needs a fresh AI call, reading only the existing plan lifecycle. `planText`
* is the plan's own if→then prose (decrypted fault-isolated in the IO seam, or
* null when its key rotated out) so the card can echo the user's own words.
*/
export interface DailyDigestCoachPlan {
id: string;
/** Lifecycle status — only `active` / `reviewed` reach the builder. */
status: string;
/** The coach-pinned review checkpoint, or null (then defaulted, see below). */
reviewDate: Date | null;
createdAt: Date;
updatedAt: Date;
/** Decrypted "if cue → then action" prose, or null when undecryptable. */
planText: string | null;
}
/** The fully-resolved, IO-free input the composer folds into a digest. */
export interface DailyDigestInput {
now: Date;
@@ -72,8 +121,20 @@ export interface DailyDigestInput {
medsToday: MedsTodayBlock;
/** Days since the freshest sleep reading; null when never recorded. */
sleepLastSeenDaysAgo: number | null;
/**
* S4 freshness (§E) — whether the event-driven morning refresh has already
* run for the user's CURRENT local date. Derived in the IO seam by comparing
* `User.morningDigestRefreshedOn` against today's local date (profile tz):
* `true` once last night's sleep arrived AND the sleep-dependent generation
* was re-run with it. This is the authoritative `final` signal — it flips
* immediately on the refresh, before the snapshot cache (which feeds
* `sleepLastSeenDaysAgo`) has expired.
*/
morningRefreshedToday: boolean;
syncIssues: DailyDigestSyncIssue[];
preventiveDue: DailyDigestPreventiveDue[];
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
coachPlans: DailyDigestCoachPlan[];
}
export interface DailyDigest {
@@ -195,6 +256,86 @@ function buildPreventiveCareItem(
};
}
/**
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
* when the read-back fired, so its `updatedAt` (the flip moment) is when the
* check-in came due. A legacy `active` plan the coach never dated defaults to
* activation + `COACH_CHECKIN_REVIEW_DAYS` — the same rule the PATCH route now
* writes forward, applied read-side for plans that predate it.
*/
function checkinDueAt(plan: DailyDigestCoachPlan): number {
if (plan.reviewDate) return plan.reviewDate.getTime();
if (plan.status === "reviewed") return plan.updatedAt.getTime();
return plan.createdAt.getTime() + COACH_CHECKIN_REVIEW_DAYS * MS_PER_DAY;
}
/**
* A plan is check-in-due when its due instant has passed AND it is still within
* the resurface window. Past the window the card retires quietly — the plan's
* status is untouched (never abandoned behind the user's back); a keep re-arms
* it, a let-go ends it, silence just stops the card.
*/
function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
const dueAt = checkinDueAt(plan);
if (dueAt > now) return false;
return now - dueAt <= COACH_CHECKIN_RESURFACE_DAYS * MS_PER_DAY;
}
/**
* The one coach check-in card (§2.3). Gated on the `coach` module. Capped at
* ONE per day across every plan: the earliest-due check-in wins, the rest wait
* for a following day. Reads existing plan state only — never a fresh AI call.
* Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust
* (navigate into the coach), let go (guilt-free retirement).
*/
function buildCoachCheckinItem(
plans: DailyDigestCoachPlan[],
modules: DigestModuleMap,
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "coach")) return null;
const nowMs = now.getTime();
const due = plans
.filter((p) => isCheckinDue(p, nowMs))
.sort((a, b) => {
const byDue = checkinDueAt(a) - checkinDueAt(b);
if (byDue !== 0) return byDue;
// Deterministic tie-break so the same day always surfaces the same card.
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
const plan = due[0];
if (!plan) return null;
const body = plan.planText
? t("daily.item.coachCheckin.bodyPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.body");
return {
kind: "coach_checkin",
title: t("daily.item.coachCheckin.title"),
body,
status: "info",
actions: [
{
labelKey: "daily.action.checkinKeep",
intent: `${COACH_CHECKIN_KEEP_INTENT}:${plan.id}`,
},
{
labelKey: "daily.action.checkinAdjust",
intent: COACH_CHECKIN_ADJUST_INTENT,
href: "/coach",
},
{
labelKey: "daily.action.checkinLetGo",
intent: `${COACH_CHECKIN_LETGO_INTENT}:${plan.id}`,
},
],
moduleKey: "coach",
};
}
/**
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
* back to the top signal's headline, then a deterministic score floor, then
@@ -217,21 +358,40 @@ export function buildDailyDigest(
input: DailyDigestInput,
t: Translate,
): DailyDigest {
// §E freshness lifecycle. The day is `final` once ANY of:
// - sleep is disabled (nothing to wait for);
// - the event-driven morning refresh has run for today (authoritative,
// fast — flips the instant the sleep-arrival job stamps the marker);
// - last night's sleep is already visibly in the record (`daysAgo === 0`) —
// the eventual-consistency backstop for when the refresh job never ran
// (no boss worker / keyless self-hoster) but the snapshot has caught up.
// Otherwise it stays `provisional`, and `sleepPending` drives the honest
// "last night's sleep not yet in" note.
const sleepEnabled = moduleEnabled(input.modules, "sleep");
const sleepPending =
sleepEnabled &&
(input.sleepLastSeenDaysAgo === null || input.sleepLastSeenDaysAgo >= 1);
const phase: DailyDigest["phase"] = sleepPending ? "provisional" : "final";
const lastNightSleepIn = input.sleepLastSeenDaysAgo === 0;
const isFinal =
!sleepEnabled || input.morningRefreshedToday || lastNightSleepIn;
const sleepPending = sleepEnabled && !isFinal;
const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional";
const topSignal = input.briefing?.signalsOfDay?.[0] ?? null;
const briefingLead = firstSentence(input.briefing?.paragraph);
// Priority order: an overdue dose is the most time-sensitive daily action, a
// broken sync next, a preventive check-up least urgent. Bounded to 3.
// broken sync next, then the calm coach check-in, a preventive check-up
// least urgent. Bounded to 3 — a check-in the cap crowds out resurfaces on a
// following day (within its window), never lost.
const worthALook: PriorityItem[] = [];
const dose = buildDoseWindowItem(input.medsToday, input.modules, t);
if (dose) worthALook.push(dose);
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
const checkin = buildCoachCheckinItem(
input.coachPlans,
input.modules,
input.now,
t,
);
if (checkin) worthALook.push(checkin);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
+112 -21
View File
@@ -20,9 +20,12 @@ import { resolveModuleMap } from "@/lib/modules/gate";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { userDayKey } from "@/lib/tz/format";
import {
buildDailyDigest,
type DailyDigest,
type DailyDigestCoachPlan,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSyncIssue,
@@ -34,6 +37,45 @@ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
/** Defensive cap on how many overdue reminders we read for the rail summary. */
const PREVENTIVE_DUE_READ_LIMIT = 20;
/** Standing plan states that can carry a check-in (§2.3). */
const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const;
/** Cap on plans scanned for the (one/day) check-in candidate. */
const CHECKIN_PLAN_READ_LIMIT = 50;
interface CoachPlanRow {
id: string;
status: string;
reviewDate: Date | null;
createdAt: Date;
updatedAt: Date;
ifCueEncrypted: Uint8Array;
thenActionEncrypted: Uint8Array;
}
/**
* Decrypt a plan's if→then prose fault-isolated (an undecryptable row keeps a
* null text, never throws) so the check-in card can echo the user's own words.
*/
function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan {
let planText: string | null = null;
try {
planText = `${decryptFromBytes(row.ifCueEncrypted)} → ${decryptFromBytes(
row.thenActionEncrypted,
)}`;
} catch {
planText = null;
}
return {
id: row.id,
status: row.status,
reviewDate: row.reviewDate,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
planText,
};
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
@@ -44,27 +86,54 @@ export async function loadDailyDigest(
user: User,
now: Date = new Date(),
): Promise<DailyDigest> {
const [{ body: snapshot, locale }, modules, syncRows, dueReminders] =
await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
prisma.integrationStatus.findMany({
where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } },
select: { integration: true, state: true },
orderBy: { integration: "asc" },
}),
prisma.measurementReminder.findMany({
where: {
userId: user.id,
enabled: true,
deletedAt: null,
nextDueAt: { not: null, lte: now },
},
select: { label: true },
orderBy: { nextDueAt: "asc" },
take: PREVENTIVE_DUE_READ_LIMIT,
}),
]);
const [
{ body: snapshot, locale },
modules,
syncRows,
dueReminders,
planRows,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
prisma.integrationStatus.findMany({
where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } },
select: { integration: true, state: true },
orderBy: { integration: "asc" },
}),
prisma.measurementReminder.findMany({
where: {
userId: user.id,
enabled: true,
deletedAt: null,
nextDueAt: { not: null, lte: now },
},
select: { label: true },
orderBy: { nextDueAt: "asc" },
take: PREVENTIVE_DUE_READ_LIMIT,
}),
// Standing plans for the (one/day) check-in candidate. The plaintext
// columns decide due-ness; the encrypted prose is only decrypted below,
// and only for a coach-enabled account (disabled-module data never
// enters the digest — the builder also drops it, this skips the decrypt).
prisma.coachPlan.findMany({
where: {
userId: user.id,
deletedAt: null,
status: { in: [...CHECKIN_PLAN_STATES] },
},
select: {
id: true,
status: true,
reviewDate: true,
createdAt: true,
updatedAt: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
},
orderBy: { updatedAt: "desc" },
take: CHECKIN_PLAN_READ_LIMIT,
}),
]);
const score: DailyDigestScore | null = snapshot.healthScore
? {
@@ -77,6 +146,16 @@ export async function loadDailyDigest(
const sleepSlot = snapshot.tiles.lastSeenByType["SLEEP_DURATION"];
const sleepLastSeenDaysAgo = sleepSlot ? sleepSlot.daysAgo : null;
// S4 freshness (§E) — the day is `final` once the sleep-arrival morning
// refresh has stamped `User.morningDigestRefreshedOn` with the user's current
// local date (profile tz). Read fresh off the row here, so it flips the
// instant the refresh job runs — ahead of the snapshot cache that feeds
// `sleepLastSeenDaysAgo`.
const todayLocalDate = userDayKey(now, user.timezone);
const morningRefreshedToday =
user.morningDigestRefreshedOn !== null &&
user.morningDigestRefreshedOn === todayLocalDate;
const syncIssues: DailyDigestSyncIssue[] = syncRows.map((row) => ({
integration: row.integration,
state: row.state,
@@ -86,6 +165,13 @@ export async function loadDailyDigest(
label: row.label,
}));
// Only decrypt plan prose for a coach-enabled account; the builder gates on
// the module too, so a disabled coach never surfaces a check-in either way.
const coachEnabled = modules.coach !== false;
const coachPlans: DailyDigestCoachPlan[] = coachEnabled
? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow))
: [];
const { t } = getServerTranslator(resolveLocale(locale));
const digest = buildDailyDigest(
@@ -96,8 +182,10 @@ export async function loadDailyDigest(
briefing: snapshot.briefing,
medsToday: snapshot.medsToday,
sleepLastSeenDaysAgo,
morningRefreshedToday,
syncIssues,
preventiveDue,
coachPlans,
},
t,
);
@@ -109,6 +197,9 @@ export async function loadDailyDigest(
daily_digest_sleep_pending: digest.sleepPending,
daily_digest_item_count: digest.worthALook.length,
daily_digest_has_score: digest.score !== null,
daily_digest_has_checkin: digest.worthALook.some(
(i) => i.kind === "coach_checkin",
),
},
});
+111
View File
@@ -0,0 +1,111 @@
/**
* S4 — the sleep-arrival trigger for the morning digest refresh.
*
* The nightly insight pre-pass (`insight-pregenerate`, 04:30) runs before last
* night's sleep has synced, so the morning digest + health score reference the
* day WITHOUT the current sleep. This trigger closes that gap the event-driven
* way: when a completed sleep segment for LAST NIGHT lands from any of the
* three sleep transports (Withings sync-sleep / the WHOOP sync / the
* Apple-Health measurement batch), it enqueues a debounced
* `morning-digest-refresh` job that re-runs the sleep-dependent generation and
* flips the day `provisional → final`.
*
* There is no single canonical sleep-persist function in the tree — each
* transport upserts through its own helper — so the three write seams each call
* `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row they just
* wrote. The debounce (queue `singletonKey` + the `User.morningDigestRefreshedOn`
* marker) collapses the many samples of one night, and across all three
* sources, into ONE refresh per user per local morning.
*
* Timezone correctness is load-bearing: "last night" is judged in the user's
* PROFILE timezone, never UTC, so a 30-day backfill re-sync never re-triggers
* for historical nights and a sleep that is "last night" locally but a
* different UTC date still triggers.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { enqueueMorningDigestRefresh } from "@/lib/jobs/morning-digest-refresh-shared";
const MS_PER_DAY = 86_400_000;
/**
* True when `measuredAt` belongs to "last night" for an observer in `tz`: its
* local calendar date is today or yesterday. An older backfilled segment (local
* date ≥ 2 days ago) returns false, so a full WHOOP / Apple / Withings re-sync
* that replays a month of nights never re-triggers the morning refresh for a
* historical night. A future-dated sample (clock skew) is rejected too.
*
* Pure + timezone-explicit so the tz-boundary cases are unit-testable without
* touching the DB or the queue.
*/
export function isLastNightLocal(
measuredAt: Date,
now: Date,
tz: string,
): boolean {
if (measuredAt.getTime() > now.getTime()) return false;
const todayKey = userDayKey(now, tz);
const yesterdayKey = userDayKey(new Date(now.getTime() - MS_PER_DAY), tz);
const segKey = userDayKey(measuredAt, tz);
return segKey === todayKey || segKey === yesterdayKey;
}
/**
* Enqueue the debounced morning refresh when at least one of the just-written
* sleep segments belongs to last night in the user's profile timezone.
*
* Best-effort and self-contained: every read is wrapped so a failure can never
* fail the sleep sync that called it. Callers still `void … .catch(() => {})`
* for defence in depth.
*
* Gates, in order:
* 1. No sleep rows → nothing to do.
* 2. `sleep` module off → the digest is always `final` (no sleep to wait
* for), so a refresh would be pointless; no-op.
* 3. No segment is "last night" locally → an old backfill; no-op.
* 4. `User.morningDigestRefreshedOn` already equals today's local date → the
* refresh already ran this morning; skip the enqueue (the handler
* re-checks the same marker, so this only saves queue churn).
*/
export async function maybeEnqueueMorningRefresh(
userId: string,
sleepMeasuredAts: Date[],
now: Date = new Date(),
): Promise<void> {
try {
if (sleepMeasuredAts.length === 0) return;
const modules = await resolveModuleMap(userId);
if (modules.sleep === false) return;
const tz = await resolveUserTimezone(userId);
const hasLastNight = sleepMeasuredAts.some((at) =>
isLastNightLocal(at, now, tz),
);
if (!hasLastNight) return;
const localDate = userDayKey(now, tz);
const row = await prisma.user.findUnique({
where: { id: userId },
select: { morningDigestRefreshedOn: true },
});
if (row?.morningDigestRefreshedOn === localDate) return;
await enqueueMorningDigestRefresh({ userId, localDate });
annotate({
action: { name: "daily.morning_refresh.triggered" },
meta: {
local_date: localDate,
sleep_samples: sleepMeasuredAts.length,
},
});
} catch {
// Never let a freshness trigger fail an ingest. The next sleep landing and
// the nightly cron are the catch-net; the digest stays honestly
// provisional until one of them refreshes it.
}
}
@@ -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 * * * *");
});
});
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { runMorningDigestRefresh } from "../morning-digest-refresh";
import type { GenerateOutcome } from "@/lib/insights/comprehensive-generate";
const LOCAL_DATE = "2026-07-16";
function makePrisma(
user: {
locale: string | null;
morningDigestRefreshedOn: string | null;
} | null,
) {
const updateMock = vi.fn(async () => ({}));
const prisma = {
user: {
findUnique: vi.fn(async () => user),
update: updateMock,
},
};
return { prisma, updateMock };
}
describe("runMorningDigestRefresh", () => {
const retryMock = vi.fn(async () => {});
beforeEach(() => {
retryMock.mockReset();
});
it("regenerates and stamps the marker → finalises the day", async () => {
const { prisma, updateMock } = makePrisma({
locale: "en",
morningDigestRefreshedOn: null,
});
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
status: "generated",
providerType: "mock",
}));
const result = await runMorningDigestRefresh(
prisma as never,
{ userId: "u1", localDate: LOCAL_DATE },
{ generate, enqueueRetry: retryMock },
);
expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true });
expect(updateMock).toHaveBeenCalledWith({
where: { id: "u1" },
data: { morningDigestRefreshedOn: LOCAL_DATE },
});
expect(result.status).toBe("finalised");
expect(retryMock).not.toHaveBeenCalled();
});
it("finalises on an `unchanged` regen (content-hash no-op is still fresh)", async () => {
const { prisma, updateMock } = makePrisma({
locale: "de",
morningDigestRefreshedOn: null,
});
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
status: "unchanged",
}));
const result = await runMorningDigestRefresh(
prisma as never,
{ userId: "u1", localDate: LOCAL_DATE },
{ generate, enqueueRetry: retryMock },
);
expect(generate).toHaveBeenCalledWith("u1", { locale: "de", force: true });
expect(updateMock).toHaveBeenCalled();
expect(result).toEqual({ status: "finalised", comprehensive: "unchanged" });
});
it("finalises a keyless user (`skipped` no-provider) — nothing to wait for", async () => {
const { prisma, updateMock } = makePrisma({
locale: "en",
morningDigestRefreshedOn: null,
});
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
status: "skipped",
reason: "no-provider",
}));
const result = await runMorningDigestRefresh(
prisma as never,
{ userId: "u1", localDate: LOCAL_DATE },
{ generate, enqueueRetry: retryMock },
);
expect(updateMock).toHaveBeenCalled();
expect(result.status).toBe("finalised");
});
it("is idempotent: a second run for the same morning no-ops via the marker", async () => {
const { prisma, updateMock } = makePrisma({
locale: "en",
morningDigestRefreshedOn: LOCAL_DATE,
});
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
status: "generated",
providerType: "mock",
}));
const result = await runMorningDigestRefresh(
prisma as never,
{ userId: "u1", localDate: LOCAL_DATE },
{ generate, enqueueRetry: retryMock },
);
expect(generate).not.toHaveBeenCalled();
expect(updateMock).not.toHaveBeenCalled();
expect(result.status).toBe("already-final");
});
it("leaves the day provisional on a hard generation failure and hands off to the retry machinery", async () => {
const { prisma, updateMock } = makePrisma({
locale: "en",
morningDigestRefreshedOn: null,
});
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
status: "failed",
reason: "provider-timeout",
}));
const result = await runMorningDigestRefresh(
prisma as never,
{ userId: "u1", localDate: LOCAL_DATE },
{ generate, enqueueRetry: retryMock },
);
expect(updateMock).not.toHaveBeenCalled();
expect(retryMock).toHaveBeenCalledWith({ userId: "u1", locale: "en" });
expect(result.status).toBe("failed");
});
it("reports missing-user when the row is gone", async () => {
const { prisma, updateMock } = makePrisma(null);
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
status: "unchanged",
}));
const result = await runMorningDigestRefresh(
prisma as never,
{ userId: "ghost", localDate: LOCAL_DATE },
{ generate, enqueueRetry: retryMock },
);
expect(generate).not.toHaveBeenCalled();
expect(updateMock).not.toHaveBeenCalled();
expect(result.status).toBe("missing-user");
});
});
+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;
}
@@ -0,0 +1,75 @@
/**
* S4 — generator-free contract for the `morning-digest-refresh` queue.
*
* The sleep-arrival trigger (`@/lib/daily/morning-refresh-trigger`) — reached
* from the Withings, WHOOP, and Apple-Health sleep-write seams — enqueues a
* debounced morning refresh here WITHOUT importing the comprehensive generator
* (which would drag the whole insight tree into every sleep-sync bundle and
* risk an import cycle through the transports). The worker-only pipeline
* (`runMorningDigestRefresh`) lives in `morning-digest-refresh.ts`, which
* re-exports the queue name from here so there is a single source of truth.
*
* Mirrors the `insight-pregenerate-shared.ts` split exactly: queue name +
* payload type + enqueue helper in the generator-free module, the concrete
* dispatch in the worker module.
*/
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
export const MORNING_DIGEST_REFRESH_QUEUE = "morning-digest-refresh";
export interface MorningDigestRefreshPayload {
userId: string;
/**
* The user-local YYYY-MM-DD (their profile timezone) this refresh finalises.
* Doubles as the debounce key and the value the handler stamps onto
* `User.morningDigestRefreshedOn` so the digest reads `final` for the day.
*/
localDate: string;
}
/**
* Fire-and-forget enqueue for the sleep-arrival trigger. The `singletonKey`
* carries the user AND the local date, so the many sleep samples of one night
* collapse into ONE queued job: while a refresh for this morning is
* created/active no duplicate can be inserted. Once it completes, the durable
* `User.morningDigestRefreshedOn` marker (re-checked in the handler AND
* pre-checked in the trigger) stops any later sample from re-running it — the
* pair is at-most-once per user per local morning.
*
* No `singletonSeconds`: a time-slot throttle would let a second morning's
* refresh be swallowed if it fell inside the window; the local-date-scoped key
* is already unique per morning, so the plain active-job constraint is exactly
* the debounce we want. No-ops cleanly when no global boss instance is
* available (a web process without an embedded worker) — the next sleep
* landing, and the nightly cron, remain the catch-net.
*/
export async function enqueueMorningDigestRefresh(
payload: MorningDigestRefreshPayload,
): Promise<void> {
const boss = getGlobalBoss();
if (!boss) {
annotate({
action: { name: "daily.morning_refresh.no_boss" },
meta: { local_date: payload.localDate },
});
return;
}
try {
await boss.send(MORNING_DIGEST_REFRESH_QUEUE, payload, {
singletonKey: `morning-refresh:${payload.userId}:${payload.localDate}`,
// A transient provider / pool failure retries with backoff rather than
// leaving the day provisional until the next sleep sample lands.
retryLimit: 2,
retryDelay: 60,
retryBackoff: true,
});
annotate({
action: { name: "daily.morning_refresh.enqueued" },
meta: { local_date: payload.localDate },
});
} catch {
// Best-effort: a failed enqueue leaves the next sleep landing + the nightly
// cron as the catch-net; the digest stays honestly provisional meanwhile.
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* S4 — the `morning-digest-refresh` worker.
*
* Runs ONE extra insight generation per user per local morning, event-driven
* off the arrival of last night's sleep (never warm-on-mount, never per page
* visit). It re-runs the SAME comprehensive pipeline the nightly
* `insight-pregenerate` cron uses — forced so the 24 h cache short-circuit is
* bypassed — then stamps `User.morningDigestRefreshedOn` so the daily digest
* flips the day `provisional → final`.
*
* Cheap by construction: the generator's own content-hash gate turns a
* no-change regeneration into an `unchanged` timestamp refresh with no provider
* call, so a morning whose sleep did not actually move the feature snapshot
* costs a hash compare, not an LLM round-trip. A real change — last night's
* sleep now folded into the features — regenerates the briefing paragraph AND
* warms the score's read path.
*
* Honest degradation (§E): on a hard generation failure the marker is NOT
* stamped, so the day stays `provisional` and the digest keeps showing the
* sleep-pending note. The generator already recorded the omit-reason via
* `recordBriefingFailure` on its failure paths, and we re-enqueue the SAME
* 45-minute bounded retry the nightly cron uses (`enqueuePregenerateFailureRetry`)
* — no parallel freshness machinery is invented here.
*
* Idempotency: a second run for the same local morning no-ops on the marker
* re-check. The enqueue-side `singletonKey` collapses the concurrent burst; the
* marker is the durable "already done" guard the singleton cannot provide once
* the first job has completed.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import {
generateComprehensiveInsight,
type GenerateOutcome,
} from "@/lib/insights/comprehensive-generate";
import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
MORNING_DIGEST_REFRESH_QUEUE,
type MorningDigestRefreshPayload,
} from "@/lib/jobs/morning-digest-refresh-shared";
export { MORNING_DIGEST_REFRESH_QUEUE };
export type { MorningDigestRefreshPayload };
export interface MorningDigestRefreshResult {
status: "finalised" | "already-final" | "missing-user" | "failed";
/** The comprehensive generator's own outcome, when it ran. */
comprehensive?: GenerateOutcome["status"];
}
type GenerateFn = (
userId: string,
options: { locale: "de" | "en"; force?: boolean },
) => Promise<GenerateOutcome>;
/**
* Execute the morning refresh for one user. Pure of queue concerns — the boss
* handler in `register-status.ts` wraps this in the background-event envelope.
*
* `deps` is injected by the tests: a fake generator + failure-retry so the
* marker lifecycle and idempotency can be asserted without a provider or a
* running boss.
*/
export async function runMorningDigestRefresh(
prisma: PrismaClient,
payload: MorningDigestRefreshPayload,
deps: {
generate?: GenerateFn;
enqueueRetry?: (args: {
userId: string;
locale: "de" | "en";
}) => Promise<void>;
} = {},
): Promise<MorningDigestRefreshResult> {
const generate: GenerateFn = deps.generate ?? generateComprehensiveInsight;
const enqueueRetry = deps.enqueueRetry ?? enqueuePregenerateFailureRetry;
const { userId, localDate } = payload;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true, morningDigestRefreshedOn: true },
});
if (!user) {
annotate({
action: { name: "daily.morning_refresh.missing_user" },
meta: { local_date: localDate },
});
return { status: "missing-user" };
}
// Idempotency: a re-fire for the same local morning is a no-op.
if (user.morningDigestRefreshedOn === localDate) {
annotate({
action: { name: "daily.morning_refresh.already_final" },
meta: { local_date: localDate },
});
return { status: "already-final" };
}
const locale = normalizeLocale(user.locale);
const outcome = await generate(userId, { locale, force: true });
// A hard provider failure leaves the day provisional and hands recovery to
// the existing 45-minute briefing-retry machinery. Every other outcome —
// including `skipped` (no provider / no consent: there is no LLM briefing to
// wait for, so the deterministic score + digest are as final as they get
// once last night's sleep is in the record) — finalises the day.
if (outcome.status === "failed") {
annotate({
action: { name: "daily.morning_refresh.deferred" },
meta: { local_date: localDate, cause: `generator:${outcome.reason}` },
});
await enqueueRetry({ userId, locale });
return { status: "failed", comprehensive: outcome.status };
}
await prisma.user.update({
where: { id: userId },
data: { morningDigestRefreshedOn: localDate },
});
annotate({
action: { name: "daily.morning_refresh.finalised" },
meta: { local_date: localDate, comprehensive: outcome.status },
});
return { status: "finalised", comprehensive: outcome.status };
}
+106
View File
@@ -24,6 +24,17 @@ import {
INSIGHT_PREGENERATE_CRON,
type InsightPregeneratePayload,
} from "@/lib/jobs/insight-pregenerate";
import {
MORNING_DIGEST_REFRESH_QUEUE,
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,
@@ -154,6 +165,11 @@ const allQueues = [
// provisions the queue and the enqueue silently drops. No cron schedule —
// a send-only queue driven by navigation.
INSIGHT_STATUS_GENERATE_QUEUE,
// S4 — event-driven morning digest refresh, enqueued by the sleep-arrival
// trigger when last night's completed sleep lands. No cron schedule — a
// send-only queue driven by sleep ingest; without this entry pg-boss never
// provisions the queue and every enqueue silently drops.
MORNING_DIGEST_REFRESH_QUEUE,
// v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress /
// Strain compute + store. The queue MUST be registered here or pg-boss
// never provisions it and the nightly schedule silently never fires.
@@ -178,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[] = [
@@ -233,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],
];
/**
@@ -295,6 +321,52 @@ export async function registerStatusQueues(
{ localConcurrency: 2 },
handleInsightPregenerateJob,
);
// S4 — event-driven morning digest refresh. One forced comprehensive
// regeneration per user per local morning, enqueued by the sleep-arrival
// trigger. Single-flight: the enqueue-side singletonKey already collapses a
// night's samples into one job, and the handler's marker re-check makes a
// rare double-fire a no-op, so one slot is enough and bounds the provider
// concurrency the same way the nightly path does.
await boss.work<MorningDigestRefreshPayload>(
MORNING_DIGEST_REFRESH_QUEUE,
{ localConcurrency: 1 },
async (jobs) => {
await withBackgroundEvent("job.morning_digest_refresh", async (evt) => {
for (const job of jobs) {
try {
const result = await runMorningDigestRefresh(
getWorkerPrisma(),
job.data,
);
evt.addMeta(
"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);
// Rethrow so the queue's retry policy re-runs the refresh; the
// marker re-check keeps a retry idempotent.
throw err;
}
}
});
},
);
// v1.8.3 — on-demand per-metric status generation enqueued by the
// read-only status route on a cold card. Low concurrency so a first
// visit that cold-misses several cards can't saturate the Prisma pool
@@ -387,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> = {
+22 -1
View File
@@ -25,6 +25,7 @@ import {
sweepStaleSleepSegments,
type SleepSegmentSweep,
} from "@/lib/sleep/sweep-stale-segments";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
import {
@@ -95,6 +96,15 @@ export async function syncUserSleep(
const imported = await upsertWhoopMeasurements(userId, readings);
await markResourceSynced(userId, "sleep");
// S4 — trigger the debounced morning refresh on a last-night segment landing.
void maybeEnqueueMorningRefresh(
userId,
readings
.filter((r) => r.type === "SLEEP_DURATION")
.map((r) => r.measuredAt),
).catch(() => {});
return imported;
}
@@ -142,5 +152,16 @@ export async function syncWhoopSleepById(
{ prefix: `${record.id}:`, keepIds },
]);
return upsertWhoopMeasurements(userId, readings);
const imported = await upsertWhoopMeasurements(userId, readings);
// S4 — a webhook-driven single-record refresh is the freshest possible
// last-night signal; kick the debounced morning refresh on its segments.
void maybeEnqueueMorningRefresh(
userId,
readings
.filter((r) => r.type === "SLEEP_DURATION")
.map((r) => r.measuredAt),
).catch(() => {});
return imported;
}
+10
View File
@@ -48,6 +48,7 @@ import {
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
sweepStaleSleepSegments,
@@ -636,6 +637,15 @@ export async function syncUserSleep(
);
}
// S4 — if a stage segment for last night just landed, kick the debounced
// morning refresh so the digest/score finalise with the current sleep. The
// trigger judges "last night" in the user's profile tz and no-ops on a
// backfill; fire-and-forget so a freshness enqueue never fails the sync.
void maybeEnqueueMorningRefresh(
userId,
touched.filter((tt) => tt.type === SLEEP_TYPE).map((tt) => tt.measuredAt),
).catch(() => {});
await recordSyncSuccess(userId, "withings");
return imported;
}