diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cc80d69..f7afa492a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8c8b32d4a..fc9fb8f51 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 53e680acd..017051510 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -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, diff --git a/messages/de.json b/messages/de.json index 8b9e48854..c8907407d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 338f5c722..11f67878f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index bfc69f1ef..79883e68e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 5ce9173e0..85d4f3acf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é", diff --git a/messages/it.json b/messages/it.json index 624584900..b0702e182 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index ce1a0a0a9..b057686a9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index a7ff7fda3..500802a1a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0244_morning_digest_refresh_marker/migration.sql b/prisma/migrations/0244_morning_digest_refresh_marker/migration.sql new file mode 100644 index 000000000..febf3b48d --- /dev/null +++ b/prisma/migrations/0244_morning_digest_refresh_marker/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 564c80639..ba24b0d8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/public/sw.js b/public/sw.js index 93dd81d29..e89f2e39d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index f89226ee8..f21351a5c 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -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. diff --git a/src/app/api/coach/plans/__tests__/route.test.ts b/src/app/api/coach/plans/__tests__/route.test.ts index a41f9d075..f0b871460 100644 --- a/src/app/api/coach/plans/__tests__/route.test.ts +++ b/src/app/api/coach/plans/__tests__/route.test.ts @@ -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; + }; expect(Object.keys(arg.data)).toEqual(["status"]); }); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 354c68d60..24e4e9131 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { } 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({ diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index a6591fe7d..fa43c9722 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -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( - {node}, + + {node} + , ); } diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 34522a09c..f0d343225 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -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 , 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 }) {
{digest.worthALook.map((item, i) => ( - + ))}
diff --git a/src/hooks/use-coach-checkin.ts b/src/hooks/use-coach-checkin.ts new file mode 100644 index 000000000..b58f741f2 --- /dev/null +++ b/src/hooks/use-coach-checkin.ts @@ -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 `` — 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 }; +} diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts new file mode 100644 index 000000000..6aae8c602 --- /dev/null +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -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 { + 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 & { dispatch: ReturnType } { + const dispatch = vi.fn(async () => ({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + })); + return { + dispatch, + loadDigest: async () => makeDigest(), + isModuleEnabled: async () => true, + ...over, + } as DailyBriefingDispatchDeps & { dispatch: ReturnType }; +} + +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(); + }); +}); diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 95d2b32d1..b98156f08 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -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 { briefing, medsToday: meds(), sleepLastSeenDaysAgo: 0, + morningRefreshedToday: false, syncIssues: [], preventiveDue: [], + coachPlans: [], + ...over, + }; +} + +const DAY = 86_400_000; + +function plan(over: Partial = {}): 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) { + 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"); + }); +}); diff --git a/src/lib/daily/__tests__/morning-refresh-trigger.test.ts b/src/lib/daily/__tests__/morning-refresh-trigger.test.ts new file mode 100644 index 000000000..17c206559 --- /dev/null +++ b/src/lib/daily/__tests__/morning-refresh-trigger.test.ts @@ -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> => ({}), +); +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(); + }); +}); diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts new file mode 100644 index 000000000..3f9e12408 --- /dev/null +++ b/src/lib/daily/daily-briefing-push.ts @@ -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; + 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 { + 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"; + } +} diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 6765aba33..4f4ff9753 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -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); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index f00632900..8747c5908 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -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 { - 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", + ), }, }); diff --git a/src/lib/daily/morning-refresh-trigger.ts b/src/lib/daily/morning-refresh-trigger.ts new file mode 100644 index 000000000..a24483855 --- /dev/null +++ b/src/lib/daily/morning-refresh-trigger.ts @@ -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 { + 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. + } +} diff --git a/src/lib/jobs/__tests__/daily-briefing.test.ts b/src/lib/jobs/__tests__/daily-briefing.test.ts new file mode 100644 index 000000000..8ae057493 --- /dev/null +++ b/src/lib/jobs/__tests__/daily-briefing.test.ts @@ -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( + 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( + 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( + 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 * * * *"); + }); +}); diff --git a/src/lib/jobs/__tests__/morning-digest-refresh.test.ts b/src/lib/jobs/__tests__/morning-digest-refresh.test.ts new file mode 100644 index 000000000..050b97d6c --- /dev/null +++ b/src/lib/jobs/__tests__/morning-digest-refresh.test.ts @@ -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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + 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"); + }); +}); diff --git a/src/lib/jobs/daily-briefing.ts b/src/lib/jobs/daily-briefing.ts new file mode 100644 index 000000000..d91d0097a --- /dev/null +++ b/src/lib/jobs/daily-briefing.ts @@ -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 { + 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(); + 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; +} diff --git a/src/lib/jobs/morning-digest-refresh-shared.ts b/src/lib/jobs/morning-digest-refresh-shared.ts new file mode 100644 index 000000000..a7486efd2 --- /dev/null +++ b/src/lib/jobs/morning-digest-refresh-shared.ts @@ -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 { + 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. + } +} diff --git a/src/lib/jobs/morning-digest-refresh.ts b/src/lib/jobs/morning-digest-refresh.ts new file mode 100644 index 000000000..acaa6151d --- /dev/null +++ b/src/lib/jobs/morning-digest-refresh.ts @@ -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; + +/** + * 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; + } = {}, +): Promise { + 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 }; +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index bba28d96a..315ed0eaf 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -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( + 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 diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 860e78af6..102871b2e 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -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 = { // 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 = { diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 84828749b..0202b68b7 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -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; } diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 791e89726..37565c3fd 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -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; }