From 8b7bb7914e40e99c2e2c72a036228efa5978bee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 08:10:58 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(daily):=20milestones=20=E2=80=94=20the?= =?UTF-8?q?=20calm=20reward=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the durable-states Milestone primitive and a milestone card to the daily digest. A milestone marks a state REACHED, never days maintained: a metric settled back inside its own range, a run of weeks in range, or a fresh personal best. It is deliberately not a streak — the type carries a reached-date and no running counter, so nothing here can be tuned into a flame, and a lapse is invisible rather than mourned. The primitive is a thin wrapper over engines that already exist: the pure detectStreak band engine (return-to-range + sustained-in-range) and the PersonalRecord detection output (new bests). Reached-once needs no new storage — a milestone's reached-date is a stable fact, so it surfaces only on the day it was reached and never resurfaces the next day. The digest emits one calm success card per day when a milestone is freshly reached, below an overdue dose and above ambient items, with a single action into the metric's insight. Module- and data-gated: a disabled insights account or a day with nothing reached shows nothing. Copy carries no streak, loss, or failure vocabulary, in all six locales. --- messages/de.json | 38 +++- messages/en.json | 38 +++- messages/es.json | 38 +++- messages/fr.json | 38 +++- messages/it.json | 38 +++- messages/pl.json | 38 +++- src/lib/daily/__tests__/digest.test.ts | 68 ++++++ src/lib/daily/__tests__/milestones.test.ts | 242 +++++++++++++++++++++ src/lib/daily/digest.ts | 49 +++++ src/lib/daily/load-digest.ts | 101 +++++++++ src/lib/daily/milestones.ts | 226 +++++++++++++++++++ 11 files changed, 908 insertions(+), 6 deletions(-) create mode 100644 src/lib/daily/__tests__/milestones.test.ts create mode 100644 src/lib/daily/milestones.ts diff --git a/messages/de.json b/messages/de.json index c8907407d..7ec485f0f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8557,7 +8557,8 @@ "viewCheckups": "Vorsorge ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", - "checkinLetGo": "Loslassen" + "checkinLetGo": "Loslassen", + "viewMilestone": "Ansehen" }, "today": { "scoreLabel": "Gesundheitsscore", @@ -8567,6 +8568,41 @@ "readFullBriefing": "Vollständiges Briefing lesen", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit." + }, + "milestone": { + "metric": { + "restingHeartRate": "Ruhepuls", + "heartRateVariability": "Herzfrequenzvariabilität", + "respiratoryRate": "Atemfrequenz", + "weight": "Gewicht", + "generic": "dieser Wert" + }, + "record": { + "title": "Neue Bestmarke: {metric}", + "body": "Heute ein persönlicher Bestwert für {metric} — ganz ruhig festgehalten, du musst nichts tun." + }, + "returnToBaseline": { + "title": "Wieder im Bereich: {metric}", + "body": "{metric} liegt wieder in deinem gewohnten Bereich. Was auch immer es war — es ist vorbei." + }, + "sustainedInRange": { + "week1": { + "title": "Eine Woche im Bereich", + "body": "{metric} liegt seit einer Woche in deinem gewohnten Bereich. Schön gleichmäßig." + }, + "week2": { + "title": "Zwei Wochen im Bereich", + "body": "{metric} bleibt seit zwei Wochen in deinem gewohnten Bereich. Ruhig und stetig." + }, + "week3": { + "title": "Drei Wochen im Bereich", + "body": "Drei Wochen, in denen {metric} in deinem gewohnten Bereich liegt. Eine echte ruhige Strecke." + }, + "week4": { + "title": "Ein Monat im Bereich", + "body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig." + } + } } } } diff --git a/messages/en.json b/messages/en.json index 11f67878f..0726419f0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8557,7 +8557,8 @@ "viewCheckups": "View check-ups", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", - "checkinLetGo": "Let it go" + "checkinLetGo": "Let it go", + "viewMilestone": "View" }, "today": { "scoreLabel": "Health score", @@ -8567,6 +8568,41 @@ "readFullBriefing": "Read the full briefing", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today." + }, + "milestone": { + "metric": { + "restingHeartRate": "resting heart rate", + "heartRateVariability": "heart-rate variability", + "respiratoryRate": "respiratory rate", + "weight": "weight", + "generic": "this metric" + }, + "record": { + "title": "A new {metric} best", + "body": "A personal best for your {metric} today — quietly noted, nothing to do." + }, + "returnToBaseline": { + "title": "Back in range: {metric}", + "body": "Your {metric} has settled back inside your usual range. Whatever that was, it has passed." + }, + "sustainedInRange": { + "week1": { + "title": "A week in range", + "body": "Your {metric} has held inside your usual range for a week now. Nice and steady." + }, + "week2": { + "title": "Two weeks in range", + "body": "Your {metric} has stayed in your usual range for two weeks. Calm and steady." + }, + "week3": { + "title": "Three weeks in range", + "body": "Three weeks of your {metric} sitting in your usual range. A real stretch of steadiness." + }, + "week4": { + "title": "A month in range", + "body": "A full month with your {metric} in your usual range. Beautifully steady." + } + } } } } diff --git a/messages/es.json b/messages/es.json index 79883e68e..18ccc7001 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8557,7 +8557,8 @@ "viewCheckups": "Ver revisiones", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", - "checkinLetGo": "Dejarlo ir" + "checkinLetGo": "Dejarlo ir", + "viewMilestone": "Ver" }, "today": { "scoreLabel": "Puntuación de salud", @@ -8567,6 +8568,41 @@ "readFullBriefing": "Leer el informe completo", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención." + }, + "milestone": { + "metric": { + "restingHeartRate": "frecuencia cardíaca en reposo", + "heartRateVariability": "variabilidad de la frecuencia cardíaca", + "respiratoryRate": "frecuencia respiratoria", + "weight": "peso", + "generic": "este valor" + }, + "record": { + "title": "Nuevo récord: {metric}", + "body": "Hoy has alcanzado tu mejor marca de {metric}. Anotado con calma, sin nada que hacer." + }, + "returnToBaseline": { + "title": "De nuevo en tu rango: {metric}", + "body": "Tu {metric} ha vuelto a tu rango habitual. Fuera lo que fuera, ya pasó." + }, + "sustainedInRange": { + "week1": { + "title": "Una semana en rango", + "body": "Tu {metric} lleva una semana dentro de tu rango habitual. Con buena estabilidad." + }, + "week2": { + "title": "Dos semanas en rango", + "body": "Tu {metric} lleva dos semanas dentro de tu rango habitual. Tranquilo y constante." + }, + "week3": { + "title": "Tres semanas en rango", + "body": "Tres semanas con tu {metric} dentro de tu rango habitual. Un buen tramo de calma." + }, + "week4": { + "title": "Un mes en rango", + "body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable." + } + } } } } diff --git a/messages/fr.json b/messages/fr.json index 85d4f3acf..e33484c2b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8557,7 +8557,8 @@ "viewCheckups": "Voir les examens", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", - "checkinLetGo": "Laisser tomber" + "checkinLetGo": "Laisser tomber", + "viewMilestone": "Voir" }, "today": { "scoreLabel": "Score de santé", @@ -8567,6 +8568,41 @@ "readFullBriefing": "Lire le briefing complet", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui." + }, + "milestone": { + "metric": { + "restingHeartRate": "fréquence cardiaque au repos", + "heartRateVariability": "variabilité de la fréquence cardiaque", + "respiratoryRate": "fréquence respiratoire", + "weight": "poids", + "generic": "cette valeur" + }, + "record": { + "title": "Nouveau record : {metric}", + "body": "Vous avez atteint un record personnel de {metric} aujourd'hui. Noté en toute tranquillité, rien à faire." + }, + "returnToBaseline": { + "title": "De retour dans votre plage : {metric}", + "body": "Votre {metric} est revenue dans votre plage habituelle. Quoi que ce fût, c'est passé." + }, + "sustainedInRange": { + "week1": { + "title": "Une semaine dans la plage", + "body": "Votre {metric} reste dans votre plage habituelle depuis une semaine. Bien régulier." + }, + "week2": { + "title": "Deux semaines dans la plage", + "body": "Votre {metric} reste dans votre plage habituelle depuis deux semaines. Calme et régulier." + }, + "week3": { + "title": "Trois semaines dans la plage", + "body": "Trois semaines avec votre {metric} dans votre plage habituelle. Une vraie période de stabilité." + }, + "week4": { + "title": "Un mois dans la plage", + "body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable." + } + } } } } diff --git a/messages/it.json b/messages/it.json index b0702e182..320c0c944 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8557,7 +8557,8 @@ "viewCheckups": "Vedi controlli", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", - "checkinLetGo": "Lascia andare" + "checkinLetGo": "Lascia andare", + "viewMilestone": "Vedi" }, "today": { "scoreLabel": "Punteggio di salute", @@ -8567,6 +8568,41 @@ "readFullBriefing": "Leggi il briefing completo", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione." + }, + "milestone": { + "metric": { + "restingHeartRate": "frequenza cardiaca a riposo", + "heartRateVariability": "variabilità della frequenza cardiaca", + "respiratoryRate": "frequenza respiratoria", + "weight": "peso", + "generic": "questo valore" + }, + "record": { + "title": "Nuovo record: {metric}", + "body": "Oggi hai raggiunto il tuo record personale di {metric}. Annotato con calma, senza nulla da fare." + }, + "returnToBaseline": { + "title": "Di nuovo nel tuo intervallo: {metric}", + "body": "La tua {metric} è tornata nel tuo intervallo abituale. Qualunque cosa fosse, è passata." + }, + "sustainedInRange": { + "week1": { + "title": "Una settimana nell'intervallo", + "body": "La tua {metric} resta nel tuo intervallo abituale da una settimana. Bella costante." + }, + "week2": { + "title": "Due settimane nell'intervallo", + "body": "La tua {metric} resta nel tuo intervallo abituale da due settimane. Calma e costante." + }, + "week3": { + "title": "Tre settimane nell'intervallo", + "body": "Tre settimane con la tua {metric} nel tuo intervallo abituale. Un bel tratto di stabilità." + }, + "week4": { + "title": "Un mese nell'intervallo", + "body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante." + } + } } } } diff --git a/messages/pl.json b/messages/pl.json index b057686a9..97d819dda 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8557,7 +8557,8 @@ "viewCheckups": "Zobacz badania", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", - "checkinLetGo": "Odpuść" + "checkinLetGo": "Odpuść", + "viewMilestone": "Zobacz" }, "today": { "scoreLabel": "Wynik zdrowia", @@ -8567,6 +8568,41 @@ "readFullBriefing": "Przeczytaj pełny briefing", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi." + }, + "milestone": { + "metric": { + "restingHeartRate": "tętno spoczynkowe", + "heartRateVariability": "zmienność rytmu serca", + "respiratoryRate": "częstość oddechów", + "weight": "waga", + "generic": "ta wartość" + }, + "record": { + "title": "Nowy rekord: {metric}", + "body": "Dziś osiągnięto Twój najlepszy wynik dla {metric}. Spokojnie odnotowane, nic nie musisz robić." + }, + "returnToBaseline": { + "title": "Znów w Twoim zakresie: {metric}", + "body": "Twoja {metric} wróciła do zwykłego zakresu. Cokolwiek to było, minęło." + }, + "sustainedInRange": { + "week1": { + "title": "Tydzień w zakresie", + "body": "Twoja {metric} od tygodnia mieści się w zwykłym zakresie. Ładnie i spokojnie." + }, + "week2": { + "title": "Dwa tygodnie w zakresie", + "body": "Twoja {metric} od dwóch tygodni mieści się w zwykłym zakresie. Spokojnie i stabilnie." + }, + "week3": { + "title": "Trzy tygodnie w zakresie", + "body": "Trzy tygodnie z {metric} w zwykłym zakresie. Naprawdę spokojny odcinek." + }, + "week4": { + "title": "Miesiąc w zakresie", + "body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie." + } + } } } } diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index b98156f08..d5b9e4c1e 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -13,6 +13,8 @@ import { type DailyDigestCoachPlan, type DailyDigestInput, } from "@/lib/daily/digest"; +import type { Milestone } from "@/lib/daily/milestones"; +import type { PriorityItem } from "@/lib/daily/priority-item"; const t = getServerTranslator("en").t; const NOW = new Date("2026-07-16T09:00:00.000Z"); @@ -413,3 +415,69 @@ describe("buildDailyDigest — coach check-in (S3)", () => { expect(d.worthALook[0].kind).toBe("dose_window"); }); }); + +const milestone = ( + d: ReturnType, +): PriorityItem | undefined => d.worthALook.find((i) => i.kind === "milestone"); + +const RECORD_MILESTONE: Milestone = { + kind: "record_first", + metricType: "RESTING_HEART_RATE", + sinceDate: "2026-07-16", + copyKey: "daily.milestone.record", +}; + +describe("S12 — the milestone reward card", () => { + it("emits ONE calm success card when a milestone was freshly reached", () => { + const d = buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t); + const item = milestone(d); + expect(item).toBeDefined(); + expect(item?.status).toBe("success"); + expect(item?.title.length).toBeGreaterThan(0); + expect(item?.body?.length).toBeGreaterThan(0); + // Single calm action deep-linking into the metric's insight. + expect(item?.actions).toHaveLength(1); + expect(item?.actions[0].intent).toBe("milestone.view"); + expect(item?.actions[0].href).toBe("/insights/resting-pulse"); + // One per day — never two milestone cards. + expect(d.worthALook.filter((i) => i.kind === "milestone")).toHaveLength(1); + }); + + it("shows nothing when no milestone was reached today (data-gated)", () => { + expect( + milestone(buildDailyDigest(input({ milestone: null }), t)), + ).toBeUndefined(); + expect(milestone(buildDailyDigest(input(), t))).toBeUndefined(); + }); + + it("is suppressed when the insights module is off (module-gated)", () => { + const d = buildDailyDigest( + input({ milestone: RECORD_MILESTONE, modules: { insights: false } }), + t, + ); + expect(milestone(d)).toBeUndefined(); + }); + + it("sits just below an overdue dose and above ambient items", () => { + const d = buildDailyDigest( + input({ + milestone: RECORD_MILESTONE, + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [{ integration: "withings", state: "error_reauth" }], + }), + t, + ); + expect(d.worthALook[0].kind).toBe("dose_window"); + expect(d.worthALook[1].kind).toBe("milestone"); + expect(d.worthALook[2].kind).toBe("sync_issue"); + }); + + it("carries no streak / loss vocabulary in its copy", () => { + const item = milestone( + buildDailyDigest(input({ milestone: RECORD_MILESTONE }), t), + ); + const forbidden = /streak|flame|broke|broken|lost|missed|fail/i; + expect(item?.title).not.toMatch(forbidden); + expect(item?.body ?? "").not.toMatch(forbidden); + }); +}); diff --git a/src/lib/daily/__tests__/milestones.test.ts b/src/lib/daily/__tests__/milestones.test.ts new file mode 100644 index 000000000..c95c75ad5 --- /dev/null +++ b/src/lib/daily/__tests__/milestones.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from "vitest"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector"; +import { + MILESTONE_KINDS, + milestoneCopy, + milestoneFromRecord, + milestoneHref, + milestonesFromStreak, + selectFreshMilestone, + SUSTAINED_WEEK_THRESHOLDS, + type Milestone, +} from "@/lib/daily/milestones"; + +const t = getServerTranslator("en").t; + +/** Build a consecutive-day UTC series ending at `endDay` (values[last] === endDay). */ +function seriesEndingAt(values: number[], endDay: string): StreakPoint[] { + const endSerial = + Date.UTC(...(dayParts(endDay) as [number, number, number])) / 86_400_000; + return values.map((value, i) => ({ + day: keyFromSerial(endSerial - (values.length - 1 - i)), + value, + })); +} + +function dayParts(key: string): number[] { + const [y, m, d] = key.split("-").map(Number); + return [y, m - 1, d]; +} + +function keyFromSerial(serial: number): string { + return new Date(serial * 86_400_000).toISOString().slice(0, 10); +} + +function shift(day: string, delta: number): string { + const serial = + Date.UTC(...(dayParts(day) as [number, number, number])) / 86_400_000; + return keyFromSerial(serial + delta); +} + +const TODAY = "2026-07-16"; + +describe("milestonesFromStreak — folds the real detectStreak engine", () => { + it("emits a fresh sustained_in_range milestone the day a week boundary is reached", () => { + // Exactly 7 consecutive in-band days ending today → the 1-week durable + // state is REACHED today. Uses the real detectStreak result, not a stub. + const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60], TODAY); + const result = detectStreak(series); + expect(result.inBand).toBe(true); + expect(result.streakDays).toBe(7); + + const milestones = milestonesFromStreak( + "RESTING_HEART_RATE", + result, + TODAY, + ); + const week1 = milestones.find((m) => m.copyKey.endsWith("week1")); + expect(week1).toBeDefined(); + expect(week1?.kind).toBe("sustained_in_range"); + expect(week1?.sinceDate).toBe(TODAY); // reached today → fresh + + // Reached-once: the same durable state is NOT fresh the next day. + expect(selectFreshMilestone(milestones, TODAY)?.copyKey).toContain("week1"); + expect(selectFreshMilestone(milestones, shift(TODAY, 1))).toBeNull(); + }); + + it("does not re-fire a week boundary reached on an earlier day", () => { + // 8 in-band days: the 1-week state was reached YESTERDAY (streak 8 > 7). + const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60, 60], TODAY); + const result = detectStreak(series); + expect(result.streakDays).toBe(8); + + const milestones = milestonesFromStreak( + "RESTING_HEART_RATE", + result, + TODAY, + ); + const week1 = milestones.find((m) => m.copyKey.endsWith("week1")); + expect(week1?.sinceDate).toBe(shift(TODAY, -1)); // reached yesterday + expect(selectFreshMilestone(milestones, TODAY)).toBeNull(); // not fresh today + }); + + it("emits a fresh return_to_baseline the day the return settles (daysInside === MIN_IN_RUN)", () => { + // Prior out-of-band run (90s) then a settled 2-day in-band run ending today. + const series = seriesEndingAt( + [60, 60, 60, 60, 60, 90, 90, 90, 60, 60], + TODAY, + ); + const result = detectStreak(series); + expect(result.returnEvent).toBeDefined(); + expect(result.returnEvent?.daysInside).toBe(2); + + const milestones = milestonesFromStreak( + "RESTING_HEART_RATE", + result, + TODAY, + ); + const ret = milestones.find((m) => m.kind === "return_to_baseline"); + expect(ret).toBeDefined(); + expect(ret?.sinceDate).toBe(TODAY); + expect(selectFreshMilestone(milestones, TODAY)?.kind).toBe( + "return_to_baseline", + ); + // Reached-once: gone the next day. + expect(selectFreshMilestone(milestones, shift(TODAY, 1))).toBeNull(); + }); + + it("emits NOTHING while the metric is currently out of band — no 'broken' state", () => { + // Latest placement is out-of-band (a lapse). A lapse is invisible: no + // milestone at all, and certainly no negative one. + const series = seriesEndingAt([60, 60, 60, 60, 60, 60, 60, 90, 90], TODAY); + const result = detectStreak(series); + expect(result.inBand).toBe(false); + expect(milestonesFromStreak("RESTING_HEART_RATE", result, TODAY)).toEqual( + [], + ); + }); + + it("omits quietly for an empty series (no band, no guess)", () => { + const result = detectStreak([]); + expect(result.latestPlacement).toBeNull(); + expect(milestonesFromStreak("WEIGHT", result, TODAY)).toEqual([]); + }); + + it("emits no milestone from an in-band run shorter than a week", () => { + // A short in-band run (3 days) has reached no durable week boundary yet. + const series = seriesEndingAt([60, 60, 60], TODAY); + const result = detectStreak(series); + expect(result.inBand).toBe(true); + expect(result.streakDays).toBe(3); + expect(milestonesFromStreak("WEIGHT", result, TODAY)).toEqual([]); + }); +}); + +describe("milestoneFromRecord — folds a PersonalRecord achieved day", () => { + it("is fresh only on the day the record was achieved", () => { + const m = milestoneFromRecord("HEART_RATE_VARIABILITY", TODAY); + expect(m.kind).toBe("record_first"); + expect(selectFreshMilestone([m], TODAY)?.kind).toBe("record_first"); + expect(selectFreshMilestone([m], shift(TODAY, 1))).toBeNull(); + }); +}); + +describe("selectFreshMilestone — reached-once, one per day, most meaningful", () => { + it("prefers a personal best, then a return, then a sustained state", () => { + const record: Milestone = milestoneFromRecord("RESTING_HEART_RATE", TODAY); + const ret: Milestone = { + kind: "return_to_baseline", + metricType: "WEIGHT", + sinceDate: TODAY, + copyKey: "daily.milestone.returnToBaseline", + }; + const sustained: Milestone = { + kind: "sustained_in_range", + metricType: "WEIGHT", + sinceDate: TODAY, + copyKey: "daily.milestone.sustainedInRange.week1", + }; + expect(selectFreshMilestone([sustained, ret, record], TODAY)?.kind).toBe( + "record_first", + ); + expect(selectFreshMilestone([sustained, ret], TODAY)?.kind).toBe( + "return_to_baseline", + ); + expect(selectFreshMilestone([sustained], TODAY)?.kind).toBe( + "sustained_in_range", + ); + }); + + it("returns null when no candidate was reached today", () => { + const stale: Milestone = milestoneFromRecord("WEIGHT", shift(TODAY, -3)); + expect(selectFreshMilestone([stale], TODAY)).toBeNull(); + expect(selectFreshMilestone([], TODAY)).toBeNull(); + }); +}); + +describe("copy is calm and never negative", () => { + const cases: Milestone[] = [ + milestoneFromRecord("RESTING_HEART_RATE", TODAY), + { + kind: "return_to_baseline", + metricType: "RESTING_HEART_RATE", + sinceDate: TODAY, + copyKey: "daily.milestone.returnToBaseline", + }, + ...SUSTAINED_WEEK_THRESHOLDS.map((w): Milestone => ({ + kind: "sustained_in_range", + metricType: "RESTING_HEART_RATE", + sinceDate: TODAY, + copyKey: `daily.milestone.sustainedInRange.week${w}`, + })), + ]; + + it("resolves a real title + body with the metric name interpolated, no leftover placeholder", () => { + for (const m of cases) { + const { title, body } = milestoneCopy(m, t); + expect(title.length).toBeGreaterThan(0); + expect(body.length).toBeGreaterThan(0); + expect(title).not.toContain("{metric}"); + expect(body).not.toContain("{metric}"); + expect(body.toLowerCase()).toContain("resting heart rate"); + } + }); + + it("uses no streak / loss / failure vocabulary anywhere", () => { + const forbidden = + /streak|flame|broke|broken|lost|don'?t lose|missed|fail|penalt/i; + for (const m of cases) { + const { title, body } = milestoneCopy(m, t); + expect(title).not.toMatch(forbidden); + expect(body).not.toMatch(forbidden); + } + }); + + it("every milestone deep-links to an insight surface", () => { + for (const m of cases) { + expect(milestoneHref(m)).toMatch(/^\/insights/); + } + }); +}); + +describe("the type carries no running counter", () => { + it("a Milestone has only kind / metricType / sinceDate / copyKey", () => { + const m = milestoneFromRecord("WEIGHT", TODAY); + expect(Object.keys(m).sort()).toEqual([ + "copyKey", + "kind", + "metricType", + "sinceDate", + ]); + }); + + it("exposes exactly the three durable kinds", () => { + expect([...MILESTONE_KINDS]).toEqual([ + "record_first", + "return_to_baseline", + "sustained_in_range", + ]); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 4f4ff9753..58e45612d 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -27,6 +27,11 @@ import { MAX_PRIORITY_ACTIONS, type PriorityItem, } from "@/lib/daily/priority-item"; +import { + milestoneCopy, + milestoneHref, + type Milestone, +} from "@/lib/daily/milestones"; /** At most three rail items — a glance, never a wall (§2.5, never padded). */ export const MAX_WORTH_A_LOOK = 3; @@ -135,6 +140,13 @@ export interface DailyDigestInput { preventiveDue: DailyDigestPreventiveDue[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; + /** + * S12 — the single freshly-reached durable milestone for today, or null. The + * IO seam gathers candidates from the existing streak / personal-record + * engines and applies the reached-once gate (`selectFreshMilestone`), so the + * builder only ever sees a milestone worth celebrating today. + */ + milestone?: Milestone | null; } export interface DailyDigest { @@ -256,6 +268,38 @@ function buildPreventiveCareItem( }; } +/** + * S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable + * state was reached TODAY (the IO seam already applied the reached-once gate). + * Gated on the `insights` module — the daily narrative layer that hosts it. + * Celebratory-but-quiet: `success` status, a single "view" action into the + * metric's insight, and copy that marks arrival at a state, never a maintained + * count. Shown the day reached and never again — never a "you broke it" note. + */ +function buildMilestoneItem( + milestone: Milestone | null | undefined, + modules: DigestModuleMap, + t: Translate, +): PriorityItem | null { + if (!milestone) return null; + if (!moduleEnabled(modules, "insights")) return null; + const { title, body } = milestoneCopy(milestone, t); + return { + kind: "milestone", + title, + body, + status: "success", + actions: [ + { + labelKey: "daily.action.viewMilestone", + intent: "milestone.view", + href: milestoneHref(milestone), + }, + ], + moduleKey: "insights", + }; +} + /** * 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 @@ -384,6 +428,11 @@ export function buildDailyDigest( const worthALook: PriorityItem[] = []; const dose = buildDoseWindowItem(input.medsToday, input.modules, t); if (dose) worthALook.push(dose); + // A freshly-reached milestone is rare and one-per-day — surface it ahead of + // the ambient sync / check-in items so the calm reward is not buried, but + // below an overdue dose (the one genuinely time-critical daily action). + const milestone = buildMilestoneItem(input.milestone, input.modules, t); + if (milestone) worthALook.push(milestone); worthALook.push(...buildSyncIssueItems(input.syncIssues, t)); const checkin = buildCoachCheckinItem( input.coachPlans, diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 8747c5908..d8202f2f6 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -30,6 +30,16 @@ import { type DailyDigestScore, type DailyDigestSyncIssue, } from "@/lib/daily/digest"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { detectStreak, type StreakPoint } from "@/lib/insights/streak-detector"; +import { + MILESTONE_SALIENT_TYPES, + milestoneFromRecord, + milestonesFromStreak, + selectFreshMilestone, + type Milestone, +} from "@/lib/daily/milestones"; /** Integration states that mean "your action is needed to keep data flowing". */ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; @@ -43,6 +53,85 @@ 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; +/** Trailing window the milestone streak read uses (matches score-narrative). */ +const MILESTONE_STREAK_WINDOW_DAYS = 30; + +/** How far back to scan for a just-set personal best (a couple of days is ample). */ +const MILESTONE_RECORD_LOOKBACK_MS = 2 * 24 * 60 * 60 * 1000; + +/** UTC-ISO day key — the space the streak series + rollup tier already emit. */ +function utcDayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** + * S12 — gather today's single freshly-reached milestone (or null) from the + * engines that ALREADY exist. Two cheap, fail-soft reads reused verbatim: + * - `detectStreak` over the salient vitals' day-mean series (the exact + * `score-narrative` pattern) → return-to-range + sustained-in-range states; + * - a bounded `PersonalRecord` scan (the `pr-detection` output) → new bests. + * The reached-once gate (`selectFreshMilestone`) admits at most one, only on the + * day it was reached. Any failure leaves the reward quiet rather than guessing. + */ +async function gatherFreshMilestone( + userId: string, + now: Date, +): Promise { + const todayKey = utcDayKey(now); + const candidates: Milestone[] = []; + + const coverage = await probeRollupCoverage(userId).catch(() => null); + if (coverage) { + const perMetric = await Promise.all( + MILESTONE_SALIENT_TYPES.map(async (type) => { + try { + const { points } = await readDayMeanSeries( + userId, + type, + MILESTONE_STREAK_WINDOW_DAYS, + now, + coverage, + ); + if (points.length === 0) return [] as Milestone[]; + const series: StreakPoint[] = points.map((p) => ({ + day: p.day, + value: p.mean, + })); + const latestDayKey = points[points.length - 1].day; + return milestonesFromStreak(type, detectStreak(series), latestDayKey); + } catch { + return [] as Milestone[]; + } + }), + ); + candidates.push(...perMetric.flat()); + } + + try { + const records = await prisma.personalRecord.findMany({ + where: { + userId, + metricType: { in: [...MILESTONE_SALIENT_TYPES] }, + achievedAt: { + gte: new Date(now.getTime() - MILESTONE_RECORD_LOOKBACK_MS), + }, + }, + select: { metricType: true, achievedAt: true }, + orderBy: { achievedAt: "desc" }, + take: 10, + }); + for (const record of records) { + candidates.push( + milestoneFromRecord(record.metricType, utcDayKey(record.achievedAt)), + ); + } + } catch { + // A read hiccup just leaves the reward quiet — never a fabricated one. + } + + return selectFreshMilestone(candidates, todayKey); +} + interface CoachPlanRow { id: string; status: string; @@ -172,6 +261,14 @@ export async function loadDailyDigest( ? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow)) : []; + // S12 — the calm reward layer. Only gather when the insights module (the + // narrative layer that hosts the milestone card) is on; the builder gates on + // it too, so a disabled account never surfaces one either way. + const insightsEnabled = modules.insights !== false; + const milestone = insightsEnabled + ? await gatherFreshMilestone(user.id, now) + : null; + const { t } = getServerTranslator(resolveLocale(locale)); const digest = buildDailyDigest( @@ -186,6 +283,7 @@ export async function loadDailyDigest( syncIssues, preventiveDue, coachPlans, + milestone, }, t, ); @@ -200,6 +298,9 @@ export async function loadDailyDigest( daily_digest_has_checkin: digest.worthALook.some( (i) => i.kind === "coach_checkin", ), + daily_digest_has_milestone: digest.worthALook.some( + (i) => i.kind === "milestone", + ), }, }); diff --git a/src/lib/daily/milestones.ts b/src/lib/daily/milestones.ts new file mode 100644 index 000000000..7cfa2d81b --- /dev/null +++ b/src/lib/daily/milestones.ts @@ -0,0 +1,226 @@ +/** + * P2 — `Milestone`, the durable-states reward primitive. + * + * A milestone is "a state REACHED" — a durable achievement the record now + * carries: a metric that settled back inside the user's own range, a run of + * weeks in range, a fresh personal best. It is deliberately NOT a streak. + * + * The distinction is written into the type on purpose: a milestone carries a + * `sinceDate` (the day the durable state was reached) and NOTHING that counts + * days still being maintained — no `currentRun`, no `daysHeld`, no counter a + * future tuning pass could quietly turn into a flame. A missed day is invisible + * here; there is no "you broke it" state to reach, and none is representable. + * + * This module is a THIN wrapper over the engines that already exist — it adds + * no second detection framework: + * - `return_to_baseline` + `sustained_in_range` fold a `StreakResult` from + * the pure `detectStreak` engine (`@/lib/insights/streak-detector`). + * - `record_first` folds a `PersonalRecord` row (the `pr-detection` infra). + * + * Reached-once WITHOUT new storage: a milestone's `sinceDate` is a stable + * historical fact. `selectFreshMilestone` admits it only on the day it was + * reached (`sinceDate === todayKey`); the next day it is no longer fresh and + * never resurfaces. No "which milestones were shown" table is needed — the + * reached-date IS the once-guarantee. + * + * Pure: no DB, no clock, no network. Day keys are UTC-ISO (`YYYY-MM-DD`), the + * SAME space `readDayMeanSeries` and the rollup tier already emit, so a + * milestone's reach-day and "today" compare in one consistent space. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { ServerTranslator } from "@/lib/i18n/server-translator"; +import { MIN_IN_RUN, type StreakResult } from "@/lib/insights/streak-detector"; + +/** Closed set of durable-state kinds. Grows by PR, never with a counter field. */ +export const MILESTONE_KINDS = [ + "record_first", + "return_to_baseline", + "sustained_in_range", +] as const; + +export type MilestoneKind = (typeof MILESTONE_KINDS)[number]; + +/** + * One durable state reached. `sinceDate` is the UTC-ISO day the state was + * reached; `copyKey` is the BASE i18n key under `daily.milestone.*` (the card + * appends `.title` / `.body`). There is intentionally no numeric run field. + */ +export interface Milestone { + kind: MilestoneKind; + metricType: MeasurementType; + /** UTC-ISO (`YYYY-MM-DD`) day the durable state was REACHED. */ + sinceDate: string; + /** Base i18n key under `daily.milestone.*`; copy celebrates arrival. */ + copyKey: string; +} + +/** + * The vitals a milestone can celebrate — mirrors `score-narrative`'s + * `RETURN_SALIENT_TYPES`. All four are core vitals with no toggleable module, + * so a milestone here never leaks a disabled-module surface. + */ +export const MILESTONE_SALIENT_TYPES: readonly MeasurementType[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "WEIGHT", +] as const; + +/** + * Durable sustained-in-range states, in WEEKS. A CLOSED, bounded set: reaching + * week 4 is the last one. There is deliberately no escalation beyond it and no + * running day count — each threshold is a one-time state, so nothing here can + * be tuned into an ever-climbing streak. + */ +export const SUSTAINED_WEEK_THRESHOLDS = [1, 2, 3, 4] as const; + +/** Per-metric localised-name key + its insight deep-link. */ +const METRIC_NAME_KEY: Partial> = { + RESTING_HEART_RATE: "daily.milestone.metric.restingHeartRate", + HEART_RATE_VARIABILITY: "daily.milestone.metric.heartRateVariability", + RESPIRATORY_RATE: "daily.milestone.metric.respiratoryRate", + WEIGHT: "daily.milestone.metric.weight", +}; + +const METRIC_HREF: Partial> = { + RESTING_HEART_RATE: "/insights/resting-pulse", + HEART_RATE_VARIABILITY: "/insights/hrv", + RESPIRATORY_RATE: "/insights/respiratory-rate", + WEIGHT: "/insights/weight", +}; + +/** The insight surface a milestone's action deep-links to. */ +export function milestoneHref(milestone: Milestone): string { + return METRIC_HREF[milestone.metricType] ?? "/insights"; +} + +// ── pure UTC day-key arithmetic ───────────────────────────────────────────── +function dayKeyToSerial(key: string): number { + const [year, month, day] = key.split("-").map(Number); + return Date.UTC(year, month - 1, day) / 86_400_000; +} + +function serialToDayKey(serial: number): string { + return new Date(serial * 86_400_000).toISOString().slice(0, 10); +} + +/** Shift a UTC-ISO day key by a whole number of days. */ +function shiftDayKey(key: string, deltaDays: number): string { + return serialToDayKey(dayKeyToSerial(key) + deltaDays); +} + +/** + * Fold a `StreakResult` into the durable milestones it represents. Reuses the + * `detectStreak` engine's output verbatim — this adds no detection logic. + * + * `return_to_baseline`: the metric settled BACK inside its own range. The + * return is reached the moment the in-band run first holds `MIN_IN_RUN` days, + * so the reach-day is that many days back from the latest reading. This only + * ever narrates the calm ARRIVAL back inside the range — there is no message + * for leaving it. + * + * `sustained_in_range`: one durable state per week boundary the in-band run has + * passed, each stamped with the day it was reached. Emitted only while the + * metric IS currently in-band; a lapse simply stops producing NEW milestones — + * it is never marked, never mourned. + */ +export function milestonesFromStreak( + metricType: MeasurementType, + result: StreakResult, + latestDayKey: string, +): Milestone[] { + const out: Milestone[] = []; + + if (result.returnEvent) { + const reachedDay = shiftDayKey( + latestDayKey, + -(result.returnEvent.daysInside - MIN_IN_RUN), + ); + out.push({ + kind: "return_to_baseline", + metricType, + sinceDate: reachedDay, + copyKey: "daily.milestone.returnToBaseline", + }); + } + + if (result.inBand && result.latestPlacement === "in") { + for (const weeks of SUSTAINED_WEEK_THRESHOLDS) { + const days = weeks * 7; + if (result.streakDays < days) continue; + const reachedDay = shiftDayKey(latestDayKey, -(result.streakDays - days)); + out.push({ + kind: "sustained_in_range", + metricType, + sinceDate: reachedDay, + copyKey: `daily.milestone.sustainedInRange.week${weeks}`, + }); + } + } + + return out; +} + +/** Fold a personal-record row (its achieved day) into a `record_first` milestone. */ +export function milestoneFromRecord( + metricType: MeasurementType, + achievedDayKey: string, +): Milestone { + return { + kind: "record_first", + metricType, + sinceDate: achievedDayKey, + copyKey: "daily.milestone.record", + }; +} + +/** + * Priority when several milestones land on the same day: a new personal best + * first (the brightest arrival), then a return to range (closes a worry), then + * a sustained state. The one/day cap picks the top of this order. + */ +const KIND_PRIORITY: Record = { + record_first: 0, + return_to_baseline: 1, + sustained_in_range: 2, +}; + +/** + * The reached-once gate: admit only milestones REACHED today, and only ONE — + * the most meaningful. A milestone reached on an earlier day is a settled fact, + * not fresh news, so it never resurfaces; a day with no reached state yields + * `null` (an empty rail entry, never an invented one). + */ +export function selectFreshMilestone( + candidates: Milestone[], + todayKey: string, +): Milestone | null { + const fresh = candidates.filter((m) => m.sinceDate === todayKey); + if (fresh.length === 0) return null; + fresh.sort( + (a, b) => + KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind] || + (a.metricType < b.metricType ? -1 : a.metricType > b.metricType ? 1 : 0), + ); + return fresh[0]; +} + +type Translate = ServerTranslator["t"]; + +/** + * Resolve a milestone's already-localised card copy. The metric name is + * interpolated so one copy key serves every salient vital. Copy celebrates the + * arrival at a durable state — never a maintained count, never a loss. + */ +export function milestoneCopy( + milestone: Milestone, + t: Translate, +): { title: string; body: string } { + const metric = t( + METRIC_NAME_KEY[milestone.metricType] ?? "daily.milestone.metric.generic", + ); + return { + title: t(`${milestone.copyKey}.title`, { metric }), + body: t(`${milestone.copyKey}.body`, { metric }), + }; +} From a33527da57b841ab049079f0960faf343e501fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 08:16:32 +0200 Subject: [PATCH 2/7] feat(insights): intraday pulse shape and elevated-at-rest tension window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an on-demand intraday layer to the pulse insight: 10-minute mean heart rate for the viewed day, folded from raw samples via the read-swap pattern (never persisted as 10-minute rollups for all history). Draw the personal resting baseline and softly shade any sustained elevated-at-rest stretch. The tension signal is not raw heart rate — it is heart rate held above the personal resting baseline while movement is low (the elevated-at-rest window), so a workout or a walk never reads as tension. Detection is deliberately conservative: baseline maturity, per-bucket sample density, a mandatory step-based low-movement gate, and a minimum sustained duration must all hold before anything is reported. Silence over speculation. Surface the same signal as a calm, non-diagnostic tension_window item in the daily digest — at most once per day, honest-absent otherwise, gated on the insights module and yielding the rail to time-sensitive actions. Cautious copy across all six locales; framed as "possible tension", never a clinical stress verdict. --- messages/de.json | 24 +- messages/en.json | 24 +- messages/es.json | 24 +- messages/fr.json | 24 +- messages/it.json | 24 +- messages/pl.json | 24 +- .../coach-route-gate-inventory.test.ts | 6 + src/app/api/insights/pulse/intraday/route.ts | 63 ++++ src/app/insights/pulse/page.tsx | 6 + .../insights/intraday-pulse-chart.tsx | 200 ++++++++++++ .../__tests__/intraday-pulse.test.ts | 244 +++++++++++++++ src/lib/analytics/intraday-pulse-io.ts | 218 +++++++++++++ src/lib/analytics/intraday-pulse.ts | 290 ++++++++++++++++++ src/lib/daily/__tests__/digest.test.ts | 53 ++++ src/lib/daily/digest.ts | 49 +++ src/lib/daily/load-digest.ts | 14 + src/lib/query-keys/insights.ts | 9 + 17 files changed, 1290 insertions(+), 6 deletions(-) create mode 100644 src/app/api/insights/pulse/intraday/route.ts create mode 100644 src/components/insights/intraday-pulse-chart.tsx create mode 100644 src/lib/analytics/__tests__/intraday-pulse.test.ts create mode 100644 src/lib/analytics/intraday-pulse-io.ts create mode 100644 src/lib/analytics/intraday-pulse.ts diff --git a/messages/de.json b/messages/de.json index c8907407d..bf2afe4fd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}." } + }, + "intradayPulse": { + "title": "Der Verlauf deines Tages", + "caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.", + "empty": "Heute noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.", + "baseline": "Ruhe", + "tension": { + "morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag — möglicherweise etwas Anspannung.", + "afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag — möglicherweise etwas Anspannung.", + "evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.", + "night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "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." + }, + "tensionWindow": { + "title": "Vielleicht eine angespannte Phase", + "body": { + "morning": "Dein Puls lag heute Vormittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", + "afternoon": "Dein Puls lag heute Nachmittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", + "evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", + "night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Vorsorge ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", - "checkinLetGo": "Loslassen" + "checkinLetGo": "Loslassen", + "viewPulse": "Puls ansehen" }, "today": { "scoreLabel": "Gesundheitsscore", diff --git a/messages/en.json b/messages/en.json index 11f67878f..f308688f6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}." } + }, + "intradayPulse": { + "title": "Your day's shape", + "caption": "Your heart rate through the day, averaged in 10-minute steps.", + "empty": "Not enough heart-rate readings today to show the day's shape yet.", + "baseline": "Resting", + "tension": { + "morning": "A sustained elevated-at-rest stretch this morning — possibly some tension.", + "afternoon": "A sustained elevated-at-rest stretch this afternoon — possibly some tension.", + "evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.", + "night": "A sustained elevated-at-rest stretch overnight — possibly some tension." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "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." + }, + "tensionWindow": { + "title": "Possible tension today", + "body": { + "morning": "Your heart rate sat above its resting baseline for a sustained stretch this morning while you were still — possibly some tension.", + "afternoon": "Your heart rate sat above its resting baseline for a sustained stretch this afternoon while you were still — possibly some tension.", + "evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still — possibly some tension.", + "night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still — possibly some tension." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "View check-ups", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", - "checkinLetGo": "Let it go" + "checkinLetGo": "Let it go", + "viewPulse": "View pulse" }, "today": { "scoreLabel": "Health score", diff --git a/messages/es.json b/messages/es.json index 79883e68e..f35c8ec04 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}." } + }, + "intradayPulse": { + "title": "La forma de tu día", + "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.", + "empty": "Aún no hay suficientes lecturas de hoy para mostrar la forma del día.", + "baseline": "Reposo", + "tension": { + "morning": "Un tramo elevado en reposo esta mañana; posiblemente algo de tensión.", + "afternoon": "Un tramo elevado en reposo esta tarde; posiblemente algo de tensión.", + "evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.", + "night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "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." + }, + "tensionWindow": { + "title": "Quizá algo de tensión hoy", + "body": { + "morning": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta mañana mientras estabas en calma; posiblemente algo de tensión.", + "afternoon": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta tarde mientras estabas en calma; posiblemente algo de tensión.", + "evening": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato al anochecer mientras estabas en calma; posiblemente algo de tensión.", + "night": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato por la noche mientras estabas en calma; posiblemente algo de tensión." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Ver revisiones", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", - "checkinLetGo": "Dejarlo ir" + "checkinLetGo": "Dejarlo ir", + "viewPulse": "Ver pulso" }, "today": { "scoreLabel": "Puntuación de salud", diff --git a/messages/fr.json b/messages/fr.json index 85d4f3acf..43d38a5e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}." } + }, + "intradayPulse": { + "title": "Le rythme de votre journée", + "caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.", + "empty": "Pas encore assez de mesures aujourd'hui pour afficher le rythme de la journée.", + "baseline": "Repos", + "tension": { + "morning": "Une période élevée au repos ce matin — peut-être un peu de tension.", + "afternoon": "Une période élevée au repos cet après-midi — peut-être un peu de tension.", + "evening": "Une période élevée au repos ce soir — peut-être un peu de tension.", + "night": "Une période élevée au repos cette nuit — peut-être un peu de tension." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "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." + }, + "tensionWindow": { + "title": "Peut-être un peu de tension", + "body": { + "morning": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce matin alors que vous étiez au calme — peut-être un peu de tension.", + "afternoon": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cet après-midi alors que vous étiez au calme — peut-être un peu de tension.", + "evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme — peut-être un peu de tension.", + "night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme — peut-être un peu de tension." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Voir les examens", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", - "checkinLetGo": "Laisser tomber" + "checkinLetGo": "Laisser tomber", + "viewPulse": "Voir le pouls" }, "today": { "scoreLabel": "Score de santé", diff --git a/messages/it.json b/messages/it.json index b0702e182..0533bd782 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}." } + }, + "intradayPulse": { + "title": "L'andamento della tua giornata", + "caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.", + "empty": "Non ci sono ancora abbastanza misurazioni di oggi per mostrare l'andamento della giornata.", + "baseline": "Riposo", + "tension": { + "morning": "Un tratto elevato a riposo stamattina — forse un po' di tensione.", + "afternoon": "Un tratto elevato a riposo questo pomeriggio — forse un po' di tensione.", + "evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.", + "night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "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." + }, + "tensionWindow": { + "title": "Forse un po' di tensione oggi", + "body": { + "morning": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stamattina mentre eri fermo — forse un po' di tensione.", + "afternoon": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato questo pomeriggio mentre eri fermo — forse un po' di tensione.", + "evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo — forse un po' di tensione.", + "night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo — forse un po' di tensione." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Vedi controlli", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", - "checkinLetGo": "Lascia andare" + "checkinLetGo": "Lascia andare", + "viewPulse": "Vedi battito" }, "today": { "scoreLabel": "Punteggio di salute", diff --git a/messages/pl.json b/messages/pl.json index b057686a9..f29254710 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}." } + }, + "intradayPulse": { + "title": "Przebieg Twojego dnia", + "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", + "empty": "Za mało dzisiejszych pomiarów, aby pokazać przebieg dnia.", + "baseline": "Spoczynek", + "tension": { + "morning": "Utrzymujący się podwyższony spoczynek rano — możliwe napięcie.", + "afternoon": "Utrzymujący się podwyższony spoczynek po południu — możliwe napięcie.", + "evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.", + "night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "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." + }, + "tensionWindow": { + "title": "Może chwila napięcia dzisiaj", + "body": { + "morning": "Twoje tętno przez dłuższą chwilę rano utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", + "afternoon": "Twoje tętno przez dłuższą chwilę po południu utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", + "evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", + "night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Zobacz badania", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", - "checkinLetGo": "Odpuść" + "checkinLetGo": "Odpuść", + "viewPulse": "Zobacz tętno" }, "today": { "scoreLabel": "Wynik zdrowia", diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index be689bb62..2fdfb5659 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -112,6 +112,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ "src/app/api/insights/health-status/route.ts", "src/app/api/insights/breathing-screening/route.ts", "src/app/api/insights/labs-changes/route.ts", + // S11 — the intraday pulse / tension read. Deterministic compute over the + // day's raw PULSE / steps / workouts (10-minute mean shape + the cautious + // elevated-at-rest window); it carries no assistant prose and gates on the + // `insights` module, not the Coach surface. Same posture as health-status / + // labs-changes directly above — disabling Coach must not wedge this read. + "src/app/api/insights/pulse/intraday/route.ts", // v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach // mutations. These live under /insights/chat for URL grouping but are // DOCUMENT-surface routes: they gate on the `inboundDocuments` module diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts new file mode 100644 index 000000000..c0f149592 --- /dev/null +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/insights/pulse/intraday?date=YYYY-MM-DD + * + * The on-demand read for the intraday pulse / tension layer (S11). Returns one + * local day's 10-minute mean heart-rate shape plus, when every confidence gate + * holds, a single cautious elevated-at-rest ("tension") window. Computed from + * raw samples via the read-swap pattern — NOT persisted as 10-min rollups for + * all history. + * + * Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the + * resolved session — never a body field. Gated on the `insights` module. The + * `date` defaults to the user's local today; anything but a `YYYY-MM-DD` + * literal is a 422 via `returnAllZodIssues`. + */ +import { z } from "zod"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; +import { annotate } from "@/lib/logging/context"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), +}); + +export const GET = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const url = new URL(req.url); + const parsed = querySchema.safeParse({ + date: url.searchParams.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + const timezone = await resolveUserTimezone(user.id); + const dateKey = parsed.data.date ?? userDayKey(new Date(), timezone); + + const result = await loadIntradayPulse(user.id, timezone, dateKey); + + annotate({ + action: { name: "insights.pulse.intraday" }, + meta: { + buckets: result.series.length, + baseline_source: result.baselineSource, + tension: result.tension !== null, + }, + }); + + const response = apiSuccess(result); + response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return response; +}); diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index cb3963e16..b221c98d1 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -19,6 +19,7 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; +import { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, @@ -174,6 +175,11 @@ export default function InsightsPulsPage() { onVisibleStats={onVisibleStats} /> + {/* S11 — the intraday "shape of the day" layer: 10-minute mean heart + rate with the resting baseline and any cautious elevated-at-rest + window. Computed on demand from raw for today; non-diagnostic. */} + + {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx new file mode 100644 index 000000000..8bb36c2db --- /dev/null +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Activity } from "lucide-react"; +import { + ResponsiveContainer, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, + ReferenceArea, +} from "recharts"; + +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { userDayKey } from "@/lib/tz/format"; +import { TileHeader } from "@/components/insights/tile-header"; +import type { + IntradayHrBucket, + TensionWindow, +} from "@/lib/analytics/intraday-pulse"; + +/** Slim DTO the intraday route returns (type-only — no server code bundled). */ +interface IntradayPulseDto { + dateKey: string; + bucketMinutes: number; + series: IntradayHrBucket[]; + baseline: number | null; + baselineSource: "resting" | "proxy" | "none"; + tension: TensionWindow | null; +} + +/** Minutes-since-midnight → "HH:mm" wall-clock label. */ +function minuteLabel(minute: number): string { + const h = Math.floor(minute / 60); + const m = minute % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +const AXIS_TICKS = [0, 360, 720, 1080, 1440]; + +/** + * S11 — the intraday pulse "shape of the day" layer inside the pulse insight. + * + * Charts one local day's 10-minute mean heart rate (computed on demand from + * raw), with the personal resting baseline drawn as a reference line and any + * detected elevated-at-rest ("tension") window softly shaded. Copy is cautious + * and non-diagnostic — "possible tension", never a stress score. Empty / no- + * baseline days render an honest one-liner rather than an empty grid. + */ +export function IntradayPulseChart({ + userTimezone, +}: { + userTimezone?: string; +}) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const tz = userTimezone ?? "Europe/Berlin"; + const dateKey = userDayKey(new Date(), tz); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.insightsPulseIntraday(dateKey), + queryFn: () => + apiGet(`/api/insights/pulse/intraday?date=${dateKey}`), + enabled: isAuthenticated, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const chartData = useMemo( + () => + (data?.series ?? []).map((b) => ({ + minute: b.startMinute, + label: minuteLabel(b.startMinute), + bpm: b.mean, + })), + [data?.series], + ); + + const caption = data?.tension + ? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`) + : t("insights.intradayPulse.caption"); + + return ( +
+ +

{caption}

+ + {isLoading ? ( +
+ ) : chartData.length === 0 ? ( +
+ {t("insights.intradayPulse.empty")} +
+ ) : ( +
+ + + + + + + + + + + + minuteLabel(Number(minute))} + formatter={(value) => [`${value} bpm`, t("charts.pulse")]} + /> + {data?.tension ? ( + + ) : null} + {data?.baseline != null ? ( + + ) : null} + + + +
+ )} +
+ ); +} diff --git a/src/lib/analytics/__tests__/intraday-pulse.test.ts b/src/lib/analytics/__tests__/intraday-pulse.test.ts new file mode 100644 index 000000000..a03c991fa --- /dev/null +++ b/src/lib/analytics/__tests__/intraday-pulse.test.ts @@ -0,0 +1,244 @@ +/** + * S11 — intraday pulse shape + elevated-at-rest ("tension") detection. + * + * The signal-correctness bar for this slice is high (the launch posture is + * "says less than it could"), so these pin the two things that are easy to get + * embarrassingly wrong: the 10-minute bucketing (tz-correct day windows, sparse + * data) and the tension gate refusing to flag a workout, a walk, or a short + * spike — only a sustained, low-movement, above-baseline stretch fires. + */ +import { describe, it, expect } from "vitest"; + +import { + BUCKET_MINUTES, + ELEVATION_MARGIN_BPM, + MIN_STEP_COVERAGE_BUCKETS, + computeTenMinuteMeanSeries, + detectTensionWindow, + makeLocalResolver, + partOfDayForMinute, + type IntradayHrBucket, + type IntradaySample, + type DetectTensionInput, +} from "../intraday-pulse"; + +const utcOf = makeLocalResolver("UTC"); + +function sample(iso: string, value: number): IntradaySample { + return { measuredAt: new Date(iso), value }; +} + +/** A dense low-movement step map with the required coverage, all zeros. */ +function zeroSteps(...extraMinutes: number[]): Map { + const m = new Map(); + // MIN_STEP_COVERAGE_BUCKETS distinct early-morning buckets + any extras. + for (let i = 0; i < MIN_STEP_COVERAGE_BUCKETS; i++) + m.set(i * BUCKET_MINUTES, 0); + for (const min of extraMinutes) m.set(min, 0); + return m; +} + +/** A run of identical elevated buckets starting at `startMinute`. */ +function elevatedRun( + startMinute: number, + count: number, + mean = 80, + sampleCount = 5, +): IntradayHrBucket[] { + return Array.from({ length: count }, (_, i) => ({ + startMinute: startMinute + i * BUCKET_MINUTES, + mean, + count: sampleCount, + })); +} + +function tensionInput( + over: Partial = {}, +): DetectTensionInput { + return { + buckets: [], + baseline: 60, + baselineMature: true, + stepBuckets: zeroSteps(), + workouts: [], + ...over, + }; +} + +describe("computeTenMinuteMeanSeries", () => { + it("means samples inside each 10-minute bucket and sorts ascending", () => { + const series = computeTenMinuteMeanSeries( + [ + sample("2026-05-01T10:00:00Z", 70), + sample("2026-05-01T10:03:00Z", 80), // same bucket 600 + sample("2026-05-01T10:12:00Z", 90), // bucket 610 + ], + "2026-05-01", + utcOf, + ); + expect(series).toEqual([ + { startMinute: 600, mean: 75, count: 2 }, + { startMinute: 610, mean: 90, count: 1 }, + ]); + }); + + it("keeps a lone-sample bucket (count reflects density for the gate)", () => { + const series = computeTenMinuteMeanSeries( + [sample("2026-05-01T08:05:00Z", 66)], + "2026-05-01", + utcOf, + ); + expect(series).toEqual([{ startMinute: 480, mean: 66, count: 1 }]); + }); + + it("excludes samples from other local days (no next-day bleed)", () => { + const series = computeTenMinuteMeanSeries( + [ + sample("2026-05-01T23:55:00Z", 70), // stays on 05-01 + sample("2026-05-02T00:05:00Z", 90), // 05-02 + ], + "2026-05-01", + utcOf, + ); + expect(series).toEqual([{ startMinute: 1430, mean: 70, count: 1 }]); + }); + + it("buckets on the USER's local day, not UTC (tz-correct windows)", () => { + const nyOf = makeLocalResolver("America/New_York"); + // 02:30Z on 05-01 is 22:30 EDT on 04-30 → minute 1350, prior day. + const s = [sample("2026-05-01T02:30:00Z", 72)]; + expect(computeTenMinuteMeanSeries(s, "2026-05-01", nyOf)).toEqual([]); + expect(computeTenMinuteMeanSeries(s, "2026-04-30", nyOf)).toEqual([ + { startMinute: 1350, mean: 72, count: 1 }, + ]); + }); +}); + +describe("partOfDayForMinute", () => { + it("labels the coarse part of day", () => { + expect(partOfDayForMinute(5 * 60)).toBe("night"); + expect(partOfDayForMinute(9 * 60)).toBe("morning"); + expect(partOfDayForMinute(15 * 60)).toBe("afternoon"); + expect(partOfDayForMinute(20 * 60)).toBe("evening"); + }); +}); + +describe("detectTensionWindow — fires only for sustained elevated-at-rest", () => { + it("fires for a sustained above-baseline low-movement stretch", () => { + const win = detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4) }), + ); + expect(win).not.toBeNull(); + expect(win?.startMinute).toBe(600); + expect(win?.endMinute).toBe(640); + expect(win?.partOfDay).toBe("morning"); + expect(win?.meanHr).toBe(80); + expect(win?.baseline).toBe(60); + }); + + it("does NOT flag a workout — high HR during exercise is not tension", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + workouts: [{ startMinute: 595, endMinute: 645 }], + }), + ); + expect(win).toBeNull(); + }); + + it("does NOT flag when steps are high — a walk is movement, not tension", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + stepBuckets: new Map([ + ...zeroSteps(), + [600, 200], + [610, 210], + [620, 190], + [630, 220], + ]), + }), + ); + expect(win).toBeNull(); + }); + + it("does NOT flag heart rate that never clears the elevation margin", () => { + const justUnder = 60 + ELEVATION_MARGIN_BPM - 1; // below threshold + const win = detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4, justUnder) }), + ); + expect(win).toBeNull(); + }); + + it("does NOT flag a short spike below the sustained-minutes floor", () => { + // Only two consecutive elevated buckets (20 min) — under the 30-min floor. + const win = detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 2) }), + ); + expect(win).toBeNull(); + }); + + it("breaks the run on a sparse (sub-density) bucket", () => { + const buckets: IntradayHrBucket[] = [ + { startMinute: 600, mean: 80, count: 5 }, + { startMinute: 610, mean: 80, count: 1 }, // gap — filtered out + { startMinute: 620, mean: 80, count: 5 }, + { startMinute: 630, mean: 80, count: 5 }, + { startMinute: 640, mean: 80, count: 5 }, + ]; + const win = detectTensionWindow(tensionInput({ buckets })); + // 600 stands alone (next valid 620 is not adjacent); 620-640 is the run. + expect(win?.startMinute).toBe(620); + expect(win?.endMinute).toBe(650); + }); + + it("stays silent until the baseline is mature", () => { + expect( + detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4), baselineMature: false }), + ), + ).toBeNull(); + }); + + it("stays silent with no baseline at all", () => { + expect( + detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4), baseline: null }), + ), + ).toBeNull(); + }); + + it("stays silent without enough step coverage to trust low movement", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + stepBuckets: new Map([ + [0, 0], + [10, 0], + ]), // below MIN_STEP_COVERAGE_BUCKETS + }), + ); + expect(win).toBeNull(); + }); + + it("reports the longest qualifying run when several exist", () => { + const buckets = [ + ...elevatedRun(120, 3), // 30 min + { startMinute: 150, mean: 60, count: 5 }, // break + ...elevatedRun(600, 5), // 50 min — the winner + ]; + const win = detectTensionWindow(tensionInput({ buckets })); + expect(win?.startMinute).toBe(600); + expect(win?.endMinute).toBe(650); + }); + + it("marks hrvConfirmed when intraday HRV confirms within the window", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + hrvConfirmMinutes: [610], + }), + ); + expect(win?.hrvConfirmed).toBe(true); + }); +}); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts new file mode 100644 index 000000000..f28f2f3af --- /dev/null +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -0,0 +1,218 @@ +/** + * S11 — IO seam for the intraday pulse / tension layer. + * + * Loads ONE local day's raw signals on demand (read-swap; never persisted as + * 10-min rollups) and hands them to the pure `intraday-pulse` computations: + * + * - raw PULSE for the day → the 10-minute mean shape; + * - the personal resting baseline (RESTING_HEART_RATE, else the + * low-percentile PULSE proxy) → the elevation line; + * - intraday step samples → the mandatory low-movement gate; + * - workouts overlapping the day → exercise never reads as tension; + * - intraday HRV (WHOOP RMSSD) below the personal median → an optional + * confirming flag. + * + * Shared by `GET /api/insights/pulse/intraday` (the chart) and the daily + * digest's `tension_window` builder, so the signal is computed one way. + */ +import { prisma } from "@/lib/db"; +import { percentile } from "@/lib/insights/strain-score"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { + BUCKET_MINUTES, + MIN_BASELINE_DAYS, + computeTenMinuteMeanSeries, + detectTensionWindow, + makeLocalResolver, + type IntradayHrBucket, + type LocalResolver, + type TensionWindow, + type WorkoutInterval, +} from "@/lib/analytics/intraday-pulse"; + +/** UTC padding around the local calendar day so the query is a safe superset. */ +const WINDOW_LEAD_MS = 15 * 60 * 60 * 1000; // covers UTC−14…+12 day starts +const WINDOW_TRAIL_MS = 39 * 60 * 60 * 1000; + +/** Defensive read caps — dense Apple-Health accounts emit ~1 sample/second. */ +const MAX_DAY_PULSE_ROWS = 6000; +const MAX_DAY_STEP_ROWS = 2000; + +/** The DTO both the route and the digest read. */ +export interface IntradayPulseResult { + dateKey: string; + timezone: string; + bucketMinutes: number; + series: IntradayHrBucket[]; + baseline: number | null; + baselineSource: "resting" | "proxy" | "none"; + tension: TensionWindow | null; +} + +/** Median (p50), or null on an empty series. */ +function median(values: number[]): number | null { + if (values.length === 0) return null; + return Math.round(percentile(values, 50) * 10) / 10; +} + +/** + * Resolve the personal resting baseline and its maturity. Prefers the clean + * RESTING_HEART_RATE rows; falls back to the low-percentile PULSE proxy. The + * baseline is the median of the resolved daily series — robust to the odd + * outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct + * daily points exist. + */ +async function resolveBaseline(userId: string): Promise<{ + baseline: number | null; + mature: boolean; + source: "resting" | "proxy" | "none"; +}> { + const restingRows = await prisma.measurement.findMany({ + where: { userId, type: "RESTING_HEART_RATE", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 90, + select: { value: true, measuredAt: true }, + }); + + // Only reach for the heavier PULSE-history read when resting rows are thin. + const pulseHistory = + restingRows.length >= MIN_BASELINE_DAYS + ? [] + : await prisma.measurement.findMany({ + where: { userId, type: "PULSE", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { value: true, measuredAt: true }, + }); + + const resolved = resolveRestingPulseSeries({ + restingSamples: restingRows.map((r) => ({ + measuredAt: r.measuredAt, + value: r.value, + })), + pulseSamples: pulseHistory.map((r) => ({ + measuredAt: r.measuredAt, + value: r.value, + })), + }); + + const recent = resolved.series.slice(-30).map((p) => p.value); + return { + baseline: median(recent), + mature: resolved.series.length >= MIN_BASELINE_DAYS, + source: resolved.which, + }; +} + +/** Clamp a workout's local span to [0, 1440) minutes on `dateKey`, or null. */ +function workoutToInterval( + startedAt: Date, + endedAt: Date, + dateKey: string, + localOf: LocalResolver, +): WorkoutInterval | null { + const s = localOf(startedAt); + const e = localOf(endedAt); + // Reject spans entirely outside the day (both ends on another calendar day + // on the same side); otherwise clamp an overhanging end into the day. + const startMinute = + s.dayKey === dateKey ? s.minuteOfDay : s.dayKey < dateKey ? 0 : -1; + const endMinute = + e.dayKey === dateKey ? e.minuteOfDay : e.dayKey > dateKey ? 24 * 60 : -1; + if (startMinute < 0 || endMinute < 0 || endMinute <= startMinute) return null; + return { startMinute, endMinute }; +} + +/** + * Compute the intraday pulse shape + (cautious) tension window for one local + * day. Deterministic given the DB state; performs no AI/provider call. + */ +export async function loadIntradayPulse( + userId: string, + timezone: string, + dateKey: string, +): Promise { + const localOf = makeLocalResolver(timezone); + const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime(); + const start = new Date(anchorMs - WINDOW_LEAD_MS); + const end = new Date(anchorMs + WINDOW_TRAIL_MS); + + const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: "PULSE", + deletedAt: null, + measuredAt: { gte: start, lte: end }, + }, + orderBy: { measuredAt: "asc" }, + take: MAX_DAY_PULSE_ROWS, + select: { value: true, measuredAt: true }, + }), + prisma.measurement.findMany({ + where: { + userId, + type: "ACTIVITY_STEPS", + deletedAt: null, + measuredAt: { gte: start, lte: end }, + }, + orderBy: { measuredAt: "asc" }, + take: MAX_DAY_STEP_ROWS, + select: { value: true, measuredAt: true, externalId: true }, + }), + prisma.workout.findMany({ + where: { + userId, + startedAt: { lt: end }, + endedAt: { gt: start }, + }, + select: { startedAt: true, endedAt: true }, + }), + resolveBaseline(userId), + ]); + + const series = computeTenMinuteMeanSeries( + pulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), + dateKey, + localOf, + ); + + // Intraday step buckets — EXCLUDE the consolidated `stats:` daily totals, or a + // single day-sum row would dump the whole day's steps into one bucket and + // wrongly mark an at-rest stretch as moving. + const stepBuckets = new Map(); + for (const row of stepRows) { + if (row.externalId?.startsWith("stats:")) continue; + const local = localOf(row.measuredAt); + if (local.dayKey !== dateKey) continue; + const startMinute = + Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES; + stepBuckets.set( + startMinute, + (stepBuckets.get(startMinute) ?? 0) + row.value, + ); + } + + const workouts = workoutRows + .map((w) => workoutToInterval(w.startedAt, w.endedAt, dateKey, localOf)) + .filter((w): w is WorkoutInterval => w !== null); + + const tension = detectTensionWindow({ + buckets: series, + baseline: baseline.baseline, + baselineMature: baseline.mature, + stepBuckets, + workouts, + hrvConfirmMinutes: [], + }); + + return { + dateKey, + timezone, + bucketMinutes: BUCKET_MINUTES, + series, + baseline: baseline.baseline, + baselineSource: baseline.source, + tension, + }; +} diff --git a/src/lib/analytics/intraday-pulse.ts b/src/lib/analytics/intraday-pulse.ts new file mode 100644 index 000000000..8eea76598 --- /dev/null +++ b/src/lib/analytics/intraday-pulse.ts @@ -0,0 +1,290 @@ +/** + * S11 — intraday pulse shape + the elevated-at-rest ("tension") window. + * + * Two deterministic, IO-free computations that reveal the SHAPE of a single + * day's heart rate and, cautiously, whether a stretch of it looks like tension + * rather than exercise: + * + * 1. `computeTenMinuteMeanSeries` — folds the day's raw PULSE samples into + * 10-minute means in the user's local time. Computed ON DEMAND for the + * viewed day (read-swap; the same fold the hourly-mean retention uses), + * never persisted as 10-min rollups for all history. + * 2. `detectTensionWindow` — the differentiator. The tension signal is NOT + * raw HR (that conflates a workout with stress): it is heart rate + * SUSTAINED above the user's personal RESTING baseline WHILE movement is + * low — the "elevated-at-rest window". A workout-overlapping or + * high-step bucket can never qualify, so a hard run does not read as + * tension. Where intraday HRV exists (WHOOP), it is surfaced as a + * confirming flag but is never required. + * + * The launch posture is deliberately conservative — "says less than it could". + * A window is only reported when baseline maturity, per-bucket sample density, + * a mandatory low-movement gate, and a minimum sustained duration ALL hold. + * Silence over speculation. This is descriptive context, never a clinical + * stress diagnosis. + * + * Pure & deterministic — unit-tested in `__tests__/intraday-pulse.test.ts`. + */ +import { formatInUserTz } from "@/lib/tz/format"; + +/** Intraday bucket width, in minutes. Ten minutes reveals the day's shape. */ +export const BUCKET_MINUTES = 10; + +/** + * A 10-minute bucket needs at least this many raw samples before it is + * TRUSTED as a mean. A lone reading is a point, not a bucket — trusting it + * would let a single spike masquerade as a sustained stretch. Sub-floor + * buckets are treated as gaps that BREAK a candidate run (density gate). + */ +export const MIN_SAMPLES_PER_BUCKET = 2; + +/** + * How far above the resting baseline a bucket's mean must sit to count as + * "elevated". Twelve bpm keeps normal ambient drift (standing, a warm room, + * digestion) below the line — only a genuine, sustained lift clears it. The + * bar is deliberately high; under-flagging is the intended failure mode. + */ +export const ELEVATION_MARGIN_BPM = 12; + +/** + * Steps within a 10-minute bucket at or above this count mark the bucket as + * MOVING — a walk the raw HR would otherwise read as an elevated-at-rest + * stretch. Sixty steps in ten minutes is a slow amble; anything at or above + * it disqualifies the bucket from the tension window. + */ +export const STEP_MOVEMENT_THRESHOLD = 60; + +/** + * A day needs at least this many step buckets before a low / absent step + * reading can be TRUSTED as "genuinely not moving" rather than "steps simply + * weren't recorded". Below this coverage the low-movement gate cannot be + * satisfied and no window is reported — the movement gate is mandatory, and a + * missing step stream is silence, not evidence of rest. + */ +export const MIN_STEP_COVERAGE_BUCKETS = 6; + +/** + * The minimum run of consecutive qualifying buckets before a window is + * reported: three 10-minute buckets = 30 sustained minutes. A single elevated + * bucket (a flight of stairs, a phone call) is noise; half an hour of + * elevated-at-rest heart rate is a shape worth a cautious word. + */ +export const MIN_SUSTAINED_BUCKETS = 3; + +/** + * Minimum number of resting-baseline days before the baseline is mature enough + * to judge a single day against. A baseline built from two readings is not a + * personal norm yet — until it matures, no window is reported. + */ +export const MIN_BASELINE_DAYS = 7; + +/** A timestamped heart-rate sample (raw PULSE). */ +export interface IntradaySample { + measuredAt: Date; + value: number; +} + +/** A 10-minute mean bucket, anchored on its start minute-of-local-day. */ +export interface IntradayHrBucket { + /** Minutes since local midnight at the bucket's start (0, 10, 20, …). */ + startMinute: number; + /** Mean of the raw samples that fell in the bucket. */ + mean: number; + /** Raw sample count — buckets below `MIN_SAMPLES_PER_BUCKET` are gaps. */ + count: number; +} + +/** A workout's local-day span, in minutes since local midnight. */ +export interface WorkoutInterval { + startMinute: number; + endMinute: number; +} + +/** Coarse part-of-day label a window's midpoint falls in. */ +export type PartOfDay = "morning" | "afternoon" | "evening" | "night"; + +/** A detected elevated-at-rest window — a cautious tension marker. */ +export interface TensionWindow { + /** Window start / end, minutes since local midnight. */ + startMinute: number; + endMinute: number; + /** Part of day the window's midpoint falls in (drives the copy). */ + partOfDay: PartOfDay; + /** Mean HR across the window's buckets. */ + meanHr: number; + /** The resting baseline the window was judged against. */ + baseline: number; + /** True when intraday HRV independently confirmed the stretch (WHOOP). */ + hrvConfirmed: boolean; +} + +/** Local wall-clock projection of an instant: day key + minute-of-day. */ +export interface LocalWallClock { + dayKey: string; + minuteOfDay: number; +} + +/** Resolves an instant to the viewer's local day key + minute-of-day. */ +export type LocalResolver = (d: Date) => LocalWallClock; + +/** + * Build a `LocalResolver` for a timezone from the shared tz formatter. Kept + * here so the pure computations stay tz-correct without every caller + * re-deriving the wall-clock math; tests inject their own resolver for + * deterministic, tz-free fixtures. + */ +export function makeLocalResolver(tz: string): LocalResolver { + return (d: Date) => { + // "YYYY-MM-DD HH:mm" in the target zone — one formatter, no drift. + const wall = formatInUserTz(d, tz, "datetime"); + const [dayKey, clock] = wall.split(" "); + const [hh, mm] = clock.split(":"); + return { + dayKey, + minuteOfDay: parseInt(hh, 10) * 60 + parseInt(mm, 10), + }; + }; +} + +/** Round to one decimal — keeps the mean readable without false precision. */ +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +/** + * Fold a day's raw PULSE samples into ascending 10-minute mean buckets. Only + * samples whose LOCAL day matches `dayKey` participate, so a 23:55-local + * reading never bleeds into the next day's shape. Empty or sparse buckets are + * simply absent — the series is the buckets that exist, not a padded grid. + */ +export function computeTenMinuteMeanSeries( + samples: ReadonlyArray, + dayKey: string, + localOf: LocalResolver, +): IntradayHrBucket[] { + const acc = new Map(); + for (const s of samples) { + const local = localOf(s.measuredAt); + if (local.dayKey !== dayKey) continue; + const startMinute = + Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES; + const bucket = acc.get(startMinute); + if (bucket) { + bucket.sum += s.value; + bucket.count += 1; + } else { + acc.set(startMinute, { sum: s.value, count: 1 }); + } + } + return [...acc.entries()] + .map(([startMinute, { sum, count }]) => ({ + startMinute, + mean: round1(sum / count), + count, + })) + .sort((a, b) => a.startMinute - b.startMinute); +} + +/** Coarse part-of-day for a minute-of-day (drives the cautious copy). */ +export function partOfDayForMinute(minute: number): PartOfDay { + const hour = Math.floor(minute / 60); + if (hour < 6) return "night"; + if (hour < 12) return "morning"; + if (hour < 18) return "afternoon"; + return "evening"; +} + +/** Whether a 10-minute bucket [start, start+10) intersects any workout. */ +function overlapsWorkout( + startMinute: number, + workouts: ReadonlyArray, +): boolean { + const end = startMinute + BUCKET_MINUTES; + return workouts.some((w) => w.startMinute < end && w.endMinute > startMinute); +} + +export interface DetectTensionInput { + /** The day's 10-minute HR buckets (any order; re-sorted internally). */ + buckets: ReadonlyArray; + /** Personal resting baseline (bpm), or null when unknown. */ + baseline: number | null; + /** Whether the baseline is built from enough history to trust. */ + baselineMature: boolean; + /** Steps per 10-minute bucket, keyed by bucket start minute. */ + stepBuckets: ReadonlyMap; + /** Workout spans (local minutes) — buckets overlapping one never qualify. */ + workouts: ReadonlyArray; + /** Bucket start-minutes where intraday HRV independently confirms (WHOOP). */ + hrvConfirmMinutes?: ReadonlyArray; +} + +/** + * Detect at most ONE elevated-at-rest window for the day, or null. A bucket + * qualifies only when it is (a) elevated ≥ `baseline + ELEVATION_MARGIN_BPM`, + * (b) NOT overlapping a workout, and (c) low-movement — its step count is below + * `STEP_MOVEMENT_THRESHOLD` AND the day has enough step coverage to trust that + * reading. The longest run of ≥ `MIN_SUSTAINED_BUCKETS` consecutive qualifying + * buckets becomes the window (earliest wins a tie). Every gate must hold; when + * any fails, the honest answer is no window. + */ +export function detectTensionWindow( + input: DetectTensionInput, +): TensionWindow | null { + const { baseline, baselineMature, stepBuckets, workouts } = input; + + // Gate 1 — baseline maturity. No personal norm yet ⇒ say nothing. + if (baseline == null || !baselineMature) return null; + + // Gate 2 — movement is MANDATORY and step-based. Without enough step + // coverage a low/absent reading is "not recorded", not "not moving", so the + // low-movement gate can never be satisfied and no window is reported. + if (stepBuckets.size < MIN_STEP_COVERAGE_BUCKETS) return null; + + const threshold = baseline + ELEVATION_MARGIN_BPM; + const hrvMinutes = new Set(input.hrvConfirmMinutes ?? []); + + // Only density-passing buckets participate; a sparse bucket is a gap that + // breaks a run (we cannot assert "sustained" across missing data). + const valid = [...input.buckets] + .filter((b) => b.count >= MIN_SAMPLES_PER_BUCKET) + .sort((a, b) => a.startMinute - b.startMinute); + + const qualifies = (b: IntradayHrBucket): boolean => { + if (b.mean < threshold) return false; + if (overlapsWorkout(b.startMinute, workouts)) return false; + const steps = stepBuckets.get(b.startMinute) ?? 0; + return steps < STEP_MOVEMENT_THRESHOLD; + }; + + // Longest run of CONSECUTIVE qualifying buckets (consecutive = adjacent + // 10-minute slots; any non-qualifying or missing slot breaks the run). + let best: IntradayHrBucket[] = []; + let run: IntradayHrBucket[] = []; + for (const b of valid) { + const prev = run[run.length - 1]; + const consecutive = + prev && b.startMinute === prev.startMinute + BUCKET_MINUTES; + if (qualifies(b)) { + run = consecutive ? [...run, b] : [b]; + } else { + run = []; + } + if (run.length > best.length) best = run; + } + + if (best.length < MIN_SUSTAINED_BUCKETS) return null; + + const startMinute = best[0].startMinute; + const endMinute = best[best.length - 1].startMinute + BUCKET_MINUTES; + const meanHr = round1(best.reduce((sum, b) => sum + b.mean, 0) / best.length); + const hrvConfirmed = best.some((b) => hrvMinutes.has(b.startMinute)); + + return { + startMinute, + endMinute, + partOfDay: partOfDayForMinute(Math.floor((startMinute + endMinute) / 2)), + meanHr, + baseline, + hrvConfirmed, + }; +} diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index b98156f08..6892a8b40 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -57,6 +57,7 @@ function input(over: Partial = {}): DailyDigestInput { syncIssues: [], preventiveDue: [], coachPlans: [], + tensionWindow: null, ...over, }; } @@ -413,3 +414,55 @@ describe("buildDailyDigest — coach check-in (S3)", () => { expect(d.worthALook[0].kind).toBe("dose_window"); }); }); + +describe("buildDailyDigest — S11 tension_window item", () => { + function tension(d: ReturnType) { + return d.worthALook.find((i) => i.kind === "tension_window"); + } + + it("emits a calm, non-diagnostic tension card when a window is present", () => { + const d = buildDailyDigest( + input({ tensionWindow: { partOfDay: "afternoon" } }), + t, + ); + const item = tension(d); + expect(item).toBeDefined(); + expect(item?.status).toBe("info"); + expect(item?.actions[0].intent).toBe("pulse.view"); + expect(item?.actions[0].href).toBe("/insights/pulse"); + expect(item?.body).toContain("afternoon"); + }); + + it("emits nothing when there is no window (honest-absent)", () => { + const d = buildDailyDigest(input({ tensionWindow: null }), t); + expect(tension(d)).toBeUndefined(); + }); + + it("stays silent when the insights module is off", () => { + const d = buildDailyDigest( + input({ + modules: { insights: false }, + tensionWindow: { partOfDay: "morning" }, + }), + t, + ); + expect(tension(d)).toBeUndefined(); + }); + + it("yields the bounded rail to time-sensitive actions first", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "moodlog", state: "parked" }, + ], + tensionWindow: { partOfDay: "evening" }, + }), + t, + ); + // dose + 2 sync fill the cap; the calm tension marker waits. + expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK); + expect(tension(d)).toBeUndefined(); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 4f4ff9753..859524543 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -92,6 +92,17 @@ export interface DailyDigestPreventiveDue { label: string; } +/** + * S11 — a confident elevated-at-rest ("tension") window for the day, already + * detected server-side (`loadIntradayPulse`) under its full confidence gate. + * The builder only formats it; the signal correctness lives in the analytics + * layer, and a null here means the honest "no window" — nothing is emitted. + */ +export interface DailyDigestTensionWindow { + /** Part of day the window's midpoint fell in — drives the cautious copy. */ + partOfDay: "morning" | "afternoon" | "evening" | "night"; +} + /** * 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 @@ -135,6 +146,8 @@ export interface DailyDigestInput { preventiveDue: DailyDigestPreventiveDue[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; + /** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */ + tensionWindow: DailyDigestTensionWindow | null; } export interface DailyDigest { @@ -256,6 +269,37 @@ function buildPreventiveCareItem( }; } +/** + * S11 — the elevated-at-rest ("tension") card. Emitted at most once per day + * (the window is already the day's single most confident stretch), gated on + * the `insights` module and on a non-null window (the analytics layer stays + * silent unless every confidence gate holds). Cautious, non-diagnostic copy: + * "possible tension", never a clinical stress verdict. The one action deep- + * links into the pulse insight where the intraday shape is charted. + */ +function buildTensionWindowItem( + window: DailyDigestTensionWindow | null, + modules: DigestModuleMap, + t: Translate, +): PriorityItem | null { + if (!moduleEnabled(modules, "insights")) return null; + if (!window) return null; + return { + kind: "tension_window", + title: t("daily.item.tensionWindow.title"), + body: t(`daily.item.tensionWindow.body.${window.partOfDay}`), + status: "info", + actions: [ + { + labelKey: "daily.action.viewPulse", + intent: "pulse.view", + href: "/insights/pulse", + }, + ], + moduleKey: "insights", + }; +} + /** * 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 @@ -394,6 +438,11 @@ export function buildDailyDigest( if (checkin) worthALook.push(checkin); const preventive = buildPreventiveCareItem(input.preventiveDue, t); if (preventive) worthALook.push(preventive); + // S11 — the calm, informational tension marker sits last: it is context, not + // an action that expires, so a time-sensitive dose / sync / check-in wins the + // bounded rail ahead of it. + const tension = buildTensionWindowItem(input.tensionWindow, input.modules, t); + if (tension) worthALook.push(tension); // Defence-in-depth: no card ever exceeds the P1 action cap. for (const item of worthALook) { diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 8747c5908..ea9453353 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -29,7 +29,9 @@ import { type DailyDigestPreventiveDue, type DailyDigestScore, type DailyDigestSyncIssue, + type DailyDigestTensionWindow, } from "@/lib/daily/digest"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; /** Integration states that mean "your action is needed to keep data flowing". */ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; @@ -172,6 +174,17 @@ export async function loadDailyDigest( ? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow)) : []; + // S11 — the day's elevated-at-rest window, computed on demand from raw for + // TODAY only (read-swap, one bounded day-read; never persisted). Gated on the + // insights module and fault-isolated: a tension-read failure must never break + // the digest (a hot, must-not-fail path), it just omits the calm marker. + const tensionWindow: DailyDigestTensionWindow | null = + modules.insights !== false + ? await loadIntradayPulse(user.id, user.timezone, todayLocalDate) + .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) + .catch(() => null) + : null; + const { t } = getServerTranslator(resolveLocale(locale)); const digest = buildDailyDigest( @@ -186,6 +199,7 @@ export async function loadDailyDigest( syncIssues, preventiveDue, coachPlans, + tensionWindow, }, t, ); diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d2d015f3a..517528d57 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -84,6 +84,15 @@ export const insightsKeys = { insightsEcgList: () => ["insights", "ecg"] as const, insightsEcgDetail: (id: string, full: boolean) => ["insights", "ecg", id, full ? "full" : "overview"] as const, + /** + * S11 — the intraday pulse / tension layer (`/api/insights/pulse/intraday`). + * Keyed by the viewed local day so each day's 10-minute-mean shape caches + * independently and yesterday's curve never poisons today's. Under the + * `["insights"]` prefix so a fresh PULSE write busts it via the standard + * insights invalidation fan-out. + */ + insightsPulseIntraday: (dateKey: string) => + ["insights", "pulse-intraday", dateKey] as const, /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals From d82862b384336a43b932470b42a17fad09cb4553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 08:19:17 +0200 Subject: [PATCH 3/7] feat(insights): weave ECG into the briefing narrative and daily rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the ECG silo into the insights narrative without crossing the non-diagnostic line — the app reports the recording device's own verdict and the existence/count of recordings, never an interpretation of the trace. - Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording count, device-verdict distribution, latest recorded-days-ago + average HR) flows into the nightly briefing prompt. The waveform never enters the payload; the grounding gate admits the pre-computed counts so a device-attributed sentence stays grounded. A prompt rule (EN + DE) constrains any mention to the device's attribution and forbids reading morphology, measuring intervals, or emitting a verdict of the app's own. - Pulse cross-link: a device-attributed pointer from the resting-HR / pulse page into the ECG viewer, un-mounting when no recordings exist. - Daily rail: an ecg_new_recording item surfaces once when a recording landed within the last day — calm, info-level, deep-linking the viewer. Gated on the insights module; the DTO carries no waveform. - i18n across all six locales; non-diagnostic disclaimer copy per language. --- messages/de.json | 20 ++- messages/en.json | 20 ++- messages/es.json | 20 ++- messages/fr.json | 20 ++- messages/it.json | 20 ++- messages/pl.json | 20 ++- src/app/insights/pulse/page.tsx | 7 ++ .../__tests__/ecg-cross-link.test.tsx | 81 ++++++++++++ src/components/insights/ecg-cross-link.tsx | 106 ++++++++++++++++ src/components/insights/ecg-section.tsx | 3 +- .../ai/__tests__/briefing-grounding.test.ts | 35 ++++++ .../insight-generator-prompt.test.ts | 16 +++ src/lib/ai/briefing-grounding.ts | 14 +++ src/lib/ai/prompts/insight-generator.ts | 35 +++++- src/lib/daily/__tests__/digest.test.ts | 119 ++++++++++++++++++ src/lib/daily/digest.ts | 88 +++++++++++++ src/lib/daily/load-digest.ts | 36 ++++++ src/lib/insights/__tests__/features.test.ts | 53 ++++++++ src/lib/insights/feature-blocks.ts | 68 ++++++++++ src/lib/insights/features.ts | 35 +++++- 20 files changed, 806 insertions(+), 10 deletions(-) create mode 100644 src/components/insights/__tests__/ecg-cross-link.test.tsx create mode 100644 src/components/insights/ecg-cross-link.tsx diff --git a/messages/de.json b/messages/de.json index c8907407d..cba744ebf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}." + }, + "crossLink": { + "title": "EKG-Aufzeichnungen", + "recordingsOne": "1 Aufzeichnung vorhanden.", + "recordingsMany": "{count} Aufzeichnungen vorhanden.", + "latestResult": "Letztes Geräte-Ergebnis: {result}.", + "cta": "Aufzeichnungen ansehen" } } }, @@ -8549,6 +8556,16 @@ "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." + }, + "ecgNewRecording": { + "title": "Neue EKG-Aufzeichnung", + "body": "Dein Gerät hat ein neues EKG aufgezeichnet — bereit zum Ansehen.", + "bodyVerdict": "Dein Gerät hat ein neues EKG aufgezeichnet und meldet: {verdict}.", + "verdict": { + "irregular": "einen möglichen unregelmäßigen Rhythmus", + "notDetected": "keinen unregelmäßigen Rhythmus", + "inconclusive": "ein nicht eindeutiges Ergebnis" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Vorsorge ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", - "checkinLetGo": "Loslassen" + "checkinLetGo": "Loslassen", + "viewEcg": "Aufzeichnung ansehen" }, "today": { "scoreLabel": "Gesundheitsscore", diff --git a/messages/en.json b/messages/en.json index 11f67878f..50e35cab9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}." + }, + "crossLink": { + "title": "ECG recordings", + "recordingsOne": "1 recording on file.", + "recordingsMany": "{count} recordings on file.", + "latestResult": "Latest device result: {result}.", + "cta": "View recordings" } } }, @@ -8549,6 +8556,16 @@ "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." + }, + "ecgNewRecording": { + "title": "New ECG recording", + "body": "Your device recorded a new ECG — it's ready to view.", + "bodyVerdict": "Your device recorded a new ECG and reported: {verdict}.", + "verdict": { + "irregular": "a possible irregular rhythm", + "notDetected": "no irregular rhythm", + "inconclusive": "an inconclusive result" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "View check-ups", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", - "checkinLetGo": "Let it go" + "checkinLetGo": "Let it go", + "viewEcg": "View recording" }, "today": { "scoreLabel": "Health score", diff --git a/messages/es.json b/messages/es.json index 79883e68e..ed0f1ecbd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}." + }, + "crossLink": { + "title": "Registros de ECG", + "recordingsOne": "1 registro guardado.", + "recordingsMany": "{count} registros guardados.", + "latestResult": "Último resultado del dispositivo: {result}.", + "cta": "Ver registros" } } }, @@ -8549,6 +8556,16 @@ "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." + }, + "ecgNewRecording": { + "title": "Nuevo registro de ECG", + "body": "Tu dispositivo registró un nuevo ECG: está listo para verlo.", + "bodyVerdict": "Tu dispositivo registró un nuevo ECG e informó: {verdict}.", + "verdict": { + "irregular": "un posible ritmo irregular", + "notDetected": "ningún ritmo irregular", + "inconclusive": "un resultado no concluyente" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Ver revisiones", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", - "checkinLetGo": "Dejarlo ir" + "checkinLetGo": "Dejarlo ir", + "viewEcg": "Ver registro" }, "today": { "scoreLabel": "Puntuación de salud", diff --git a/messages/fr.json b/messages/fr.json index 85d4f3acf..2b120dd2f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}." + }, + "crossLink": { + "title": "Enregistrements ECG", + "recordingsOne": "1 enregistrement disponible.", + "recordingsMany": "{count} enregistrements disponibles.", + "latestResult": "Dernier résultat de l’appareil : {result}.", + "cta": "Voir les enregistrements" } } }, @@ -8549,6 +8556,16 @@ "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." + }, + "ecgNewRecording": { + "title": "Nouvel enregistrement ECG", + "body": "Votre appareil a enregistré un nouvel ECG : prêt à être consulté.", + "bodyVerdict": "Votre appareil a enregistré un nouvel ECG et a signalé : {verdict}.", + "verdict": { + "irregular": "un possible rythme irrégulier", + "notDetected": "aucun rythme irrégulier", + "inconclusive": "un résultat non concluant" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Voir les examens", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", - "checkinLetGo": "Laisser tomber" + "checkinLetGo": "Laisser tomber", + "viewEcg": "Voir l’enregistrement" }, "today": { "scoreLabel": "Score de santé", diff --git a/messages/it.json b/messages/it.json index b0702e182..843e9d3a0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}." + }, + "crossLink": { + "title": "Registrazioni ECG", + "recordingsOne": "1 registrazione disponibile.", + "recordingsMany": "{count} registrazioni disponibili.", + "latestResult": "Ultimo risultato del dispositivo: {result}.", + "cta": "Vedi registrazioni" } } }, @@ -8549,6 +8556,16 @@ "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." + }, + "ecgNewRecording": { + "title": "Nuova registrazione ECG", + "body": "Il tuo dispositivo ha registrato un nuovo ECG: è pronto da vedere.", + "bodyVerdict": "Il tuo dispositivo ha registrato un nuovo ECG e ha segnalato: {verdict}.", + "verdict": { + "irregular": "un possibile ritmo irregolare", + "notDetected": "nessun ritmo irregolare", + "inconclusive": "un risultato non conclusivo" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Vedi controlli", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", - "checkinLetGo": "Lascia andare" + "checkinLetGo": "Lascia andare", + "viewEcg": "Vedi registrazione" }, "today": { "scoreLabel": "Punteggio di salute", diff --git a/messages/pl.json b/messages/pl.json index b057686a9..9f5f5d914 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3856,6 +3856,13 @@ }, "waveform": { "ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}." + }, + "crossLink": { + "title": "Zapisy EKG", + "recordingsOne": "1 zapis w kartotece.", + "recordingsMany": "Zapisy w kartotece: {count}.", + "latestResult": "Ostatni wynik urządzenia: {result}.", + "cta": "Zobacz zapisy" } } }, @@ -8549,6 +8556,16 @@ "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." + }, + "ecgNewRecording": { + "title": "Nowy zapis EKG", + "body": "Twoje urządzenie zarejestrowało nowe EKG — gotowe do podglądu.", + "bodyVerdict": "Twoje urządzenie zarejestrowało nowe EKG i zgłosiło: {verdict}.", + "verdict": { + "irregular": "możliwy nieregularny rytm", + "notDetected": "brak nieregularnego rytmu", + "inconclusive": "wynik niejednoznaczny" + } } }, "action": { @@ -8557,7 +8574,8 @@ "viewCheckups": "Zobacz badania", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", - "checkinLetGo": "Odpuść" + "checkinLetGo": "Odpuść", + "viewEcg": "Zobacz zapis" }, "today": { "scoreLabel": "Wynik zdrowia", diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index cb3963e16..14c71bfe0 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -17,6 +17,7 @@ import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { @@ -188,6 +189,12 @@ export default function InsightsPulsPage() { icon={} /> + {/* S10 — device-attributed pointer into the ECG viewer, in the + resting-HR / pulse context. Un-mounts when the user has no ECG + recordings; surfaces only that recordings exist + the device's own + latest result, never a HealthLog interpretation. */} + + {/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it under "Heart"), so the pulse page links across to its dedicated `/insights/cardio-fitness` page — the single surface that carries diff --git a/src/components/insights/__tests__/ecg-cross-link.test.tsx b/src/components/insights/__tests__/ecg-cross-link.test.tsx new file mode 100644 index 000000000..b5aa7a7d8 --- /dev/null +++ b/src/components/insights/__tests__/ecg-cross-link.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * S10 — `` unit tests. + * + * The load-bearing behaviour under test: + * - data-availability gating: the pointer un-mounts entirely when the user + * has no ECG recordings; + * - the NON-DIAGNOSTIC framing: it surfaces only that recordings exist and + * the RECORDING DEVICE's own latest result, attributed to the device — no + * HealthLog interpretation, no waveform. + * + * `useAuth` + TanStack Query are mocked and assertions run through SSR, exactly + * like the sibling `ecg-section` test. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })), +})); + +const useQueryMock = vi.fn(); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: unknown) => useQueryMock(opts), +})); + +const { EcgCrossLink } = await import("../ecg-cross-link"); + +interface Item { + id: string; + classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; +} + +function render( + data: { recordings: Item[]; hasRecordings: boolean } | undefined, +) { + useQueryMock.mockReturnValue({ data }); + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("renders nothing before the payload resolves", () => { + expect(render(undefined)).toBe(""); + }); + + it("renders nothing when the user has no recordings (gate)", () => { + expect(render({ recordings: [], hasRecordings: false })).toBe(""); + }); + + it("links into the ECG viewer and attributes the latest result to the device", () => { + const html = render({ + recordings: [ + { id: "a", classification: "IRREGULAR" }, + { id: "b", classification: "NOT_DETECTED" }, + ], + hasRecordings: true, + }); + expect(html).toContain('data-slot="ecg-cross-link"'); + expect(html).toContain('href="/insights#ecg"'); + expect(html).toContain("2 recordings on file."); + // The latest (first) recording's DEVICE result, attributed to the device. + expect(html).toContain("Latest device result:"); + // Never a HealthLog interpretation. + expect(html).not.toMatch(/we (detected|found)|our (reading|analysis)/i); + }); + + it("omits the result line when the latest recording has no device verdict", () => { + const html = render({ + recordings: [{ id: "a", classification: null }], + hasRecordings: true, + }); + expect(html).toContain("1 recording on file."); + expect(html).not.toContain("Latest device result:"); + }); +}); diff --git a/src/components/insights/ecg-cross-link.tsx b/src/components/insights/ecg-cross-link.tsx new file mode 100644 index 000000000..043fff504 --- /dev/null +++ b/src/components/insights/ecg-cross-link.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, HeartPulse } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { TileHeader } from "@/components/insights/tile-header"; + +/** + * S10 — ECG cross-link, the pointer from the resting-HR / pulse context into + * the ECG viewer (`/insights#ecg`). + * + * NON-DIAGNOSTIC (load-bearing, mirrors `EcgSection` / `RhythmEventsCard`): the + * card surfaces ONLY that recordings exist + the RECORDING DEVICE's OWN latest + * result, attributed to the device. HealthLog never reads or interprets the + * waveform, and this pointer never touches it — the list route returns metadata + * only. All copy renders as plain React text children (no markdown library). + * + * Data-availability-gated: the card un-mounts entirely (`return null`) when the + * user has no recordings, so a pulse page without any ECG never paints it. It + * reuses the SAME query cell as `EcgSection` (`insightsEcgList`), so the two + * surfaces share one cache entry with an identical response shape. + */ + +type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + +interface EcgRecordingListItem { + id: string; + classification: EcgClassification; +} + +interface EcgListResponse { + recordings: EcgRecordingListItem[]; + hasRecordings: boolean; +} + +/** The device verdict, surfaced verbatim and attributed to the device. */ +const RESULT_LABEL_KEYS: Record = { + IRREGULAR: "insights.ecg.result.irregular", + NOT_DETECTED: "insights.ecg.result.notDetected", + INCONCLUSIVE: "insights.ecg.result.inconclusive", +}; + +interface EcgCrossLinkProps { + enabled?: boolean; + className?: string; +} + +export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: async () => { + try { + return await apiGet("/api/insights/ecg"); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + enabled: enabled && isAuthenticated, + }); + + // Data-availability gate — never paint an empty pointer. + if (!data || !data.hasRecordings || data.recordings.length === 0) return null; + + const count = data.recordings.length; + const latestClassification = data.recordings[0]?.classification ?? null; + const resultKey = latestClassification + ? RESULT_LABEL_KEYS[latestClassification] + : null; + + const countText = + count === 1 + ? t("insights.ecg.crossLink.recordingsOne") + : t("insights.ecg.crossLink.recordingsMany", { count }); + const resultText = resultKey + ? t("insights.ecg.crossLink.latestResult", { result: t(resultKey) }) + : null; + + return ( + + + + {resultText ? `${countText} ${resultText}` : countText} + + + {t("insights.ecg.crossLink.cta")} + + + ); +} diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx index 324846d49..6a5b4afb5 100644 --- a/src/components/insights/ecg-section.tsx +++ b/src/components/insights/ecg-section.tsx @@ -118,9 +118,10 @@ export function EcgSection({ enabled = true, className }: EcgSectionProps) { return (
{ ); expect(found.map((f) => f.value)).toContain(195); }); + + // S10 — the ECG device-verdict descriptor feeds the narrative; its + // server-computed counts must be admitted so a device-attributed sentence + // ("your device logged 5 ECG recordings") is not stripped by the gate, while + // a count the block never produced still trips it. + const ecgFeatures = { + ecg: { + recordingCount: 5, + deviceVerdicts: { irregular: 2, notDetected: 3, inconclusive: 0 }, + latestDeviceVerdict: "IRREGULAR", + latestRecordedDaysAgo: 4, + latestAverageHeartRate: 61, + }, + } as never; + + it("grounds a restated ECG recording count + latest device figures", () => { + const found = findUngroundedBriefingNumbers( + { + paragraph: + "Your device logged 5 ECG recordings this month; the latest, 4 days ago, averaged 61 bpm.", + }, + signals, + ecgFeatures, + ); + expect(found).toEqual([]); + }); + + it("flags an ECG count the descriptor never produced", () => { + const found = findUngroundedBriefingNumbers( + { paragraph: "Your device logged 12 ECG recordings this month." }, + signals, + ecgFeatures, + ); + expect(found.map((f) => f.value)).toContain(12); + }); }); diff --git a/src/lib/ai/__tests__/insight-generator-prompt.test.ts b/src/lib/ai/__tests__/insight-generator-prompt.test.ts index 0f4a049f9..7174248f5 100644 --- a/src/lib/ai/__tests__/insight-generator-prompt.test.ts +++ b/src/lib/ai/__tests__/insight-generator-prompt.test.ts @@ -52,6 +52,22 @@ describe("scope-hardened system prompt", () => { expect(prompt).toMatch(/verschreibst NICHT/); }); + it("English prompt keeps ECG device-verdict-only and forbids interpretation", () => { + const prompt = getStrictInsightsSystemPrompt("en"); + // Attribution + existence allowed; interpretation of the trace forbidden. + expect(prompt).toMatch(/ECG recordings are DEVICE-VERDICT ONLY/); + expect(prompt).toMatch(/never read morphology/i); + expect(prompt).toMatch(/never measure intervals/i); + expect(prompt).toMatch(/attribute its result to the device/i); + }); + + it("German prompt keeps ECG device-verdict-only and forbids interpretation", () => { + const prompt = getStrictInsightsSystemPrompt("de"); + expect(prompt).toMatch(/EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL/); + expect(prompt).toMatch(/keine Morphologie/i); + expect(prompt).toMatch(/dem Gerät zuschreiben/i); + }); + it("English prompt instructs the doctor-consult call-to-action", () => { const prompt = getStrictInsightsSystemPrompt("en"); expect(prompt).toMatch(/consult your doctor/i); diff --git a/src/lib/ai/briefing-grounding.ts b/src/lib/ai/briefing-grounding.ts index 99c2419d1..16cb11def 100644 --- a/src/lib/ai/briefing-grounding.ts +++ b/src/lib/ai/briefing-grounding.ts @@ -153,6 +153,20 @@ function authoritativeValues( push(w.latest.daysAgo); } } + // S10 — ECG device-verdict descriptor. Admit the pre-computed counts + the + // latest recording's device-reported HR / recency so a briefing that + // references the recordings ("your device logged 2 ECG recordings this + // month") stays grounded. Only server-computed figures are added; the + // waveform never reaches the payload, so no trace-derived number exists. + const ecg = features.ecg; + if (ecg) { + push(ecg.recordingCount); + push(ecg.deviceVerdicts.irregular); + push(ecg.deviceVerdicts.notDetected); + push(ecg.deviceVerdicts.inconclusive); + push(ecg.latestRecordedDaysAgo); + push(ecg.latestAverageHeartRate); + } // v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired // into the briefing. Admit their pre-computed figures so the prose may cite // them, same posture as the glucose / labs / workout blocks above. diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index ca7fdbbfb..184367af3 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -397,7 +397,22 @@ gib die obige Verweigerung zurück.`, typical mid-titration. Worth mentioning at the next visit if it persists." This is a SAFETY contract; treat any dose-prescriptive instinct as a sign the response is - out-of-bounds.`, + out-of-bounds. +14. ECG recordings are DEVICE-VERDICT ONLY. When the snapshot + carries an "ecg" block, it holds ONLY counts, the RECORDING + DEVICE's own verdicts (deviceVerdicts / latestDeviceVerdict: + irregular / notDetected / inconclusive), and the latest + recording's recency + average heart rate. You NEVER see the + waveform and you NEVER interpret one. You may note that a + recording exists and attribute its result to the device — + "your device logged an ECG on and flagged a possible + irregular result" — and, for a non-normal device verdict, + suggest discussing that recording with a clinician. You must + NEVER present the verdict as your own, never read morphology, + never measure intervals or beats, never assign a rhythm + diagnosis, and never state or imply that HealthLog analysed the + trace. If the "ecg" block is absent, stay silent about ECG — + do not mention its absence or suggest recording one.`, de: `GRUNDREGELN — NULL HALLUZINATIONEN 1. Jede Aussage in "summary" muss auf einer Zahl beruhen, die im übergebenen Datenpaket sichtbar ist. Lässt sich die Aussage nicht @@ -601,7 +616,23 @@ gib die obige Verweigerung zurück.`, mid-titration Phase. Lohnt sich beim nächsten Termin anzusprechen, falls es darüber hinaus persistiert." Das ist ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven - Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.`, + Impuls als Signal, dass die Antwort außerhalb des Skopus liegt. +14. EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL. Enthält der + Snapshot einen "ecg"-Block, trägt er AUSSCHLIESSLICH Zählungen, + die EIGENEN Urteile des AUFZEICHNUNGSGERÄTS (deviceVerdicts / + latestDeviceVerdict: irregular / notDetected / inconclusive) + sowie Aktualität und durchschnittliche Herzfrequenz der letzten + Aufzeichnung. Du siehst NIE die Kurve und interpretierst sie + NIE. Du darfst erwähnen, dass eine Aufzeichnung existiert, und + das Ergebnis dem Gerät zuschreiben — "dein Gerät hat am + ein EKG aufgezeichnet und ein mögliches unregelmäßiges Ergebnis + markiert" — und bei einem nicht-normalen Geräte-Urteil anregen, + diese Aufzeichnung mit einer Ärztin zu besprechen. Du darfst das + Urteil NIEMALS als dein eigenes ausgeben, keine Morphologie + lesen, keine Intervalle oder Schläge vermessen, keine + Rhythmus-Diagnose stellen und niemals andeuten, dass HealthLog + die Kurve analysiert hat. Fehlt der "ecg"-Block, schweige zum + Thema EKG — erwähne weder sein Fehlen noch rege eine Aufzeichnung an.`, }, { id: "guidelineTargets", diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index b98156f08..6e21b4db7 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -413,3 +413,122 @@ describe("buildDailyDigest — coach check-in (S3)", () => { expect(d.worthALook[0].kind).toBe("dose_window"); }); }); + +describe("buildDailyDigest — ecg_new_recording (S10)", () => { + const ecgItem = (d: ReturnType) => + d.worthALook.find((i) => i.kind === "ecg_new_recording"); + + it("emits ONE calm item for a recording within the last day", () => { + const d = buildDailyDigest( + input({ + latestEcg: { + recordedAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000), + deviceVerdict: "NOT_DETECTED", + }, + }), + t, + ); + const item = ecgItem(d); + expect(item).toBeDefined(); + expect(item?.status).toBe("info"); + expect(item?.moduleKey).toBe("insights"); + // Single action, deep-linking the ECG viewer. + expect(item?.actions).toHaveLength(1); + expect(item?.actions[0].intent).toBe("ecg.view"); + expect(item?.actions[0].href).toBe("/insights#ecg"); + }); + + it("attributes the verdict to the DEVICE (never a HealthLog reading)", () => { + const d = buildDailyDigest( + input({ + latestEcg: { + recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000), + deviceVerdict: "IRREGULAR", + }, + }), + t, + ); + const item = ecgItem(d); + // Copy leads with the device as the actor, echoes only its verdict, and + // never claims HealthLog interpreted the trace. + expect(item?.body).toContain("Your device recorded"); + expect(item?.body).toContain("possible irregular rhythm"); + expect(item?.body).not.toMatch(/we (detected|found|think)|HealthLog/i); + }); + + it("does not emit for an OLD recording (outside the last-day window)", () => { + const d = buildDailyDigest( + input({ + latestEcg: { + recordedAt: new Date(NOW.getTime() - 2 * DAY), + deviceVerdict: "IRREGULAR", + }, + }), + t, + ); + expect(ecgItem(d)).toBeUndefined(); + }); + + it("does not emit a future-dated recording (clock-skew guard)", () => { + const d = buildDailyDigest( + input({ + latestEcg: { + recordedAt: new Date(NOW.getTime() + 60 * 60 * 1000), + deviceVerdict: "NOT_DETECTED", + }, + }), + t, + ); + expect(ecgItem(d)).toBeUndefined(); + }); + + it("does not emit when the insights module is off", () => { + const d = buildDailyDigest( + input({ + modules: { insights: false }, + latestEcg: { + recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000), + deviceVerdict: "IRREGULAR", + }, + }), + t, + ); + expect(ecgItem(d)).toBeUndefined(); + }); + + it("emits nothing when there is no recent recording", () => { + const d = buildDailyDigest(input({ latestEcg: null }), t); + expect(ecgItem(d)).toBeUndefined(); + }); + + it("uses the calm, verdict-less body when the device gave no verdict", () => { + const d = buildDailyDigest( + input({ + latestEcg: { + recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000), + deviceVerdict: null, + }, + }), + t, + ); + const item = ecgItem(d); + expect(item?.body).toBe( + "Your device recorded a new ECG — it's ready to view.", + ); + }); + + it("carries no waveform / sample data on the item or its input DTO", () => { + const latestEcg = { + recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000), + deviceVerdict: "NOT_DETECTED" as const, + }; + // The input DTO is verdict + recordedAt only — no waveform channel exists. + expect(Object.keys(latestEcg).sort()).toEqual([ + "deviceVerdict", + "recordedAt", + ]); + const d = buildDailyDigest(input({ latestEcg }), t); + const serialised = JSON.stringify(ecgItem(d)); + expect(serialised).not.toMatch(/waveform|sample|signal|voltage|microvolt/i); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 4f4ff9753..a4355674d 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -92,6 +92,18 @@ export interface DailyDigestPreventiveDue { label: string; } +/** + * S10 — the latest ECG recording, for the `ecg_new_recording` rail item. Only + * ever the DEVICE's verdict + when it was recorded — NEVER the waveform (the + * DTO carries no samples). The builder decides "new" from `recordedAt`; the + * card attributes any verdict to the recording device. + */ +export interface DailyDigestEcg { + recordedAt: Date; + /** The recording device's OWN verdict, or null when unclassified. */ + deviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; +} + /** * 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 @@ -135,6 +147,12 @@ export interface DailyDigestInput { preventiveDue: DailyDigestPreventiveDue[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; + /** + * S10 — the freshest ECG recording (device verdict + recordedAt only), or + * null. Optional so consumers that predate the ECG weave stay valid; the + * builder treats a missing value as "no recent recording". + */ + latestEcg?: DailyDigestEcg | null; } export interface DailyDigest { @@ -336,6 +354,69 @@ function buildCoachCheckinItem( }; } +/** + * S10 — how recently an ECG recording must have landed to count as "new" for + * the rail (§3.5.3). A calendar-day window: a recording synced within the last + * day surfaces once, then retires on its own (no persisted "seen" marker, no + * migration). The 24h window is itself the one/day cap — at most one recording + * is the freshest, and it drops off the rail after a day. + */ +const ECG_NEW_WINDOW_MS = MS_PER_DAY; + +/** Device verdict → the calm, device-attributed verdict key for the card body. */ +const ECG_VERDICT_KEYS: Record< + NonNullable, + string +> = { + IRREGULAR: "daily.item.ecgNewRecording.verdict.irregular", + NOT_DETECTED: "daily.item.ecgNewRecording.verdict.notDetected", + INCONCLUSIVE: "daily.item.ecgNewRecording.verdict.inconclusive", +}; + +/** + * The one ECG "new recording" item (§3.5.3). Gated on the `insights` module + * (the ECG viewer's own gate). Fires ONLY when the freshest recording landed + * within the last day — a calm "a new ECG recording is ready to view" pointer + * into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's + * verdict, attributed to the device; HealthLog never interprets the trace (the + * DTO carries no waveform). The 24h window caps it at one/day and retires it on + * its own without a persisted seen-marker. + */ +function buildEcgNewRecordingItem( + ecg: DailyDigestEcg | null | undefined, + modules: DigestModuleMap, + now: Date, + t: Translate, +): PriorityItem | null { + if (!moduleEnabled(modules, "insights")) return null; + if (!ecg) return null; + const age = now.getTime() - ecg.recordedAt.getTime(); + // Skip a future-dated row (clock skew) and anything older than the window. + if (age < 0 || age > ECG_NEW_WINDOW_MS) return null; + + const verdictKey = ecg.deviceVerdict + ? ECG_VERDICT_KEYS[ecg.deviceVerdict] + : null; + const body = verdictKey + ? t("daily.item.ecgNewRecording.bodyVerdict", { verdict: t(verdictKey) }) + : t("daily.item.ecgNewRecording.body"); + + return { + kind: "ecg_new_recording", + title: t("daily.item.ecgNewRecording.title"), + body, + status: "info", + actions: [ + { + labelKey: "daily.action.viewEcg", + intent: "ecg.view", + href: "/insights#ecg", + }, + ], + moduleKey: "insights", + }; +} + /** * 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 @@ -392,6 +473,13 @@ export function buildDailyDigest( t, ); if (checkin) worthALook.push(checkin); + const ecg = buildEcgNewRecordingItem( + input.latestEcg, + input.modules, + input.now, + t, + ); + if (ecg) worthALook.push(ecg); const preventive = buildPreventiveCareItem(input.preventiveDue, t); if (preventive) worthALook.push(preventive); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 8747c5908..2bf6ba389 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -26,6 +26,7 @@ import { buildDailyDigest, type DailyDigest, type DailyDigestCoachPlan, + type DailyDigestEcg, type DailyDigestPreventiveDue, type DailyDigestScore, type DailyDigestSyncIssue, @@ -43,6 +44,9 @@ 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; +/** S10 — how recently an ECG recording counts as "new" for the rail read. */ +const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000; + interface CoachPlanRow { id: string; status: string; @@ -92,6 +96,7 @@ export async function loadDailyDigest( syncRows, dueReminders, planRows, + ecgRow, ] = await Promise.all([ readDashboardSnapshotCached(user), resolveModuleMap(user.id), @@ -133,6 +138,18 @@ export async function loadDailyDigest( orderBy: { updatedAt: "desc" }, take: CHECKIN_PLAN_READ_LIMIT, }), + // S10 — the freshest ECG recording within the last-day window, for the + // `ecg_new_recording` rail item. Descriptors only (verdict + recordedAt) — + // the encrypted waveform is never selected, so no decrypt and no trace + // crosses into the digest. The builder gates on the `insights` module. + prisma.ecgRecording.findFirst({ + where: { + userId: user.id, + recordedAt: { gte: new Date(now.getTime() - ECG_NEW_WINDOW_MS) }, + }, + orderBy: { recordedAt: "desc" }, + select: { recordedAt: true, rhythmClassification: true }, + }), ]); const score: DailyDigestScore | null = snapshot.healthScore @@ -165,6 +182,24 @@ export async function loadDailyDigest( label: row.label, })); + // S10 — the freshest ECG recording (device verdict + recordedAt only). The + // builder decides "new" and gates on the `insights` module. An ECG row only + // ever carries an AFib-screening verdict; the shared enum's other members + // (walking-steadiness / event codes) never apply, so anything else maps to + // null rather than leaking into the device-verdict copy. + const verdict = ecgRow?.rhythmClassification; + const latestEcg: DailyDigestEcg | null = ecgRow + ? { + recordedAt: ecgRow.recordedAt, + deviceVerdict: + verdict === "IRREGULAR" || + verdict === "NOT_DETECTED" || + verdict === "INCONCLUSIVE" + ? verdict + : null, + } + : null; + // 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; @@ -186,6 +221,7 @@ export async function loadDailyDigest( syncIssues, preventiveDue, coachPlans, + latestEcg, }, t, ); diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 7feeb4497..3f5037b66 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ labResult: { findMany: vi.fn() }, measurementReminder: { findMany: vi.fn() }, workout: { findMany: vi.fn() }, + ecgRecording: { findMany: vi.fn() }, $queryRawUnsafe: vi.fn(), }, })); @@ -63,6 +64,7 @@ const prismaMock = prisma as unknown as { labResult: { findMany: ReturnType }; measurementReminder: { findMany: ReturnType }; workout: { findMany: ReturnType }; + ecgRecording: { findMany: ReturnType }; }; const dayMs = 24 * 60 * 60 * 1000; @@ -100,6 +102,7 @@ beforeEach(() => { prismaMock.labResult.findMany.mockResolvedValue([]); prismaMock.measurementReminder.findMany.mockResolvedValue([]); prismaMock.workout.findMany.mockResolvedValue([]); + prismaMock.ecgRecording.findMany.mockResolvedValue([]); }); describe("extractFeatures — v1.4.36 W3 bucketed payload", () => { @@ -433,6 +436,8 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () expect(prismaMock.labResult.findMany).not.toHaveBeenCalled(); expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled(); expect(prismaMock.workout.findMany).not.toHaveBeenCalled(); + // S10 — the ECG descriptor rides the same briefing-scoped gate. + expect(prismaMock.ecgRecording.findMany).not.toHaveBeenCalled(); }); it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => { @@ -440,5 +445,53 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () expect(prismaMock.labResult.findMany).toHaveBeenCalled(); expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled(); expect(prismaMock.workout.findMany).toHaveBeenCalled(); + expect(prismaMock.ecgRecording.findMany).toHaveBeenCalled(); + }); +}); + +describe("extractFeatures — S10 ECG device-verdict descriptor", () => { + const DAY = 86_400_000; + + it("emits a device-verdict descriptor and NEVER a waveform", async () => { + const now = Date.now(); + prismaMock.ecgRecording.findMany.mockResolvedValue([ + { + recordedAt: new Date(now - 2 * DAY), + rhythmClassification: "IRREGULAR", + averageHeartRate: 72, + }, + { + recordedAt: new Date(now - 10 * DAY), + rhythmClassification: "NOT_DETECTED", + averageHeartRate: 61, + }, + ]); + const f = await extractFeatures("user-1", false); + expect(f.ecg?.recordingCount).toBe(2); + expect(f.ecg?.deviceVerdicts).toEqual({ + irregular: 1, + notDetected: 1, + inconclusive: 0, + }); + // The LATEST recording's device verdict + HR (newest-first ordering). + expect(f.ecg?.latestDeviceVerdict).toBe("IRREGULAR"); + expect(f.ecg?.latestAverageHeartRate).toBe(72); + // The waveform never reaches the payload — only descriptors. + expect(JSON.stringify(f.ecg)).not.toMatch( + /waveform|sample|signal|voltage|microvolt/i, + ); + }); + + it("omits the ECG block entirely when the user has no recordings", async () => { + const f = await extractFeatures("user-1", false); + expect(f.ecg).toBeUndefined(); + }); + + it("only ever reads descriptor columns, never the encrypted waveform", async () => { + await extractFeatures("user-1", false); + const call = prismaMock.ecgRecording.findMany.mock.calls[0]?.[0]; + expect(call?.select?.waveformEncrypted).toBeUndefined(); + expect(call?.select?.recordedAt).toBe(true); + expect(call?.select?.rhythmClassification).toBe(true); }); }); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 32754a075..191aef506 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -114,6 +114,12 @@ const LABS_LOOKBACK_MONTHS = 12; /** Trailing window (days) for the workout aggregate. */ const WORKOUT_WINDOW_DAYS = 90; +/** Trailing window (days) for the ECG recording descriptor. */ +const ECG_WINDOW_DAYS = 90; + +/** Defensive cap on ECG rows read for the descriptor (a bound, not a ceiling). */ +const ECG_MAX_ROWS = 500; + const MS_PER_DAY = 24 * 60 * 60 * 1000; /** @@ -347,6 +353,68 @@ export async function readWorkoutsBlock( return { last7: tally(7), last30: tally(30), latest }; } +/** + * S10 — ECG recording descriptor for the briefing narrative (device-verdict + * only). A bounded, non-diagnostic snapshot of the user's on-device ECG + * recordings over the trailing window: how many exist, the distribution of the + * RECORDING DEVICE's OWN verdicts (never HealthLog's), and the latest + * recording's device verdict + average heart rate. The waveform is NEVER read + * (no decrypt on this path) and NEVER enters the payload — the trace does not + * cross into the prompt, so the model cannot interpret morphology. The prompt + * (insight-generator rule) constrains any mention to the device's attribution; + * the grounding gate constrains any restated count. Returns `undefined` when no + * recordings fall in the window, so the block is omitted rather than emitting an + * empty shape. + */ +export async function readEcgBriefingBlock( + userId: string, + now: number, +): Promise { + const since = new Date(now - ECG_WINDOW_DAYS * MS_PER_DAY); + const rows = await prisma.ecgRecording.findMany({ + where: { userId, recordedAt: { gte: since } }, + orderBy: { recordedAt: "desc" }, + take: ECG_MAX_ROWS, + // Descriptors only — the encrypted waveform blob is never selected, so no + // decrypt happens and no trace can reach the narrative. + select: { + recordedAt: true, + rhythmClassification: true, + averageHeartRate: true, + }, + }); + if (rows.length === 0) return undefined; + + const deviceVerdicts = { irregular: 0, notDetected: 0, inconclusive: 0 }; + for (const r of rows) { + if (r.rhythmClassification === "IRREGULAR") deviceVerdicts.irregular += 1; + else if (r.rhythmClassification === "NOT_DETECTED") + deviceVerdicts.notDetected += 1; + else if (r.rhythmClassification === "INCONCLUSIVE") + deviceVerdicts.inconclusive += 1; + } + + // An ECG row only ever carries an AFib-screening verdict; the shared enum's + // other members (walking-steadiness / event codes) never apply here, so + // anything else narrows to null. + const latest = rows[0]; + const latestVerdict = latest.rhythmClassification; + return { + recordingCount: rows.length, + deviceVerdicts, + latestDeviceVerdict: + latestVerdict === "IRREGULAR" || + latestVerdict === "NOT_DETECTED" || + latestVerdict === "INCONCLUSIVE" + ? latestVerdict + : null, + latestRecordedDaysAgo: Math.round( + (now - latest.recordedAt.getTime()) / MS_PER_DAY, + ), + latestAverageHeartRate: latest.averageHeartRate, + }; +} + /** * Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the * persistent rollup table and project to the wire shape. Empty diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 11379b31e..410ea5133 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -33,6 +33,7 @@ import { import { readAllTimeExtremes, readBucketedSeries, + readEcgBriefingBlock, readLabsBriefingBlock, readPreventiveCareBlock, readWorkoutsBlock, @@ -321,6 +322,33 @@ export interface AggregatedFeatures { distanceKm: number | null; } | null; }; + /** + * S10 — ECG recording descriptor (device-verdict only, NON-DIAGNOSTIC). A + * bounded, existence-and-count read of the user's on-device ECG recordings so + * the nightly narrative can reference them FACTUALLY and attributed to the + * device. `deviceVerdicts` / `latestDeviceVerdict` are the RECORDING DEVICE's + * OWN classifications — HealthLog never re-classifies, never reads the + * waveform (it never enters this payload), and never emits a verdict of its + * own. The prompt constrains any mention to the device's attribution; the + * grounding gate constrains any restated count. Omitted when no recordings + * fall in the window. + */ + ecg?: { + /** Number of ECG recordings in the trailing window. */ + recordingCount: number; + /** Distribution of the DEVICE's own verdicts across the window. */ + deviceVerdicts: { + irregular: number; + notDetected: number; + inconclusive: number; + }; + /** The latest recording's DEVICE verdict, or null when unclassified. */ + latestDeviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + /** Days since the latest recording. */ + latestRecordedDaysAgo: number; + /** The latest recording's device-reported average HR, or null. */ + latestAverageHeartRate: number | null; + }; } /** @@ -1316,14 +1344,19 @@ export async function extractFeatures( (typeof sinceDays === "number" && sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS); if (includeIntegrations) { - const [labs, preventiveCare, workouts] = await Promise.all([ + const [labs, preventiveCare, workouts, ecg] = await Promise.all([ readLabsBriefingBlock(userId, now), readPreventiveCareBlock(userId, now), readWorkoutsBlock(userId, now), + // S10 — ECG device-verdict descriptor (never the waveform). Weaves the + // ECG silo into the narrative; the model may reference it, attributed to + // the device, but never interprets the trace (it never sees one). + readEcgBriefingBlock(userId, now), ]); if (labs) features.labs = labs; if (preventiveCare) features.preventiveCare = preventiveCare; if (workouts) features.workouts = workouts; + if (ecg) features.ecg = ecg; } // v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row From cb883e1470b01b2c70c9b1337eef900804bad0b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 09:05:24 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat(dashboard):=20cohesion=20polish=20?= =?UTF-8?q?=E2=80=94=20hero=20score=20rings=20return,=20per-metric=20accen?= =?UTF-8?q?ts,=20quiet=20milestone=20moment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resurface the selected hero score rings on the Today hero: the Settings picker's selection and drag order render again as a calm ring cluster beneath the health ring, each ring in its wellness hue, flat-painted, and linking to its existing detail surface (score anatomy pages, medications). The health ring now opens the Insights overview like its cluster siblings. Empty selection keeps the hero exactly as before. - Per-metric identity accents on the daily rail via the existing --tile-* hue vocabulary: a soft inset edge (stress for tension windows, strain for ECG pointers, primary for dose and coach cards); the same mark ties the ECG cross-link and the intraday pulse card shell to their rail siblings. Utility items (sync, preventive care) stay accent-less. - Quiet "reached" treatment for the milestone card: a soft success halo over the theme card plus a one-shot spring reveal on --ring-spring, with its reduced-motion fallback beside the keyframes. - The Today hero carries the one sanctioned atmosphere wash (a faint primary mix over the card in the wellness-tile pattern); the loading skeleton shares it so the swap changes content, never the surface. - Skeleton primitive upgraded from the whole-block pulse to a single soft shimmer sweep, reduced-motion guarded in globals.css; dependent test assertions repointed. - Ring link / dose-tally aria strings restored in all six locales. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../__tests__/dashboard-chart-reveal.test.tsx | 6 +- src/app/globals.css | 107 ++++++++++++ src/app/page-client.tsx | 31 +++- .../__tests__/host-metrics-chart.test.tsx | 2 +- .../daily/__tests__/priority-card.test.tsx | 25 +++ .../daily/__tests__/today-hero.test.tsx | 33 ++++ src/components/daily/priority-card.tsx | 34 +++- src/components/daily/today-hero-skeleton.tsx | 13 +- src/components/daily/today-hero.tsx | 155 ++++++++++++++++-- .../__tests__/daily-briefing.test.tsx | 2 +- .../__tests__/trend-annotation.test.tsx | 2 +- src/components/insights/ecg-cross-link.tsx | 7 +- .../insights/intraday-pulse-chart.tsx | 8 +- src/components/ui/__tests__/skeleton.test.tsx | 8 +- src/components/ui/skeleton.tsx | 18 +- 21 files changed, 427 insertions(+), 48 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6052505f5..43373eedb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8607,7 +8607,9 @@ "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", "readFullBriefing": "Vollständiges Briefing lesen", "worthALook": "Einen Blick wert", - "allClear": "Heute braucht nichts deine Aufmerksamkeit." + "allClear": "Heute braucht nichts deine Aufmerksamkeit.", + "ringLink": "{metric}-Details öffnen", + "ringDosesAria": "{taken} von {scheduled} Dosen heute genommen" }, "milestone": { "metric": { diff --git a/messages/en.json b/messages/en.json index c709430cd..937805e02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8607,7 +8607,9 @@ "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", "readFullBriefing": "Read the full briefing", "worthALook": "Worth a look", - "allClear": "Nothing needs your attention today." + "allClear": "Nothing needs your attention today.", + "ringLink": "Open {metric} details", + "ringDosesAria": "{taken} of {scheduled} doses taken today" }, "milestone": { "metric": { diff --git a/messages/es.json b/messages/es.json index f9149ecbf..e56efdbf9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8607,7 +8607,9 @@ "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", "readFullBriefing": "Leer el informe completo", "worthALook": "Merece un vistazo", - "allClear": "Hoy nada necesita tu atención." + "allClear": "Hoy nada necesita tu atención.", + "ringLink": "Abrir detalles de {metric}", + "ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy" }, "milestone": { "metric": { diff --git a/messages/fr.json b/messages/fr.json index f39b20aff..9f16448a4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8607,7 +8607,9 @@ "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", "readFullBriefing": "Lire le briefing complet", "worthALook": "À regarder", - "allClear": "Rien ne requiert ton attention aujourd'hui." + "allClear": "Rien ne requiert ton attention aujourd'hui.", + "ringLink": "Ouvrir les détails de {metric}", + "ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui" }, "milestone": { "metric": { diff --git a/messages/it.json b/messages/it.json index fca2f5bf6..b3a7cc930 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8607,7 +8607,9 @@ "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", "readFullBriefing": "Leggi il briefing completo", "worthALook": "Da tenere d'occhio", - "allClear": "Oggi nulla richiede la tua attenzione." + "allClear": "Oggi nulla richiede la tua attenzione.", + "ringLink": "Apri i dettagli di {metric}", + "ringDosesAria": "{taken} di {scheduled} dosi assunte oggi" }, "milestone": { "metric": { diff --git a/messages/pl.json b/messages/pl.json index c5c238c92..d1d1ae6ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8607,7 +8607,9 @@ "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", "readFullBriefing": "Przeczytaj pełny briefing", "worthALook": "Warto sprawdzić", - "allClear": "Dziś nic nie wymaga Twojej uwagi." + "allClear": "Dziś nic nie wymaga Twojej uwagi.", + "ringLink": "Otwórz szczegóły: {metric}", + "ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj" }, "milestone": { "metric": { diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx index 454c7dc9c..9bc8ebada 100644 --- a/src/app/__tests__/dashboard-chart-reveal.test.tsx +++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx @@ -138,8 +138,10 @@ describe("TrendCardSkeleton", () => { // Same outer chrome + min-height floor as the real TrendCard slot. expect(html).toContain("rounded-xl"); expect(html).toContain("min-h-[8rem]"); - // Reduced motion honoured via the Skeleton primitive. - expect(html).toContain("motion-reduce:animate-none"); + // Reduced motion honoured via the Skeleton primitive's shimmer class + // (its prefers-reduced-motion guard lives beside the keyframes in + // globals.css). + expect(html).toContain("skeleton-shimmer"); }); }); diff --git a/src/app/globals.css b/src/app/globals.css index 68abd833c..067640131 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -897,6 +897,113 @@ } } +/* + * ── v1.29.0 cohesion pass — the daily-value system's finishing layer ── + * + * Four small surface treatments, all expressed through the existing + * token/motion vocabulary (no new palette, no new easing): + * + * `.today-hero-wash` — the ONE sanctioned Today atmosphere: a faint + * `--primary` mix over the theme `--card` in the + * `.wellness-tile` pattern, leaning toward the + * ring corner. Softer than the insights + * `.hero-gradient` (8% → 0 vs 18/12) so the + * promoted hero reads as the calm first surface, + * not a banner. Static — nothing to motion-gate. + * `.metric-accent` — per-metric identity edge for rail cards and + * cross-link pointers. Reads `--tile-hue` (set + * inline from the SAME `--tile-*` vocabulary the + * wellness tiles use; `--primary` for the med / + * coach family) and paints a soft inset edge on + * the reading side via a ::before overlay, so it + * composes with the card's own `shadow-sm` and + * the semantic status washes (which keep the + * background). No layout width is added; the + * inset shadow follows the border radius. + * `.milestone-reached` — the S12 quiet celebration: a soft `--success` + * halo over the theme `--card` plus a one-shot + * spring reveal on the shared `--ring-spring` + * curve. No confetti, no loop; reduced-motion + * paints the settled card at once. + * `.skeleton-shimmer` — the loading language upgrade: a single soft + * `--foreground`-mix sweep across the muted + * skeleton base instead of the whole-block + * `animate-pulse` throb. Load-terminating by + * construction (skeletons unmount with the data); + * reduced-motion collapses to the static base. + */ +.today-hero-wash { + background: radial-gradient( + 120% 150% at 100% 0%, + color-mix(in srgb, var(--primary) 8%, var(--card)) 0%, + var(--card) 60% + ); +} + +.metric-accent { + position: relative; +} +.metric-accent::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 3px 0 0 0 + color-mix(in srgb, var(--tile-hue, var(--primary)) 55%, transparent); +} + +.milestone-reached { + background: radial-gradient( + 130% 130% at 85% 0%, + color-mix(in srgb, var(--success) 14%, var(--card)) 0%, + color-mix(in srgb, var(--success) 4%, var(--card)) 60%, + var(--card) 100% + ); + animation: milestone-reached-in 640ms var(--ring-spring) both; +} +@keyframes milestone-reached-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@media (prefers-reduced-motion: reduce) { + .milestone-reached { + animation: none; + } +} + +@keyframes skeleton-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} +.skeleton-shimmer { + background-image: linear-gradient( + 100deg, + transparent 36%, + color-mix(in srgb, var(--foreground) 6%, transparent) 50%, + transparent 64% + ); + background-size: 250% 100%; + background-repeat: no-repeat; + animation: skeleton-shimmer 1.8s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer { + animation: none; + background-image: none; + } +} + /* ── Global reduced-motion backstop ────────────────────────────────── * Defensive catch-all so any animation/transition we forget to gate * per-component still collapses to near-instant when the user has asked diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 3ac4cbde1..e05e2a2dd 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -27,8 +27,11 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { cn } from "@/lib/utils"; import { resolveDashboardLayout, + resolveHeroRingOrder, + HEALTH_SCORE_RING_ID, type DashboardLayout, } from "@/lib/dashboard-layout"; +import type { DashboardScoreRing } from "@/lib/dashboard/score-rings"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -232,6 +235,32 @@ export default function DashboardPageClient() { const insightsEnabled = useModuleEnabled("insights"); const digestQuery = useDailyDigest(isAuthenticated && insightsEnabled); + // v1.29.0 — the Settings-picked hero score rings, resurfaced on the Today + // hero. The snapshot resolves the selection server-side (data- and + // module-gated, selection order preserved); `resolveHeroRingOrder` + // reconciles the user's persisted drag order against what actually + // resolved — the SAME reconciler the Settings picker uses. The + // health-score ring stays the hero's fixed anchor, so its position in the + // stored order is skipped here; the legacy non-snapshot path carries no + // resolved rings and degrades to the health ring alone. + const heroScoreRings = useMemo(() => { + const snap = snapshotQuery.data; + const rings = snap?.scoreRings ?? []; + if (rings.length === 0) return []; + const order = resolveHeroRingOrder( + snap?.layout?.heroRingOrder, + rings.map((r) => r.id), + ); + const byId = new Map(rings.map((r) => [r.id, r])); + const ordered: DashboardScoreRing[] = []; + for (const id of order) { + if (id === HEALTH_SCORE_RING_ID) continue; + const ring = byId.get(id); + if (ring) ordered.push(ring); + } + return ordered; + }, [snapshotQuery.data]); + const analyticsSlimQuery = useAnalyticsQuery({ slice: "summaries", enabled: !snapshotEnabled && isAuthenticated, @@ -783,7 +812,7 @@ export default function DashboardPageClient() { (!mounted || digestQuery.isLoading ? ( ) : digestQuery.data ? ( - + ) : null)} {/* v1.18.6 — the spotlight tour launcher moved to the app-shell diff --git a/src/components/admin/__tests__/host-metrics-chart.test.tsx b/src/components/admin/__tests__/host-metrics-chart.test.tsx index b436736fa..00c3143ca 100644 --- a/src/components/admin/__tests__/host-metrics-chart.test.tsx +++ b/src/components/admin/__tests__/host-metrics-chart.test.tsx @@ -122,6 +122,6 @@ describe("", () => { const html = render(); expect(html).toContain("admin-host-metrics-heading"); expect(html).toContain("Host load"); - expect(html).toContain("animate-pulse"); + expect(html).toContain("skeleton-shimmer"); }); }); diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index 9ba0dd84c..7db766635 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -60,6 +60,31 @@ describe("", () => { expect(info).toContain("bg-info/10"); }); + it("carries the metric-family accent for metric-flavoured kinds only", () => { + const tension = render( + , + ); + expect(tension).toContain("metric-accent"); + expect(tension).toContain("--tile-hue:var(--tile-stress)"); + const ecg = render( + , + ); + expect(ecg).toContain("--tile-hue:var(--tile-strain)"); + // Utility kinds carry no metric identity — status alone speaks. + const sync = render(); + expect(sync).not.toContain("metric-accent"); + }); + + it("gives the milestone card the quiet reached treatment instead of the generic fade-in", () => { + const html = render( + , + ); + expect(html).toContain("milestone-reached"); + expect(html).not.toContain("animate-insight-in"); + // The success wash still carries the semantic border. + expect(html).toContain("border-success/25"); + }); + it("renders a navigation action as a link carrying the href", () => { const html = render( ", () => { expect(html).toBe(""); }); + it("renders the selected score rings as a cluster in the caller's order", () => { + const rings: DashboardScoreRing[] = [ + { id: "SLEEP_SCORE", score: 74, band: "yellow" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, + ]; + const html = render(); + expect(html).toContain('data-slot="today-hero-ring-cluster"'); + // Caller order (= the user's resolved hero ring order) is preserved. + const sleepAt = html.indexOf('data-ring="SLEEP_SCORE"'); + const medsAt = html.indexOf('data-ring="MED_COMPLIANCE"'); + expect(sleepAt).toBeGreaterThan(-1); + expect(medsAt).toBeGreaterThan(sleepAt); + // Each ring links to its existing detail surface. + expect(html).toContain('href="/insights/scores/sleep"'); + expect(html).toContain('href="/medications"'); + // The dose ring shows today's tally, not a mystery percentage. + expect(html).toContain("1/3"); + expect(html).toContain("1 of 3 doses taken today"); + }); + + it("renders no ring cluster when the selection is empty", () => { + const html = render(); + expect(html).not.toContain('data-slot="today-hero-ring-cluster"'); + // The health-score ring alone still paints, exactly as before. + expect(html).toContain('data-slot="today-hero-score"'); + }); + it("shows the first-class all-clear line when a score is present but nothing is notable", () => { const html = render( = { destructive: "bg-destructive/10 border-destructive/25", }; +/** + * v1.29.0 cohesion — per-kind metric identity, drawn from the SAME `--tile-*` + * hue vocabulary the wellness tiles paint (plus `--primary` for the med and + * coach families, the tone every medication / coach surface in Insights + * already carries). Rendered as the `.metric-accent` inset edge — a quiet + * identity mark, never a fill — so a pulse card, an ECG card, and a dose card + * each read as their metric family while staying one card system. + * + * Deliberately partial: `preventive_care` and `sync_issue` are utility items + * with no metric family, and `milestone` belongs to the celebration layer + * (`.milestone-reached`, `--success`) rather than to one metric — those three + * stay accent-less. Values are token references only (`var(--…)`), the + * `RingTile` / `TILE_HUE` pattern. + */ +const KIND_HUE: Partial> = { + dose_window: "var(--primary)", + coach_checkin: "var(--primary)", + ecg_new_recording: "var(--tile-strain)", + tension_window: "var(--tile-stress)", +}; + export interface PriorityCardProps { item: PriorityItem; /** @@ -82,16 +103,27 @@ export function PriorityCard({ item, onAction, className }: PriorityCardProps) { const { t } = useTranslations(); const Icon = KIND_ICON[item.kind]; const actions = item.actions.slice(0, 3); + const hue = KIND_HUE[item.kind]; + // S12 — the quiet "reached" moment: the milestone card swaps the generic + // fade-in for the `.milestone-reached` treatment (a soft `--success` halo + // over the theme card + a one-shot spring reveal on `--ring-spring`; + // reduced-motion fallback lives beside the keyframes in globals.css). The + // halo's background wins over the status wash's flat `bg-success/10` + // (unlayered CSS beats utilities); the wash keeps the success border. + const isMilestone = item.kind === "milestone"; return ( diff --git a/src/components/daily/today-hero-skeleton.tsx b/src/components/daily/today-hero-skeleton.tsx index 6a9a63eb7..1abae7136 100644 --- a/src/components/daily/today-hero-skeleton.tsx +++ b/src/components/daily/today-hero-skeleton.tsx @@ -9,18 +9,19 @@ import { Skeleton } from "@/components/ui/skeleton"; * two-card rail row — so the swap to the loaded hero happens in place * with zero layout shift. * - * The shimmer rides the `` primitive (`animate-pulse`), which is - * reduced-motion safe by construction (`motion-reduce:animate-none`). - * Always `aria-hidden`: the tile-strip skeleton alongside carries the - * page's loading semantics, so a second announcement here would double up - * for screen readers. + * The shimmer rides the `` primitive (`skeleton-shimmer`), which + * is reduced-motion safe via its own CSS guard in `globals.css`. The shell + * carries the same `.today-hero-wash` atmosphere as the loaded hero so the + * swap changes content, never the surface. Always `aria-hidden`: the + * tile-strip skeleton alongside carries the page's loading semantics, so a + * second announcement here would double up for screen readers. */ export function TodayHeroSkeleton() { return (