feat(insights): surface the same-time baseline on Today and in the grid

The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.

The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.

The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.

Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:32:22 +02:00
1 parent b2955beffd
commit 6bd8d387ae
24 files changed
+1204 -32

No files matched your search

+3
View File
@@ -7396,6 +7396,7 @@ paths:
- STAIR_DESCENT_SPEED_BASELINE
- SIX_MINUTE_WALK_BAND
- TRAJECTORY
- SAME_TIME_BASELINE
description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI,
READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an
`insufficient` value with reason `not_implemented`."
@@ -27283,6 +27284,7 @@ components:
- STAIR_DESCENT_SPEED_BASELINE
- SIX_MINUTE_WALK_BAND
- TRAJECTORY
- SAME_TIME_BASELINE
description: Echoes the requested derived-metric id (tags the union).
status:
type: string
@@ -32913,6 +32915,7 @@ components:
- milestone
- ecg_new_recording
- tension_window
- same_time_baseline
description: Closed rail-item kind.
itemKey:
description: Stable dismiss identity, namespaced `<kind>:...`. Present only on the observational kinds (milestone /
+23 -1
View File
@@ -3640,6 +3640,10 @@
"TRAJECTORY": {
"method": "Eine Ausgleichsgerade (kleinste Quadrate), an deine jüngsten Tageswerte angepasst und über einen kurzen Zeitraum fortgeschrieben — mit dem klassischen Vorhersageintervall, das umso breiter wird, je weiter es reicht. Wird nur berechnet, wenn der jüngste Trend deutlich genug und deine Datenreihe lang und aktuell genug ist; sonst wird keine Linie gezeichnet.",
"caveat": "Eine Fortschreibung deines eigenen jüngsten Trends — keine Vorhersage, kein Ziel und keine klinische Prognose. Sie gilt nur, solange das jüngste Muster anhält; das schattierte Band zeigt, wie schnell diese Sicherheit nachlässt."
},
"SAME_TIME_BASELINE": {
"method": "Dein heutiger laufender Gesamtwert im Vergleich zum Median derselben Ortszeit-Stunde aus deinen letzten vier Wochen, mit einem robusten Median-±-MAD-Band darum. Nur Tage mit ausreichend aufgezeichneten Details zählen, und es wird kein Vergleich gezeigt, bevor mindestens vierzehn davon vorliegen.",
"caveat": "Ein Vergleich mit deiner eigenen jüngeren Vergangenheit, kein Ziel. Er sagt, wo der heutige Tag zu dieser Stunde gegenüber deinem üblichen Muster steht — und nichts darüber, ob dieses Muster für dich das richtige ist."
}
},
"scores": {
@@ -3673,6 +3677,13 @@
"caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.",
"insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.",
"daysNeeded": "Noch etwa {count} Tage Erfassung nötig, bis eine Projektion erscheinen kann."
},
"sameTime": {
"sectionTitle": "Heute bisher",
"sectionSubtitle": "Deine laufenden Summen im Vergleich zu dem, was zu dieser Stunde für dich üblich ist",
"learning": "Lernt deinen üblichen Tag — {count} von {target} Tagen",
"framing": "Üblich bis {time}: {typical} {unit}",
"comparisonLabel": "Dein üblicher Tag"
}
},
"cardioFitness": {
@@ -8979,6 +8990,16 @@
"notDetected": "keinen unregelmäßigen Rhythmus",
"inconclusive": "ein nicht eindeutiges Ergebnis"
}
},
"sameTimeBaseline": {
"title": {
"below": "Hinter deinem üblichen Tempo",
"above": "Vor deinem üblichen Tempo"
},
"body": {
"below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.",
"above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind."
}
}
},
"action": {
@@ -8990,7 +9011,8 @@
"checkinLetGo": "Loslassen",
"viewMilestone": "Ansehen",
"viewPulse": "Puls ansehen",
"viewEcg": "Aufzeichnung ansehen"
"viewEcg": "Aufzeichnung ansehen",
"viewSteps": "Schritte ansehen"
},
"today": {
"scoreLabel": "Gesundheitsscore",
+23 -1
View File
@@ -3640,6 +3640,10 @@
"TRAJECTORY": {
"method": "A least-squares line fitted to your recent daily values and projected forward over a short horizon, with the textbook prediction interval that widens the further it reaches. Computed only when the recent trend is strong enough and your history is long and current enough; otherwise no line is drawn.",
"caveat": "A projection of your own recent trend — not a prediction, a target, or a clinical forecast. It holds only if the recent pattern continues; the shaded band shows how quickly that certainty fades."
},
"SAME_TIME_BASELINE": {
"method": "Your running total for today set against the median of the same local hour across your last four weeks, with a robust median ± MAD band around it. Only days with enough recorded detail count towards it, and no comparison is shown until at least fourteen of them are in hand.",
"caveat": "A comparison with your own recent history, not a target. It says where today sits against your usual pattern at this hour and nothing about whether that pattern is right for you."
}
},
"scores": {
@@ -3673,6 +3677,13 @@
"caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.",
"insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough.",
"daysNeeded": "About {count} more days of tracking needed before a projection can appear."
},
"sameTime": {
"sectionTitle": "Today so far",
"sectionSubtitle": "Your running totals against what is usual for you at this hour",
"learning": "Learning your usual day — {count} of {target} days",
"framing": "Typical by {time}: {typical} {unit}",
"comparisonLabel": "Your typical day"
}
},
"cardioFitness": {
@@ -8979,6 +8990,16 @@
"notDetected": "no irregular rhythm",
"inconclusive": "an inconclusive result"
}
},
"sameTimeBaseline": {
"title": {
"below": "Behind your usual pace",
"above": "Ahead of your usual pace"
},
"body": {
"below": "By {time} you are at {today} steps. On a usual day you are around {typical} by now.",
"above": "By {time} you are at {today} steps, ahead of the {typical} that is usual for you by now."
}
}
},
"action": {
@@ -8990,7 +9011,8 @@
"checkinLetGo": "Let it go",
"viewMilestone": "View",
"viewPulse": "View pulse",
"viewEcg": "View recording"
"viewEcg": "View recording",
"viewSteps": "View steps"
},
"today": {
"scoreLabel": "Health score",
+23 -1
View File
@@ -3640,6 +3640,10 @@
"TRAJECTORY": {
"method": "Una recta de mínimos cuadrados ajustada a tus valores diarios recientes y proyectada hacia delante en un horizonte corto, con el intervalo de predicción clásico que se ensancha cuanto más lejos llega. Solo se calcula cuando la tendencia reciente es lo bastante marcada y tu historial es lo bastante largo y actual; de lo contrario no se traza ninguna línea.",
"caveat": "Una proyección de tu propia tendencia reciente, no una predicción, un objetivo ni un pronóstico clínico. Solo se cumple si el patrón reciente continúa; la banda sombreada muestra con qué rapidez se desvanece esa certeza."
},
"SAME_TIME_BASELINE": {
"method": "Tu total acumulado de hoy frente a la mediana de esa misma hora local durante tus últimas cuatro semanas, con una banda robusta de mediana ± MAD alrededor. Solo cuentan los días con suficiente detalle registrado, y no se muestra ninguna comparación hasta tener al menos catorce.",
"caveat": "Una comparación con tu propio historial reciente, no un objetivo. Indica dónde se sitúa hoy frente a tu patrón habitual a esta hora, y nada sobre si ese patrón es el adecuado para ti."
}
},
"scores": {
@@ -3673,6 +3677,13 @@
"caveat": "Una proyección de tu propia tendencia reciente, no una predicción. La banda sombreada se ensancha para mostrar la rapidez con que crece la incertidumbre.",
"insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado.",
"daysNeeded": "Faltan unos {count} días más de registro para que pueda aparecer una proyección."
},
"sameTime": {
"sectionTitle": "Hoy hasta ahora",
"sectionSubtitle": "Tus totales acumulados frente a lo que es habitual en ti a esta hora",
"learning": "Aprendiendo tu día habitual — {count} de {target} días",
"framing": "Habitual a las {time}: {typical} {unit}",
"comparisonLabel": "Tu día habitual"
}
},
"cardioFitness": {
@@ -8979,6 +8990,16 @@
"notDetected": "ningún ritmo irregular",
"inconclusive": "un resultado no concluyente"
}
},
"sameTimeBaseline": {
"title": {
"below": "Por detrás de tu ritmo habitual",
"above": "Por delante de tu ritmo habitual"
},
"body": {
"below": "A las {time} llevas {today} pasos. En un día habitual rondas los {typical} a esta hora.",
"above": "A las {time} llevas {today} pasos, por encima de los {typical} habituales a esta hora."
}
}
},
"action": {
@@ -8990,7 +9011,8 @@
"checkinLetGo": "Dejarlo ir",
"viewMilestone": "Ver",
"viewPulse": "Ver pulso",
"viewEcg": "Ver registro"
"viewEcg": "Ver registro",
"viewSteps": "Ver pasos"
},
"today": {
"scoreLabel": "Puntuación de salud",
+23 -1
View File
@@ -3640,6 +3640,10 @@
"TRAJECTORY": {
"method": "Une droite des moindres carrés ajustée à vos valeurs journalières récentes et projetée sur un court horizon, avec l’intervalle de prédiction classique qui s’élargit à mesure qu’il s’éloigne. Calculée uniquement lorsque la tendance récente est suffisamment marquée et que votre historique est assez long et récent ; sinon aucune ligne n’est tracée.",
"caveat": "Une projection de votre propre tendance récente — ni une prédiction, ni un objectif, ni un pronostic clinique. Elle ne tient que si le schéma récent se poursuit ; la bande ombrée montre la vitesse à laquelle cette certitude s’estompe."
},
"SAME_TIME_BASELINE": {
"method": "Votre total cumulé du jour comparé à la médiane de cette même heure locale sur vos quatre dernières semaines, avec une bande robuste médiane ± MAD autour. Seuls les jours suffisamment détaillés comptent, et aucune comparaison n'apparaît avant d'en avoir au moins quatorze.",
"caveat": "Une comparaison avec votre propre historique récent, pas un objectif. Elle dit où se situe aujourd'hui par rapport à votre habitude à cette heure, et rien sur le fait que cette habitude vous convienne."
}
},
"scores": {
@@ -3673,6 +3677,13 @@
"caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.",
"insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.",
"daysNeeded": "Encore environ {count} jours d’enregistrement nécessaires avant qu’une projection puisse apparaître."
},
"sameTime": {
"sectionTitle": "Aujourd'hui jusqu'ici",
"sectionSubtitle": "Vos totaux cumulés face à ce qui est habituel chez vous à cette heure",
"learning": "Apprentissage de votre journée habituelle — {count} jours sur {target}",
"framing": "Habituel à {time} : {typical} {unit}",
"comparisonLabel": "Votre journée habituelle"
}
},
"cardioFitness": {
@@ -8979,6 +8990,16 @@
"notDetected": "aucun rythme irrégulier",
"inconclusive": "un résultat non concluant"
}
},
"sameTimeBaseline": {
"title": {
"below": "En retrait de votre rythme habituel",
"above": "En avance sur votre rythme habituel"
},
"body": {
"below": "À {time} vous en êtes à {today} pas. Un jour habituel, vous tournez autour de {typical} à cette heure.",
"above": "À {time} vous en êtes à {today} pas, au-dessus des {typical} habituels à cette heure."
}
}
},
"action": {
@@ -8990,7 +9011,8 @@
"checkinLetGo": "Laisser tomber",
"viewMilestone": "Voir",
"viewPulse": "Voir le pouls",
"viewEcg": "Voir l’enregistrement"
"viewEcg": "Voir l’enregistrement",
"viewSteps": "Voir les pas"
},
"today": {
"scoreLabel": "Score de santé",
+23 -1
View File
@@ -3640,6 +3640,10 @@
"TRAJECTORY": {
"method": "Una retta ai minimi quadrati adattata ai tuoi valori giornalieri recenti e proiettata in avanti su un breve orizzonte, con l’intervallo di previsione classico che si allarga man mano che si estende. Calcolata solo quando la tendenza recente è abbastanza marcata e la tua cronologia è abbastanza lunga e aggiornata; altrimenti non viene tracciata alcuna linea.",
"caveat": "Una proiezione della tua tendenza recente — non una previsione, un obiettivo o una prognosi clinica. Vale solo se lo schema recente continua; la banda ombreggiata mostra quanto rapidamente quella certezza svanisce."
},
"SAME_TIME_BASELINE": {
"method": "Il tuo totale progressivo di oggi confrontato con la mediana della stessa ora locale nelle ultime quattro settimane, con una banda robusta mediana ± MAD attorno. Contano solo i giorni con abbastanza dettaglio registrato, e nessun confronto viene mostrato finché non ce ne sono almeno quattordici.",
"caveat": "Un confronto con la tua storia recente, non un obiettivo. Dice dove si colloca oggi rispetto alla tua abitudine a quest'ora, e nulla sul fatto che quell'abitudine sia quella giusta per te."
}
},
"scores": {
@@ -3673,6 +3677,13 @@
"caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.",
"insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.",
"daysNeeded": "Servono ancora circa {count} giorni di registrazione prima che possa comparire una proiezione."
},
"sameTime": {
"sectionTitle": "Oggi finora",
"sectionSubtitle": "I tuoi totali progressivi rispetto a ciò che per te è abituale a quest'ora",
"learning": "Sto imparando la tua giornata tipo — {count} di {target} giorni",
"framing": "Abituale entro le {time}: {typical} {unit}",
"comparisonLabel": "La tua giornata tipo"
}
},
"cardioFitness": {
@@ -8979,6 +8990,16 @@
"notDetected": "nessun ritmo irregolare",
"inconclusive": "un risultato non conclusivo"
}
},
"sameTimeBaseline": {
"title": {
"below": "Sotto il tuo ritmo abituale",
"above": "Sopra il tuo ritmo abituale"
},
"body": {
"below": "Alle {time} sei a {today} passi. In una giornata tipo a quest'ora sei intorno a {typical}.",
"above": "Alle {time} sei a {today} passi, sopra i {typical} che per te sono abituali a quest'ora."
}
}
},
"action": {
@@ -8990,7 +9011,8 @@
"checkinLetGo": "Lascia andare",
"viewMilestone": "Vedi",
"viewPulse": "Vedi battito",
"viewEcg": "Vedi registrazione"
"viewEcg": "Vedi registrazione",
"viewSteps": "Vedi i passi"
},
"today": {
"scoreLabel": "Punteggio di salute",
+23 -1
View File
@@ -3640,6 +3640,10 @@
"TRAJECTORY": {
"method": "Prosta dopasowana metodą najmniejszych kwadratów do Twoich ostatnich wartości dziennych i rzutowana w przód na krótki horyzont, z klasycznym przedziałem predykcji, który poszerza się tym bardziej, im dalej sięga. Obliczana tylko wtedy, gdy ostatni trend jest dość wyraźny, a Twoja historia wystarczająco długa i aktualna; w przeciwnym razie linia nie jest rysowana.",
"caveat": "Rzut Twojego własnego ostatniego trendu — nie prognoza, cel ani kliniczne przewidywanie. Sprawdza się tylko, jeśli ostatni wzorzec się utrzyma; zacieniony pas pokazuje, jak szybko ta pewność maleje."
},
"SAME_TIME_BASELINE": {
"method": "Twoja dzisiejsza suma narastająca zestawiona z medianą tej samej godziny lokalnej z ostatnich czterech tygodni, z odpornym pasmem mediana ± MAD wokół niej. Liczą się tylko dni z wystarczającą liczbą zapisów, a porównanie nie pojawia się, dopóki nie ma ich co najmniej czternastu.",
"caveat": "Porównanie z twoją własną niedawną historią, a nie cel. Mówi, gdzie dziś jesteś względem swojego zwykłego wzorca o tej godzinie — i nic o tym, czy ten wzorzec jest dla ciebie właściwy."
}
},
"scores": {
@@ -3673,6 +3677,13 @@
"caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.",
"insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.",
"daysNeeded": "Potrzeba jeszcze około {count} dni rejestrowania, zanim pojawi się rzut."
},
"sameTime": {
"sectionTitle": "Dziś do tej pory",
"sectionSubtitle": "Twoje sumy narastające względem tego, co o tej godzinie jest u ciebie zwykłe",
"learning": "Uczę się twojego zwykłego dnia — {count} z {target} dni",
"framing": "Zwykle do {time}: {typical} {unit}",
"comparisonLabel": "Twój zwykły dzień"
}
},
"cardioFitness": {
@@ -8979,6 +8990,16 @@
"notDetected": "brak nieregularnego rytmu",
"inconclusive": "wynik niejednoznaczny"
}
},
"sameTimeBaseline": {
"title": {
"below": "Poniżej swojego zwykłego tempa",
"above": "Powyżej swojego zwykłego tempa"
},
"body": {
"below": "Do {time} masz {today} kroków. W zwykły dzień o tej porze jesteś w okolicach {typical}.",
"above": "Do {time} masz {today} kroków, powyżej zwykłych {typical} o tej porze."
}
}
},
"action": {
@@ -8990,7 +9011,8 @@
"checkinLetGo": "Odpuść",
"viewMilestone": "Zobacz",
"viewPulse": "Zobacz tętno",
"viewEcg": "Zobacz zapis"
"viewEcg": "Zobacz zapis",
"viewSteps": "Zobacz kroki"
},
"today": {
"scoreLabel": "Wynik zdrowia",
+9 -9
View File
@@ -1983,34 +1983,34 @@ model MeasurementRollup {
// still inside the grace window have no row yet and are folded live
// from the raw rows on read, so the current day is always available.
model IntradayCumulativeProfile {
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
/// Local calendar day the profile describes (`YYYY-MM-DD`), in the
/// user's timezone at fold time. Lexicographically ordered, so a
/// trailing-window read and the retention prune are both range scans.
dateKey String @map("date_key") @db.VarChar(10)
dateKey String @map("date_key") @db.VarChar(10)
/// Twenty-four running totals in canonical units — element `h` is the
/// value accumulated from local midnight through the END of local hour
/// `h`, so the array is non-decreasing and `[23]` equals `dayTotal`.
/// A DST day still has exactly 24 slots: the repeated hour folds into
/// its slot and a skipped hour repeats its predecessor's running total,
/// which keeps every stored day index-comparable with every other.
hourlyCumulative Float[] @map("hourly_cumulative")
hourlyCumulative Float[] @map("hourly_cumulative")
/// The day's total — `hourlyCumulative[23]`, denormalised so a coverage
/// check never has to read the array.
dayTotal Float @map("day_total")
dayTotal Float @map("day_total")
/// How many raw per-sample rows backed this profile. A day folded from
/// two rows is not a shape; the baseline reader uses this to refuse such
/// a day rather than let it drag the median.
sampleCount Int @map("sample_count")
sampleCount Int @map("sample_count")
/// The IANA zone the day was bucketed in. A user who later moves zones
/// leaves behind profiles cut on the old clock; carrying the zone lets
/// the reader drop the mismatched days instead of silently comparing
/// hours that mean different things.
timezone String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+2
View File
@@ -22,6 +22,7 @@ import {
Activity,
Award,
CalendarClock,
Footprints,
HeartPulse,
MessageCircle,
Pill,
@@ -53,6 +54,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
milestone: Award,
ecg_new_recording: HeartPulse,
tension_window: Activity,
same_time_baseline: Footprints,
};
/**
@@ -10,20 +10,49 @@ import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
* (`@/components/charts/chart-runtime`); its fixed 40 px container is owned
* by the tile, so the async gap paints an empty band of identical size —
* zero layout shift, pixel-identical once mounted.
*
* Two modes, one chart.
*
* - **Trend** (the original): one series, area-filled under a sentiment
* stroke. Every baseline tile in the vitals grid uses this.
* - **Comparison** (v1.34.0): the same series against a second one, drawn
* index-aligned behind it as a dashed muted line with no fill. The
* same-time baseline needs two trajectories on one pair of axes — today's
* running total and the person's typical one — and nothing in the tree
* drew that. It is a second mode here rather than a new chart family
* because it is the same picture with one more line: same container, same
* fixed height, same shared y-domain, so the two curves are read against
* each other and not against two different scales.
*
* The comparison line renders as an `<Area>` with no fill rather than a
* `<Line>` so the chart stays a plain `AreaChart` and both series contribute
* to the `dataMin`/`dataMax` domain automatically.
*/
export function DeltaSparkline({
data,
strokeVar,
comparisonLabel,
}: {
/** `{ i, v }` points, already length-guarded (≥2) by the tile. */
data: Array<{ i: number; v: number }>;
/**
* `{ i, v }` points, already length-guarded (≥2) by the tile. An optional
* `b` on a point is the comparison series' value at the same index —
* present on every point or on none.
*/
data: Array<{ i: number; v: number; b?: number }>;
/** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */
strokeVar: string;
/**
* Accessible name for the comparison curve, when one is present. Passed
* through to the series so a screen reader reaching the chart is told what
* the second line is rather than meeting an unnamed shape.
*/
comparisonLabel?: string;
}) {
// A stable per-instance id for the gradient <defs>. Deriving it from a
// label slug collides when two tiles share a localized label (the gradient
// fill on the second tile would not resolve); useId() is collision-free.
const fillId = useId();
const hasComparison = data.some((point) => point.b !== undefined);
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 2, left: 0 }}>
@@ -34,6 +63,21 @@ export function DeltaSparkline({
</linearGradient>
</defs>
<YAxis hide domain={["dataMin", "dataMax"]} />
{/* The comparison curve sits UNDER the live one so today's line is
never occluded by the reference it is being judged against. */}
{hasComparison ? (
<Area
type="monotone"
dataKey="b"
name={comparisonLabel}
stroke="var(--muted-foreground)"
strokeWidth={1.5}
strokeDasharray="3 3"
fill="none"
isAnimationActive={false}
dot={false}
/>
) : null}
<Area
type="monotone"
dataKey="v"
@@ -53,6 +53,17 @@ export interface SparklineDeltaTileProps {
* small fixed height.
*/
series?: number[];
/**
* v1.34.0 — an optional second curve drawn behind `series` on the same
* axes, index-aligned with it. This is what turns the sparkline into the
* two-trajectory comparison the same-time baseline needs: today's running
* total against the person's typical one. Ignored unless it is exactly as
* long as `series` — two curves of different lengths would be read as
* aligned when they are not, which is worse than one curve.
*/
comparisonSeries?: number[];
/** Accessible name for `comparisonSeries` (e.g. "your typical day"). */
comparisonLabel?: string;
/** Signed delta vs the baseline; drives the arrow + the (Δ) caption. */
delta?: number | null;
/** Whether an up move is good / bad / neutral for this metric. */
@@ -90,6 +101,8 @@ export function SparklineDeltaTile({
unit,
icon: Icon,
series,
comparisonSeries,
comparisonLabel,
delta = null,
directionSentiment = "neutral",
framing,
@@ -117,8 +130,16 @@ export function SparklineDeltaTile({
return `${sign}${fmt.number(Math.abs(d), precision)}`;
};
// The comparison curve only rides along when it lines up point-for-point;
// a mismatched length would draw two curves that look aligned and are not.
const paired =
comparisonSeries && series && comparisonSeries.length === series.length
? comparisonSeries
: null;
const sparkData =
series && series.length >= 2 ? series.map((v, i) => ({ i, v })) : null;
series && series.length >= 2
? series.map((v, i) => (paired ? { i, v, b: paired[i] } : { i, v }))
: null;
// The sparkline tints with the trend sentiment so it reads "same signal"
// as the arrow; neutral metrics ride the muted-foreground line.
@@ -199,7 +220,11 @@ export function SparklineDeltaTile({
className="mt-3 h-10 w-full"
data-slot="sparkline-delta-tile-spark"
>
<DeltaSparkline data={sparkData} strokeVar={strokeVar} />
<DeltaSparkline
data={sparkData}
strokeVar={strokeVar}
comparisonLabel={comparisonLabel}
/>
</div>
) : null}
@@ -42,12 +42,25 @@ export const SECTION_MOBILITY: { metric: DerivedMetricId; type: string }[] = [
{ metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" },
];
/**
* v1.34.0 — the cumulative metrics that carry a same-time baseline. Order is
* the order the section renders in: steps leads because it is the number
* people actually check, and the one a "how am I doing" question is usually
* about.
*/
export const SECTION_CUMULATIVE: string[] = [
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"FLIGHTS_CLIMBED",
];
/**
* The full set of tokens the overview reads in one batch — the five wellness
* scores + the four derived re-frames + one baseline per vital (minus HRV,
* which has its own balance tile) + the mobility/body metrics. The
* coincident-deviation flag is read by the dedicated "Today's signal" card,
* not here.
* which has its own balance tile) + the mobility/body metrics + one same-time
* baseline per cumulative metric. The coincident-deviation flag is read by the
* dedicated "Today's signal" card, not here.
*/
export function dashboardTokens(): DerivedBatchToken[] {
const tokens: DerivedBatchToken[] = [
@@ -69,6 +82,9 @@ export function dashboardTokens(): DerivedBatchToken[] {
for (const { metric } of SECTION_MOBILITY) {
tokens.push({ metric });
}
for (const type of SECTION_CUMULATIVE) {
tokens.push({ metric: "SAME_TIME_BASELINE", type });
}
return tokens;
}
@@ -23,8 +23,10 @@ import { type DerivedBatchRead } from "./use-derived-metric";
import {
SECTION_VITALS,
SECTION_MOBILITY,
SECTION_CUMULATIVE,
type DashboardDerived,
} from "./use-dashboard-derived";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
@@ -34,6 +36,7 @@ import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"
import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance";
import type { BmiValue } from "@/lib/insights/derived/bmi";
import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
/**
@@ -129,6 +132,14 @@ const MOBILITY_TILE_LAYOUT_ID: Record<string, string> = {
WRIST_TEMPERATURE: "wrist-temperature",
};
/** Same-time-baseline `MeasurementType` → layout tile id (routed slug). */
const CUMULATIVE_TILE_LAYOUT_ID: Record<string, string> = {
ACTIVITY_STEPS: "steps",
ACTIVE_ENERGY_BURNED: "active-energy",
WALKING_RUNNING_DISTANCE: "walking-distance",
FLIGHTS_CLIMBED: "flights-climbed",
};
/**
* v1.15.11 W2c — `true` when the layout marks this tile id visible (or the
* layout does not enumerate the id, in which case the tile is always-on). A
@@ -308,6 +319,118 @@ function BaselineTile({
);
}
/**
* v1.34.0 — the same-time baseline tile for one cumulative metric.
*
* Every other tile in this grid frames a latest reading against a band. A
* cumulative metric has no latest reading, so this one frames today's running
* total against the person's own typical total at the same local hour, and the
* sparkline carries both trajectories on one pair of axes rather than one.
*
* The states are the states the rest of the grid already has, with one
* addition that matters more here than anywhere else: a baseline built from
* three days is not a baseline, so below the floor the tile says it is still
* learning and shows how far along it is. It never renders a number it does
* not have the history for.
*/
function SameTimeBaselineTile({
type,
read,
isLoading,
}: TileProps & {
type: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const unitDisplay = useUnitDisplay();
const data = read<SameTimeBaselineValue>({
metric: "SAME_TIME_BASELINE",
type,
});
if (isLoading || !data) return null;
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Footprints;
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const label = labelKey ? t(labelKey) : type;
const tileId = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type;
if (data.status === "insufficient") {
// Absent, not gated. No sub-daily rows today (every daily-total source —
// Fitbit, Withings, Polar — is permanently here), an unsupported type, or
// a day whose first hour has not finished yet. None of these is a state
// the user can act on, so the tile does not render at all rather than
// occupying the grid with an apology.
if (data.reason !== "learning_usual_day") return null;
return (
<div
data-slot="same-time-tile"
data-metric={tileId}
data-state="learning"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader
icon={Icon}
title={label}
titleAs="h2"
titleClassName="truncate"
/>
<p
className="text-muted-foreground text-sm"
data-slot="same-time-tile-learning"
>
{t("insights.derived.sameTime.learning", {
count: data.coverage.historyDays,
target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
})}
</p>
<CoverageMeter coverage={data.coverage} />
</div>
);
}
const v = data.value!;
const unit = unitDisplay.unitFor(type);
const toShown = (raw: number) => unitDisplay.toDisplay(type, raw);
// A transformed distance reads in kilometres and wants a decimal; a step,
// flight or kilocalorie count is a whole number and a decimal on it is noise.
const precision = unitDisplay.isTransformed(type) ? 1 : 0;
// The comparison is anchored to the END of `asOfHour`, which is the top of
// the following hour on the clock. 24-hour form, built from the number the
// server already resolved — deriving a locale-formatted instant on the
// client from a server-side local hour is how a hydration mismatch starts.
const byClock = `${String(v.asOfHour + 1).padStart(2, "0")}:00`;
return (
<div data-slot="same-time-tile" data-metric={tileId} data-state="ok">
<SparklineDeltaTile
label={label}
value={toShown(v.todayValue)}
unit={unit}
icon={Icon}
series={v.todayCurve.map(toShown)}
comparisonSeries={v.typicalCurve.map(toShown)}
comparisonLabel={t("insights.derived.sameTime.comparisonLabel")}
delta={unitDisplay.toDisplayDelta(type, v.delta)}
directionSentiment="up-good"
precision={precision}
framing={t("insights.derived.sameTime.framing", {
time: byClock,
typical: fmt.number(toShown(v.typicalValue), precision),
unit,
})}
provenance={
<MetricProvenance
metric="SAME_TIME_BASELINE"
provenance={data.provenance}
/>
}
footer={<LearnMoreLink concept={tileId} />}
/>
</div>
);
}
/**
* Estimated 6-minute-walk band tile (`SIX_MINUTE_WALK_BAND` passthrough
* re-frame). Surfaces the device's estimated distance + trend always; the
@@ -632,6 +755,29 @@ function hasRenderableMobility(
return false;
}
/**
* Whether the batch holds at least one same-time tile worth painting. Mirrors
* the tile's own gate exactly: only an `ok` value or the honest "still
* learning" state counts, so the section heading never strands over a grid
* that turned out to be empty (every daily-total source lands there).
*/
function hasRenderableSameTime(
read: DerivedBatchRead,
layout: InsightsLayout | undefined,
): boolean {
for (const type of SECTION_CUMULATIVE) {
if (!tileVisible(layout, CUMULATIVE_TILE_LAYOUT_ID[type] ?? type)) continue;
const data = read<SameTimeBaselineValue>({
metric: "SAME_TIME_BASELINE",
type,
});
if (!data) continue;
if (data.status === "ok") return true;
if (data.reason === "learning_usual_day") return true;
}
return false;
}
export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
const { t } = useTranslations();
@@ -724,6 +870,20 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, read, isLoading]);
const cumulativeTiles = useMemo(() => {
return SECTION_CUMULATIVE.map((type) => {
const id = CUMULATIVE_TILE_LAYOUT_ID[type] ?? type;
return {
id,
order: tileOrder(layout, id),
node: <SameTimeBaselineTile key={type} type={type} {...tileProps} />,
};
})
.filter((e) => tileVisible(layout, e.id))
.sort((a, b) => a.order - b.order);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout, read, isLoading]);
// A failed batch (retry:0 + the 8 s client ceiling) must read as an error,
// not as "no data" — otherwise the whole Vitals + Wellness surface silently
// vanishes. Surface a compact message + a Retry that refetches the one
@@ -761,6 +921,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
// pops in below it — the CLS this pass removes.
const showSection = isLoading || hasRenderableVital(read, layout);
const showMobility = !isLoading && hasRenderableMobility(read, layout);
const showSameTime = !isLoading && hasRenderableSameTime(read, layout);
return (
<div
@@ -802,6 +963,30 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
</div>
</section>
)}
{showSameTime && (
<section
data-slot="vitals-same-time"
aria-label={t("insights.derived.sameTime.sectionTitle")}
className="space-y-3"
>
<SectionHeading
icon={Footprints}
title={t("insights.derived.sameTime.sectionTitle")}
subtitle={t("insights.derived.sameTime.sectionSubtitle")}
/>
<div
data-slot="vitals-same-time-grid"
className={cn(
"grid grid-cols-1 gap-4",
// A lone tile spans the full width rather than orphaning a
// one-third column, matching the sections above it.
cumulativeTiles.length > 1 && "sm:grid-cols-2 lg:grid-cols-3",
)}
>
{cumulativeTiles.map((tile) => tile.node)}
</div>
</section>
)}
{showMobility && (
<section
data-slot="vitals-mobility"
+124
View File
@@ -10,6 +10,7 @@ import {
MAX_WORTH_A_LOOK,
type DailyDigestCoachPlan,
type DailyDigestInput,
type DailyDigestSameTime,
} from "@/lib/daily/digest";
import {
COACH_CHECKIN_KEEP_INTENT,
@@ -21,6 +22,7 @@ import type { PriorityItem } from "@/lib/daily/priority-item";
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
@@ -823,6 +825,128 @@ describe("buildDailyDigest — S11 tension_window item", () => {
});
});
describe("buildDailyDigest — same_time_baseline item", () => {
const sameTimeItem = (d: ReturnType<typeof buildDailyDigest>) =>
d.worthALook.find((i) => i.kind === "same_time_baseline");
function sameTime(
over: Partial<DailyDigestSameTime> = {},
): DailyDigestSameTime {
return {
type: "ACTIVITY_STEPS",
band: "below",
asOfHour: 20,
todayValue: 1834,
typicalValue: 5111,
todayLabel: "1,834",
typicalLabel: "5,111",
...over,
};
}
it("names both totals and the hour they are compared at", () => {
const d = buildDailyDigest(input({ sameTime: sameTime() }), t);
const item = sameTimeItem(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
// The comparison covers everything through the END of hour 20, which
// reads on the clock as 21:00.
expect(item?.body).toContain("21:00");
expect(item?.body).toContain("1,834");
expect(item?.body).toContain("5,111");
expect(item?.actions[0].intent).toBe("steps.view");
expect(item?.actions[0].href).toBe("/insights/steps");
});
it("says nothing about a day that is tracking its own normal", () => {
// A rail that reports "exactly as usual" every afternoon teaches people to
// stop reading the rail.
const d = buildDailyDigest(
input({ sameTime: sameTime({ band: "within" }) }),
t,
);
expect(sameTimeItem(d)).toBeUndefined();
});
it("says nothing when the engine returned no comparison", () => {
expect(
sameTimeItem(buildDailyDigest(input({ sameTime: null }), t)),
).toBeUndefined();
expect(sameTimeItem(buildDailyDigest(input({}), t))).toBeUndefined();
});
it("stays silent when the insights module is off", () => {
const d = buildDailyDigest(
input({ modules: { insights: false }, sameTime: sameTime() }),
t,
);
expect(sameTimeItem(d)).toBeUndefined();
});
it("uses a different sentence when today is ahead", () => {
const below = buildDailyDigest(input({ sameTime: sameTime() }), t);
const above = buildDailyDigest(
input({ sameTime: sameTime({ band: "above" }) }),
t,
);
expect(sameTimeItem(above)?.title).not.toBe(sameTimeItem(below)?.title);
expect(sameTimeItem(above)?.body).not.toBe(sameTimeItem(below)?.body);
});
it("keys the dismissal by day and metric, never by the hour", () => {
// Folding the hour in would undo the dismissal every sixty minutes.
const d = buildDailyDigest(
input({ sameTime: sameTime(), todayLocalDate: "2026-07-16" }),
t,
);
expect(sameTimeItem(d)?.itemKey).toBe(
sameTimeBaselineItemKey("2026-07-16", "ACTIVITY_STEPS"),
);
// The same key whatever the clock says — that is the whole point.
const later = buildDailyDigest(
input({
sameTime: sameTime({ asOfHour: 9 }),
todayLocalDate: "2026-07-16",
}),
t,
);
expect(sameTimeItem(later)?.itemKey).toBe(sameTimeItem(d)?.itemKey);
});
it("is dropped once dismissed, and stays dropped as the hour moves on", () => {
const dismissed = new Set([
sameTimeBaselineItemKey("2026-07-16", "ACTIVITY_STEPS"),
]);
for (const asOfHour of [9, 14, 20]) {
const d = buildDailyDigest(
input({
sameTime: sameTime({ asOfHour }),
todayLocalDate: "2026-07-16",
dismissedItemKeys: dismissed,
}),
t,
);
expect(sameTimeItem(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: "nightscout", state: "parked" },
],
sameTime: sameTime(),
}),
t,
);
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
expect(sameTimeItem(d)).toBeUndefined();
});
});
describe("buildDailyDigest — ecg_new_recording (S10)", () => {
const ecgItem = (d: ReturnType<typeof buildDailyDigest>) =>
d.worthALook.find((i) => i.kind === "ecg_new_recording");
+96
View File
@@ -31,6 +31,7 @@ import {
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import {
@@ -127,6 +128,37 @@ export interface DailyDigestTensionWindow {
* DTO carries no samples). The builder decides "new" from `recordedAt`; the
* card attributes any verdict to the recording device.
*/
/**
* v1.34.0 — the day's same-time activity read, already computed and gated by
* `computeSameTimeBaseline`. The builder formats it and decides nothing: the
* band verdict, the two totals and the hour are the server's, and the copy is
* a deterministic template over them.
*
* Null whenever the engine returned its `insufficient` arm — still learning
* the usual day, no intraday rows today, or the day too young to compare. The
* rail stays quiet in every one of those cases rather than guessing.
*/
export interface DailyDigestSameTime {
/** The cumulative metric compared (`ACTIVITY_STEPS`). */
type: string;
/** Where today sits against the typical band at this hour. */
band: "below" | "within" | "above";
/** The last completed local hour the comparison is anchored to (0–23). */
asOfHour: number;
/** Today's running total through the end of `asOfHour`, already rounded. */
todayValue: number;
/** The typical total at the same hour, already rounded. */
typicalValue: number;
/**
* The same two figures grouped for the reader's locale. The copy
* interpolates these because the server translator substitutes raw strings
* and "1834 steps" is not how anybody writes it; the numbers above stay so a
* consumer that wants to compute rather than read still can.
*/
todayLabel: string;
typicalLabel: string;
}
export interface DailyDigestEcg {
recordedAt: Date;
/** The recording device's OWN verdict, or null when unclassified. */
@@ -213,6 +245,13 @@ export interface DailyDigestInput {
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
/**
* v1.34.0 — today's cumulative-activity read against this person's own
* typical total at the same local hour, or null. Optional so a consumer that
* predates it stays valid; the builder treats a missing value as "nothing to
* say about today's activity yet".
*/
sameTime?: DailyDigestSameTime | null;
/**
* S10 — the freshest ECG recording (device verdict + recordedAt only), or
* null. Optional so consumers that predate the ECG weave stay valid; the
@@ -514,6 +553,53 @@ function buildTensionWindowItem(
};
}
/**
* v1.34.0 — the same-time activity card.
*
* A cumulative metric has no meaningful latest reading, so this is the one
* rail item whose whole content is a comparison: today's running total against
* the person's own typical total at this hour. Every number in it was computed
* and gated server-side; the builder only chooses which of two sentences to
* render and fills the blanks.
*
* It speaks only when today is actually OUTSIDE the typical band. A day
* tracking its own normal is not worth a card, and a rail that says "you are
* doing exactly what you usually do" every afternoon teaches people to stop
* reading the rail.
*/
function buildSameTimeBaselineItem(
sameTime: DailyDigestSameTime | null | undefined,
modules: DigestModuleMap,
todayLocalDate: string,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!sameTime) return null;
if (sameTime.band === "within") return null;
// The comparison is against everything accumulated through the END of
// `asOfHour`, which reads on the clock as the top of the next hour.
const time = `${String(sameTime.asOfHour + 1).padStart(2, "0")}:00`;
return {
kind: "same_time_baseline",
itemKey: sameTimeBaselineItemKey(todayLocalDate, sameTime.type),
title: t(`daily.item.sameTimeBaseline.title.${sameTime.band}`),
body: t(`daily.item.sameTimeBaseline.body.${sameTime.band}`, {
time,
today: sameTime.todayLabel,
typical: sameTime.typicalLabel,
}),
status: "info",
actions: [
{
labelKey: "daily.action.viewSteps",
intent: "steps.view",
href: "/insights/steps",
},
],
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
@@ -779,6 +865,16 @@ export function buildDailyDigest(
t,
);
if (tension) worthALook.push(tension);
// v1.34.0 — the same-time activity read sits last for the same reason the
// tension marker does: it is context about the day so far, not an action
// that expires, so anything time-sensitive wins the bounded rail ahead of it.
const sameTime = buildSameTimeBaselineItem(
input.sameTime,
input.modules,
input.todayLocalDate,
t,
);
if (sameTime) worthALook.push(sameTime);
// Defence-in-depth: no card ever exceeds the P1 action cap.
for (const item of worthALook) {
+62 -5
View File
@@ -33,12 +33,14 @@ import {
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSameTime,
type DailyDigestSyncIssue,
type DailyDigestTensionWindow,
} from "@/lib/daily/digest";
import {
ecgItemKey,
milestoneItemKey,
sameTimeBaselineItemKey,
tensionWindowItemKey,
} from "@/lib/daily/priority-item-key";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
@@ -52,6 +54,7 @@ import {
type Milestone,
} from "@/lib/daily/milestones";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { computeSameTimeBaseline } from "@/lib/insights/derived/same-time-baseline";
/** Integration states that mean "your action is needed to keep data flowing". */
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
@@ -166,8 +169,25 @@ const DIGEST_EXTRAS_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
interface DailyDigestExtras {
milestone: Milestone | null;
tensionWindow: DailyDigestTensionWindow | null;
/**
* The cached cell holds the same-time read WITHOUT its locale-formatted
* labels: the cell is keyed on (user, local day) and a locale-specific
* string in it would be served to a reader who has since switched language.
* The labels are grouped at the call site, where the locale is resolved.
*/
sameTime: Omit<DailyDigestSameTime, "todayLabel" | "typicalLabel"> | null;
}
/**
* v1.34.0 — the rail's same-time read is STEPS only.
*
* All four cumulative metrics carry a same-time baseline and all four have a
* tile in the Insights grid. The rail is a glance of at most three items, so
* putting four activity cards on it would crowd out an overdue dose to say the
* same thing four ways. Steps is the number people actually check.
*/
const SAME_TIME_RAIL_TYPE = "ACTIVITY_STEPS";
function digestExtrasCacheKey(userId: string, dateKey: string): string {
return `${userId}|daily-digest-extras|${dateKey}`;
}
@@ -186,13 +206,35 @@ async function loadDailyDigestExtrasCached(
// revision ran them sequentially). Each is already fault-isolated
// internally (a milestone read-hiccup or a tension-read failure leaves
// its own field quiet rather than breaking the other).
const [milestone, tensionWindow] = await Promise.all([
const [milestone, tensionWindow, sameTime] = await Promise.all([
gatherFreshMilestone(userId, now),
loadIntradayPulse(userId, timezone, todayLocalDate)
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
.catch(() => null),
// The engine gates itself — it returns its `insufficient` arm while it
// is still learning the usual day, when the account has no intraday
// rows at all (every daily-total source), and before the day's first
// hour has finished. Only the `ok` arm reaches the rail. Passing no
// profile because the engine reads none; fault-isolated like its
// siblings, since the digest is a must-not-fail path.
computeSameTimeBaseline(userId, null, {
type: SAME_TIME_RAIL_TYPE,
now,
})
.then((d): DailyDigestExtras["sameTime"] =>
d.status === "ok"
? {
type: d.value.type,
band: d.value.band,
asOfHour: d.value.asOfHour,
todayValue: Math.round(d.value.todayValue),
typicalValue: Math.round(d.value.typicalValue),
}
: null,
)
.catch(() => null),
]);
return { milestone, tensionWindow };
return { milestone, tensionWindow, sameTime };
},
annotate,
DIGEST_EXTRAS_CACHE_TTL_MS,
@@ -435,14 +477,14 @@ export async function loadDailyDigest(
// per-sample tension reads on every request — see its docblock for the
// cache/invalidation contract.
const insightsEnabled = modules.insights !== false;
const { milestone, tensionWindow } = insightsEnabled
const { milestone, tensionWindow, sameTime } = insightsEnabled
? await loadDailyDigestExtrasCached(
user.id,
user.timezone,
todayLocalDate,
now,
)
: { milestone: null, tensionWindow: null };
: { milestone: null, tensionWindow: null, sameTime: null };
// Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds
// (milestone / ecg_new_recording / tension_window) can ever be dismissed, so
@@ -454,6 +496,7 @@ export async function loadDailyDigest(
tensionWindow
? tensionWindowItemKey(todayLocalDate, tensionWindow.partOfDay)
: null,
sameTime ? sameTimeBaselineItemKey(todayLocalDate, sameTime.type) : null,
].filter((k): k is string => k !== null);
const dismissedRows =
@@ -465,7 +508,20 @@ export async function loadDailyDigest(
: [];
const dismissedItemKeys = new Set(dismissedRows.map((r) => r.itemKey));
const { t } = getServerTranslator(resolveLocale(locale));
const resolvedLocale = resolveLocale(locale);
const { t } = getServerTranslator(resolvedLocale);
// Group the two figures for the reader. `Intl.NumberFormat` is the only
// locale-aware step the digest takes on its own; everything else it renders
// comes through the translator.
const groupNumber = new Intl.NumberFormat(resolvedLocale).format;
const sameTimeItem: DailyDigestSameTime | null = sameTime
? {
...sameTime,
todayLabel: groupNumber(sameTime.todayValue),
typicalLabel: groupNumber(sameTime.typicalValue),
}
: null;
const digest = buildDailyDigest(
{
@@ -481,6 +537,7 @@ export async function loadDailyDigest(
coachPlans,
milestone,
tensionWindow,
sameTime: sameTimeItem,
latestEcg,
todayLocalDate,
dismissedItemKeys,
+16
View File
@@ -39,3 +39,19 @@ export function tensionWindowItemKey(
): string {
return `tension_window:${localDayKey}:${partOfDay}`;
}
/**
* `same_time_baseline:<localDayKey>:<MeasurementType>` — one key per metric
* per local day.
*
* Deliberately NOT keyed on the hour the comparison was anchored to. The card
* refreshes as the day goes on, and folding the hour into the key would mean a
* dismissal at 14:00 was undone at 15:00 — the user would have to swat the
* same card away nine times before bed. Dismissing it means "not today".
*/
export function sameTimeBaselineItemKey(
localDayKey: string,
type: string,
): string {
return `same_time_baseline:${localDayKey}:${type}`;
}
+4 -1
View File
@@ -36,13 +36,15 @@ export const PRIORITY_ITEM_KINDS = [
"milestone",
"ecg_new_recording",
"tension_window",
"same_time_baseline",
] as const;
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
/**
* The OBSERVATIONAL kinds — a durable-state reward, a new-ECG pointer, an
* elevated-at-rest window. These carry no pending action; the user can only
* elevated-at-rest window, a same-time activity read. These carry no pending
* action; the user can only
* acknowledge them, so they're the only kinds the Today rail lets you
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
@@ -54,6 +56,7 @@ export const DISMISSIBLE_PRIORITY_ITEM_KINDS = [
"milestone",
"ecg_new_recording",
"tension_window",
"same_time_baseline",
] as const satisfies readonly PriorityItemKind[];
export type DismissiblePriorityItemKind =
+5
View File
@@ -49,6 +49,11 @@ export const WIPE_MODELS = [
// The rollup tier is a per-day, per-metric reconstruction of the same
// history. A wipe that leaves it behind is not a wipe.
"MeasurementRollup",
// The intraday cumulative profile is the hourly shape of a day that the
// fold kept after deleting the samples it came from. It is that day's
// record and nothing else holds it, so a wipe that leaves it behind leaves
// behind an account's activity pattern.
"IntradayCumulativeProfile",
"MeasurementReminder",
// ── Medication ──────────────────────────────────────────────────────────
+6
View File
@@ -52,6 +52,12 @@
export const BACKED_UP_MODELS = [
// ── Measurements ──────────────────────────────────────────────────────────
"Measurement",
// Not derived, despite looking it. The hourly shape of a cumulative day is
// folded out of the per-sample rows in the same transaction that deletes
// them, so past the 36-hour grace window there is nothing left to rebuild it
// from. A restore without it silently resets the same-time baseline to
// "still learning" for two weeks.
"IntradayCumulativeProfile",
// ── Medications, and everything hanging off one ───────────────────────────
// The parent alone restores a drug list with no schedule, no history and no
@@ -85,7 +85,8 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY");
expect(DERIVED_METRIC_IDS.length).toBe(16);
expect(DERIVED_METRIC_IDS).toContain("SAME_TIME_BASELINE");
expect(DERIVED_METRIC_IDS.length).toBe(17);
});
});
@@ -0,0 +1,278 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const findUniqueUser = vi.fn();
const findFirstMeasurement = vi.fn();
const findManyProfiles = vi.fn();
const findManyMeasurements = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (...a: unknown[]) => findUniqueUser(...a) },
measurement: {
findFirst: (...a: unknown[]) => findFirstMeasurement(...a),
findMany: (...a: unknown[]) => findManyMeasurements(...a),
},
intradayCumulativeProfile: {
findMany: (...a: unknown[]) => findManyProfiles(...a),
},
},
}));
import { computeSameTimeBaseline } from "../same-time-baseline";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "../registry";
/**
* The honesty contract, pinned.
*
* A same-time comparison is only worth anything if it refuses to speak when it
* cannot. Most of what follows tests silence, on purpose: the failure this
* feature must never have is a confident "you are behind" derived from three
* days of history, or from an account that has no intraday stream at all and
* therefore has today's total wrong.
*/
const TZ = "UTC";
// 20:30 UTC, so the last COMPLETED local hour is 19.
const NOW = new Date("2026-06-10T20:30:00Z");
const TODAY = "2026-06-10";
/** A curve that reaches `total` linearly across the day. */
function curve(total: number): number[] {
return Array.from({ length: 24 }, (_, h) =>
Math.round((total * (h + 1)) / 24),
);
}
function profileRow(dateKey: string, total: number, sampleCount = 200) {
return {
dateKey,
timezone: TZ,
hourlyCumulative: curve(total),
dayTotal: total,
sampleCount,
};
}
/** `days` historical profiles, newest first, ending the day before today. */
function history(days: number, total: number, sampleCount = 200) {
return Array.from({ length: days }, (_, i) => {
const d = new Date(`${TODAY}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - (i + 1));
return profileRow(d.toISOString().slice(0, 10), total, sampleCount);
});
}
beforeEach(() => {
vi.clearAllMocks();
findUniqueUser.mockResolvedValue({ timezone: TZ });
findFirstMeasurement.mockResolvedValue({ unit: "steps" });
// No live rows by default — every day under test comes off a stored profile.
findManyMeasurements.mockResolvedValue([]);
});
describe("computeSameTimeBaseline", () => {
it("refuses to speak below the history floor", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(SAME_TIME_BASELINE_MIN_HISTORY_DAYS - 1, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("learning_usual_day");
expect(d.coverage.historyDays).toBe(
SAME_TIME_BASELINE_MIN_HISTORY_DAYS - 1,
);
});
it("speaks once the floor is reached", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(SAME_TIME_BASELINE_MIN_HISTORY_DAYS, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.baselineDays).toBe(SAME_TIME_BASELINE_MIN_HISTORY_DAYS);
expect(d.value.asOfHour).toBe(19);
// Both curves cover hour 0 … 19 and are index-aligned.
expect(d.value.todayCurve).toHaveLength(20);
expect(d.value.typicalCurve).toHaveLength(20);
expect(d.value.todayValue).toBe(d.value.todayCurve[19]);
expect(d.value.typicalValue).toBe(d.value.typicalCurve[19]);
expect(d.value.delta).toBe(d.value.todayValue - d.value.typicalValue);
expect(d.value.band).toBe("below");
expect(d.value.unit).toBe("steps");
});
it("says nothing when the account has no intraday rows today", async () => {
// The permanent state for every daily-total source. Honest absence, and
// it must never be confused with a day of zero activity.
findManyProfiles.mockResolvedValue(history(20, 10_000));
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("no_intraday_today");
});
it("says nothing before the day's first hour has finished", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 100),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: new Date("2026-06-10T00:20:00Z"),
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("day_too_young");
});
it("drops days that were cut on another timezone", async () => {
// After a move, "by 21:00" means a different moment. Averaging the two
// would quietly compare unlike hours.
const moved = history(20, 10_000).map((p) => ({
...p,
timezone: "Pacific/Auckland",
}));
findManyProfiles.mockResolvedValue([profileRow(TODAY, 4000), ...moved]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
// Twenty stored days, none of them usable, and the metric says so rather
// than comparing hours that mean different things.
expect(d.reason).toBe("learning_usual_day");
expect(d.coverage.historyDays).toBe(0);
});
it("drops days too sparsely sampled to be a shape", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(20, 10_000, 2),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("insufficient");
if (d.status !== "insufficient") throw new Error("unreachable");
expect(d.reason).toBe("learning_usual_day");
expect(d.coverage.historyDays).toBe(0);
});
it("calls a day inside its own band 'within'", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 10_000),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.band).toBe("within");
expect(d.value.percentOfTypical).toBe(100);
});
it("calls a busy day 'above'", async () => {
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 30_000),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.band).toBe("above");
expect(d.value.delta).toBeGreaterThan(0);
});
it("never puts the band's lower edge below zero", async () => {
// A cumulative total cannot be negative, and a sub-zero edge would widen
// the "within" verdict on the low side for free.
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 0),
...history(20, 200),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.typicalLow).toBeGreaterThanOrEqual(0);
});
it("reports no percentage rather than a fabricated one at an empty hour", async () => {
// At 05:00 the typical total is legitimately zero. A ratio against zero is
// undefined, not a hundred percent.
findManyProfiles.mockResolvedValue([
{ ...profileRow(TODAY, 0), hourlyCumulative: new Array(24).fill(0) },
...history(20, 0).map((p) => ({
...p,
hourlyCumulative: new Array(24).fill(0),
})),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(d.value.percentOfTypical).toBeNull();
});
it("ships no projected day total", async () => {
// Extrapolating a partial day is a guess, and this feature exists to
// replace guesses with the person's own history.
findManyProfiles.mockResolvedValue([
profileRow(TODAY, 4000),
...history(20, 10_000),
]);
const d = await computeSameTimeBaseline("u1", null, {
type: "ACTIVITY_STEPS",
now: NOW,
});
expect(d.status).toBe("ok");
if (d.status !== "ok") throw new Error("unreachable");
expect(Object.keys(d.value)).not.toContain("projectedDayTotal");
});
});
@@ -128,12 +128,14 @@ export interface SameTimeBaselineOpts {
* `profile` is accepted for signature parity with every other engine in this
* directory — the dispatcher hands each one the same once-loaded profile — but
* a same-time comparison is purely against the person's own history and reads
* nothing off it. The timezone it does need is read here, because a comparison
* whose whole subject is "at this hour" cannot borrow anybody else's clock.
* nothing off it. It is therefore nullable, so a caller outside the dispatcher
* (the daily digest) does not have to issue a profile read it has no use for.
* The timezone the engine does need is read here, because a comparison whose
* whole subject is "at this hour" cannot borrow anybody else's clock.
*/
export async function computeSameTimeBaseline(
userId: string,
_profile: BaselineProfile,
_profile: BaselineProfile | null,
opts: SameTimeBaselineOpts,
): Promise<Derived<SameTimeBaselineValue>> {
const now = opts.now ?? new Date();
@@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import {
PROFILE_HOURS,
foldHourlyCumulative,
} from "../intraday-cumulative-profile";
import type { PerSampleRow } from "../consolidation-tz";
/**
* The pure fold is the whole substrate: everything the same-time baseline
* compares is a slice of what this function produced, months earlier, in the
* transaction that deleted the rows it read. If the fold is wrong nothing
* downstream can notice, because the evidence is gone.
*/
const TZ = "Europe/Berlin";
function row(
iso: string,
value: number,
externalId: string | null = null,
): PerSampleRow {
return {
id: `row-${iso}-${value}`,
type: "ACTIVITY_STEPS",
value,
measuredAt: new Date(iso),
externalId,
};
}
describe("foldHourlyCumulative", () => {
it("accumulates into the local hour each sample fell in", () => {
// 07:30 and 07:50 Berlin (= 05:30 / 05:50 UTC in June) land in hour 7;
// 21:10 Berlin lands in hour 21.
const fold = foldHourlyCumulative(
[
row("2026-06-10T05:30:00Z", 400),
row("2026-06-10T05:50:00Z", 600),
row("2026-06-10T19:10:00Z", 1000),
],
"2026-06-10",
TZ,
);
expect(fold).not.toBeNull();
expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS);
expect(fold!.hourlyCumulative[6]).toBe(0);
expect(fold!.hourlyCumulative[7]).toBe(1000);
expect(fold!.hourlyCumulative[20]).toBe(1000);
expect(fold!.hourlyCumulative[21]).toBe(2000);
expect(fold!.hourlyCumulative[23]).toBe(2000);
expect(fold!.dayTotal).toBe(2000);
expect(fold!.sampleCount).toBe(3);
});
it("never decreases", () => {
const fold = foldHourlyCumulative(
[
row("2026-06-10T06:00:00Z", 120),
row("2026-06-10T10:00:00Z", 80),
row("2026-06-10T16:00:00Z", 300),
],
"2026-06-10",
TZ,
);
const curve = fold!.hourlyCumulative;
for (let h = 1; h < curve.length; h += 1) {
expect(curve[h]).toBeGreaterThanOrEqual(curve[h - 1]);
}
});
it("skips an already-collapsed daily total", () => {
// The trap this guards: a `stats:` day-sum row would dump the whole day
// into whichever hour it happens to sit in and invent a shape that never
// happened. Only the per-sample row may contribute.
const fold = foldHourlyCumulative(
[
row(
"2026-06-10T10:00:00Z",
12_000,
"stats:HKQuantityTypeIdentifierStepCount:2026-06-10",
),
row("2026-06-10T06:00:00Z", 500),
],
"2026-06-10",
TZ,
);
expect(fold!.dayTotal).toBe(500);
expect(fold!.sampleCount).toBe(1);
});
it("skips rows from another local day", () => {
// 23:30 UTC on the 9th is already the 10th in Berlin, and 22:30 UTC on the
// 10th is already the 11th — a caller may hand over a padded window.
const fold = foldHourlyCumulative(
[
row("2026-06-09T21:00:00Z", 999),
row("2026-06-10T06:00:00Z", 500),
row("2026-06-10T22:30:00Z", 777),
],
"2026-06-10",
TZ,
);
expect(fold!.dayTotal).toBe(500);
expect(fold!.sampleCount).toBe(1);
});
it("returns null for a day with no usable sample", () => {
// Absence is absence. A zero-filled profile would claim the person walked
// nothing all day, which is a different statement from "nobody measured".
expect(foldHourlyCumulative([], "2026-06-10", TZ)).toBeNull();
expect(
foldHourlyCumulative(
[row("2026-06-10T10:00:00Z", 9000, "stats:X:2026-06-10")],
"2026-06-10",
TZ,
),
).toBeNull();
});
it("keeps a measured day of genuine zeros as a real profile", () => {
const fold = foldHourlyCumulative(
[row("2026-06-10T06:00:00Z", 0), row("2026-06-10T14:00:00Z", 0)],
"2026-06-10",
TZ,
);
expect(fold).not.toBeNull();
expect(fold!.dayTotal).toBe(0);
expect(fold!.sampleCount).toBe(2);
});
it("still produces 24 slots across a DST transition", () => {
// 2026-03-29 is the spring-forward day in Berlin: 02:00 local never
// happens. The profile must still be 24 slots so every stored day stays
// index-comparable with every other.
const fold = foldHourlyCumulative(
[row("2026-03-29T00:30:00Z", 100), row("2026-03-29T09:00:00Z", 400)],
"2026-03-29",
"Europe/Berlin",
);
expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS);
expect(fold!.dayTotal).toBe(500);
});
it("folds a fall-back day's repeated hour into one slot", () => {
// 2026-10-25 in Berlin runs 02:00–02:59 twice (00:30 and 01:30 UTC).
const fold = foldHourlyCumulative(
[row("2026-10-25T00:30:00Z", 10), row("2026-10-25T01:30:00Z", 20)],
"2026-10-25",
"Europe/Berlin",
);
expect(fold!.hourlyCumulative).toHaveLength(PROFILE_HOURS);
expect(fold!.hourlyCumulative[2]).toBe(30);
expect(fold!.dayTotal).toBe(30);
expect(fold!.sampleCount).toBe(2);
});
it("adds element-wise to the same curve a single fold would produce", () => {
// The property the additive merge in the drain relies on: a cumulative sum
// is linear, so folding two halves and adding equals folding the union.
const first = [row("2026-06-10T06:00:00Z", 500)];
const second = [row("2026-06-10T16:00:00Z", 700)];
const a = foldHourlyCumulative(first, "2026-06-10", TZ)!;
const b = foldHourlyCumulative(second, "2026-06-10", TZ)!;
const union = foldHourlyCumulative(
[...first, ...second],
"2026-06-10",
TZ,
)!;
const merged = a.hourlyCumulative.map((v, h) => v + b.hourlyCumulative[h]);
expect(merged).toEqual(union.hourlyCumulative);
expect(a.sampleCount + b.sampleCount).toBe(union.sampleCount);
});
});