mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle
This commit is contained in:
commit
d470d406a7
35 files changed
+2390
-41
No files matched your search
+19
-1
@@ -2,7 +2,25 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.54] — 2026-07-16 — One hero on the dashboard
|
||||
## [1.28.55] — 2026-07-16 — The Today view comes to life
|
||||
|
||||
Three pieces turn the Today view into a daily loop:
|
||||
|
||||
- **A coach check-in.** When a coach plan is due for a look-back, it surfaces as
|
||||
a calm card in Today with keep, adjust, or let-go — one tap. Letting go retires
|
||||
the plan quietly; silence never changes it.
|
||||
- **A morning that reflects last night.** The score and read are prepared
|
||||
overnight, but last night's sleep usually hasn't synced by then. Now, the
|
||||
moment it arrives, the sleep-dependent parts refresh on their own — so by the
|
||||
time you look, the day is up to date. Until sleep is in, Today says so plainly
|
||||
instead of showing a stale score.
|
||||
- **An optional daily nudge.** A once-a-day morning notification with the day's
|
||||
read, off by default and opt-in per channel under Notifications. It carries the
|
||||
refreshed digest when sleep is in, never repeats within a day, and is a calm
|
||||
note — never an alert.
|
||||
|
||||
All three read the digest prepared overnight; nothing is generated when you open
|
||||
the app.
|
||||
|
||||
With the Today view now leading the dashboard, the older opt-in hero card it
|
||||
replaced is retired, so the two never stack. The greeting stays, the tiles and
|
||||
|
||||
@@ -1,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.
|
||||
|
||||
|
||||
@@ -130,6 +130,31 @@ test.describe("authenticated dashboard render", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
// S3 — a coach check-in card (keep / adjust / let-go). Rendered
|
||||
// only; the mutating taps PATCH the plan route, which this spec
|
||||
// never clicks, so it stays green without a plan-route stub.
|
||||
{
|
||||
kind: "coach_checkin",
|
||||
title: "Time to check in on a plan",
|
||||
body: "About a week ago you set: “every morning → weigh in”.",
|
||||
status: "info",
|
||||
moduleKey: "coach",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.checkinKeep",
|
||||
intent: "coach.checkin.keep:p1",
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.checkinAdjust",
|
||||
intent: "coach.checkin.adjust",
|
||||
href: "/coach",
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.checkinLetGo",
|
||||
intent: "coach.checkin.letGo:p1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
|
||||
+15
-1
@@ -6536,6 +6536,8 @@
|
||||
"eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.",
|
||||
"eventMedicationIntakeSync": "Einnahme-Synchronisierung",
|
||||
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
|
||||
"eventDailyBriefing": "Täglicher Überblick",
|
||||
"eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
|
||||
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
|
||||
@@ -8524,6 +8526,10 @@
|
||||
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal."
|
||||
},
|
||||
"push": {
|
||||
"title": "Dein Morgenüberblick",
|
||||
"bodyProvisional": "{line} · Der Schlaf von letzter Nacht fehlt noch."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medikament fällig",
|
||||
@@ -8538,12 +8544,20 @@
|
||||
"syncIssue": {
|
||||
"title": "Synchronisierung braucht Aufmerksamkeit",
|
||||
"body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten."
|
||||
},
|
||||
"coachCheckin": {
|
||||
"title": "Zeit, einen Plan anzuschauen",
|
||||
"body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.",
|
||||
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"logDose": "Dosis erfassen",
|
||||
"reconnect": "Neu verbinden",
|
||||
"viewCheckups": "Vorsorge ansehen"
|
||||
"viewCheckups": "Vorsorge ansehen",
|
||||
"checkinKeep": "Behalten",
|
||||
"checkinAdjust": "Anpassen",
|
||||
"checkinLetGo": "Loslassen"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Gesundheitsscore",
|
||||
|
||||
+15
-1
@@ -6536,6 +6536,8 @@
|
||||
"eventMeasurementReminderDesc": "Sent when a tracked measurement is due.",
|
||||
"eventMedicationIntakeSync": "Medication intake sync",
|
||||
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
|
||||
"eventDailyBriefing": "Daily briefing",
|
||||
"eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.",
|
||||
"security": {
|
||||
"newDeviceTitle": "New sign-in to your account",
|
||||
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
|
||||
@@ -8524,6 +8526,10 @@
|
||||
"score": "Your health score today is {score}.",
|
||||
"allClear": "Nothing needs your attention today — everything's tracking normally."
|
||||
},
|
||||
"push": {
|
||||
"title": "Your morning briefing",
|
||||
"bodyProvisional": "{line} · Last night's sleep isn't in yet."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medication due",
|
||||
@@ -8538,12 +8544,20 @@
|
||||
"syncIssue": {
|
||||
"title": "Sync needs attention",
|
||||
"body": "{integration} isn't syncing — reconnect to keep your data current."
|
||||
},
|
||||
"coachCheckin": {
|
||||
"title": "Time to check in on a plan",
|
||||
"body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
|
||||
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"logDose": "Log dose",
|
||||
"reconnect": "Reconnect",
|
||||
"viewCheckups": "View check-ups"
|
||||
"viewCheckups": "View check-ups",
|
||||
"checkinKeep": "Keep it",
|
||||
"checkinAdjust": "Adjust",
|
||||
"checkinLetGo": "Let it go"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Health score",
|
||||
|
||||
+15
-1
@@ -6536,6 +6536,8 @@
|
||||
"eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.",
|
||||
"eventMedicationIntakeSync": "Sincronización de tomas",
|
||||
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
|
||||
"eventDailyBriefing": "Resumen diario",
|
||||
"eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
|
||||
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
|
||||
@@ -8524,6 +8526,10 @@
|
||||
"score": "Tu puntuación de salud hoy es {score}.",
|
||||
"allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad."
|
||||
},
|
||||
"push": {
|
||||
"title": "Tu resumen matinal",
|
||||
"bodyProvisional": "{line} · El sueño de anoche aún no está disponible."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medicación pendiente",
|
||||
@@ -8538,12 +8544,20 @@
|
||||
"syncIssue": {
|
||||
"title": "La sincronización necesita atención",
|
||||
"body": "{integration} no se está sincronizando: vuelve a conectarlo para mantener tus datos al día."
|
||||
},
|
||||
"coachCheckin": {
|
||||
"title": "Es buen momento para revisar un plan",
|
||||
"body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.",
|
||||
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"logDose": "Registrar dosis",
|
||||
"reconnect": "Reconectar",
|
||||
"viewCheckups": "Ver revisiones"
|
||||
"viewCheckups": "Ver revisiones",
|
||||
"checkinKeep": "Conservar",
|
||||
"checkinAdjust": "Ajustar",
|
||||
"checkinLetGo": "Dejarlo ir"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Puntuación de salud",
|
||||
|
||||
+15
-1
@@ -6536,6 +6536,8 @@
|
||||
"eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.",
|
||||
"eventMedicationIntakeSync": "Synchronisation des prises",
|
||||
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
|
||||
"eventDailyBriefing": "Résumé quotidien",
|
||||
"eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nouvelle connexion à votre compte",
|
||||
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
|
||||
@@ -8524,6 +8526,10 @@
|
||||
"score": "Ton score de santé aujourd'hui est de {score}.",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement."
|
||||
},
|
||||
"push": {
|
||||
"title": "Votre résumé du matin",
|
||||
"bodyProvisional": "{line} · Le sommeil de la nuit dernière n'est pas encore disponible."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Médicament à prendre",
|
||||
@@ -8538,12 +8544,20 @@
|
||||
"syncIssue": {
|
||||
"title": "La synchronisation demande votre attention",
|
||||
"body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour."
|
||||
},
|
||||
"coachCheckin": {
|
||||
"title": "Il est temps de revoir un plan",
|
||||
"body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.",
|
||||
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"logDose": "Enregistrer la dose",
|
||||
"reconnect": "Reconnecter",
|
||||
"viewCheckups": "Voir les examens"
|
||||
"viewCheckups": "Voir les examens",
|
||||
"checkinKeep": "Garder",
|
||||
"checkinAdjust": "Ajuster",
|
||||
"checkinLetGo": "Laisser tomber"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Score de santé",
|
||||
|
||||
+15
-1
@@ -6536,6 +6536,8 @@
|
||||
"eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.",
|
||||
"eventMedicationIntakeSync": "Sincronizzazione assunzioni",
|
||||
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
|
||||
"eventDailyBriefing": "Riepilogo quotidiano",
|
||||
"eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nuovo accesso al tuo account",
|
||||
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
|
||||
@@ -8524,6 +8526,10 @@
|
||||
"score": "Il tuo punteggio di salute oggi è {score}.",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente."
|
||||
},
|
||||
"push": {
|
||||
"title": "Il tuo riepilogo mattutino",
|
||||
"bodyProvisional": "{line} · Il sonno della scorsa notte non è ancora disponibile."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Farmaco da assumere",
|
||||
@@ -8538,12 +8544,20 @@
|
||||
"syncIssue": {
|
||||
"title": "La sincronizzazione richiede attenzione",
|
||||
"body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati."
|
||||
},
|
||||
"coachCheckin": {
|
||||
"title": "È il momento di rivedere un piano",
|
||||
"body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.",
|
||||
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"logDose": "Registra dose",
|
||||
"reconnect": "Riconnetti",
|
||||
"viewCheckups": "Vedi controlli"
|
||||
"viewCheckups": "Vedi controlli",
|
||||
"checkinKeep": "Mantieni",
|
||||
"checkinAdjust": "Modifica",
|
||||
"checkinLetGo": "Lascia andare"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Punteggio di salute",
|
||||
|
||||
+15
-1
@@ -6536,6 +6536,8 @@
|
||||
"eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.",
|
||||
"eventMedicationIntakeSync": "Synchronizacja przyjęć leku",
|
||||
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
|
||||
"eventDailyBriefing": "Codzienne podsumowanie",
|
||||
"eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.",
|
||||
"security": {
|
||||
"newDeviceTitle": "Nowe logowanie do Twojego konta",
|
||||
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
|
||||
@@ -8524,6 +8526,10 @@
|
||||
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie."
|
||||
},
|
||||
"push": {
|
||||
"title": "Twoje poranne podsumowanie",
|
||||
"bodyProvisional": "{line} · Brakuje jeszcze snu z zeszłej nocy."
|
||||
},
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Lek do przyjęcia",
|
||||
@@ -8538,12 +8544,20 @@
|
||||
"syncIssue": {
|
||||
"title": "Synchronizacja wymaga uwagi",
|
||||
"body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne."
|
||||
},
|
||||
"coachCheckin": {
|
||||
"title": "Czas przyjrzeć się planowi",
|
||||
"body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.",
|
||||
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"logDose": "Zapisz dawkę",
|
||||
"reconnect": "Połącz ponownie",
|
||||
"viewCheckups": "Zobacz badania"
|
||||
"viewCheckups": "Zobacz badania",
|
||||
"checkinKeep": "Zachowaj",
|
||||
"checkinAdjust": "Dostosuj",
|
||||
"checkinLetGo": "Odpuść"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Wynik zdrowia",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.54",
|
||||
"version": "1.28.55",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Morning-freshness marker (S4 of the unified daily-value system).
|
||||
--
|
||||
-- The nightly insight pre-pass runs ~04:30, before last night's sleep has
|
||||
-- synced, so the morning digest/health-score reference the day WITHOUT the
|
||||
-- current sleep. This column records the user-local YYYY-MM-DD (their profile
|
||||
-- timezone) for which the event-driven morning refresh has completed: last
|
||||
-- night's sleep arrived AND the sleep-dependent insight/score cache was
|
||||
-- regenerated with it. The daily digest reports `final` once this equals the
|
||||
-- user's current local date, `provisional` otherwise (honest degradation when
|
||||
-- sleep is late or never arrives). Stamped by the `morning-digest-refresh`
|
||||
-- pg-boss job the sleep-arrival trigger enqueues, debounced once per local
|
||||
-- morning. NULL for every existing row = provisional until the next sleep
|
||||
-- landing, which is the correct pre-feature reading.
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "morning_digest_refreshed_on" TEXT;
|
||||
@@ -111,6 +111,15 @@ model User {
|
||||
// without re-paying for the findings. This stamps the day a re-roll ran so
|
||||
// the gate stays at most one extra call per day.
|
||||
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
|
||||
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
|
||||
// timezone) for which the event-driven morning refresh has run — i.e. last
|
||||
// night's sleep has arrived AND the sleep-dependent insight/score cache was
|
||||
// regenerated with it. The daily digest reads `final` when this equals the
|
||||
// user's current local date, `provisional` otherwise. Stamped by the
|
||||
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
|
||||
// string form makes the "same local morning" comparison timezone-honest
|
||||
// without a second timestamp read.
|
||||
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -34,6 +34,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
|
||||
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
|
||||
|
||||
interface RouteCtx {
|
||||
@@ -94,6 +95,23 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
: null;
|
||||
}
|
||||
|
||||
// S3 (§2.3.1) — every accepted plan earns a check-in. When the coach
|
||||
// activates a plan and set no review date of its own, default `reviewDate` to
|
||||
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
|
||||
// the current row first so an explicitly-pinned review date is never
|
||||
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
|
||||
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
|
||||
const current = await prisma.coachPlan.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { reviewDate: true },
|
||||
});
|
||||
if (current && current.reviewDate === null) {
|
||||
data.reviewDate = new Date(
|
||||
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// `updateMany` (not `update`) so an unknown / cross-user / already-deleted id
|
||||
// is a 0-count no-op rather than a P2025 throw — the existence channel never
|
||||
// leaks across accounts.
|
||||
|
||||
@@ -315,7 +315,68 @@ describe("PATCH /api/coach/plans/[id]", () => {
|
||||
expect(arg.where.id).toBe("p1");
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(arg.where.deletedAt).toBeNull();
|
||||
// Only the lifecycle field is written — never metric / encrypted text.
|
||||
// S3 (§2.3.1): activating a plan the coach never dated defaults a review
|
||||
// checkpoint ~7d out. Only lifecycle fields are written (status +
|
||||
// reviewDate) — never metric / encrypted text (no mass assignment).
|
||||
expect(new Set(Object.keys(arg.data))).toEqual(
|
||||
new Set(["status", "reviewDate"]),
|
||||
);
|
||||
expect(arg.data.status).toBe("active");
|
||||
expect(arg.data.reviewDate).toBeInstanceOf(Date);
|
||||
const days =
|
||||
((arg.data.reviewDate as Date).getTime() - Date.now()) / 86_400_000;
|
||||
expect(days).toBeGreaterThan(6.5);
|
||||
expect(days).toBeLessThan(7.5);
|
||||
});
|
||||
|
||||
it("does NOT clobber a review date the caller pinned on activate", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCueEncrypted: bytes("every morning"),
|
||||
thenActionEncrypted: bytes("weigh in"),
|
||||
targetEncrypted: null,
|
||||
status: "active",
|
||||
reviewDate: new Date("2026-08-01T00:00:00Z"),
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-03T00:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPatch({
|
||||
status: "active",
|
||||
reviewDate: "2026-08-01T00:00:00.000Z",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
|
||||
data: { reviewDate: Date };
|
||||
};
|
||||
// The pinned date is honoured verbatim, not the +7d default.
|
||||
expect(arg.data.reviewDate.toISOString()).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does NOT default a review date when marking a plan met", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCueEncrypted: bytes("every morning"),
|
||||
thenActionEncrypted: bytes("weigh in"),
|
||||
targetEncrypted: null,
|
||||
status: "met",
|
||||
reviewDate: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-03T00:00:00Z"),
|
||||
} as never);
|
||||
|
||||
await callPatch({ status: "met" });
|
||||
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(arg.data)).toEqual(["status"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
@@ -764,6 +765,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
} catch (err) {
|
||||
console.warn("[measurements] rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// S4 — Apple-Health sleep is the third sleep transport. If any SLEEP_DURATION
|
||||
// row for last night just landed, kick the debounced morning refresh so the
|
||||
// digest/score finalise with the current sleep. The trigger judges "last
|
||||
// night" in the user's profile tz, so a historical Apple export replaying
|
||||
// months of nights never re-triggers for an old night. Fire-and-forget.
|
||||
void maybeEnqueueMorningRefresh(
|
||||
user.id,
|
||||
prepared
|
||||
.filter((p) => p.row.type === "SLEEP_DURATION")
|
||||
.map((p) =>
|
||||
p.row.measuredAt instanceof Date
|
||||
? p.row.measuredAt
|
||||
: new Date(p.row.measuredAt as string),
|
||||
),
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TodayHero } from "../today-hero";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
|
||||
// The hero now wires the coach check-in card's keep / let-go taps through
|
||||
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient();
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,14 @@ import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { PriorityCard } from "@/components/daily/priority-card";
|
||||
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import {
|
||||
COACH_CHECKIN_KEEP_INTENT,
|
||||
COACH_CHECKIN_LETGO_INTENT,
|
||||
type DailyDigest,
|
||||
} from "@/lib/daily/digest";
|
||||
|
||||
/** Format the server-computed score delta as a signed, muted chip string. */
|
||||
function formatDelta(
|
||||
@@ -53,6 +58,21 @@ function formatDelta(
|
||||
|
||||
export function TodayHero({ digest }: { digest: DailyDigest }) {
|
||||
const { t } = useTranslations();
|
||||
const { keep, letGo } = useCoachCheckinAction();
|
||||
|
||||
// The coach check-in card's keep / let-go intents carry the plan id after the
|
||||
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
|
||||
// as a <Link>, so it never lands here. Every other rail kind is pure
|
||||
// navigation (dose / sync / preventive), so their taps never call this.
|
||||
const handleAction = (intent: string) => {
|
||||
const keepPrefix = `${COACH_CHECKIN_KEEP_INTENT}:`;
|
||||
const letGoPrefix = `${COACH_CHECKIN_LETGO_INTENT}:`;
|
||||
if (intent.startsWith(keepPrefix)) {
|
||||
keep.mutate(intent.slice(keepPrefix.length));
|
||||
} else if (intent.startsWith(letGoPrefix)) {
|
||||
letGo.mutate(intent.slice(letGoPrefix.length));
|
||||
}
|
||||
};
|
||||
|
||||
const hasScore = digest.score !== null;
|
||||
const hasItems = digest.worthALook.length > 0;
|
||||
@@ -174,7 +194,11 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{digest.worthALook.map((item, i) => (
|
||||
<PriorityCard key={`${item.kind}-${i}`} item={item} />
|
||||
<PriorityCard
|
||||
key={`${item.kind}-${i}`}
|
||||
item={item}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* S3 — the coach check-in card's keep / let-go actions (§2.3).
|
||||
*
|
||||
* The Today rail surfaces a `coach_checkin` `PriorityItem` whose two mutating
|
||||
* one-tap actions close the coach loop through the EXISTING plan-lifecycle
|
||||
* route (`PATCH /api/coach/plans/[id]`) — no new backend surface:
|
||||
* - keep → re-arm: status `active` + `reviewDate` pushed out one more cycle,
|
||||
* so the check-in comes back in a week rather than the same day;
|
||||
* - letGo → guilt-free retirement: status `abandoned`, a terminal, respected
|
||||
* outcome (the calm inversion of a streak). The card never nags.
|
||||
*
|
||||
* "Adjust" is navigation (an `href` into the coach) handled by `PriorityCard`
|
||||
* as a `<Link>` — it never reaches this hook.
|
||||
*
|
||||
* Both invalidate the daily digest (so the acted-on card leaves the rail) and
|
||||
* every plans-list slot (so the /coach/plans ledger stays in step). Keys route
|
||||
* through the centralised factory — no bare arrays.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest";
|
||||
|
||||
export function useCoachCheckinAction() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.coachPlansAll() });
|
||||
};
|
||||
|
||||
const keep = useMutation({
|
||||
mutationFn: async (planId: string) => {
|
||||
const reviewDate = new Date(
|
||||
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
|
||||
).toISOString();
|
||||
await apiPatch(`/api/coach/plans/${encodeURIComponent(planId)}`, {
|
||||
status: "active",
|
||||
reviewDate,
|
||||
});
|
||||
return planId;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const letGo = useMutation({
|
||||
mutationFn: async (planId: string) => {
|
||||
await apiPatch(`/api/coach/plans/${encodeURIComponent(planId)}`, {
|
||||
status: "abandoned",
|
||||
});
|
||||
return planId;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { keep, letGo };
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* S5 — daily-briefing push decision seam + composer.
|
||||
*
|
||||
* Pins the once-per-day, opt-in-only, morning-window, non-alert contract of
|
||||
* `maybeDispatchDailyBriefing` and the floor/lead composition of
|
||||
* `buildDailyBriefingPush`, all without a DB / provider / boss.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
maybeDispatchDailyBriefing,
|
||||
buildDailyBriefingPush,
|
||||
digestHasSubstance,
|
||||
type DailyBriefingDispatchDeps,
|
||||
} from "@/lib/daily/daily-briefing-push";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import { isUrgentPayload } from "@/lib/notifications/types";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
// 06:00Z → 08:00 in Europe/Berlin (summer) → inside the morning window AND the
|
||||
// fixed fallback hour. The tz-window cases move only this instant.
|
||||
const IN_WINDOW = new Date("2026-07-16T06:00:00Z");
|
||||
const BEFORE_WINDOW = new Date("2026-07-16T00:30:00Z"); // 02:30 Berlin
|
||||
const AFTER_WINDOW = new Date("2026-07-16T12:30:00Z"); // 14:30 Berlin
|
||||
|
||||
function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
return {
|
||||
generatedAt: "2026-07-16T06:00:00.000Z",
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "good", delta: 1 },
|
||||
topSignal: null,
|
||||
briefingLead: "Sleep looked solid last night.",
|
||||
line: "Sleep looked solid last night.",
|
||||
worthALook: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function makePrisma(opts: {
|
||||
user?: unknown;
|
||||
optIn?: unknown;
|
||||
lastOk?: { createdAt: Date } | null;
|
||||
}) {
|
||||
const user =
|
||||
opts.user === undefined
|
||||
? {
|
||||
id: "u1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
morningDigestRefreshedOn: null,
|
||||
}
|
||||
: opts.user;
|
||||
return {
|
||||
user: { findUnique: vi.fn(async () => user) },
|
||||
notificationPreference: {
|
||||
findFirst: vi.fn(async () =>
|
||||
opts.optIn === undefined ? { id: "pref1" } : opts.optIn,
|
||||
),
|
||||
},
|
||||
pushAttempt: { findFirst: vi.fn(async () => opts.lastOk ?? null) },
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
over: Partial<DailyBriefingDispatchDeps> = {},
|
||||
): DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> } {
|
||||
const dispatch = vi.fn(async () => ({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
}));
|
||||
return {
|
||||
dispatch,
|
||||
loadDigest: async () => makeDigest(),
|
||||
isModuleEnabled: async () => true,
|
||||
...over,
|
||||
} as DailyBriefingDispatchDeps & { dispatch: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
describe("buildDailyBriefingPush", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
|
||||
it("uses the digest line verbatim as the body when the day is final", () => {
|
||||
const digest = makeDigest({ line: "Pulse ran a touch high yesterday." });
|
||||
const { title, body } = buildDailyBriefingPush(digest, t);
|
||||
expect(body).toBe("Pulse ran a touch high yesterday.");
|
||||
expect(title).toBe(t("daily.push.title"));
|
||||
});
|
||||
|
||||
it("floor: a no-AI user (no briefing lead) still gets the deterministic line", () => {
|
||||
// composeLine's floor already sits in `digest.line`; the push carries it
|
||||
// unchanged, so a keyless self-hoster gets a first-class body.
|
||||
const digest = makeDigest({
|
||||
briefingLead: null,
|
||||
topSignal: null,
|
||||
line: "Your health score today is 82.",
|
||||
});
|
||||
const { body } = buildDailyBriefingPush(digest, t);
|
||||
expect(body).toBe("Your health score today is 82.");
|
||||
});
|
||||
|
||||
it("provisional: appends the honest sleep-pending wording", () => {
|
||||
const digest = makeDigest({
|
||||
sleepPending: true,
|
||||
line: "Trends held steady this week.",
|
||||
});
|
||||
const { body } = buildDailyBriefingPush(digest, t);
|
||||
expect(body).toContain("Trends held steady this week.");
|
||||
expect(body).not.toBe("Trends held steady this week.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("digestHasSubstance", () => {
|
||||
it("is false for an empty account (score/lead/signal all null, no items)", () => {
|
||||
expect(
|
||||
digestHasSubstance(
|
||||
makeDigest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
topSignal: null,
|
||||
worthALook: [],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when a score is present", () => {
|
||||
expect(
|
||||
digestHasSubstance(
|
||||
makeDigest({ briefingLead: null, topSignal: null, worthALook: [] }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeDispatchDailyBriefing", () => {
|
||||
it("opted-in + substantive digest + in window → exactly one DAILY_BRIEFING dispatch", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(deps.dispatch).toHaveBeenCalledTimes(1);
|
||||
const payload = deps.dispatch.mock.calls[0][0];
|
||||
expect(payload.eventType).toBe("DAILY_BRIEFING");
|
||||
expect(payload.metadata?.url).toBe("/");
|
||||
});
|
||||
|
||||
it("never routes as an alert: the payload is non-urgent", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps();
|
||||
await maybeDispatchDailyBriefing(prisma, "u1", IN_WINDOW, deps);
|
||||
const payload = deps.dispatch.mock.calls[0][0];
|
||||
// No urgent flag AND not MEDICATION_REMINDER → the urgency classifier is
|
||||
// false, so it can never escalate to a time-sensitive / Focus-bypass send.
|
||||
expect(payload.urgent).toBeUndefined();
|
||||
expect(isUrgentPayload(payload)).toBe(false);
|
||||
});
|
||||
|
||||
it("opted-out (no enabled preference) → no dispatch", async () => {
|
||||
const prisma = makePrisma({ optIn: null });
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("opted-out");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no digest substance → no dispatch", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps({
|
||||
loadDigest: async () =>
|
||||
makeDigest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
topSignal: null,
|
||||
worthALook: [],
|
||||
}),
|
||||
});
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("no-digest");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("insights module off → no dispatch", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps({ isModuleEnabled: async () => false });
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("module-off");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("frequency cap: an ok ledger row earlier the SAME local day suppresses the second push", async () => {
|
||||
const prisma = makePrisma({
|
||||
// 04:00Z → 06:00 Berlin, same local day as the 08:00 attempt.
|
||||
lastOk: { createdAt: new Date("2026-07-16T04:00:00Z") },
|
||||
});
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("suppressed-frequency");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("frequency cap: an ok ledger row on the PREVIOUS local day does not suppress", async () => {
|
||||
const prisma = makePrisma({
|
||||
// 20:00Z on the 15th → 22:00 Berlin on the 15th → previous local day.
|
||||
lastOk: { createdAt: new Date("2026-07-15T20:00:00Z") },
|
||||
});
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("sent");
|
||||
expect(deps.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tz timing: before the local morning window → no dispatch", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
BEFORE_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("outside-window");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tz timing: after the local morning window → no dispatch", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
AFTER_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("outside-window");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no channel delivered → no-channel (ledger slot left free)", async () => {
|
||||
const prisma = makePrisma({});
|
||||
const deps = makeDeps({
|
||||
dispatch: vi.fn(async () => ({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
})) as unknown as DailyBriefingDispatchDeps["dispatch"],
|
||||
});
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("no-channel");
|
||||
});
|
||||
|
||||
it("missing user → missing-user, never throws", async () => {
|
||||
const prisma = makePrisma({ user: null });
|
||||
const deps = makeDeps();
|
||||
const result = await maybeDispatchDailyBriefing(
|
||||
prisma,
|
||||
"u1",
|
||||
IN_WINDOW,
|
||||
deps,
|
||||
);
|
||||
expect(result).toBe("missing-user");
|
||||
expect(deps.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,12 @@ import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import {
|
||||
buildDailyDigest,
|
||||
COACH_CHECKIN_KEEP_INTENT,
|
||||
COACH_CHECKIN_LETGO_INTENT,
|
||||
COACH_CHECKIN_RESURFACE_DAYS,
|
||||
COACH_CHECKIN_REVIEW_DAYS,
|
||||
MAX_WORTH_A_LOOK,
|
||||
type DailyDigestCoachPlan,
|
||||
type DailyDigestInput,
|
||||
} from "@/lib/daily/digest";
|
||||
|
||||
@@ -48,8 +53,28 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
|
||||
briefing,
|
||||
medsToday: meds(),
|
||||
sleepLastSeenDaysAgo: 0,
|
||||
morningRefreshedToday: false,
|
||||
syncIssues: [],
|
||||
preventiveDue: [],
|
||||
coachPlans: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function plan(over: Partial<DailyDigestCoachPlan> = {}): DailyDigestCoachPlan {
|
||||
// Default: an active plan whose defaulted review (createdAt + 7d) is due.
|
||||
const createdAt = new Date(
|
||||
NOW.getTime() - (COACH_CHECKIN_REVIEW_DAYS + 1) * DAY,
|
||||
);
|
||||
return {
|
||||
id: "p1",
|
||||
status: "active",
|
||||
reviewDate: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
planText: "every morning → weigh in",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -127,6 +152,34 @@ describe("buildDailyDigest — freshness (provisional/final)", () => {
|
||||
expect(d.phase).toBe("final");
|
||||
expect(d.sleepPending).toBe(false);
|
||||
});
|
||||
|
||||
it("the morning-refresh marker finalises the day even while the snapshot's sleep last-seen is still stale (S4 fast path)", () => {
|
||||
// Sleep last-seen still lags at 1 day (snapshot cache not yet expired), but
|
||||
// the sleep-arrival refresh has stamped the marker for today — the digest
|
||||
// must read `final` immediately off the authoritative marker.
|
||||
const provisional = buildDailyDigest(
|
||||
input({ sleepLastSeenDaysAgo: 1, morningRefreshedToday: false }),
|
||||
t,
|
||||
);
|
||||
expect(provisional.phase).toBe("provisional");
|
||||
expect(provisional.sleepPending).toBe(true);
|
||||
|
||||
const finalised = buildDailyDigest(
|
||||
input({ sleepLastSeenDaysAgo: 1, morningRefreshedToday: true }),
|
||||
t,
|
||||
);
|
||||
expect(finalised.phase).toBe("final");
|
||||
expect(finalised.sleepPending).toBe(false);
|
||||
});
|
||||
|
||||
it("stays provisional when sleep never arrives (no marker, no reading)", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ sleepLastSeenDaysAgo: null, morningRefreshedToday: false }),
|
||||
t,
|
||||
);
|
||||
expect(d.phase).toBe("provisional");
|
||||
expect(d.sleepPending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
@@ -233,3 +286,130 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => {
|
||||
expect(d.worthALook).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — coach check-in (S3)", () => {
|
||||
function checkin(d: ReturnType<typeof buildDailyDigest>) {
|
||||
return d.worthALook.find((i) => i.kind === "coach_checkin");
|
||||
}
|
||||
|
||||
it("emits a check-in when an active plan's defaulted review has come due", () => {
|
||||
const d = buildDailyDigest(input({ coachPlans: [plan()] }), t);
|
||||
const item = checkin(d);
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.status).toBe("info");
|
||||
expect(item?.moduleKey).toBe("coach");
|
||||
expect(item?.actions).toHaveLength(3);
|
||||
// The plan's own words are echoed in the body.
|
||||
expect(item?.body).toContain("every morning → weigh in");
|
||||
// Keep / let-go carry the plan id; adjust is a plain navigation href.
|
||||
expect(item?.actions[0].intent).toBe(`${COACH_CHECKIN_KEEP_INTENT}:p1`);
|
||||
expect(item?.actions[1].href).toBe("/coach");
|
||||
expect(item?.actions[2].intent).toBe(`${COACH_CHECKIN_LETGO_INTENT}:p1`);
|
||||
});
|
||||
|
||||
it("emits a review-due check-in from a plan's pinned reviewDate", () => {
|
||||
const reviewDate = new Date(NOW.getTime() - DAY);
|
||||
const d = buildDailyDigest(
|
||||
input({ coachPlans: [plan({ reviewDate })] }),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits a check-in for a reviewed plan (post-sweep read-back state)", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
coachPlans: [
|
||||
plan({
|
||||
status: "reviewed",
|
||||
reviewDate: null,
|
||||
updatedAt: new Date(NOW.getTime() - DAY),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to a generic body when the plan text is undecryptable", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ coachPlans: [plan({ planText: null })] }),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)?.body).toBe(
|
||||
"It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT emit a check-in before the review is due", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
coachPlans: [plan({ reviewDate: new Date(NOW.getTime() + DAY) })],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT emit a check-in when the coach module is off", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({ modules: { coach: false }, coachPlans: [plan()] }),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits none when there are no standing plans", () => {
|
||||
const d = buildDailyDigest(input({ coachPlans: [] }), t);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops resurfacing after the resurface window (quiet retirement)", () => {
|
||||
const stale = new Date(
|
||||
NOW.getTime() - (COACH_CHECKIN_RESURFACE_DAYS + 2) * DAY,
|
||||
);
|
||||
const d = buildDailyDigest(
|
||||
input({ coachPlans: [plan({ reviewDate: stale })] }),
|
||||
t,
|
||||
);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps at ONE check-in per day, surfacing the earliest-due plan", () => {
|
||||
const older = new Date(NOW.getTime() - 5 * DAY);
|
||||
const newer = new Date(NOW.getTime() - 1 * DAY);
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
coachPlans: [
|
||||
plan({ id: "recent", reviewDate: newer }),
|
||||
plan({ id: "oldest", reviewDate: older }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const items = d.worthALook.filter((i) => i.kind === "coach_checkin");
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].actions[0].intent).toBe(
|
||||
`${COACH_CHECKIN_KEEP_INTENT}:oldest`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not displace an overdue dose from the bounded rail", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }),
|
||||
syncIssues: [
|
||||
{ integration: "withings", state: "error_reauth" },
|
||||
{ integration: "moodlog", state: "parked" },
|
||||
],
|
||||
coachPlans: [plan()],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
// dose + 2 sync fill the cap; the check-in waits for a following day.
|
||||
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
|
||||
expect(checkin(d)).toBeUndefined();
|
||||
expect(d.worthALook[0].kind).toBe("dose_window");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The queue send is inspected through a fake global boss so the debounce key +
|
||||
// payload can be asserted without a running worker.
|
||||
const sendMock = vi.fn();
|
||||
let bossInstance: { send: typeof sendMock } | null = { send: sendMock };
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => bossInstance,
|
||||
}));
|
||||
|
||||
// Module gate + tz resolver are stubbed; the tz FORMAT helpers stay real so the
|
||||
// "last night in the profile tz" logic is exercised for real.
|
||||
const resolveModuleMapMock = vi.fn(
|
||||
async (): Promise<Record<string, boolean>> => ({}),
|
||||
);
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: () => resolveModuleMapMock(),
|
||||
}));
|
||||
|
||||
let tz = "America/Los_Angeles";
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: async () => tz,
|
||||
}));
|
||||
|
||||
const userFindUniqueMock = vi.fn(
|
||||
async (): Promise<{ morningDigestRefreshedOn: string | null } | null> => ({
|
||||
morningDigestRefreshedOn: null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: () => userFindUniqueMock() } },
|
||||
}));
|
||||
|
||||
import {
|
||||
isLastNightLocal,
|
||||
maybeEnqueueMorningRefresh,
|
||||
} from "../morning-refresh-trigger";
|
||||
|
||||
describe("isLastNightLocal", () => {
|
||||
it("counts a segment whose LOCAL date is yesterday but whose UTC date is today", () => {
|
||||
// LA (UTC-7 in July). now = 07:00 local on the 16th.
|
||||
const now = new Date("2026-07-16T14:00:00Z");
|
||||
// 06:00Z = 23:00 local on the 15th → yesterday LOCALLY, but the 16th in UTC.
|
||||
const measuredAt = new Date("2026-07-16T06:00:00Z");
|
||||
expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(true);
|
||||
});
|
||||
|
||||
it("counts a this-morning segment across the UTC date line (Auckland)", () => {
|
||||
// Auckland (UTC+12). now = 12:30 local on the 16th; the segment is 06:00
|
||||
// local on the 16th (this morning) but 15th in UTC — a naive UTC-day check
|
||||
// would wrongly reject it.
|
||||
const now = new Date("2026-07-16T00:30:00Z");
|
||||
const measuredAt = new Date("2026-07-15T18:00:00Z");
|
||||
expect(isLastNightLocal(measuredAt, now, "Pacific/Auckland")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an old backfilled segment", () => {
|
||||
const now = new Date("2026-07-16T14:00:00Z");
|
||||
const measuredAt = new Date("2026-07-10T06:00:00Z");
|
||||
expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a future-dated sample", () => {
|
||||
const now = new Date("2026-07-16T14:00:00Z");
|
||||
const measuredAt = new Date("2026-07-16T20:00:00Z");
|
||||
expect(isLastNightLocal(measuredAt, now, "America/Los_Angeles")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeEnqueueMorningRefresh", () => {
|
||||
const NOW = new Date("2026-07-16T14:00:00Z"); // 07:00 on the 16th in LA
|
||||
const lastNight = new Date("2026-07-16T06:00:00Z"); // 23:00 on the 15th, LA
|
||||
const oldNight = new Date("2026-07-10T06:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset();
|
||||
bossInstance = { send: sendMock };
|
||||
tz = "America/Los_Angeles";
|
||||
resolveModuleMapMock.mockReset();
|
||||
resolveModuleMapMock.mockResolvedValue({});
|
||||
userFindUniqueMock.mockReset();
|
||||
userFindUniqueMock.mockResolvedValue({ morningDigestRefreshedOn: null });
|
||||
});
|
||||
|
||||
it("enqueues exactly one debounced refresh for many last-night samples", async () => {
|
||||
await maybeEnqueueMorningRefresh(
|
||||
"u1",
|
||||
[lastNight, lastNight, lastNight, lastNight],
|
||||
NOW,
|
||||
);
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
const [queue, payload, opts] = sendMock.mock.calls[0]!;
|
||||
expect(queue).toBe("morning-digest-refresh");
|
||||
// The debounce key + payload date are the user's LOCAL date (the 16th),
|
||||
// not the UTC date of the samples.
|
||||
expect(payload).toMatchObject({ userId: "u1", localDate: "2026-07-16" });
|
||||
expect((opts as { singletonKey: string }).singletonKey).toBe(
|
||||
"morning-refresh:u1:2026-07-16",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT trigger for an old/backfilled sleep only", async () => {
|
||||
await maybeEnqueueMorningRefresh("u1", [oldNight, oldNight], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when the sleep module is off", async () => {
|
||||
resolveModuleMapMock.mockResolvedValue({ sleep: false });
|
||||
await maybeEnqueueMorningRefresh("u1", [lastNight], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the enqueue when the day is already finalised (marker == today)", async () => {
|
||||
userFindUniqueMock.mockResolvedValue({
|
||||
morningDigestRefreshedOn: "2026-07-16",
|
||||
});
|
||||
await maybeEnqueueMorningRefresh("u1", [lastNight], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops with no sleep samples", async () => {
|
||||
await maybeEnqueueMorningRefresh("u1", [], NOW);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws when a dependency fails", async () => {
|
||||
resolveModuleMapMock.mockRejectedValue(new Error("db down"));
|
||||
await expect(
|
||||
maybeEnqueueMorningRefresh("u1", [lastNight], NOW),
|
||||
).resolves.toBeUndefined();
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* S5 — the daily briefing push (§2.2).
|
||||
*
|
||||
* A CALM, once-per-day morning nudge that rides the EXISTING notification
|
||||
* cascade (`dispatchNotification` → APNs → Telegram → ntfy → Webhook → Email →
|
||||
* Web Push), inheriting the hard-reject classification and the `push_attempts`
|
||||
* ledger for free. It reads the ALREADY-CACHED daily digest (S1 `buildDailyDigest`
|
||||
* via `loadDailyDigest`) — it NEVER makes a fresh AI/provider call (the
|
||||
* warm-on-mount ban extends to warm-on-dispatch), so a keyless self-hoster gets
|
||||
* a first-class push from the digest's deterministic `line` floor.
|
||||
*
|
||||
* This module owns ONE decision seam — `maybeDispatchDailyBriefing` — that both
|
||||
* triggers funnel through: the S4 morning-refresh finalisation hook (event-
|
||||
* driven, on sleep arrival) and the fixed local-morning fallback cron
|
||||
* (`@/lib/jobs/daily-briefing`). Whichever fires first that morning writes the
|
||||
* `ok` ledger row; the frequency-cap check suppresses the second — one push per
|
||||
* user per local day, no "you haven't opened the app" second push, ever.
|
||||
*
|
||||
* Privacy + calm posture (load-bearing): the body is `digest.line`, which is
|
||||
* non-clinical by construction (a briefing lead sentence, the top signal's
|
||||
* headline, a 0–100 score statement, or the honest all-clear — never a raw BP /
|
||||
* glucose figure). It is dispatched NON-URGENT, so it never escalates to a
|
||||
* Focus-bypassing / time-sensitive delivery — a concerning reading always stays
|
||||
* on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning
|
||||
* window, which is the quiet-hours guarantee for this nudge.
|
||||
*/
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
|
||||
/**
|
||||
* The local-morning window this nudge may fire in, [earliest, latest). Both the
|
||||
* sleep-arrival finalisation hook and the fixed fallback slot are gated by it,
|
||||
* so a late-afternoon sleep sync (a full re-sync, a device that reconnected
|
||||
* hours after waking) can never fire a "morning briefing" at an odd hour —
|
||||
* outside the window the day simply carries no push (never-nag). 05:00 is the
|
||||
* earliest a genuine wake-time sleep sync lands; noon is a firm ceiling.
|
||||
*/
|
||||
export const DAILY_BRIEFING_MORNING_EARLIEST_HOUR = 5;
|
||||
export const DAILY_BRIEFING_MORNING_LATEST_HOUR = 12;
|
||||
|
||||
/**
|
||||
* The fixed local hour the fallback cron dispatches at when sleep never
|
||||
* arrived (so the finalisation hook never fired). Inside the window above; the
|
||||
* earlier part of the window is left for the event-driven finalisation push, so
|
||||
* a user whose sleep synced on wake gets the warmer FINAL digest first and the
|
||||
* ledger suppresses this slot.
|
||||
*/
|
||||
export const DAILY_BRIEFING_FALLBACK_HOUR = 8;
|
||||
|
||||
/** The dashboard / Today view the push deep-links to (same-origin path). */
|
||||
const DAILY_BRIEFING_DEEP_LINK = "/";
|
||||
|
||||
/**
|
||||
* The outcome of one dispatch attempt — one value per branch so the wide-event
|
||||
* annotation (and the tests) can assert the exact decision the seam took.
|
||||
*/
|
||||
export type DailyBriefingDispatchResult =
|
||||
| "sent"
|
||||
| "suppressed-frequency"
|
||||
| "no-digest"
|
||||
| "opted-out"
|
||||
| "module-off"
|
||||
| "outside-window"
|
||||
| "no-channel"
|
||||
| "missing-user"
|
||||
| "error";
|
||||
|
||||
export interface DailyBriefingDispatchDeps {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
loadDigest?: (user: User, now: Date) => Promise<DailyDigest>;
|
||||
isModuleEnabled?: typeof isModuleEnabled;
|
||||
}
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locales.includes(locale as Locale)
|
||||
? (locale as Locale)
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* A digest is "substantive" — worth a morning push — when it carries a score, a
|
||||
* briefing lead, a top signal, OR a worth-a-look item. A brand-new / empty
|
||||
* account whose only line would be the generic all-clear is NOT nudged every
|
||||
* morning (the never-nag rule); it falls through to the `no-digest` skip.
|
||||
*/
|
||||
export function digestHasSubstance(digest: DailyDigest): boolean {
|
||||
return (
|
||||
digest.score !== null ||
|
||||
digest.briefingLead !== null ||
|
||||
digest.topSignal !== null ||
|
||||
digest.worthALook.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the push title + body from the cached digest. The body IS
|
||||
* `digest.line` — the cached briefing lead when present, else the deterministic
|
||||
* floor — so a no-AI self-hoster still gets a meaningful push. When the day is
|
||||
* still provisional (last night's sleep not yet in), the honest sleep-pending
|
||||
* wording is appended so the read is never silently presented as final.
|
||||
*/
|
||||
export function buildDailyBriefingPush(
|
||||
digest: DailyDigest,
|
||||
t: ServerTranslator["t"],
|
||||
): { title: string; body: string } {
|
||||
const title = t("daily.push.title");
|
||||
const body = digest.sleepPending
|
||||
? t("daily.push.bodyProvisional", { line: digest.line })
|
||||
: digest.line;
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE dispatch-decision seam. Fully fault-isolated — every failure returns
|
||||
* a result value rather than throwing, so neither the sleep-arrival hook nor
|
||||
* the cron tick can be broken by it.
|
||||
*
|
||||
* Gates, cheapest first:
|
||||
* 1. user missing → `missing-user`
|
||||
* 2. no opt-in (default OFF) → `opted-out` (no enabled DAILY_BRIEFING pref)
|
||||
* 3. outside morning window → `outside-window`
|
||||
* 4. insights module off → `module-off`
|
||||
* 5. already pushed today → `suppressed-frequency` (push_attempts ledger)
|
||||
* 6. digest not substantive → `no-digest`
|
||||
* 7. dispatch; no channel → `no-channel`; else `sent`.
|
||||
*/
|
||||
export async function maybeDispatchDailyBriefing(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
now: Date,
|
||||
deps: DailyBriefingDispatchDeps = {},
|
||||
): Promise<DailyBriefingDispatchResult> {
|
||||
const dispatch = deps.dispatch ?? dispatchNotification;
|
||||
const loadDigest = deps.loadDigest ?? loadDailyDigest;
|
||||
const moduleGate = deps.isModuleEnabled ?? isModuleEnabled;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
annotate({ action: { name: "daily.briefing_push.missing_user" } });
|
||||
return "missing-user";
|
||||
}
|
||||
|
||||
// Opt-in: DAILY_BRIEFING defaults OFF, so an opted-in user is one with at
|
||||
// least one enabled per-channel preference row. Silence until then.
|
||||
const optIn = await prisma.notificationPreference.findFirst({
|
||||
where: {
|
||||
eventType: "DAILY_BRIEFING",
|
||||
enabled: true,
|
||||
channel: { userId },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!optIn) {
|
||||
annotate({ action: { name: "daily.briefing_push.opted_out" } });
|
||||
return "opted-out";
|
||||
}
|
||||
|
||||
const tz = user.timezone || "Europe/Berlin";
|
||||
const { hour } = wallClockInTz(now, tz);
|
||||
if (
|
||||
hour < DAILY_BRIEFING_MORNING_EARLIEST_HOUR ||
|
||||
hour >= DAILY_BRIEFING_MORNING_LATEST_HOUR
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "daily.briefing_push.outside_window" },
|
||||
meta: { local_hour: hour },
|
||||
});
|
||||
return "outside-window";
|
||||
}
|
||||
|
||||
// Insights is the digest's home module; a user who turned it off gets no
|
||||
// digest surfaces at all, so no morning push either.
|
||||
if (!(await moduleGate(userId, "insights"))) {
|
||||
annotate({ action: { name: "daily.briefing_push.module_off" } });
|
||||
return "module-off";
|
||||
}
|
||||
|
||||
// One per user per LOCAL day. Anchored on the `push_attempts` ledger the
|
||||
// senders already write (`ok` on a successful delivery), exactly like the
|
||||
// coach-nudge cap: whichever trigger fired first this morning left an `ok`
|
||||
// row, so the second is suppressed. Compared by local day-key (DST-safe),
|
||||
// so the day boundary is the user's, not UTC's.
|
||||
const lastOk = await prisma.pushAttempt.findFirst({
|
||||
where: { userId, eventType: "DAILY_BRIEFING", result: "ok" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
if (lastOk && userDayKey(lastOk.createdAt, tz) === userDayKey(now, tz)) {
|
||||
annotate({
|
||||
action: { name: "daily.briefing_push.suppressed_frequency" },
|
||||
});
|
||||
return "suppressed-frequency";
|
||||
}
|
||||
|
||||
const digest = await loadDigest(user, now);
|
||||
if (!digestHasSubstance(digest)) {
|
||||
annotate({ action: { name: "daily.briefing_push.no_digest" } });
|
||||
return "no-digest";
|
||||
}
|
||||
|
||||
const { t } = getServerTranslator(resolveLocale(user.locale));
|
||||
const { title, body } = buildDailyBriefingPush(digest, t);
|
||||
|
||||
const outcome = await dispatch({
|
||||
// NON-URGENT by construction: no `urgent` flag, and DAILY_BRIEFING is not
|
||||
// MEDICATION_REMINDER, so `isUrgentPayload` is false — this never
|
||||
// escalates to a time-sensitive / Focus-bypassing delivery. It is a calm
|
||||
// nudge, never the anomaly channel.
|
||||
eventType: "DAILY_BRIEFING",
|
||||
userId,
|
||||
title,
|
||||
message: body,
|
||||
metadata: {
|
||||
url: DAILY_BRIEFING_DEEP_LINK,
|
||||
phase: digest.phase,
|
||||
scheduledAt: now.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!outcome.dispatched) {
|
||||
// No channel delivered — leave the ledger slot free (no `ok` row) so a
|
||||
// later trigger this morning can still succeed once a channel recovers.
|
||||
annotate({ action: { name: "daily.briefing_push.no_channel" } });
|
||||
return "no-channel";
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "daily.briefing_push.sent" },
|
||||
meta: {
|
||||
daily_briefing_phase: digest.phase,
|
||||
daily_briefing_sleep_pending: digest.sleepPending,
|
||||
},
|
||||
});
|
||||
return "sent";
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`daily-briefing push failed for ${userId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
+165
-5
@@ -34,6 +34,36 @@ export const MAX_WORTH_A_LOOK = 3;
|
||||
/** Trim a briefing-lead sentence to a lock-screen-friendly length. */
|
||||
const MAX_LINE_LENGTH = 160;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* S3 — coach check-in loop (§2.3). Days added to a plan's activation when the
|
||||
* coach set no review date: every accepted plan earns a check-in, not only the
|
||||
* ones the coach explicitly dated. The PATCH-to-`active` route defaults
|
||||
* `reviewDate` to `+COACH_CHECKIN_REVIEW_DAYS`; this constant is the one source.
|
||||
*/
|
||||
export const COACH_CHECKIN_REVIEW_DAYS = 7;
|
||||
|
||||
/**
|
||||
* After a check-in comes due, it resurfaces for at most this many days before
|
||||
* it stops appearing on its own — the calm inversion of a streak (§2.3.3): an
|
||||
* ignored check-in sits quiet after ~two cycles rather than nagging forever,
|
||||
* and the plan's status is NEVER changed behind the user's back. Only an
|
||||
* explicit keep / let-go moves the plan; silence just retires the card.
|
||||
*/
|
||||
export const COACH_CHECKIN_RESURFACE_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Closed allowlist of the check-in card's two MUTATING intents (§2.3.2). The
|
||||
* generic, id-less `PriorityCard` forwards only a single `intent` string, so
|
||||
* the target plan id is appended after the ":" — the Today handler recovers it
|
||||
* and PATCHes the existing plan-lifecycle route. "Adjust" is navigation (an
|
||||
* `href` into the coach), so it carries no plan id and never mutates here.
|
||||
*/
|
||||
export const COACH_CHECKIN_KEEP_INTENT = "coach.checkin.keep";
|
||||
export const COACH_CHECKIN_LETGO_INTENT = "coach.checkin.letGo";
|
||||
export const COACH_CHECKIN_ADJUST_INTENT = "coach.checkin.adjust";
|
||||
|
||||
type Translate = ServerTranslator["t"];
|
||||
|
||||
/** Module map in the registry's DISABLED-allowlist shape (missing = enabled). */
|
||||
@@ -62,6 +92,25 @@ export interface DailyDigestPreventiveDue {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
|
||||
* builder computes due-ness deterministically from the plain columns — it never
|
||||
* needs a fresh AI call, reading only the existing plan lifecycle. `planText`
|
||||
* is the plan's own if→then prose (decrypted fault-isolated in the IO seam, or
|
||||
* null when its key rotated out) so the card can echo the user's own words.
|
||||
*/
|
||||
export interface DailyDigestCoachPlan {
|
||||
id: string;
|
||||
/** Lifecycle status — only `active` / `reviewed` reach the builder. */
|
||||
status: string;
|
||||
/** The coach-pinned review checkpoint, or null (then defaulted, see below). */
|
||||
reviewDate: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
/** Decrypted "if cue → then action" prose, or null when undecryptable. */
|
||||
planText: string | null;
|
||||
}
|
||||
|
||||
/** The fully-resolved, IO-free input the composer folds into a digest. */
|
||||
export interface DailyDigestInput {
|
||||
now: Date;
|
||||
@@ -72,8 +121,20 @@ export interface DailyDigestInput {
|
||||
medsToday: MedsTodayBlock;
|
||||
/** Days since the freshest sleep reading; null when never recorded. */
|
||||
sleepLastSeenDaysAgo: number | null;
|
||||
/**
|
||||
* S4 freshness (§E) — whether the event-driven morning refresh has already
|
||||
* run for the user's CURRENT local date. Derived in the IO seam by comparing
|
||||
* `User.morningDigestRefreshedOn` against today's local date (profile tz):
|
||||
* `true` once last night's sleep arrived AND the sleep-dependent generation
|
||||
* was re-run with it. This is the authoritative `final` signal — it flips
|
||||
* immediately on the refresh, before the snapshot cache (which feeds
|
||||
* `sleepLastSeenDaysAgo`) has expired.
|
||||
*/
|
||||
morningRefreshedToday: boolean;
|
||||
syncIssues: DailyDigestSyncIssue[];
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
|
||||
coachPlans: DailyDigestCoachPlan[];
|
||||
}
|
||||
|
||||
export interface DailyDigest {
|
||||
@@ -195,6 +256,86 @@ function buildPreventiveCareItem(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate`
|
||||
* wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep
|
||||
* when the read-back fired, so its `updatedAt` (the flip moment) is when the
|
||||
* check-in came due. A legacy `active` plan the coach never dated defaults to
|
||||
* activation + `COACH_CHECKIN_REVIEW_DAYS` — the same rule the PATCH route now
|
||||
* writes forward, applied read-side for plans that predate it.
|
||||
*/
|
||||
function checkinDueAt(plan: DailyDigestCoachPlan): number {
|
||||
if (plan.reviewDate) return plan.reviewDate.getTime();
|
||||
if (plan.status === "reviewed") return plan.updatedAt.getTime();
|
||||
return plan.createdAt.getTime() + COACH_CHECKIN_REVIEW_DAYS * MS_PER_DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plan is check-in-due when its due instant has passed AND it is still within
|
||||
* the resurface window. Past the window the card retires quietly — the plan's
|
||||
* status is untouched (never abandoned behind the user's back); a keep re-arms
|
||||
* it, a let-go ends it, silence just stops the card.
|
||||
*/
|
||||
function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean {
|
||||
const dueAt = checkinDueAt(plan);
|
||||
if (dueAt > now) return false;
|
||||
return now - dueAt <= COACH_CHECKIN_RESURFACE_DAYS * MS_PER_DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one coach check-in card (§2.3). Gated on the `coach` module. Capped at
|
||||
* ONE per day across every plan: the earliest-due check-in wins, the rest wait
|
||||
* for a following day. Reads existing plan state only — never a fresh AI call.
|
||||
* Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust
|
||||
* (navigate into the coach), let go (guilt-free retirement).
|
||||
*/
|
||||
function buildCoachCheckinItem(
|
||||
plans: DailyDigestCoachPlan[],
|
||||
modules: DigestModuleMap,
|
||||
now: Date,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!moduleEnabled(modules, "coach")) return null;
|
||||
const nowMs = now.getTime();
|
||||
const due = plans
|
||||
.filter((p) => isCheckinDue(p, nowMs))
|
||||
.sort((a, b) => {
|
||||
const byDue = checkinDueAt(a) - checkinDueAt(b);
|
||||
if (byDue !== 0) return byDue;
|
||||
// Deterministic tie-break so the same day always surfaces the same card.
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
const plan = due[0];
|
||||
if (!plan) return null;
|
||||
|
||||
const body = plan.planText
|
||||
? t("daily.item.coachCheckin.bodyPlan", { plan: plan.planText })
|
||||
: t("daily.item.coachCheckin.body");
|
||||
|
||||
return {
|
||||
kind: "coach_checkin",
|
||||
title: t("daily.item.coachCheckin.title"),
|
||||
body,
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.checkinKeep",
|
||||
intent: `${COACH_CHECKIN_KEEP_INTENT}:${plan.id}`,
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.checkinAdjust",
|
||||
intent: COACH_CHECKIN_ADJUST_INTENT,
|
||||
href: "/coach",
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.checkinLetGo",
|
||||
intent: `${COACH_CHECKIN_LETGO_INTENT}:${plan.id}`,
|
||||
},
|
||||
],
|
||||
moduleKey: "coach",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
|
||||
* back to the top signal's headline, then a deterministic score floor, then
|
||||
@@ -217,21 +358,40 @@ export function buildDailyDigest(
|
||||
input: DailyDigestInput,
|
||||
t: Translate,
|
||||
): DailyDigest {
|
||||
// §E freshness lifecycle. The day is `final` once ANY of:
|
||||
// - sleep is disabled (nothing to wait for);
|
||||
// - the event-driven morning refresh has run for today (authoritative,
|
||||
// fast — flips the instant the sleep-arrival job stamps the marker);
|
||||
// - last night's sleep is already visibly in the record (`daysAgo === 0`) —
|
||||
// the eventual-consistency backstop for when the refresh job never ran
|
||||
// (no boss worker / keyless self-hoster) but the snapshot has caught up.
|
||||
// Otherwise it stays `provisional`, and `sleepPending` drives the honest
|
||||
// "last night's sleep not yet in" note.
|
||||
const sleepEnabled = moduleEnabled(input.modules, "sleep");
|
||||
const sleepPending =
|
||||
sleepEnabled &&
|
||||
(input.sleepLastSeenDaysAgo === null || input.sleepLastSeenDaysAgo >= 1);
|
||||
const phase: DailyDigest["phase"] = sleepPending ? "provisional" : "final";
|
||||
const lastNightSleepIn = input.sleepLastSeenDaysAgo === 0;
|
||||
const isFinal =
|
||||
!sleepEnabled || input.morningRefreshedToday || lastNightSleepIn;
|
||||
const sleepPending = sleepEnabled && !isFinal;
|
||||
const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional";
|
||||
|
||||
const topSignal = input.briefing?.signalsOfDay?.[0] ?? null;
|
||||
const briefingLead = firstSentence(input.briefing?.paragraph);
|
||||
|
||||
// Priority order: an overdue dose is the most time-sensitive daily action, a
|
||||
// broken sync next, a preventive check-up least urgent. Bounded to 3.
|
||||
// broken sync next, then the calm coach check-in, a preventive check-up
|
||||
// least urgent. Bounded to 3 — a check-in the cap crowds out resurfaces on a
|
||||
// following day (within its window), never lost.
|
||||
const worthALook: PriorityItem[] = [];
|
||||
const dose = buildDoseWindowItem(input.medsToday, input.modules, t);
|
||||
if (dose) worthALook.push(dose);
|
||||
worthALook.push(...buildSyncIssueItems(input.syncIssues, t));
|
||||
const checkin = buildCoachCheckinItem(
|
||||
input.coachPlans,
|
||||
input.modules,
|
||||
input.now,
|
||||
t,
|
||||
);
|
||||
if (checkin) worthALook.push(checkin);
|
||||
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
|
||||
if (preventive) worthALook.push(preventive);
|
||||
|
||||
|
||||
+112
-21
@@ -20,9 +20,12 @@ import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
buildDailyDigest,
|
||||
type DailyDigest,
|
||||
type DailyDigestCoachPlan,
|
||||
type DailyDigestPreventiveDue,
|
||||
type DailyDigestScore,
|
||||
type DailyDigestSyncIssue,
|
||||
@@ -34,6 +37,45 @@ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
/** Defensive cap on how many overdue reminders we read for the rail summary. */
|
||||
const PREVENTIVE_DUE_READ_LIMIT = 20;
|
||||
|
||||
/** Standing plan states that can carry a check-in (§2.3). */
|
||||
const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const;
|
||||
|
||||
/** Cap on plans scanned for the (one/day) check-in candidate. */
|
||||
const CHECKIN_PLAN_READ_LIMIT = 50;
|
||||
|
||||
interface CoachPlanRow {
|
||||
id: string;
|
||||
status: string;
|
||||
reviewDate: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ifCueEncrypted: Uint8Array;
|
||||
thenActionEncrypted: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a plan's if→then prose fault-isolated (an undecryptable row keeps a
|
||||
* null text, never throws) so the check-in card can echo the user's own words.
|
||||
*/
|
||||
function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan {
|
||||
let planText: string | null = null;
|
||||
try {
|
||||
planText = `${decryptFromBytes(row.ifCueEncrypted)} → ${decryptFromBytes(
|
||||
row.thenActionEncrypted,
|
||||
)}`;
|
||||
} catch {
|
||||
planText = null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
planText,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locales.includes(locale as Locale)
|
||||
? (locale as Locale)
|
||||
@@ -44,27 +86,54 @@ export async function loadDailyDigest(
|
||||
user: User,
|
||||
now: Date = new Date(),
|
||||
): Promise<DailyDigest> {
|
||||
const [{ body: snapshot, locale }, modules, syncRows, dueReminders] =
|
||||
await Promise.all([
|
||||
readDashboardSnapshotCached(user),
|
||||
resolveModuleMap(user.id),
|
||||
prisma.integrationStatus.findMany({
|
||||
where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } },
|
||||
select: { integration: true, state: true },
|
||||
orderBy: { integration: "asc" },
|
||||
}),
|
||||
prisma.measurementReminder.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
deletedAt: null,
|
||||
nextDueAt: { not: null, lte: now },
|
||||
},
|
||||
select: { label: true },
|
||||
orderBy: { nextDueAt: "asc" },
|
||||
take: PREVENTIVE_DUE_READ_LIMIT,
|
||||
}),
|
||||
]);
|
||||
const [
|
||||
{ body: snapshot, locale },
|
||||
modules,
|
||||
syncRows,
|
||||
dueReminders,
|
||||
planRows,
|
||||
] = await Promise.all([
|
||||
readDashboardSnapshotCached(user),
|
||||
resolveModuleMap(user.id),
|
||||
prisma.integrationStatus.findMany({
|
||||
where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } },
|
||||
select: { integration: true, state: true },
|
||||
orderBy: { integration: "asc" },
|
||||
}),
|
||||
prisma.measurementReminder.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
enabled: true,
|
||||
deletedAt: null,
|
||||
nextDueAt: { not: null, lte: now },
|
||||
},
|
||||
select: { label: true },
|
||||
orderBy: { nextDueAt: "asc" },
|
||||
take: PREVENTIVE_DUE_READ_LIMIT,
|
||||
}),
|
||||
// Standing plans for the (one/day) check-in candidate. The plaintext
|
||||
// columns decide due-ness; the encrypted prose is only decrypted below,
|
||||
// and only for a coach-enabled account (disabled-module data never
|
||||
// enters the digest — the builder also drops it, this skips the decrypt).
|
||||
prisma.coachPlan.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: [...CHECKIN_PLAN_STATES] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
reviewDate: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
ifCueEncrypted: true,
|
||||
thenActionEncrypted: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: CHECKIN_PLAN_READ_LIMIT,
|
||||
}),
|
||||
]);
|
||||
|
||||
const score: DailyDigestScore | null = snapshot.healthScore
|
||||
? {
|
||||
@@ -77,6 +146,16 @@ export async function loadDailyDigest(
|
||||
const sleepSlot = snapshot.tiles.lastSeenByType["SLEEP_DURATION"];
|
||||
const sleepLastSeenDaysAgo = sleepSlot ? sleepSlot.daysAgo : null;
|
||||
|
||||
// S4 freshness (§E) — the day is `final` once the sleep-arrival morning
|
||||
// refresh has stamped `User.morningDigestRefreshedOn` with the user's current
|
||||
// local date (profile tz). Read fresh off the row here, so it flips the
|
||||
// instant the refresh job runs — ahead of the snapshot cache that feeds
|
||||
// `sleepLastSeenDaysAgo`.
|
||||
const todayLocalDate = userDayKey(now, user.timezone);
|
||||
const morningRefreshedToday =
|
||||
user.morningDigestRefreshedOn !== null &&
|
||||
user.morningDigestRefreshedOn === todayLocalDate;
|
||||
|
||||
const syncIssues: DailyDigestSyncIssue[] = syncRows.map((row) => ({
|
||||
integration: row.integration,
|
||||
state: row.state,
|
||||
@@ -86,6 +165,13 @@ export async function loadDailyDigest(
|
||||
label: row.label,
|
||||
}));
|
||||
|
||||
// Only decrypt plan prose for a coach-enabled account; the builder gates on
|
||||
// the module too, so a disabled coach never surfaces a check-in either way.
|
||||
const coachEnabled = modules.coach !== false;
|
||||
const coachPlans: DailyDigestCoachPlan[] = coachEnabled
|
||||
? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow))
|
||||
: [];
|
||||
|
||||
const { t } = getServerTranslator(resolveLocale(locale));
|
||||
|
||||
const digest = buildDailyDigest(
|
||||
@@ -96,8 +182,10 @@ export async function loadDailyDigest(
|
||||
briefing: snapshot.briefing,
|
||||
medsToday: snapshot.medsToday,
|
||||
sleepLastSeenDaysAgo,
|
||||
morningRefreshedToday,
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
coachPlans,
|
||||
},
|
||||
t,
|
||||
);
|
||||
@@ -109,6 +197,9 @@ export async function loadDailyDigest(
|
||||
daily_digest_sleep_pending: digest.sleepPending,
|
||||
daily_digest_item_count: digest.worthALook.length,
|
||||
daily_digest_has_score: digest.score !== null,
|
||||
daily_digest_has_checkin: digest.worthALook.some(
|
||||
(i) => i.kind === "coach_checkin",
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* S4 — the sleep-arrival trigger for the morning digest refresh.
|
||||
*
|
||||
* The nightly insight pre-pass (`insight-pregenerate`, 04:30) runs before last
|
||||
* night's sleep has synced, so the morning digest + health score reference the
|
||||
* day WITHOUT the current sleep. This trigger closes that gap the event-driven
|
||||
* way: when a completed sleep segment for LAST NIGHT lands from any of the
|
||||
* three sleep transports (Withings sync-sleep / the WHOOP sync / the
|
||||
* Apple-Health measurement batch), it enqueues a debounced
|
||||
* `morning-digest-refresh` job that re-runs the sleep-dependent generation and
|
||||
* flips the day `provisional → final`.
|
||||
*
|
||||
* There is no single canonical sleep-persist function in the tree — each
|
||||
* transport upserts through its own helper — so the three write seams each call
|
||||
* `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row they just
|
||||
* wrote. The debounce (queue `singletonKey` + the `User.morningDigestRefreshedOn`
|
||||
* marker) collapses the many samples of one night, and across all three
|
||||
* sources, into ONE refresh per user per local morning.
|
||||
*
|
||||
* Timezone correctness is load-bearing: "last night" is judged in the user's
|
||||
* PROFILE timezone, never UTC, so a 30-day backfill re-sync never re-triggers
|
||||
* for historical nights and a sleep that is "last night" locally but a
|
||||
* different UTC date still triggers.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { enqueueMorningDigestRefresh } from "@/lib/jobs/morning-digest-refresh-shared";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* True when `measuredAt` belongs to "last night" for an observer in `tz`: its
|
||||
* local calendar date is today or yesterday. An older backfilled segment (local
|
||||
* date ≥ 2 days ago) returns false, so a full WHOOP / Apple / Withings re-sync
|
||||
* that replays a month of nights never re-triggers the morning refresh for a
|
||||
* historical night. A future-dated sample (clock skew) is rejected too.
|
||||
*
|
||||
* Pure + timezone-explicit so the tz-boundary cases are unit-testable without
|
||||
* touching the DB or the queue.
|
||||
*/
|
||||
export function isLastNightLocal(
|
||||
measuredAt: Date,
|
||||
now: Date,
|
||||
tz: string,
|
||||
): boolean {
|
||||
if (measuredAt.getTime() > now.getTime()) return false;
|
||||
const todayKey = userDayKey(now, tz);
|
||||
const yesterdayKey = userDayKey(new Date(now.getTime() - MS_PER_DAY), tz);
|
||||
const segKey = userDayKey(measuredAt, tz);
|
||||
return segKey === todayKey || segKey === yesterdayKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the debounced morning refresh when at least one of the just-written
|
||||
* sleep segments belongs to last night in the user's profile timezone.
|
||||
*
|
||||
* Best-effort and self-contained: every read is wrapped so a failure can never
|
||||
* fail the sleep sync that called it. Callers still `void … .catch(() => {})`
|
||||
* for defence in depth.
|
||||
*
|
||||
* Gates, in order:
|
||||
* 1. No sleep rows → nothing to do.
|
||||
* 2. `sleep` module off → the digest is always `final` (no sleep to wait
|
||||
* for), so a refresh would be pointless; no-op.
|
||||
* 3. No segment is "last night" locally → an old backfill; no-op.
|
||||
* 4. `User.morningDigestRefreshedOn` already equals today's local date → the
|
||||
* refresh already ran this morning; skip the enqueue (the handler
|
||||
* re-checks the same marker, so this only saves queue churn).
|
||||
*/
|
||||
export async function maybeEnqueueMorningRefresh(
|
||||
userId: string,
|
||||
sleepMeasuredAts: Date[],
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (sleepMeasuredAts.length === 0) return;
|
||||
|
||||
const modules = await resolveModuleMap(userId);
|
||||
if (modules.sleep === false) return;
|
||||
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const hasLastNight = sleepMeasuredAts.some((at) =>
|
||||
isLastNightLocal(at, now, tz),
|
||||
);
|
||||
if (!hasLastNight) return;
|
||||
|
||||
const localDate = userDayKey(now, tz);
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { morningDigestRefreshedOn: true },
|
||||
});
|
||||
if (row?.morningDigestRefreshedOn === localDate) return;
|
||||
|
||||
await enqueueMorningDigestRefresh({ userId, localDate });
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.triggered" },
|
||||
meta: {
|
||||
local_date: localDate,
|
||||
sleep_samples: sleepMeasuredAts.length,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Never let a freshness trigger fail an ingest. The next sleep landing and
|
||||
// the nightly cron are the catch-net; the digest stays honestly
|
||||
// provisional until one of them refreshes it.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* S5 — daily-briefing fallback cron: tz slot-gating + result accounting, plus
|
||||
* the dead-queue wiring guard (imported / in allQueues / scheduled / worked).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
runDailyBriefingTick,
|
||||
DAILY_BRIEFING_QUEUE,
|
||||
DAILY_BRIEFING_CRON,
|
||||
} from "@/lib/jobs/daily-briefing";
|
||||
import type { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push";
|
||||
|
||||
// 06:00Z → 08:00 Berlin (the fallback hour) but 02:00 in New York (not the
|
||||
// slot), so a mixed-tz cohort exercises the per-user local-hour gate.
|
||||
const NOW = new Date("2026-07-16T06:00:00Z");
|
||||
|
||||
function makePrisma(
|
||||
cohort: Array<{ userId: string; timezone: string }>,
|
||||
): PrismaClient {
|
||||
return {
|
||||
notificationPreference: {
|
||||
findMany: vi.fn(async () =>
|
||||
cohort.map((c) => ({
|
||||
channel: { userId: c.userId, user: { timezone: c.timezone } },
|
||||
})),
|
||||
),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("runDailyBriefingTick — slot gating", () => {
|
||||
it("only calls the dispatch seam for users at their local fallback hour", async () => {
|
||||
const prisma = makePrisma([
|
||||
{ userId: "berlin", timezone: "Europe/Berlin" }, // 08:00 → in slot
|
||||
{ userId: "ny", timezone: "America/New_York" }, // 02:00 → not slot
|
||||
]);
|
||||
const maybeDispatch = vi.fn<typeof maybeDispatchDailyBriefing>(
|
||||
async () => "sent",
|
||||
);
|
||||
|
||||
const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch });
|
||||
|
||||
expect(summary.candidatesScanned).toBe(2);
|
||||
expect(summary.inSlot).toBe(1);
|
||||
expect(summary.sent).toBe(1);
|
||||
expect(maybeDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(maybeDispatch.mock.calls[0][1]).toBe("berlin");
|
||||
});
|
||||
|
||||
it("routes each dispatch result into its summary bucket", async () => {
|
||||
const prisma = makePrisma([
|
||||
{ userId: "berlin", timezone: "Europe/Berlin" },
|
||||
]);
|
||||
const maybeDispatch = vi.fn<typeof maybeDispatchDailyBriefing>(
|
||||
async () => "suppressed-frequency",
|
||||
);
|
||||
|
||||
const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch });
|
||||
expect(summary.suppressedFrequency).toBe(1);
|
||||
expect(summary.sent).toBe(0);
|
||||
});
|
||||
|
||||
it("an empty opted-in cohort is a clean no-op", async () => {
|
||||
const prisma = makePrisma([]);
|
||||
const maybeDispatch = vi.fn<typeof maybeDispatchDailyBriefing>(
|
||||
async () => "sent",
|
||||
);
|
||||
const summary = await runDailyBriefingTick(prisma, NOW, { maybeDispatch });
|
||||
expect(summary.candidatesScanned).toBe(0);
|
||||
expect(maybeDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reminder-worker — daily-briefing wiring", () => {
|
||||
const registrar = readFileSync(
|
||||
join(__dirname, "..", "reminder", "register-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("imports the queue symbols from the daily-briefing module", () => {
|
||||
expect(registrar).toMatch(/from\s*["']@\/lib\/jobs\/daily-briefing["']/);
|
||||
expect(registrar).toMatch(/\bDAILY_BRIEFING_QUEUE\b/);
|
||||
expect(registrar).toMatch(/\bDAILY_BRIEFING_CRON\b/);
|
||||
expect(registrar).toMatch(/\brunDailyBriefingTick\b/);
|
||||
});
|
||||
|
||||
it("registers the queue in allQueues and schedules the cron", () => {
|
||||
const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
expect(allQueues).not.toBeNull();
|
||||
expect(allQueues![1]).toMatch(/\bDAILY_BRIEFING_QUEUE\b/);
|
||||
expect(registrar).toMatch(
|
||||
/\[DAILY_BRIEFING_QUEUE,\s*DAILY_BRIEFING_CRON\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("wires a boss.work handler that runs the tick", () => {
|
||||
expect(registrar).toMatch(
|
||||
/boss\.work[\s\S]{0,200}DAILY_BRIEFING_QUEUE[\s\S]{0,400}runDailyBriefingTick/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fires the finalisation-hook push after a finalised morning refresh", () => {
|
||||
expect(registrar).toMatch(
|
||||
/result\.status\s*===\s*["']finalised["'][\s\S]{0,300}maybeDispatchDailyBriefing/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an every-15-min cron for the fallback slot", () => {
|
||||
expect(DAILY_BRIEFING_QUEUE).toBe("daily-briefing");
|
||||
expect(DAILY_BRIEFING_CRON).toBe("*/15 * * * *");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { runMorningDigestRefresh } from "../morning-digest-refresh";
|
||||
import type { GenerateOutcome } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
const LOCAL_DATE = "2026-07-16";
|
||||
|
||||
function makePrisma(
|
||||
user: {
|
||||
locale: string | null;
|
||||
morningDigestRefreshedOn: string | null;
|
||||
} | null,
|
||||
) {
|
||||
const updateMock = vi.fn(async () => ({}));
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: vi.fn(async () => user),
|
||||
update: updateMock,
|
||||
},
|
||||
};
|
||||
return { prisma, updateMock };
|
||||
}
|
||||
|
||||
describe("runMorningDigestRefresh", () => {
|
||||
const retryMock = vi.fn(async () => {});
|
||||
|
||||
beforeEach(() => {
|
||||
retryMock.mockReset();
|
||||
});
|
||||
|
||||
it("regenerates and stamps the marker → finalises the day", async () => {
|
||||
const { prisma, updateMock } = makePrisma({
|
||||
locale: "en",
|
||||
morningDigestRefreshedOn: null,
|
||||
});
|
||||
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
|
||||
status: "generated",
|
||||
providerType: "mock",
|
||||
}));
|
||||
|
||||
const result = await runMorningDigestRefresh(
|
||||
prisma as never,
|
||||
{ userId: "u1", localDate: LOCAL_DATE },
|
||||
{ generate, enqueueRetry: retryMock },
|
||||
);
|
||||
|
||||
expect(generate).toHaveBeenCalledWith("u1", { locale: "en", force: true });
|
||||
expect(updateMock).toHaveBeenCalledWith({
|
||||
where: { id: "u1" },
|
||||
data: { morningDigestRefreshedOn: LOCAL_DATE },
|
||||
});
|
||||
expect(result.status).toBe("finalised");
|
||||
expect(retryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalises on an `unchanged` regen (content-hash no-op is still fresh)", async () => {
|
||||
const { prisma, updateMock } = makePrisma({
|
||||
locale: "de",
|
||||
morningDigestRefreshedOn: null,
|
||||
});
|
||||
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
|
||||
status: "unchanged",
|
||||
}));
|
||||
|
||||
const result = await runMorningDigestRefresh(
|
||||
prisma as never,
|
||||
{ userId: "u1", localDate: LOCAL_DATE },
|
||||
{ generate, enqueueRetry: retryMock },
|
||||
);
|
||||
|
||||
expect(generate).toHaveBeenCalledWith("u1", { locale: "de", force: true });
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
expect(result).toEqual({ status: "finalised", comprehensive: "unchanged" });
|
||||
});
|
||||
|
||||
it("finalises a keyless user (`skipped` no-provider) — nothing to wait for", async () => {
|
||||
const { prisma, updateMock } = makePrisma({
|
||||
locale: "en",
|
||||
morningDigestRefreshedOn: null,
|
||||
});
|
||||
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
|
||||
status: "skipped",
|
||||
reason: "no-provider",
|
||||
}));
|
||||
|
||||
const result = await runMorningDigestRefresh(
|
||||
prisma as never,
|
||||
{ userId: "u1", localDate: LOCAL_DATE },
|
||||
{ generate, enqueueRetry: retryMock },
|
||||
);
|
||||
|
||||
expect(updateMock).toHaveBeenCalled();
|
||||
expect(result.status).toBe("finalised");
|
||||
});
|
||||
|
||||
it("is idempotent: a second run for the same morning no-ops via the marker", async () => {
|
||||
const { prisma, updateMock } = makePrisma({
|
||||
locale: "en",
|
||||
morningDigestRefreshedOn: LOCAL_DATE,
|
||||
});
|
||||
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
|
||||
status: "generated",
|
||||
providerType: "mock",
|
||||
}));
|
||||
|
||||
const result = await runMorningDigestRefresh(
|
||||
prisma as never,
|
||||
{ userId: "u1", localDate: LOCAL_DATE },
|
||||
{ generate, enqueueRetry: retryMock },
|
||||
);
|
||||
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
expect(result.status).toBe("already-final");
|
||||
});
|
||||
|
||||
it("leaves the day provisional on a hard generation failure and hands off to the retry machinery", async () => {
|
||||
const { prisma, updateMock } = makePrisma({
|
||||
locale: "en",
|
||||
morningDigestRefreshedOn: null,
|
||||
});
|
||||
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
|
||||
status: "failed",
|
||||
reason: "provider-timeout",
|
||||
}));
|
||||
|
||||
const result = await runMorningDigestRefresh(
|
||||
prisma as never,
|
||||
{ userId: "u1", localDate: LOCAL_DATE },
|
||||
{ generate, enqueueRetry: retryMock },
|
||||
);
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
expect(retryMock).toHaveBeenCalledWith({ userId: "u1", locale: "en" });
|
||||
expect(result.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("reports missing-user when the row is gone", async () => {
|
||||
const { prisma, updateMock } = makePrisma(null);
|
||||
const generate = vi.fn(async (): Promise<GenerateOutcome> => ({
|
||||
status: "unchanged",
|
||||
}));
|
||||
|
||||
const result = await runMorningDigestRefresh(
|
||||
prisma as never,
|
||||
{ userId: "ghost", localDate: LOCAL_DATE },
|
||||
{ generate, enqueueRetry: retryMock },
|
||||
);
|
||||
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
expect(result.status).toBe("missing-user");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* S5 — the daily-briefing fallback cron.
|
||||
*
|
||||
* The PRIMARY trigger for the morning push is event-driven: the S4
|
||||
* morning-refresh worker dispatches it the instant last night's sleep lands and
|
||||
* the digest finalises (see `register-status.ts`). This cron is the FALLBACK
|
||||
* slot for the honest-degradation case — a user whose sleep never synced, so no
|
||||
* finalisation ever fired. It ticks every 15 minutes (the mood- /
|
||||
* measurement-reminder cadence) and, for each OPTED-IN user whose local wall
|
||||
* clock is at the fixed fallback hour, funnels through the same
|
||||
* `maybeDispatchDailyBriefing` decision seam. The `push_attempts` frequency cap
|
||||
* means a user who already got the finalisation push earlier this morning is
|
||||
* suppressed here — one push per user per local day, no matter which trigger
|
||||
* won the race.
|
||||
*
|
||||
* The opted-in cohort is small (DAILY_BRIEFING defaults OFF), and the per-user
|
||||
* work is gated on the exact local fallback hour, so the tick is cheap: it only
|
||||
* loads a digest for the handful of users at their fallback minute-window this
|
||||
* tick.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import {
|
||||
DAILY_BRIEFING_FALLBACK_HOUR,
|
||||
maybeDispatchDailyBriefing,
|
||||
type DailyBriefingDispatchDeps,
|
||||
} from "@/lib/daily/daily-briefing-push";
|
||||
|
||||
export const DAILY_BRIEFING_QUEUE = "daily-briefing";
|
||||
/** Every 15 minutes; the per-user local-hour gate selects the fallback slot. */
|
||||
export const DAILY_BRIEFING_CRON = "*/15 * * * *";
|
||||
|
||||
export interface DailyBriefingTickSummary {
|
||||
candidatesScanned: number;
|
||||
inSlot: number;
|
||||
sent: number;
|
||||
suppressedFrequency: number;
|
||||
noDigest: number;
|
||||
optedOut: number;
|
||||
moduleOff: number;
|
||||
noChannel: number;
|
||||
outsideWindow: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one fallback-cron tick. Injectable `dispatch` / `maybeDispatch` so the
|
||||
* unit tests pin the slot-gating + result accounting without a running boss,
|
||||
* provider, or DB.
|
||||
*/
|
||||
export async function runDailyBriefingTick(
|
||||
prisma: PrismaClient,
|
||||
now: Date,
|
||||
options: {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
/** Injectable decision seam (defaults to the real one). */
|
||||
maybeDispatch?: typeof maybeDispatchDailyBriefing;
|
||||
dispatchDeps?: DailyBriefingDispatchDeps;
|
||||
} = {},
|
||||
): Promise<DailyBriefingTickSummary> {
|
||||
const maybeDispatch = options.maybeDispatch ?? maybeDispatchDailyBriefing;
|
||||
const dispatchDeps: DailyBriefingDispatchDeps = {
|
||||
...(options.dispatchDeps ?? {}),
|
||||
...(options.dispatch ? { dispatch: options.dispatch } : {}),
|
||||
};
|
||||
|
||||
const summary: DailyBriefingTickSummary = {
|
||||
candidatesScanned: 0,
|
||||
inSlot: 0,
|
||||
sent: 0,
|
||||
suppressedFrequency: 0,
|
||||
noDigest: 0,
|
||||
optedOut: 0,
|
||||
moduleOff: 0,
|
||||
noChannel: 0,
|
||||
outsideWindow: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
// The opted-in cohort: users with at least one enabled DAILY_BRIEFING
|
||||
// preference row. Deduped by userId (a user may have opted several channels
|
||||
// in) and carrying the timezone so the slot gate needs no extra read.
|
||||
const prefs = await prisma.notificationPreference.findMany({
|
||||
where: { eventType: "DAILY_BRIEFING", enabled: true },
|
||||
select: {
|
||||
channel: {
|
||||
select: { userId: true, user: { select: { timezone: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cohort = new Map<string, string>();
|
||||
for (const pref of prefs) {
|
||||
const userId = pref.channel?.userId;
|
||||
if (!userId) continue;
|
||||
cohort.set(userId, pref.channel.user?.timezone || "Europe/Berlin");
|
||||
}
|
||||
|
||||
for (const [userId, tz] of cohort) {
|
||||
summary.candidatesScanned += 1;
|
||||
try {
|
||||
// Fire only at the fixed fallback hour; the earlier part of the morning
|
||||
// window belongs to the event-driven finalisation push.
|
||||
if (wallClockInTz(now, tz).hour !== DAILY_BRIEFING_FALLBACK_HOUR) {
|
||||
continue;
|
||||
}
|
||||
summary.inSlot += 1;
|
||||
|
||||
const result = await maybeDispatch(prisma, userId, now, dispatchDeps);
|
||||
switch (result) {
|
||||
case "sent":
|
||||
summary.sent += 1;
|
||||
break;
|
||||
case "suppressed-frequency":
|
||||
summary.suppressedFrequency += 1;
|
||||
break;
|
||||
case "no-digest":
|
||||
summary.noDigest += 1;
|
||||
break;
|
||||
case "opted-out":
|
||||
summary.optedOut += 1;
|
||||
break;
|
||||
case "module-off":
|
||||
summary.moduleOff += 1;
|
||||
break;
|
||||
case "no-channel":
|
||||
summary.noChannel += 1;
|
||||
break;
|
||||
case "outside-window":
|
||||
summary.outsideWindow += 1;
|
||||
break;
|
||||
default:
|
||||
summary.failed += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
summary.failed += 1;
|
||||
getEvent()?.addWarning(
|
||||
`daily-briefing tick failed for ${userId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* S4 — generator-free contract for the `morning-digest-refresh` queue.
|
||||
*
|
||||
* The sleep-arrival trigger (`@/lib/daily/morning-refresh-trigger`) — reached
|
||||
* from the Withings, WHOOP, and Apple-Health sleep-write seams — enqueues a
|
||||
* debounced morning refresh here WITHOUT importing the comprehensive generator
|
||||
* (which would drag the whole insight tree into every sleep-sync bundle and
|
||||
* risk an import cycle through the transports). The worker-only pipeline
|
||||
* (`runMorningDigestRefresh`) lives in `morning-digest-refresh.ts`, which
|
||||
* re-exports the queue name from here so there is a single source of truth.
|
||||
*
|
||||
* Mirrors the `insight-pregenerate-shared.ts` split exactly: queue name +
|
||||
* payload type + enqueue helper in the generator-free module, the concrete
|
||||
* dispatch in the worker module.
|
||||
*/
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const MORNING_DIGEST_REFRESH_QUEUE = "morning-digest-refresh";
|
||||
|
||||
export interface MorningDigestRefreshPayload {
|
||||
userId: string;
|
||||
/**
|
||||
* The user-local YYYY-MM-DD (their profile timezone) this refresh finalises.
|
||||
* Doubles as the debounce key and the value the handler stamps onto
|
||||
* `User.morningDigestRefreshedOn` so the digest reads `final` for the day.
|
||||
*/
|
||||
localDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue for the sleep-arrival trigger. The `singletonKey`
|
||||
* carries the user AND the local date, so the many sleep samples of one night
|
||||
* collapse into ONE queued job: while a refresh for this morning is
|
||||
* created/active no duplicate can be inserted. Once it completes, the durable
|
||||
* `User.morningDigestRefreshedOn` marker (re-checked in the handler AND
|
||||
* pre-checked in the trigger) stops any later sample from re-running it — the
|
||||
* pair is at-most-once per user per local morning.
|
||||
*
|
||||
* No `singletonSeconds`: a time-slot throttle would let a second morning's
|
||||
* refresh be swallowed if it fell inside the window; the local-date-scoped key
|
||||
* is already unique per morning, so the plain active-job constraint is exactly
|
||||
* the debounce we want. No-ops cleanly when no global boss instance is
|
||||
* available (a web process without an embedded worker) — the next sleep
|
||||
* landing, and the nightly cron, remain the catch-net.
|
||||
*/
|
||||
export async function enqueueMorningDigestRefresh(
|
||||
payload: MorningDigestRefreshPayload,
|
||||
): Promise<void> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.no_boss" },
|
||||
meta: { local_date: payload.localDate },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await boss.send(MORNING_DIGEST_REFRESH_QUEUE, payload, {
|
||||
singletonKey: `morning-refresh:${payload.userId}:${payload.localDate}`,
|
||||
// A transient provider / pool failure retries with backoff rather than
|
||||
// leaving the day provisional until the next sleep sample lands.
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.enqueued" },
|
||||
meta: { local_date: payload.localDate },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: a failed enqueue leaves the next sleep landing + the nightly
|
||||
// cron as the catch-net; the digest stays honestly provisional meanwhile.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* S4 — the `morning-digest-refresh` worker.
|
||||
*
|
||||
* Runs ONE extra insight generation per user per local morning, event-driven
|
||||
* off the arrival of last night's sleep (never warm-on-mount, never per page
|
||||
* visit). It re-runs the SAME comprehensive pipeline the nightly
|
||||
* `insight-pregenerate` cron uses — forced so the 24 h cache short-circuit is
|
||||
* bypassed — then stamps `User.morningDigestRefreshedOn` so the daily digest
|
||||
* flips the day `provisional → final`.
|
||||
*
|
||||
* Cheap by construction: the generator's own content-hash gate turns a
|
||||
* no-change regeneration into an `unchanged` timestamp refresh with no provider
|
||||
* call, so a morning whose sleep did not actually move the feature snapshot
|
||||
* costs a hash compare, not an LLM round-trip. A real change — last night's
|
||||
* sleep now folded into the features — regenerates the briefing paragraph AND
|
||||
* warms the score's read path.
|
||||
*
|
||||
* Honest degradation (§E): on a hard generation failure the marker is NOT
|
||||
* stamped, so the day stays `provisional` and the digest keeps showing the
|
||||
* sleep-pending note. The generator already recorded the omit-reason via
|
||||
* `recordBriefingFailure` on its failure paths, and we re-enqueue the SAME
|
||||
* 45-minute bounded retry the nightly cron uses (`enqueuePregenerateFailureRetry`)
|
||||
* — no parallel freshness machinery is invented here.
|
||||
*
|
||||
* Idempotency: a second run for the same local morning no-ops on the marker
|
||||
* re-check. The enqueue-side `singletonKey` collapses the concurrent burst; the
|
||||
* marker is the durable "already done" guard the singleton cannot provide once
|
||||
* the first job has completed.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
generateComprehensiveInsight,
|
||||
type GenerateOutcome,
|
||||
} from "@/lib/insights/comprehensive-generate";
|
||||
import { enqueuePregenerateFailureRetry } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import {
|
||||
MORNING_DIGEST_REFRESH_QUEUE,
|
||||
type MorningDigestRefreshPayload,
|
||||
} from "@/lib/jobs/morning-digest-refresh-shared";
|
||||
|
||||
export { MORNING_DIGEST_REFRESH_QUEUE };
|
||||
export type { MorningDigestRefreshPayload };
|
||||
|
||||
export interface MorningDigestRefreshResult {
|
||||
status: "finalised" | "already-final" | "missing-user" | "failed";
|
||||
/** The comprehensive generator's own outcome, when it ran. */
|
||||
comprehensive?: GenerateOutcome["status"];
|
||||
}
|
||||
|
||||
type GenerateFn = (
|
||||
userId: string,
|
||||
options: { locale: "de" | "en"; force?: boolean },
|
||||
) => Promise<GenerateOutcome>;
|
||||
|
||||
/**
|
||||
* Execute the morning refresh for one user. Pure of queue concerns — the boss
|
||||
* handler in `register-status.ts` wraps this in the background-event envelope.
|
||||
*
|
||||
* `deps` is injected by the tests: a fake generator + failure-retry so the
|
||||
* marker lifecycle and idempotency can be asserted without a provider or a
|
||||
* running boss.
|
||||
*/
|
||||
export async function runMorningDigestRefresh(
|
||||
prisma: PrismaClient,
|
||||
payload: MorningDigestRefreshPayload,
|
||||
deps: {
|
||||
generate?: GenerateFn;
|
||||
enqueueRetry?: (args: {
|
||||
userId: string;
|
||||
locale: "de" | "en";
|
||||
}) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<MorningDigestRefreshResult> {
|
||||
const generate: GenerateFn = deps.generate ?? generateComprehensiveInsight;
|
||||
const enqueueRetry = deps.enqueueRetry ?? enqueuePregenerateFailureRetry;
|
||||
const { userId, localDate } = payload;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { locale: true, morningDigestRefreshedOn: true },
|
||||
});
|
||||
if (!user) {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.missing_user" },
|
||||
meta: { local_date: localDate },
|
||||
});
|
||||
return { status: "missing-user" };
|
||||
}
|
||||
|
||||
// Idempotency: a re-fire for the same local morning is a no-op.
|
||||
if (user.morningDigestRefreshedOn === localDate) {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.already_final" },
|
||||
meta: { local_date: localDate },
|
||||
});
|
||||
return { status: "already-final" };
|
||||
}
|
||||
|
||||
const locale = normalizeLocale(user.locale);
|
||||
const outcome = await generate(userId, { locale, force: true });
|
||||
|
||||
// A hard provider failure leaves the day provisional and hands recovery to
|
||||
// the existing 45-minute briefing-retry machinery. Every other outcome —
|
||||
// including `skipped` (no provider / no consent: there is no LLM briefing to
|
||||
// wait for, so the deterministic score + digest are as final as they get
|
||||
// once last night's sleep is in the record) — finalises the day.
|
||||
if (outcome.status === "failed") {
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.deferred" },
|
||||
meta: { local_date: localDate, cause: `generator:${outcome.reason}` },
|
||||
});
|
||||
await enqueueRetry({ userId, locale });
|
||||
return { status: "failed", comprehensive: outcome.status };
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { morningDigestRefreshedOn: localDate },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "daily.morning_refresh.finalised" },
|
||||
meta: { local_date: localDate, comprehensive: outcome.status },
|
||||
});
|
||||
return { status: "finalised", comprehensive: outcome.status };
|
||||
}
|
||||
@@ -24,6 +24,17 @@ import {
|
||||
INSIGHT_PREGENERATE_CRON,
|
||||
type InsightPregeneratePayload,
|
||||
} from "@/lib/jobs/insight-pregenerate";
|
||||
import {
|
||||
MORNING_DIGEST_REFRESH_QUEUE,
|
||||
runMorningDigestRefresh,
|
||||
type MorningDigestRefreshPayload,
|
||||
} from "@/lib/jobs/morning-digest-refresh";
|
||||
import {
|
||||
DAILY_BRIEFING_QUEUE,
|
||||
DAILY_BRIEFING_CRON,
|
||||
runDailyBriefingTick,
|
||||
} from "@/lib/jobs/daily-briefing";
|
||||
import { maybeDispatchDailyBriefing } from "@/lib/daily/daily-briefing-push";
|
||||
import {
|
||||
RECOVERY_SCORE_QUEUE,
|
||||
RECOVERY_SCORE_CRON,
|
||||
@@ -154,6 +165,11 @@ const allQueues = [
|
||||
// provisions the queue and the enqueue silently drops. No cron schedule —
|
||||
// a send-only queue driven by navigation.
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
// S4 — event-driven morning digest refresh, enqueued by the sleep-arrival
|
||||
// trigger when last night's completed sleep lands. No cron schedule — a
|
||||
// send-only queue driven by sleep ingest; without this entry pg-boss never
|
||||
// provisions the queue and every enqueue silently drops.
|
||||
MORNING_DIGEST_REFRESH_QUEUE,
|
||||
// v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress /
|
||||
// Strain compute + store. The queue MUST be registered here or pg-boss
|
||||
// never provisions it and the nightly schedule silently never fires.
|
||||
@@ -178,6 +194,11 @@ const allQueues = [
|
||||
// v1.16.11 — daily medication low-stock pass. Without this entry the 09:00
|
||||
// schedule silently no-ops and no low-stock alert ever fires.
|
||||
MEDICATION_LOW_STOCK_QUEUE,
|
||||
// S5 — daily-briefing fallback cron. The primary morning push rides the
|
||||
// sleep-arrival finalisation hook below; this queue is the fixed local-morning
|
||||
// slot for the no-sleep-arrived case. Without this entry the every-15-min
|
||||
// schedule silently no-ops and the fallback push never fires.
|
||||
DAILY_BRIEFING_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
@@ -233,6 +254,11 @@ const schedules: ScheduleEntry[] = [
|
||||
// can act on it. Once daily; the per-medication stamp keeps it at
|
||||
// one push per threshold crossing.
|
||||
[MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON],
|
||||
// S5 — daily-briefing fallback slot. Ticks every 15 min; the per-user
|
||||
// local-hour gate inside the tick selects each user's fixed morning slot, so
|
||||
// one UTC cron serves every timezone. The `push_attempts` cap suppresses it
|
||||
// when the sleep-arrival finalisation push already fired this morning.
|
||||
[DAILY_BRIEFING_QUEUE, DAILY_BRIEFING_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -295,6 +321,52 @@ export async function registerStatusQueues(
|
||||
{ localConcurrency: 2 },
|
||||
handleInsightPregenerateJob,
|
||||
);
|
||||
// S4 — event-driven morning digest refresh. One forced comprehensive
|
||||
// regeneration per user per local morning, enqueued by the sleep-arrival
|
||||
// trigger. Single-flight: the enqueue-side singletonKey already collapses a
|
||||
// night's samples into one job, and the handler's marker re-check makes a
|
||||
// rare double-fire a no-op, so one slot is enough and bounds the provider
|
||||
// concurrency the same way the nightly path does.
|
||||
await boss.work<MorningDigestRefreshPayload>(
|
||||
MORNING_DIGEST_REFRESH_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async (jobs) => {
|
||||
await withBackgroundEvent("job.morning_digest_refresh", async (evt) => {
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const result = await runMorningDigestRefresh(
|
||||
getWorkerPrisma(),
|
||||
job.data,
|
||||
);
|
||||
evt.addMeta(
|
||||
"morning_refresh",
|
||||
`${result.status}:${result.comprehensive ?? "none"}`,
|
||||
);
|
||||
// S5 — the day just finalised (last night's sleep folded in), so
|
||||
// this is the natural, event-driven moment to fire the calm morning
|
||||
// push carrying the FINAL digest. Fully fault-isolated inside the
|
||||
// seam; the opt-in / morning-window / once-per-day gates all live
|
||||
// there, so a user who never opted in or is outside the window is a
|
||||
// clean no-op. The fallback cron covers the no-sleep-arrived case.
|
||||
if (result.status === "finalised") {
|
||||
const pushResult = await maybeDispatchDailyBriefing(
|
||||
getWorkerPrisma(),
|
||||
job.data.userId,
|
||||
new Date(),
|
||||
);
|
||||
evt.addMeta("daily_briefing_push", pushResult);
|
||||
}
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", "[morning-digest-refresh] pass failed", err);
|
||||
// Rethrow so the queue's retry policy re-runs the refresh; the
|
||||
// marker re-check keeps a retry idempotent.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
// v1.8.3 — on-demand per-metric status generation enqueued by the
|
||||
// read-only status route on a cold card. Low concurrency so a first
|
||||
// visit that cold-misses several cards can't saturate the Prisma pool
|
||||
@@ -387,6 +459,40 @@ export async function registerStatusQueues(
|
||||
});
|
||||
},
|
||||
);
|
||||
// S5 — daily-briefing fallback slot. Single-flight; the `push_attempts`
|
||||
// frequency cap makes an overlapping tick a no-op (a second same-day attempt
|
||||
// is suppressed), and the tick only loads a digest for opted-in users at
|
||||
// their exact local fallback hour. No provider call — it reads the cached
|
||||
// digest and dispatches the deterministic line + cached lead.
|
||||
await boss.work(DAILY_BRIEFING_QUEUE, { localConcurrency: 1 }, async () => {
|
||||
await withBackgroundEvent("job.daily_briefing", async (evt) => {
|
||||
try {
|
||||
const summary = await runDailyBriefingTick(
|
||||
getWorkerPrisma(),
|
||||
new Date(),
|
||||
);
|
||||
evt.setBackground({
|
||||
task_name: "job.daily_briefing",
|
||||
result: {
|
||||
candidates_scanned: summary.candidatesScanned,
|
||||
in_slot: summary.inSlot,
|
||||
sent: summary.sent,
|
||||
suppressed_frequency: summary.suppressedFrequency,
|
||||
no_digest: summary.noDigest,
|
||||
opted_out: summary.optedOut,
|
||||
module_off: summary.moduleOff,
|
||||
no_channel: summary.noChannel,
|
||||
outside_window: summary.outsideWindow,
|
||||
failed: summary.failed,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
// v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived
|
||||
// proxy) compute + store. Single-flight so two ticks never double-walk
|
||||
// the cohort. The runner iterates every eligible user and upserts one
|
||||
|
||||
@@ -101,6 +101,17 @@ export const EVENT_TYPES = [
|
||||
// would defeat the feature. An explicit per-channel `NotificationPreference`
|
||||
// row still wins for a user who opts out.
|
||||
"SECURITY_ALERT",
|
||||
// S5 — the once-daily calm morning briefing. Default OFF (see
|
||||
// EVENT_DEFAULT_ENABLED, the PERSONAL_RECORD precedent): the user opts in
|
||||
// per-channel from the /notifications matrix, so the channel choice is
|
||||
// explicit at opt-in. It reads the CACHED daily digest — never a fresh AI
|
||||
// call — and carries a minimal, non-clinical line (cached briefing lead with
|
||||
// a deterministic floor). It is a nudge, NEVER the anomaly channel: it is
|
||||
// dispatched non-urgent, fired at most once per user per local day (the
|
||||
// `push_attempts` ledger is the frequency anchor), and only inside a local
|
||||
// morning window, so a concerning reading always stays on
|
||||
// `MEASUREMENT_ANOMALY` and this never bypasses Focus.
|
||||
"DAILY_BRIEFING",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -162,6 +173,12 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
|
||||
// needs surfaced. An explicit per-channel `NotificationPreference` row
|
||||
// (enabled=false) still suppresses it for a user who opts out.
|
||||
SECURITY_ALERT: true,
|
||||
// S5 — OFF by default (the PERSONAL_RECORD precedent). The calm daily
|
||||
// briefing stays silent until the user explicitly opts in per-channel at
|
||||
// /notifications; the dispatcher's default-policy gate suppresses every
|
||||
// channel that has no explicit enabled row, so a fresh account never
|
||||
// receives a morning push it did not ask for.
|
||||
DAILY_BRIEFING: false,
|
||||
};
|
||||
|
||||
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user