feat(daily): milestones — the calm reward layer

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 08:10:58 +02:00
1 parent 1427125b7e
commit 8b7bb7914e
11 files changed
+908 -6

No files matched your search

+37 -1
View File
@@ -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."
}
}
}
}
}
+37 -1
View File
@@ -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."
}
}
}
}
}
+37 -1
View File
@@ -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."
}
}
}
}
}
+37 -1
View File
@@ -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."
}
}
}
}
}
+37 -1
View File
@@ -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."
}
}
}
}
}
+37 -1
View File
@@ -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."
}
}
}
}
}
+68
View File
@@ -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<typeof buildDailyDigest>,
): 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);
});
});
+242
View File
@@ -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",
]);
});
});
+49
View File
@@ -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,
+101
View File
@@ -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<Milestone | null> {
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",
),
},
});
+226
View File
@@ -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<Record<MeasurementType, string>> = {
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<Record<MeasurementType, string>> = {
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<MilestoneKind, number> = {
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 }),
};
}