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] =?UTF-8?q?feat(daily):=20milestones=20=E2=80=94=20the=20c?= =?UTF-8?q?alm=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 }), + }; +}