diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87f9ee19..0e642c52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [1.10.3] — 2026-06-03 — Personalised strain, a daily signal card, deeper derived metrics
+
+### Added
+
+- **A "Today's signal" card.** The coincident-deviation read — which notices when several of your vitals sit off their personal baseline at once — now leads the Insights overview as a calm daily card instead of a flag buried in the grid. It shows an all-clear on a normal day, names the vitals to keep an eye on when a few drift, and always frames them as possible factors, never a cause or a diagnosis.
+- **Derived bands for more of what your watch measures.** Overnight wrist temperature and stair-climbing and stair-descent pace each get a personal typical-range band, and your device's estimated six-minute-walk distance is placed against a published reference for your age, height, weight and sex. Each is shown with its method and its cited standard, and only once there is enough history. They appear under a new "Mobility & body" group.
+- **Trailing trend sparklines** on the derived tiles, drawn from the readings the tile already uses.
+
+### Changed
+
+- **The Strain score is now anchored to your own training load.** It reads how hard a day was relative to your own recent training-day effort rather than a fixed population figure, so a genuinely hard day reads high even while you are building back up; with too little history it falls back to a general reference and says so.
+- Age-banded reference norms now interpolate across bracket boundaries instead of stepping, and the sleep midpoint is computed in your own timezone.
+
## [1.10.2] — 2026-06-03 — Honest AI connection test, consistent insights, retention re-enabled
### Fixed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d4873dfe..07014f7e 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.10.2
+ version: 1.10.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2466,6 +2466,10 @@ paths:
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
+ - WRIST_TEMPERATURE_BASELINE
+ - STAIR_ASCENT_SPEED_BASELINE
+ - STAIR_DESCENT_SPEED_BASELINE
+ - SIX_MINUTE_WALK_BAND
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`."
@@ -7769,6 +7773,10 @@ components:
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
+ - WRIST_TEMPERATURE_BASELINE
+ - STAIR_ASCENT_SPEED_BASELINE
+ - STAIR_DESCENT_SPEED_BASELINE
+ - SIX_MINUTE_WALK_BAND
description: Echoes the requested derived-metric id (tags the union).
status:
type: string
@@ -7784,8 +7792,9 @@ components:
type: string
additionalProperties: {}
- type: "null"
- description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k }
- for VITALS_BASELINE); null when 'insufficient'.
+ description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k,
+ series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline);
+ null when 'insufficient'.
coverage:
$ref: "#/components/schemas/DerivedCoverage"
confidence:
diff --git a/messages/de.json b/messages/de.json
index 10928eb9..f790fc6f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2550,7 +2550,14 @@
"normal": "Normalbereich (WHO)",
"overweight": "Übergewicht (WHO)",
"obese": "Adipositas (WHO)"
- }
+ },
+ "sixMinuteBand": {
+ "green": "{percent}% des für dein Profil vorhergesagten Werts",
+ "yellow": "{percent}% des für dein Profil vorhergesagten Werts",
+ "red": "{percent}% des für dein Profil vorhergesagten Werts"
+ },
+ "sixMinuteNoBand": "Ergänze Alter, Größe, Gewicht und Geschlecht, um dies einzuordnen",
+ "mobilitySectionTitle": "Mobilität & Körper"
},
"anatomy": {
"outOf": "/100",
@@ -2618,9 +2625,25 @@
"description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV."
},
"STRAIN_SCORE": {
- "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.",
+ "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag. Die Skala ist an deine eigene jüngste Trainingstagslast angelehnt — relativ zu deiner typischen Anstrengung —, sodass ein für dich wirklich harter Tag hoch ausfällt. Solange du noch Verlauf aufbaust, wird stattdessen ein allgemeiner Referenzwert verwendet.",
"caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.",
- "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung."
+ "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung."
+ },
+ "WRIST_TEMPERATURE_BASELINE": {
+ "method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.",
+ "caveat": "Nur ein persönliches Abweichungsband — beschreibend, kein Anzeichen für Krankheit, Fieber oder Zyklusphase. Du interpretierst es selbst."
+ },
+ "STAIR_ASCENT_SPEED_BASELINE": {
+ "method": "Dein typisches Treppensteig-Tempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.",
+ "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung."
+ },
+ "STAIR_DESCENT_SPEED_BASELINE": {
+ "method": "Dein typisches Treppen-Abstiegstempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.",
+ "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung."
+ },
+ "SIX_MINUTE_WALK_BAND": {
+ "method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts — hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.",
+ "caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung — ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose."
}
},
"scores": {
@@ -2632,7 +2655,15 @@
"coincident": {
"label": "Mehrere Vitalwerte außerhalb der Baseline",
"summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.",
- "vitals": "Außerhalb des Bereichs: {list}"
+ "vitals": "Außerhalb des Bereichs: {list}",
+ "cardTitle": "Signal des Tages",
+ "allClear": "Alle deine Vitalwerte liegen heute in ihrem persönlichen Bereich.",
+ "allClearMeta": "{count} deiner Vitalwerte gegen deinen eigenen typischen Bereich geprüft.",
+ "watch": "Ein Vitalwert liegt heute außerhalb seines üblichen Bereichs.",
+ "watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.",
+ "firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.",
+ "factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.",
+ "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies."
}
},
"cardioFitness": {
diff --git a/messages/en.json b/messages/en.json
index 84e8cfc5..f9c78cba 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2550,7 +2550,14 @@
"normal": "Normal range (WHO)",
"overweight": "Overweight (WHO)",
"obese": "Obese (WHO)"
- }
+ },
+ "sixMinuteBand": {
+ "green": "{percent}% of predicted for your profile",
+ "yellow": "{percent}% of predicted for your profile",
+ "red": "{percent}% of predicted for your profile"
+ },
+ "sixMinuteNoBand": "Add your age, height, weight and sex to frame this",
+ "mobilitySectionTitle": "Mobility & body"
},
"anatomy": {
"outOf": "/100",
@@ -2618,9 +2625,25 @@
"description": "A nightly stress proxy from the shape of your intra-day HRV."
},
"STRAIN_SCORE": {
- "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
+ "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day. The scale is anchored to your own recent training-day load — relative to your typical effort — so a genuinely hard day for you reads high. While you are still building history it uses a general reference instead.",
"caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.",
- "description": "A nightly activity-load proxy from your heart-rate-weighted effort."
+ "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort."
+ },
+ "WRIST_TEMPERATURE_BASELINE": {
+ "method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.",
+ "caveat": "A personal-deviation band only — descriptive, not an illness, fever or cycle-phase signal. Interpret it yourself."
+ },
+ "STAIR_ASCENT_SPEED_BASELINE": {
+ "method": "Your typical stair-climbing pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.",
+ "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment."
+ },
+ "STAIR_DESCENT_SPEED_BASELINE": {
+ "method": "Your typical stair-descent pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.",
+ "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment."
+ },
+ "SIX_MINUTE_WALK_BAND": {
+ "method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate — never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.",
+ "caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis."
}
},
"scores": {
@@ -2632,7 +2655,15 @@
"coincident": {
"label": "Several vitals off baseline",
"summary": "{count} of your vitals sat outside their personal range today.",
- "vitals": "Outside range: {list}"
+ "vitals": "Outside range: {list}",
+ "cardTitle": "Today's signal",
+ "allClear": "All your vitals are within their personal range today.",
+ "allClearMeta": "Checked {count} of your vitals against your own typical range.",
+ "watch": "One vital is outside its usual range today.",
+ "watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.",
+ "firedHeadline": "{count} of your vitals are outside their personal range today.",
+ "factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.",
+ "building": "Building your personal baselines — track a few more days and this will appear."
}
},
"cardioFitness": {
diff --git a/messages/es.json b/messages/es.json
index e6741d04..4d7b1d04 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2550,7 +2550,14 @@
"normal": "Rango normal (OMS)",
"overweight": "Sobrepeso (OMS)",
"obese": "Obesidad (OMS)"
- }
+ },
+ "sixMinuteBand": {
+ "green": "{percent}% de lo previsto para tu perfil",
+ "yellow": "{percent}% de lo previsto para tu perfil",
+ "red": "{percent}% de lo previsto para tu perfil"
+ },
+ "sixMinuteNoBand": "Añade tu edad, altura, peso y sexo para encuadrar esto",
+ "mobilitySectionTitle": "Movilidad y cuerpo"
},
"anatomy": {
"outOf": "/100",
@@ -2618,9 +2625,25 @@
"description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día."
},
"STRAIN_SCORE": {
- "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.",
+ "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente. La escala se ancla a tu propia carga reciente de días de entrenamiento —relativa a tu esfuerzo habitual—, de modo que un día realmente duro para ti aparece alto. Mientras aún estás acumulando historial usa una referencia general en su lugar.",
"caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.",
- "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca."
+ "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual."
+ },
+ "WRIST_TEMPERATURE_BASELINE": {
+ "method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.",
+ "caveat": "Solo una banda de desviación personal: descriptiva, no una señal de enfermedad, fiebre ni fase del ciclo. Tú la interpretas."
+ },
+ "STAIR_ASCENT_SPEED_BASELINE": {
+ "method": "Tu ritmo típico al subir escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.",
+ "caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas."
+ },
+ "STAIR_DESCENT_SPEED_BASELINE": {
+ "method": "Tu ritmo típico al bajar escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.",
+ "caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas."
+ },
+ "SIX_MINUTE_WALK_BAND": {
+ "method": "La distancia ESTIMADA de marcha de seis minutos de tu dispositivo situada frente a la referencia de Enright y Sherrill para tu edad, estatura, peso y sexo, como porcentaje de lo previsto. La distancia es la estimación del dispositivo: nunca se recalcula aquí. Sin tu edad, estatura, peso y sexo se oculta el porcentaje y solo se muestran la distancia y la tendencia.",
+ "caveat": "Una estimación reformulada frente a una referencia publicada: una señal de capacidad funcional, no una prueba clínica de marcha de seis minutos ni un diagnóstico."
}
},
"scores": {
@@ -2632,7 +2655,15 @@
"coincident": {
"label": "Varias constantes fuera de la base",
"summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.",
- "vitals": "Fuera de rango: {list}"
+ "vitals": "Fuera de rango: {list}",
+ "cardTitle": "Señal de hoy",
+ "allClear": "Hoy todas tus constantes están dentro de su rango personal.",
+ "allClearMeta": "Se revisaron {count} de tus constantes frente a tu propio rango habitual.",
+ "watch": "Hoy una constante está fuera de su rango habitual.",
+ "watchVital": "Fuera de rango: {vital}. A menudo es algo puntual; conviene vigilarlo los próximos días.",
+ "firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.",
+ "factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.",
+ "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá."
}
},
"cardioFitness": {
diff --git a/messages/fr.json b/messages/fr.json
index 79b4a319..ba70c8a3 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2550,7 +2550,14 @@
"normal": "Plage normale (OMS)",
"overweight": "Surpoids (OMS)",
"obese": "Obésité (OMS)"
- }
+ },
+ "sixMinuteBand": {
+ "green": "{percent}% de la valeur prédite pour votre profil",
+ "yellow": "{percent}% de la valeur prédite pour votre profil",
+ "red": "{percent}% de la valeur prédite pour votre profil"
+ },
+ "sixMinuteNoBand": "Ajoutez votre âge, taille, poids et sexe pour cadrer ceci",
+ "mobilitySectionTitle": "Mobilité et corps"
},
"anatomy": {
"outOf": "/100",
@@ -2618,9 +2625,25 @@
"description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée."
},
"STRAIN_SCORE": {
- "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.",
+ "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante. L’échelle est calée sur votre propre charge récente des jours d’entraînement — relative à votre effort habituel —, de sorte qu’une journée vraiment difficile pour vous ressort élevée. Tant que vous constituez encore votre historique, elle utilise plutôt une référence générale.",
"caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.",
- "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque."
+ "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel."
+ },
+ "WRIST_TEMPERATURE_BASELINE": {
+ "method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.",
+ "caveat": "Une simple bande d’écart personnel — descriptive, ni un signe de maladie, de fièvre ou de phase du cycle. À vous de l’interpréter."
+ },
+ "STAIR_ASCENT_SPEED_BASELINE": {
+ "method": "Votre allure typique en montant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.",
+ "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute."
+ },
+ "STAIR_DESCENT_SPEED_BASELINE": {
+ "method": "Votre allure typique en descendant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.",
+ "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute."
+ },
+ "SIX_MINUTE_WALK_BAND": {
+ "method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil — jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.",
+ "caveat": "Une estimation reformulée par rapport à une référence publiée — un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic."
}
},
"scores": {
@@ -2632,7 +2655,15 @@
"coincident": {
"label": "Plusieurs constantes hors référence",
"summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.",
- "vitals": "Hors plage : {list}"
+ "vitals": "Hors plage : {list}",
+ "cardTitle": "Signal du jour",
+ "allClear": "Aujourd’hui, toutes vos constantes sont dans leur plage personnelle.",
+ "allClearMeta": "{count} de vos constantes vérifiées par rapport à votre propre plage habituelle.",
+ "watch": "Aujourd’hui, une constante est hors de sa plage habituelle.",
+ "watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.",
+ "firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.",
+ "factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.",
+ "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra."
}
},
"cardioFitness": {
diff --git a/messages/it.json b/messages/it.json
index be4f3266..883ac29a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2550,7 +2550,14 @@
"normal": "Intervallo normale (OMS)",
"overweight": "Sovrappeso (OMS)",
"obese": "Obesità (OMS)"
- }
+ },
+ "sixMinuteBand": {
+ "green": "{percent}% del valore previsto per il tuo profilo",
+ "yellow": "{percent}% del valore previsto per il tuo profilo",
+ "red": "{percent}% del valore previsto per il tuo profilo"
+ },
+ "sixMinuteNoBand": "Aggiungi età, altezza, peso e sesso per inquadrare questo dato",
+ "mobilitySectionTitle": "Mobilità e corpo"
},
"anatomy": {
"outOf": "/100",
@@ -2618,9 +2625,25 @@
"description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata."
},
"STRAIN_SCORE": {
- "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.",
+ "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa. La scala è ancorata al tuo carico recente nei giorni di allenamento — relativo al tuo sforzo abituale —, così che una giornata davvero dura per te risulti alta. Finché stai ancora costruendo lo storico usa invece un riferimento generale.",
"caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.",
- "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca."
+ "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale."
+ },
+ "WRIST_TEMPERATURE_BASELINE": {
+ "method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.",
+ "caveat": "Solo una banda di scostamento personale — descrittiva, non un segnale di malattia, febbre o fase del ciclo. Sei tu a interpretarla."
+ },
+ "STAIR_ASCENT_SPEED_BASELINE": {
+ "method": "Il tuo passo tipico in salita sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.",
+ "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta."
+ },
+ "STAIR_DESCENT_SPEED_BASELINE": {
+ "method": "Il tuo passo tipico in discesa sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.",
+ "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta."
+ },
+ "SIX_MINUTE_WALK_BAND": {
+ "method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo — mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.",
+ "caveat": "Una stima riformulata rispetto a un riferimento pubblicato — un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi."
}
},
"scores": {
@@ -2632,7 +2655,15 @@
"coincident": {
"label": "Più parametri fuori dalla base",
"summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.",
- "vitals": "Fuori intervallo: {list}"
+ "vitals": "Fuori intervallo: {list}",
+ "cardTitle": "Segnale di oggi",
+ "allClear": "Oggi tutti i tuoi parametri vitali sono nel loro intervallo personale.",
+ "allClearMeta": "Controllati {count} dei tuoi parametri rispetto al tuo intervallo abituale.",
+ "watch": "Oggi un parametro è fuori dal suo intervallo abituale.",
+ "watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.",
+ "firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.",
+ "factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.",
+ "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà."
}
},
"cardioFitness": {
diff --git a/messages/pl.json b/messages/pl.json
index acbf8933..a1da6cf7 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2550,7 +2550,14 @@
"normal": "Zakres prawidłowy (WHO)",
"overweight": "Nadwaga (WHO)",
"obese": "Otyłość (WHO)"
- }
+ },
+ "sixMinuteBand": {
+ "green": "{percent}% wartości przewidywanej dla Twojego profilu",
+ "yellow": "{percent}% wartości przewidywanej dla Twojego profilu",
+ "red": "{percent}% wartości przewidywanej dla Twojego profilu"
+ },
+ "sixMinuteNoBand": "Dodaj wiek, wzrost, wagę i płeć, aby to ująć w kontekście",
+ "mobilitySectionTitle": "Mobilność i ciało"
},
"anatomy": {
"outOf": "/100",
@@ -2618,9 +2625,25 @@
"description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia."
},
"STRAIN_SCORE": {
- "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.",
+ "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień. Skala jest zakotwiczona w Twoim własnym niedawnym obciążeniu z dni treningowych — względem Twojego typowego wysiłku — więc naprawdę ciężki dzień dla Ciebie wypada wysoko. Dopóki dopiero budujesz historię, używa zamiast tego ogólnego punktu odniesienia.",
"caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.",
- "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem."
+ "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku."
+ },
+ "WRIST_TEMPERATURE_BASELINE": {
+ "method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.",
+ "caveat": "Tylko osobiste pasmo odchylenia — opisowe, nie sygnał choroby, gorączki ani fazy cyklu. Interpretujesz je samodzielnie."
+ },
+ "STAIR_ASCENT_SPEED_BASELINE": {
+ "method": "Twoje typowe tempo wchodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.",
+ "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku."
+ },
+ "STAIR_DESCENT_SPEED_BASELINE": {
+ "method": "Twoje typowe tempo schodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.",
+ "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku."
+ },
+ "SIX_MINUTE_WALK_BAND": {
+ "method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia — nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.",
+ "caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza."
}
},
"scores": {
@@ -2632,7 +2655,15 @@
"coincident": {
"label": "Kilka parametrów poza bazą",
"summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.",
- "vitals": "Poza zakresem: {list}"
+ "vitals": "Poza zakresem: {list}",
+ "cardTitle": "Sygnał dnia",
+ "allClear": "Dziś wszystkie Twoje parametry życiowe mieszczą się w osobistym zakresie.",
+ "allClearMeta": "Sprawdzono {count} Twoich parametrów względem Twojego typowego zakresu.",
+ "watch": "Dziś jeden parametr jest poza swoim zwykłym zakresem.",
+ "watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.",
+ "firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.",
+ "factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.",
+ "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi."
}
},
"cardioFitness": {
diff --git a/package.json b/package.json
index 7a7689f1..86a4a942 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.10.2",
+ "version": "1.10.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
diff --git a/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql b/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql
new file mode 100644
index 00000000..e7ca80e2
--- /dev/null
+++ b/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql
@@ -0,0 +1,48 @@
+-- v1.10.3 — Strain personal-anchor cache.
+--
+-- A server-internal per-(user, day) cache that lets the nightly Strain engine
+-- anchor the 0–100 map to the user's OWN recent training-day load (an
+-- EWMA-smoothed P75 of day-total TRIMP over a 42-day chronic window) instead
+-- of the fixed population reference. Persisting the day-total TRIMP + the
+-- running EWMA reference means the chronic window reads cheap cached values
+-- rather than re-integrating 42 days of HR series every night.
+--
+-- Purely additive: one new table, no enum changes, no backfill. The nightly
+-- idempotent recompute populates the cache forward; until a user accrues
+-- enough personal training history the engine falls back to the population
+-- anchor, so behaviour matches today until the cache fills in — a clean,
+-- self-healing rollout.
+--
+-- NOT a Measurement: never read through the derived registry, never on a
+-- user-facing surface, never in the doctor PDF or FHIR bundle, never ingested
+-- from a client. A dedicated table keeps it server-internal and out of every
+-- MeasurementType completeness wall.
+--
+-- Reversibility: forward-only; dropping the table loses only the cache, and
+-- the nightly job rebuilds it (falling back to the population anchor in the
+-- interim).
+
+CREATE TABLE IF NOT EXISTS "strain_trimp_cache" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "day" TEXT NOT NULL,
+ "day_trimp" DOUBLE PRECISION NOT NULL,
+ "ref_personal" DOUBLE PRECISION NOT NULL,
+ "anchor" TEXT NOT NULL,
+ "training_days" INTEGER NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "strain_trimp_cache_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_key"
+ ON "strain_trimp_cache" ("user_id", "day");
+
+CREATE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_idx"
+ ON "strain_trimp_cache" ("user_id", "day" DESC);
+
+ALTER TABLE "strain_trimp_cache"
+ ADD CONSTRAINT "strain_trimp_cache_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users" ("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f47c76dd..14a888a8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -404,6 +404,11 @@ model User {
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
// `reminder-worker.ts`).
pushAttempts PushAttempt[]
+ // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
+ // TRIMP + running EWMA reference) so the nightly Strain engine anchors
+ // the 0–100 map to the user's own training load without re-integrating
+ // the 42-day chronic window of HR series each night.
+ strainTrimpCache StrainTrimpCache[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -933,6 +938,50 @@ model WorkoutSamples {
@@map("workout_samples")
}
+// ─── Strain personal anchor cache (v1.10.3) ─────────────────
+//
+// Server-internal per-(user, day) cache that lets the nightly Strain
+// engine anchor the 0–100 map to the USER'S OWN recent training-day load
+// instead of a fixed population reference. Each row records the scored
+// day's day-total Banister TRIMP plus the running EWMA-smoothed personal
+// reference and which anchor produced that night's score (personal once
+// the cold-start floor is met, population before). Persisting day-total
+// TRIMP means the 42-day chronic window reads cheap cached values instead
+// of re-integrating 42 days of HR series every night.
+//
+// NOT a Measurement: it is never read through the derived registry, never
+// surfaced on any user-facing surface, never in the doctor PDF or FHIR
+// bundle, and never ingested from a client. Reusing the Measurement enum
+// would have pulled an internal cache type through every MeasurementType
+// completeness wall (chart tokens, categories, PR direction); a dedicated
+// table keeps it cleanly server-internal. Idempotent on `(userId, day)` so
+// a re-fired nightly tick overwrites the day's cache row in place.
+model StrainTrimpCache {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// The scored UTC calendar day (`YYYY-MM-DD`), matching the score row's day key.
+ day String
+ /// Day-total Banister TRIMP for the day (0 when no usable HR series).
+ dayTrimp Float @map("day_trimp")
+ /// EWMA-smoothed personal reference TRIMP in effect for this day's score.
+ refPersonal Float @map("ref_personal")
+ /// Which anchor produced this day's score: `personal` once enough personal
+ /// training history accrued, `population` during cold start.
+ anchor String
+ /// Distinct training days (TRIMP > 0) seen in the chronic window — drives
+ /// the cold-start gate + the confidence label.
+ trainingDays Int @map("training_days")
+
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, day])
+ @@index([userId, day(sort: Desc)])
+ @@map("strain_trimp_cache")
+}
+
// ─── Personal records (v1.4.25 W8d — schema only) ──────────
//
// MAX vs MIN direction is stored on the row so the future detection
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 1302c484..5e13eae8 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -97,6 +97,28 @@ const RhythmEventsCard = dynamic(
})),
{ ssr: false },
);
+// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
+// a buried below-the-fold tile to the top-of-overview daily read (the
+// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
+// owns its own derived-metric query and renders four calm states. The chunk
+// loader's skeleton shares the card's `min-h-48` footprint (and the in-card
+// `CardSkeleton` matches it too) so the top of the page does not shift across
+// loading → any resolved state. Decorative → `aria-hidden`.
+const CoincidentDeviationCard = dynamic(
+ () =>
+ import("@/components/insights/coincident-deviation-card").then((mod) => ({
+ default: mod.CoincidentDeviationCard,
+ })),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
/**
* v1.4.25 W4d — Insights mother page.
@@ -246,6 +268,8 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
+
+
({
+ useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a),
+}));
+// The provenance explainer pulls a mobile flag + the i18n context; render it
+// in its desktop (Popover) form for the static markup so its trigger is in the
+// tree. The card test only asserts the trigger is present, not its open state.
+vi.mock("@/hooks/use-is-mobile", () => ({ useIsMobile: () => false }));
+
+import { CoincidentDeviationCard } from "../coincident-deviation-card";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+type Resp = DerivedMetricResponse;
+
+function deviation(
+ type: string,
+ outside: boolean,
+): CoincidentDeviationValue["vitals"][number] {
+ return {
+ type: type as never,
+ value: outside ? 70 : 55,
+ center: 55,
+ low: 48,
+ high: 62,
+ outside,
+ direction: outside ? "above" : "in",
+ };
+}
+
+function ok(
+ value: CoincidentDeviationValue,
+ band: "high" | "medium" | "low" | "draft" = "high",
+): Resp {
+ return {
+ metric: "COINCIDENT_DEVIATION",
+ status: "ok",
+ value,
+ coverage: { requiredInputs: 5, presentInputs: 5, historyDays: 30, missing: [] },
+ confidence: { score: 90, band },
+ provenance: {
+ inputs: ["RESTING_HEART_RATE", "RESPIRATORY_RATE"],
+ source: "DAY",
+ windowDays: 30,
+ computedAt: "2026-06-03T00:00:00.000Z",
+ },
+ reason: null,
+ };
+}
+
+function insufficient(): Resp {
+ return {
+ metric: "COINCIDENT_DEVIATION",
+ status: "insufficient",
+ value: null,
+ coverage: { requiredInputs: 2, presentInputs: 1, historyDays: 0, missing: [] },
+ confidence: null,
+ provenance: {
+ inputs: ["RESTING_HEART_RATE"],
+ source: "live",
+ windowDays: 30,
+ computedAt: "2026-06-03T00:00:00.000Z",
+ },
+ reason: "too_few_banded_vitals",
+ };
+}
+
+function mock(data: Resp | undefined) {
+ useDerivedMetric.mockReturnValue({ data });
+}
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("", () => {
+ it("renders a CLS-safe skeleton while the read is in flight", () => {
+ mock(undefined);
+ const html = render( );
+ expect(html).toContain('data-slot="coincident-deviation-card-skeleton"');
+ expect(html).not.toContain('data-slot="coincident-deviation-card"');
+ // The skeleton reserves the resolved card's footprint (no downward shift).
+ expect(html).toContain("min-h-48");
+ });
+
+ it("the skeleton and a resolved card share the same min-height footprint", () => {
+ mock(undefined);
+ const skeleton = render( );
+ mock(
+ ok({
+ fired: true,
+ day: "2026-06-03",
+ vitals: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ contributing: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ }),
+ );
+ const fired = render( );
+ expect(skeleton).toContain("min-h-48");
+ expect(fired).toContain("min-h-48");
+ });
+
+ it("renders the insufficient (building baselines) state, never an alarm", () => {
+ mock(insufficient());
+ const html = render( );
+ expect(html).toContain('data-state="insufficient"');
+ expect(html).toContain('data-slot="coincident-building"');
+ expect(html).toContain('data-slot="coverage-meter"');
+ // Calm, not amber/red.
+ expect(html).not.toContain("border-warning");
+ expect(html).not.toContain("text-destructive");
+ });
+
+ it("renders the all-clear state with the count checked", () => {
+ mock(
+ ok({
+ fired: false,
+ day: "2026-06-03",
+ vitals: [deviation("RESTING_HEART_RATE", false), deviation("WEIGHT", false)],
+ contributing: [],
+ }),
+ );
+ const html = render( );
+ expect(html).toContain('data-state="all-clear"');
+ expect(html).toContain("All your vitals are within their personal range");
+ expect(html).toContain("text-success");
+ // No alarm tone.
+ expect(html).not.toContain("border-warning");
+ expect(html).not.toContain("text-destructive");
+ });
+
+ it("renders the watch state for a single out-of-band vital, not the fired tone", () => {
+ mock(
+ ok({
+ fired: false,
+ day: "2026-06-03",
+ vitals: [deviation("RESTING_HEART_RATE", true), deviation("WEIGHT", false)],
+ contributing: [deviation("RESTING_HEART_RATE", true)],
+ }),
+ );
+ const html = render( );
+ expect(html).toContain('data-state="watch"');
+ expect(html).toContain("One vital is outside its usual range");
+ // Watch never uses the fired/alert border or red.
+ expect(html).not.toContain("border-warning");
+ expect(html).not.toContain("text-destructive");
+ });
+
+ it("renders the fired state with the named vitals + the possible-factors line", () => {
+ mock(
+ ok({
+ fired: true,
+ day: "2026-06-03",
+ vitals: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ contributing: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ }),
+ );
+ const html = render( );
+ expect(html).toContain('data-state="fired"');
+ expect(html).toContain('data-slot="coincident-factors"');
+ // The load-bearing framing line.
+ expect(html).toContain("Possible factors — never a cause");
+ expect(html).toContain("not a diagnosis");
+ // At most amber — never destructive/red.
+ expect(html).toContain("border-warning");
+ expect(html).not.toContain("text-destructive");
+ // The provenance affordance reaches the card.
+ expect(html).toContain('data-slot="provenance-explainer-trigger"');
+ // The state change is announced politely to SR users (not assertively).
+ expect(html).toContain('role="status"');
+ expect(html).not.toContain('role="alert"');
+ });
+
+ it("softens a fired flag to the watch tone when history is thin", () => {
+ mock(
+ ok(
+ {
+ fired: true,
+ day: "2026-06-03",
+ vitals: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ contributing: [
+ deviation("RESTING_HEART_RATE", true),
+ deviation("RESPIRATORY_RATE", true),
+ ],
+ },
+ "low",
+ ),
+ );
+ const html = render( );
+ // Two contributing + fired, but thin history → watch tone, not fired.
+ expect(html).toContain('data-state="watch"');
+ expect(html).not.toContain('data-state="fired"');
+ expect(html).not.toContain("border-warning");
+ // The coverage meter accompanies the softened flag.
+ expect(html).toContain('data-slot="coverage-meter"');
+ });
+});
diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx
new file mode 100644
index 00000000..68eb1d2f
--- /dev/null
+++ b/src/components/insights/coincident-deviation-card.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import { CheckCircle2, AlertTriangle, Activity } from "lucide-react";
+
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
+import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
+import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
+import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
+import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric";
+// Type-only — the compute payload never drags the server graph into the bundle
+// (the v1.9.0 lesson, mirrored at vitals-dashboard.tsx).
+import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
+import type { DerivedProvenance } from "@/lib/insights/derived/types";
+
+/**
+ * v1.10.3 — "Today's signal" headline card.
+ *
+ * Promotes the COINCIDENT_DEVIATION flag from one buried below-the-fold vitals
+ * tile (painted only when it fired) to a dedicated, always-mounted card at the
+ * top of the Insights overview, matching the always-present pattern Apple
+ * Vitals / WHOOP Health Monitor / Oura Symptom Radar use. The signal is the
+ * daily headline read, not an alarm: an "all clear" day is itself the
+ * reassuring product.
+ *
+ * It renders four calm states off the SINGLE existing `Derived` payload — no
+ * new engine math, no new route, no schema change:
+ * - insufficient (< 2 banded vitals) → "building your baselines" + coverage.
+ * - all-clear (ok, !fired, 0 contributing) → a green check + the count
+ * checked.
+ * - watch (ok, !fired, 1 contributing) → a calm, non-alert line naming the
+ * one vital.
+ * - fired (ok, fired, ≥ 2 contributing) → an amber awareness card naming the
+ * vitals + the load-bearing "possible factors — never a cause" line.
+ *
+ * Restraint guarantees: at most the `warning` (amber) band — never
+ * `destructive`/red, never a score, never a chart (so no Recharts), never a
+ * push. When `confidence.band` is thin (low/draft) the fired tone is softened
+ * to the watch tone + the coverage meter, so a multi-signal flag from too
+ * little history never reads as a confident verdict.
+ */
+
+const COINCIDENT_METRIC = "COINCIDENT_DEVIATION";
+
+interface CoincidentDeviationCardProps {
+ /** Gate the underlying derived-metric read (e.g. on the auth flag). */
+ enabled?: boolean;
+ className?: string;
+}
+
+/** The four calm visual states, derived from the single payload. */
+type SignalState = "insufficient" | "all-clear" | "watch" | "fired";
+
+/** The provenance ⓘ explainer for the coincident flag, wired from the map. */
+function CoincidentProvenance({
+ provenance,
+}: {
+ provenance: DerivedProvenance;
+}) {
+ const { t } = useTranslations();
+ const meta = METRIC_PROVENANCE.COINCIDENT_DEVIATION;
+ const method = (
+ <>
+ {meta.caveatKey ? (
+
+ {t(meta.caveatKey)}
+
+ ) : null}
+ {t(meta.methodKey)}
+ >
+ );
+ return (
+
+ );
+}
+
+/**
+ * The card shell — uppercase label + the provenance affordance, with the
+ * state-specific body as children. Keeps every state on one card geometry so
+ * the page reserves a stable footprint.
+ */
+function CardShell({
+ state,
+ provenance,
+ children,
+}: {
+ state: SignalState;
+ provenance?: DerivedProvenance;
+ children: React.ReactNode;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+
+ {t("insights.derived.coincident.cardTitle")}
+
+ {provenance ? : null}
+
+ {children}
+
+ );
+}
+
+/**
+ * A fixed-footprint skeleton matching the resolved card geometry exactly
+ * (CLS-safe): the same `min-h-48` shell, the same gap, and a 44px header row
+ * mirroring the provenance trigger the resolved header carries — so the card
+ * does not shift when the real content drops in.
+ */
+function CardSkeleton() {
+ return (
+
+ );
+}
+
+export function CoincidentDeviationCard({
+ enabled = true,
+ className,
+}: CoincidentDeviationCardProps) {
+ const { t } = useTranslations();
+ const { data } = useDerivedMetric(
+ COINCIDENT_METRIC,
+ { enabled },
+ );
+
+ // CLS-safe placeholder while the single read is in flight.
+ if (!data) {
+ return (
+
+
+
+ );
+ }
+
+ // Building the baselines — fewer than two banded vitals. Calm, never an
+ // alarm, never blank.
+ if (data.status === "insufficient") {
+ return (
+
+
+
+ {t("insights.derived.coincident.building")}
+
+
+
+
+ );
+ }
+
+ const v = data.value!;
+ const contributing = v.contributing;
+ const names = contributing
+ .map((d) => {
+ const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type];
+ return labelKey ? t(labelKey) : d.type;
+ })
+ .join(", ");
+
+ // Thin history behind the deepest contributing vital → soften a fired flag
+ // to the watch tone rather than presenting a confident multi-signal verdict
+ // from too little data. Read straight off the existing payload.
+ const band = data.confidence?.band;
+ const thinHistory = band === "low" || band === "draft";
+
+ // Map the single payload onto one of the four calm states.
+ const state: SignalState =
+ contributing.length === 0
+ ? "all-clear"
+ : v.fired && contributing.length >= 2 && !thinHistory
+ ? "fired"
+ : "watch";
+
+ if (state === "all-clear") {
+ return (
+
+
+
+
+
+
+ {t("insights.derived.coincident.allClear")}
+
+
+ {t("insights.derived.coincident.allClearMeta", {
+ count: v.vitals.length,
+ })}
+
+
+
+
+
+ );
+ }
+
+ if (state === "watch") {
+ return (
+
+
+
+
+
+
+ {t("insights.derived.coincident.watch")}
+
+
+ {t("insights.derived.coincident.watchVital", { vital: names })}
+
+
+
+ {thinHistory ? : null}
+
+
+ );
+ }
+
+ // fired — amber awareness, named vitals, possible-factors line (mandatory).
+ // `role="status"` (a polite live region, not the assertive `role="alert"`)
+ // so a screen-reader user gets the day-to-day state change in the calm tone
+ // the card keeps visually — the one state whose meaning shifts vs all-clear.
+ return (
+
+
+
+
+
+
+ {t("insights.derived.coincident.firedHeadline", {
+ count: contributing.length,
+ })}
+
+
+ {t("insights.derived.coincident.vitals", { list: names })}
+
+
+
+
+ {t("insights.derived.coincident.factors")}
+
+
+
+ );
+}
diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
index b87746fd..26969df0 100644
--- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
+++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
@@ -173,36 +173,80 @@ describe("", () => {
expect(html).not.toContain('data-metric="BMI"');
});
- it("surfaces the coincident-deviation flag when it fired", () => {
+ it("renders the estimated 6-minute-walk band tile with its percent framing", () => {
mockBatch((token) => {
- if (token.metric === "COINCIDENT_DEVIATION") {
+ if (token.metric === "SIX_MINUTE_WALK_BAND") {
return ok({
- fired: true,
- day: "2026-06-02",
- vitals: [],
- contributing: [
- { type: "RESTING_HEART_RATE", value: 70, center: 55, low: 48, high: 62, outside: true, direction: "above" },
- { type: "RESPIRATORY_RATE", value: 20, center: 14, low: 12, high: 16, outside: true, direction: "above" },
- ],
+ distanceM: 540,
+ predictedM: 600,
+ percentOfPredicted: 90,
+ band: "green",
+ trendDelta: 12,
+ readingCount: 5,
+ series: [520, 530, 540],
});
}
return insufficient("no_readings_in_window");
});
const html = render( );
- expect(html).toContain('data-metric="COINCIDENT_DEVIATION"');
- expect(html).toContain('data-state="fired"');
- // The provenance affordance reaches the flag.
- expect(html).toContain('data-slot="provenance-explainer-trigger"');
+ expect(html).toContain('data-slot="vitals-mobility"');
+ expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"');
+ expect(html).toContain("90% of predicted");
});
- it("hides the coincident-deviation flag when it did not fire", () => {
+ it("renders the 6-minute-walk tile distance-only when demographics are absent", () => {
mockBatch((token) => {
- if (token.metric === "COINCIDENT_DEVIATION") {
- return ok({ fired: false, day: "2026-06-02", vitals: [], contributing: [] });
+ if (token.metric === "SIX_MINUTE_WALK_BAND") {
+ return ok({
+ distanceM: 540,
+ predictedM: null,
+ percentOfPredicted: null,
+ band: null,
+ trendDelta: null,
+ readingCount: 1,
+ series: [],
+ });
+ }
+ return insufficient("no_readings_in_window");
+ });
+ const html = render( );
+ expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"');
+ // Honest prompt, never a fabricated placement.
+ expect(html).toContain("Add your age, height, weight and sex");
+ });
+
+ it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => {
+ mockBatch((token) => {
+ if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") {
+ return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] });
}
return insufficient("no_readings_in_window");
});
const html = render( );
+ expect(html).toContain('data-slot="vitals-mobility"');
+ expect(html).toContain('data-metric="STAIR_ASCENT_SPEED_BASELINE"');
+ expect(html).toContain("Mobility & body");
+ });
+
+ it("hides the whole Mobility & body section when no mobility metric has content", () => {
+ mockBatch(() => insufficient("no_readings_in_window"));
+ const html = render( );
+ expect(html).not.toContain('data-slot="vitals-mobility"');
+ expect(html).not.toContain("Mobility & body");
+ expect(html).not.toContain('data-metric="SIX_MINUTE_WALK_BAND"');
+ expect(html).not.toContain('data-metric="WRIST_TEMPERATURE_BASELINE"');
+ });
+
+ it("does not read the coincident-deviation flag (now the top-of-overview card)", () => {
+ // The flag moved to the dedicated `CoincidentDeviationCard`; the dashboard
+ // batch no longer requests it and the grid never paints it.
+ let requestedCoincident = false;
+ mockBatch((token) => {
+ if (token.metric === "COINCIDENT_DEVIATION") requestedCoincident = true;
+ return insufficient("no_readings_in_window");
+ });
+ const html = render( );
+ expect(requestedCoincident).toBe(false);
expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"');
});
});
diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx
index ddc1dada..1acb2cb8 100644
--- a/src/components/insights/derived/sparkline-delta-tile.tsx
+++ b/src/components/insights/derived/sparkline-delta-tile.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useId } from "react";
import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
import { cn } from "@/lib/utils";
@@ -80,6 +81,10 @@ export function SparklineDeltaTile({
}: SparklineDeltaTileProps) {
const { t } = useTranslations();
const fmt = useFormatters();
+ // A stable per-instance id for the gradient . Deriving it from the
+ // 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 arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment);
const trendColor = sentimentColorClass(arrowSentiment);
@@ -111,7 +116,6 @@ export function SparklineDeltaTile({
: arrowSentiment === "negative"
? "var(--warning)"
: "var(--muted-foreground)";
- const fillId = `spark-${label.replace(/[^a-zA-Z0-9]/g, "")}`;
return (
=
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
+ WRIST_TEMPERATURE_BASELINE: {
+ methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
+ standard: {
+ // Robust personal-deviation band via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
+ },
+ STAIR_ASCENT_SPEED_BASELINE: {
+ methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
+ standard: {
+ // Robust personal-trend band via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
+ },
+ STAIR_DESCENT_SPEED_BASELINE: {
+ methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
+ standard: {
+ // Robust personal-trend band via the median ± k·MAD.
+ name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
+ url: "https://doi.org/10.1016/j.jesp.2013.03.013",
+ },
+ caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
+ },
+ SIX_MINUTE_WALK_BAND: {
+ methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
+ standard: {
+ // Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
+ name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
+ url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
+ },
+ caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
+ },
};
diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx
index 8f16c0b2..0bfb8ccb 100644
--- a/src/components/insights/derived/vitals-dashboard.tsx
+++ b/src/components/insights/derived/vitals-dashboard.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
-import { Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
+import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -29,9 +29,8 @@ import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
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 { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
+import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
-import { AlertTriangle } from "lucide-react";
/**
* v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid).
@@ -64,6 +63,20 @@ const SECTION_VITALS: string[] = [
"WEIGHT",
];
+/**
+ * v1.10.3 — the any-user HealthKit baseline bands, each pinning one input and
+ * framing as a personal typical range (median ± k·MAD). The `metric` is the
+ * batch token + provenance key; `type` drives icon/label/unit. The estimated
+ * 6-minute-walk re-frame rides the same Mobility section but reads its own
+ * value shape (`SixMinuteWalkTile`). Most users carry none of these, so the
+ * tiles simply do not appear (the per-tile absent gate).
+ */
+const SECTION_MOBILITY: { metric: keyof typeof METRIC_PROVENANCE; type: string }[] = [
+ { metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" },
+ { metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" },
+ { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" },
+];
+
/** Up-is-bad for the vitals where a rise is unfavourable. */
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
@@ -134,15 +147,31 @@ interface TileProps {
isLoading: boolean;
}
-/** A single personal-typical-range tile for one vital. */
+/**
+ * A single median ± k·MAD personal-band tile. Shared by the per-vital
+ * `VITALS_BASELINE` reads and the v1.10.3 any-user HealthKit bands
+ * (`WRIST_TEMPERATURE_BASELINE`, `STAIR_ASCENT_SPEED_BASELINE`,
+ * `STAIR_DESCENT_SPEED_BASELINE`) — every one returns the same
+ * `VitalsBaselineValue` and frames as a personal typical range. The `metric`
+ * selects the batch token + provenance entry; `type` drives the icon, label
+ * and unit (and, for `VITALS_BASELINE`, the read token's `type`).
+ */
function BaselineTile({
+ metric,
type,
read,
isLoading,
-}: TileProps & { type: string }) {
+}: TileProps & {
+ metric: keyof typeof METRIC_PROVENANCE;
+ type: string;
+}) {
const { t } = useTranslations();
const fmt = useFormatters();
- const data = read
({ metric: "VITALS_BASELINE", type });
+ // `VITALS_BASELINE` is the type-generic engine and needs the type on the
+ // token; the dedicated bands pin their own single input, so no `type`.
+ const data = read(
+ metric === "VITALS_BASELINE" ? { metric, type } : { metric },
+ );
if (isLoading || !data) return null;
// Absent → don't render the tile at all.
@@ -154,13 +183,17 @@ function BaselineTile({
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const label = labelKey ? t(labelKey) : type;
const unit = displayUnit(type);
+ // `VITALS_BASELINE` tiles tag the DOM by their vital type (the stable
+ // contract the existing surfaces + tests key on); the dedicated bands tag
+ // by their own metric id.
+ const tileId = metric === "VITALS_BASELINE" ? type : metric;
// Provisional — building the band; show value-less coverage state.
if (data.status === "insufficient") {
return (
@@ -188,16 +221,53 @@ function BaselineTile({
});
return (
-
+
}
+ />
+
+ );
+}
+
+/**
+ * Estimated 6-minute-walk band tile (`SIX_MINUTE_WALK_BAND` passthrough
+ * re-frame). Surfaces the device's estimated distance + trend always; the
+ * percent-of-predicted framing only when the Enright equation's demographics
+ * are present, otherwise an honest "add your demographics" prompt. Absent
+ * when no reading exists in the window.
+ */
+function SixMinuteWalkTile({ read, isLoading }: TileProps) {
+ const { t } = useTranslations();
+ const data = read
({ metric: "SIX_MINUTE_WALK_BAND" });
+ if (isLoading || !data || data.status !== "ok" || !data.value) return null;
+ const v = data.value;
+ const framing =
+ v.band != null && v.percentOfPredicted != null
+ ? t(`insights.derived.vitals.sixMinuteBand.${v.band}`, {
+ percent: v.percentOfPredicted,
+ })
+ : t("insights.derived.vitals.sixMinuteNoBand");
+ return (
+
+
+
}
/>
@@ -322,6 +392,7 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
value={v.recentAvg}
unit={getUnitForType("HEART_RATE_VARIABILITY")}
icon={HeartPulse}
+ series={v.series}
directionSentiment="up-good"
framing={framing}
precision={0}
@@ -347,6 +418,7 @@ function BmiTile({ read, isLoading }: TileProps) {
value={v.bmi}
unit={getUnitForType("BODY_MASS_INDEX")}
icon={Scale}
+ series={v.series}
directionSentiment="neutral"
framing={framing}
precision={1}
@@ -358,59 +430,6 @@ function BmiTile({ read, isLoading }: TileProps) {
);
}
-/**
- * Coincident-deviation flag tile — surfaces ONLY when the flag fired today
- * (≥ N vitals outside their personal band on the same day). Lists the
- * contributing vitals as plain text and carries the provenance affordance
- * (the Hampel/Leys median ± k·MAD basis + the descriptive-not-clinical
- * caveat). When the flag has not fired the tile un-mounts entirely — never
- * an alarming empty card.
- */
-function CoincidentDeviationTile({ read, isLoading }: TileProps) {
- const { t } = useTranslations();
- const data = read({
- metric: "COINCIDENT_DEVIATION",
- });
- if (isLoading || !data || data.status !== "ok" || !data.value) return null;
- const v = data.value;
- if (!v.fired || v.contributing.length === 0) return null;
-
- const names = v.contributing
- .map((d) => {
- const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type];
- return labelKey ? t(labelKey) : d.type;
- })
- .join(", ");
-
- return (
-
-
-
- {t("insights.derived.coincident.label")}
-
-
-
-
-
- {t("insights.derived.coincident.summary", {
- count: v.contributing.length,
- })}
-
-
- {t("insights.derived.coincident.vitals", { list: names })}
-
-
- );
-}
-
/**
* A loading placeholder occupying the resolved tile footprint so the grid
* reserves its final height and the real tiles drop in without a layout
@@ -442,17 +461,6 @@ function VitalsTileSkeleton() {
* above — if a new tile gate changes, reflect it here.
*/
function hasRenderableVital(read: DerivedBatchRead): boolean {
- const coincident = read({
- metric: "COINCIDENT_DEVIATION",
- });
- if (
- coincident?.status === "ok" &&
- coincident.value?.fired &&
- coincident.value.contributing.length > 0
- ) {
- return true;
- }
-
const fitness = read({ metric: "FITNESS_AGE" });
if (fitness?.status === "ok" && fitness.value) return true;
@@ -490,10 +498,37 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
return false;
}
+/**
+ * Whether the resolved batch holds at least one mobility/body tile worth
+ * painting. Mirrors the per-tile gates so the Mobility section heading is
+ * only rendered once there is content under it (no stranded heading over
+ * blank space). Most users carry none of these → the whole section hides.
+ */
+function hasRenderableMobility(read: DerivedBatchRead): boolean {
+ const sixmw = read({ metric: "SIX_MINUTE_WALK_BAND" });
+ if (sixmw?.status === "ok" && sixmw.value) return true;
+
+ for (const { metric } of SECTION_MOBILITY) {
+ const band = read({ metric });
+ if (
+ band &&
+ !(band.status === "insufficient" && band.reason === "no_readings_in_window")
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
/**
* The full set of tokens the dashboard reads in one batch — the five
- * wellness scores + the four derived re-frames + the coincident-deviation
- * flag + one baseline per vital (minus HRV, which has its own balance tile).
+ * wellness scores + the four derived re-frames + one baseline per vital
+ * (minus HRV, which has its own balance tile) + the four v1.10.3 mobility/body
+ * metrics (estimated 6-minute-walk band + the three HealthKit baseline bands).
+ * The coincident-deviation flag is no longer read here: it is now the
+ * dedicated "Today's signal" card at the top of the overview
+ * (`CoincidentDeviationCard`).
*/
function dashboardTokens(): DerivedBatchToken[] {
const tokens: DerivedBatchToken[] = [
@@ -506,12 +541,15 @@ function dashboardTokens(): DerivedBatchToken[] {
{ metric: "VASCULAR_AGE_DELTA" },
{ metric: "HRV_BALANCE" },
{ metric: "BMI" },
- { metric: "COINCIDENT_DEVIATION" },
+ { metric: "SIX_MINUTE_WALK_BAND" },
];
for (const type of SECTION_VITALS) {
if (type === "HEART_RATE_VARIABILITY") continue;
tokens.push({ metric: "VITALS_BASELINE", type });
}
+ for (const { metric } of SECTION_MOBILITY) {
+ tokens.push({ metric });
+ }
return tokens;
}
@@ -576,6 +614,7 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
// vitals the heading would otherwise strand over blank space, then content
// pops in below it — the CLS this pass removes.
const showSection = isLoading || hasRenderableVital(read);
+ const showMobility = !isLoading && hasRenderableMobility(read);
return (
@@ -604,7 +643,6 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
))
) : (
<>
-
@@ -612,13 +650,43 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
{vitals
.filter((type) => type !== "HEART_RATE_VARIABILITY")
.map((type) => (
-
+
))}
>
)}
)}
+ {showMobility && (
+
+
+ {t("insights.derived.vitals.mobilitySectionTitle")}
+
+
+
+ {SECTION_MOBILITY.map(({ metric, type }) => (
+
+ ))}
+
+
+ )}
);
}
diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts
index 4be24fe0..16e3a354 100644
--- a/src/lib/insights/__tests__/strain-score.test.ts
+++ b/src/lib/insights/__tests__/strain-score.test.ts
@@ -17,8 +17,15 @@ import {
tanakaHrMax,
banisterTrimp,
saturateToScore,
+ percentile,
+ resolvePersonalReference,
persistStrainScore,
STRAIN_SCORE_EXTERNAL_ID_PREFIX,
+ STRAIN_TRIMP_REFERENCE,
+ STRAIN_MIN_TRAINING_DAYS,
+ STRAIN_PERSONAL_REF_FLOOR,
+ STRAIN_PERSONAL_REF_PERCENTILE,
+ STRAIN_EWMA_ALPHA,
} from "../strain-score";
const NOW = new Date("2026-06-02T08:30:00Z");
@@ -118,6 +125,13 @@ function makePrisma(opts: {
rhr?: number | null;
gender?: string;
dateOfBirth?: Date | null;
+ /** Cached training-day TRIMP history rows (newest day first). */
+ cacheRows?: Array<{
+ day: string;
+ dayTrimp: number;
+ refPersonal: number;
+ trainingDays?: number;
+ }>;
}) {
const energyRows = (opts.energy ?? []).map((value, i) => ({
value,
@@ -136,6 +150,15 @@ function makePrisma(opts: {
: new Date("1986-01-01T00:00:00Z"),
gender: opts.gender ?? "MALE",
});
+ const cacheFindMany = vi.fn().mockResolvedValue(
+ (opts.cacheRows ?? []).map((r) => ({
+ // A cached row with TRIMP > 0 was a training day; default its window
+ // training-day count to 1 so it reads as a warmed EWMA unless overridden.
+ trainingDays: r.dayTrimp > 0 ? 1 : 0,
+ ...r,
+ })),
+ );
+ const cacheUpsert = vi.fn().mockResolvedValue({});
return {
prisma: {
measurement: {
@@ -145,8 +168,11 @@ function makePrisma(opts: {
},
workout: { findMany: workoutFindMany },
user: { findUnique },
+ strainTrimpCache: { findMany: cacheFindMany, upsert: cacheUpsert },
} as unknown as Parameters
[0],
upsert,
+ cacheFindMany,
+ cacheUpsert,
};
}
@@ -258,3 +284,207 @@ describe("persistStrainScore", () => {
expect(secondKey).toEqual(firstKey);
});
});
+
+// ─── v1.10.3 personal-relative anchor ─────────────────────────
+
+describe("percentile", () => {
+ it("returns 0 for an empty sample and the single value for one", () => {
+ expect(percentile([], 75)).toBe(0);
+ expect(percentile([42], 75)).toBe(42);
+ });
+
+ it("interpolates between order statistics", () => {
+ // P50 of {10,20,30,40} → rank 1.5 → 20 + 0.5·(30−20) = 25.
+ expect(percentile([40, 10, 30, 20], 50)).toBeCloseTo(25, 6);
+ // P75 of {10,20,30,40} → rank 2.25 → 30 + 0.25·(40−30) = 32.5.
+ expect(percentile([10, 20, 30, 40], 75)).toBeCloseTo(32.5, 6);
+ });
+});
+
+describe("resolvePersonalReference", () => {
+ it("falls back to the population anchor below the cold-start floor", () => {
+ // Only a handful of training days — under STRAIN_MIN_TRAINING_DAYS.
+ const trimps = Array.from(
+ { length: STRAIN_MIN_TRAINING_DAYS - 1 },
+ () => 25,
+ );
+ const r = resolvePersonalReference({
+ trainingDayTrimps: trimps,
+ priorRefPersonal: null,
+ });
+ expect(r.anchor).toBe("population");
+ expect(r.reference).toBe(STRAIN_TRIMP_REFERENCE);
+ expect(r.trainingDays).toBe(STRAIN_MIN_TRAINING_DAYS - 1);
+ // The EWMA still warms up so it is ready the night the user qualifies.
+ expect(r.refPersonalToPersist).not.toBeNull();
+ });
+
+ it("uses the personal P75 anchor once the floor is met (seed run)", () => {
+ // Eight identical-ish training days at ~25 TRIMP — a deconditioned user.
+ const trimps = [20, 22, 24, 25, 25, 26, 28, 30];
+ const r = resolvePersonalReference({
+ trainingDayTrimps: trimps,
+ priorRefPersonal: null,
+ });
+ expect(r.anchor).toBe("personal");
+ expect(r.trainingDays).toBe(8);
+ // Seed run (no prior EWMA) → reference is the window P75 of the trimps.
+ const expectedP75 = percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE);
+ expect(r.reference).toBeCloseTo(expectedP75, 6);
+ // The deconditioned user's hard day (~30) now scores meaningfully high
+ // instead of being pinned near 0 against the population 150.
+ expect(saturateToScore(30, r.reference)).toBeGreaterThan(60);
+ expect(saturateToScore(30, STRAIN_TRIMP_REFERENCE)).toBeLessThan(25);
+ });
+
+ it("EWMA-blends this window's P75 with the prior reference", () => {
+ const trimps = [40, 45, 50, 55, 60, 65, 70, 80];
+ const prior = 100;
+ const r = resolvePersonalReference({
+ trainingDayTrimps: trimps,
+ priorRefPersonal: prior,
+ });
+ const windowP75 = percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE);
+ const expected =
+ STRAIN_EWMA_ALPHA * windowP75 + (1 - STRAIN_EWMA_ALPHA) * prior;
+ expect(r.reference).toBeCloseTo(expected, 6);
+ // Smoothing keeps the reference between the prior and the window value.
+ expect(r.reference).toBeLessThan(prior);
+ expect(r.reference).toBeGreaterThan(windowP75);
+ });
+
+ it("floors the personal reference so trivial days do not all score 100", () => {
+ // All training days are very light — P75 would sit below the floor.
+ const trimps = [2, 3, 3, 4, 4, 5, 5, 6];
+ const r = resolvePersonalReference({
+ trainingDayTrimps: trimps,
+ priorRefPersonal: null,
+ });
+ expect(r.anchor).toBe("personal");
+ expect(r.reference).toBe(STRAIN_PERSONAL_REF_FLOOR);
+ });
+
+ it("ignores rest-day zeros — distribution is training-days only", () => {
+ // Zeros must never enter the percentile (they would crush the anchor).
+ const withZeros = [0, 0, 0, 20, 22, 24, 25, 26, 28, 30];
+ const r = resolvePersonalReference({
+ trainingDayTrimps: withZeros,
+ priorRefPersonal: null,
+ });
+ expect(r.trainingDays).toBe(7); // only the seven > 0 days count
+ expect(r.reference).toBeCloseTo(
+ percentile([20, 22, 24, 25, 26, 28, 30], STRAIN_PERSONAL_REF_PERCENTILE),
+ 6,
+ );
+ });
+});
+
+describe("persistStrainScore — personal anchor + cache", () => {
+ const hardSeries = {
+ samples: [
+ { t: "2026-06-01T08:00:00.000Z", hr: 150 },
+ { t: "2026-06-01T08:30:00.000Z", hr: 155 },
+ { t: "2026-06-01T09:00:00.000Z", hr: 150 },
+ ],
+ };
+
+ it("cold start (empty cache) scores against the population anchor + writes the cache row", async () => {
+ const { prisma, cacheUpsert } = makePrisma({
+ rhr: 50,
+ workouts: [{ id: "w1", samples: hardSeries }],
+ cacheRows: [], // no personal history yet
+ });
+
+ const result = await persistStrainScore(prisma, "user-1", NOW);
+
+ expect(result.outcome).toBe("stored");
+ expect(result.anchor).toBe("population");
+ // The cache row is always written so the distribution fills forward.
+ expect(cacheUpsert).toHaveBeenCalledTimes(1);
+ const cacheArg = cacheUpsert.mock.calls[0][0];
+ expect(cacheArg.where.userId_day).toEqual({
+ userId: "user-1",
+ day: "2026-06-01",
+ });
+ expect(cacheArg.create.dayTrimp).toBeGreaterThan(0);
+ expect(cacheArg.create.anchor).toBe("population");
+ });
+
+ it("uses the personal anchor once the cache holds enough training days", async () => {
+ // Seven prior training days of light load (~25 TRIMP) → with today's
+ // session that is ≥ the cold-start floor, so the personal anchor fires.
+ const cacheRows = Array.from({ length: 7 }, (_, i) => ({
+ day: `2026-05-${String(25 - i).padStart(2, "0")}`,
+ dayTrimp: 25,
+ refPersonal: 25,
+ }));
+ const { prisma, cacheUpsert } = makePrisma({
+ rhr: 50,
+ workouts: [{ id: "w1", samples: hardSeries }],
+ cacheRows,
+ });
+
+ const result = await persistStrainScore(prisma, "user-1", NOW);
+
+ expect(result.outcome).toBe("stored");
+ expect(result.anchor).toBe("personal");
+ const cacheArg = cacheUpsert.mock.calls[0][0];
+ expect(cacheArg.create.anchor).toBe("personal");
+ // refPersonal is the EWMA-blended personal reference, not the population 150.
+ expect(cacheArg.create.refPersonal).toBeLessThan(STRAIN_TRIMP_REFERENCE);
+ });
+
+ it("self-heals with no backfill — a re-run reads the prior EWMA, not its own write", async () => {
+ // A prior cache row strictly before the scored day carries the prior EWMA.
+ const cacheRows = [
+ { day: "2026-05-31", dayTrimp: 40, refPersonal: 60 },
+ ...Array.from({ length: 7 }, (_, i) => ({
+ day: `2026-05-${String(24 - i).padStart(2, "0")}`,
+ dayTrimp: 40,
+ refPersonal: 60,
+ })),
+ ];
+ const { prisma, cacheFindMany, cacheUpsert } = makePrisma({
+ rhr: 50,
+ workouts: [{ id: "w1", samples: hardSeries }],
+ cacheRows,
+ });
+
+ await persistStrainScore(prisma, "user-1", NOW);
+
+ // The history read excludes the scored day itself (lt: dayKey) so a
+ // re-run never blends against its own write — the nightly idempotent
+ // recompute converges instead of compounding.
+ const findArg = cacheFindMany.mock.calls[0][0];
+ expect(findArg.where.day.lt).toBe("2026-06-01");
+ expect(cacheUpsert).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not adopt a pure energy-only seed row as the EWMA prior", async () => {
+ // Seven prior training days (warm the personal anchor) plus an
+ // energy-only placeholder row (trainingDays: 0, refPersonal: population)
+ // that must NOT be blended in as the prior — it would stall activation.
+ const cacheRows = [
+ // newest is the energy-only placeholder.
+ { day: "2026-05-31", dayTrimp: 0, refPersonal: STRAIN_TRIMP_REFERENCE },
+ ...Array.from({ length: 7 }, (_, i) => ({
+ day: `2026-05-${String(24 - i).padStart(2, "0")}`,
+ dayTrimp: 25,
+ refPersonal: 25,
+ })),
+ ];
+ const { prisma, cacheUpsert } = makePrisma({
+ rhr: 50,
+ workouts: [{ id: "w1", samples: hardSeries }],
+ cacheRows,
+ });
+
+ await persistStrainScore(prisma, "user-1", NOW);
+
+ const cacheArg = cacheUpsert.mock.calls[0][0];
+ expect(cacheArg.create.anchor).toBe("personal");
+ // The blended reference rides the warmed ~25-TRIMP prior, NOT the
+ // population 150 carried by the energy-only placeholder row.
+ expect(cacheArg.create.refPersonal).toBeLessThan(80);
+ });
+});
diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts
index 8a86f6a4..da9881ff 100644
--- a/src/lib/insights/correlation-discovery.ts
+++ b/src/lib/insights/correlation-discovery.ts
@@ -249,4 +249,12 @@ export const DISCOVERY_OUTCOMES = [
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"WEIGHT",
+ // Wrist temperature is a credible future OUTCOME channel (near-daily, so
+ // n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night
+ // temperature?"), but it is deliberately NOT a channel yet: it is
+ // privacy-sensitive and deviation-framed, and folding it into the FDR
+ // matrix risks surfacing a cycle-phase correlation that strays into
+ // reproductive inference. Held pending a deliberate privacy review —
+ // documented here so the omission is intentional, not a gap (same posture
+ // as the medication-compliance omission above).
] as const;
diff --git a/src/lib/insights/derived/__tests__/baseline.test.ts b/src/lib/insights/derived/__tests__/baseline.test.ts
index 98f1c95c..88f3b5d8 100644
--- a/src/lib/insights/derived/__tests__/baseline.test.ts
+++ b/src/lib/insights/derived/__tests__/baseline.test.ts
@@ -102,10 +102,41 @@ describe("computeVitalsBaseline — ok path (rollup tier)", () => {
expect(result.provenance.source).toBe("DAY");
expect(result.coverage.historyDays).toBe(8);
expect(result.confidence.score).toBeGreaterThan(0);
+ // The trailing per-day mean series backs the inline sparkline: one
+ // point per contributing day, oldest → newest (55..62).
+ expect(result.value.series).toEqual([
+ 55, 56, 57, 58, 59, 60, 61, 62,
+ ]);
}
// rollup-covered path must not touch raw SQL
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
+
+ it("caps the sparkline series to the last 30 points on a long window", async () => {
+ vi.mocked(probeRollupCoverage).mockResolvedValue(
+ new Map([["RESTING_HEART_RATE", true]]),
+ );
+ // 40 days of DAY rollups — the series must trail to the last 30, newest last.
+ const rows = Array.from({ length: 40 }, (_, i) => {
+ const d = new Date(Date.UTC(2026, 3, 1 + i)).toISOString().slice(0, 10);
+ return dayRow(d, 50 + i);
+ });
+ vi.mocked(readBestGranularityRollups).mockResolvedValue({
+ granularity: "DAY",
+ rows,
+ });
+
+ const result = await computeVitalsBaseline("u1", PROFILE, {
+ type: "RESTING_HEART_RATE",
+ now: NOW,
+ });
+ expect(result.status).toBe("ok");
+ if (result.status === "ok") {
+ expect(result.value.series).toHaveLength(30);
+ // The newest day's mean is the final series point (50 + 39 = 89).
+ expect(result.value.series.at(-1)).toBe(89);
+ }
+ });
});
describe("computeVitalsBaseline — coverage-miss live fallback", () => {
diff --git a/src/lib/insights/derived/__tests__/bmi.test.ts b/src/lib/insights/derived/__tests__/bmi.test.ts
index 1b540778..a414ca66 100644
--- a/src/lib/insights/derived/__tests__/bmi.test.ts
+++ b/src/lib/insights/derived/__tests__/bmi.test.ts
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-const findFirst = vi.fn();
+const findMany = vi.fn();
vi.mock("@/lib/db", () => ({
- prisma: { measurement: { findFirst: (...a: unknown[]) => findFirst(...a) } },
+ prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } },
}));
import { computeBmi, classifyBmi } from "../bmi";
@@ -31,18 +31,18 @@ describe("computeBmi", () => {
const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: null }, { now: NOW });
expect(r.status).toBe("insufficient");
if (r.status === "insufficient") expect(r.reason).toBe("no_height_on_profile");
- expect(findFirst).not.toHaveBeenCalled();
+ expect(findMany).not.toHaveBeenCalled();
});
it("insufficient when height present but no recent weight", async () => {
- findFirst.mockResolvedValueOnce(null);
+ findMany.mockResolvedValueOnce([]);
const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW });
expect(r.status).toBe("insufficient");
if (r.status === "insufficient") expect(r.reason).toBe("no_weight_in_window");
});
it("ok exact BMI from weight + height", async () => {
- findFirst.mockResolvedValueOnce({ value: 80 });
+ findMany.mockResolvedValueOnce([{ value: 80 }]);
const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
@@ -55,4 +55,23 @@ describe("computeBmi", () => {
expect(r.confidence.score).toBeGreaterThan(0);
}
});
+
+ it("derives a trailing BMI series from the recent weights (oldest → newest)", async () => {
+ // Weights are read newest-first; the series trails oldest → newest.
+ findMany.mockResolvedValueOnce([
+ { value: 80 },
+ { value: 81 },
+ { value: 82 },
+ ]);
+ const r = await computeBmi(
+ "u1",
+ { ageYears: 40, sex: "MALE", heightCm: 180 },
+ { now: NOW },
+ );
+ expect(r.status).toBe("ok");
+ if (r.status === "ok") {
+ // 82,81,80 kg / 1.8² → 25.3, 25.0, 24.7, reversed to oldest → newest.
+ expect(r.value.series).toEqual([25.3, 25, 24.7]);
+ }
+ });
});
diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts
index 8e0c1b98..7c0bc7d4 100644
--- a/src/lib/insights/derived/__tests__/dispatch.test.ts
+++ b/src/lib/insights/derived/__tests__/dispatch.test.ts
@@ -12,6 +12,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
}));
+vi.mock("@/lib/tz/resolver", () => ({
+ resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
+}));
import { computeDerivedMetric } from "../dispatch";
import {
@@ -39,6 +42,16 @@ describe("registry", () => {
expect(getDerivedMetricMeta("RECOVERY_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRESS_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRAIN_SCORE")?.implemented).toBe(true);
+ expect(
+ getDerivedMetricMeta("WRIST_TEMPERATURE_BASELINE")?.implemented,
+ ).toBe(true);
+ expect(
+ getDerivedMetricMeta("STAIR_ASCENT_SPEED_BASELINE")?.implemented,
+ ).toBe(true);
+ expect(
+ getDerivedMetricMeta("STAIR_DESCENT_SPEED_BASELINE")?.implemented,
+ ).toBe(true);
+ expect(getDerivedMetricMeta("SIX_MINUTE_WALK_BAND")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -59,7 +72,11 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE");
- expect(DERIVED_METRIC_IDS.length).toBe(11);
+ expect(DERIVED_METRIC_IDS).toContain("WRIST_TEMPERATURE_BASELINE");
+ expect(DERIVED_METRIC_IDS).toContain("STAIR_ASCENT_SPEED_BASELINE");
+ expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
+ expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
+ expect(DERIVED_METRIC_IDS.length).toBe(15);
});
});
@@ -127,6 +144,43 @@ describe("computeDerivedMetric dispatch", () => {
}
});
+ it.each([
+ "WRIST_TEMPERATURE_BASELINE",
+ "STAIR_ASCENT_SPEED_BASELINE",
+ "STAIR_DESCENT_SPEED_BASELINE",
+ ] as const)(
+ "routes %s through the baseline engine (no data → insufficient, not not_implemented)",
+ async (metric) => {
+ const result = await computeDerivedMetric({
+ metric,
+ userId: "u1",
+ profile: PROFILE,
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).not.toBe("not_implemented");
+ // The baseline engine names its fixed type as the missing input.
+ expect(result.provenance.inputs).toContain(
+ metric.replace("_BASELINE", ""),
+ );
+ }
+ },
+ );
+
+ it("routes SIX_MINUTE_WALK_BAND to its engine (no data → insufficient, not not_implemented)", async () => {
+ const result = await computeDerivedMetric({
+ metric: "SIX_MINUTE_WALK_BAND",
+ userId: "u1",
+ profile: PROFILE,
+ now: NOW,
+ });
+ expect(result.status).toBe("insufficient");
+ if (result.status === "insufficient") {
+ expect(result.reason).toBe("no_readings_in_window");
+ }
+ });
+
it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => {
const result = await computeDerivedMetric({
metric: "VITALS_BASELINE",
diff --git a/src/lib/insights/derived/__tests__/fitness-age.test.ts b/src/lib/insights/derived/__tests__/fitness-age.test.ts
index 08879d6b..8189941a 100644
--- a/src/lib/insights/derived/__tests__/fitness-age.test.ts
+++ b/src/lib/insights/derived/__tests__/fitness-age.test.ts
@@ -61,11 +61,16 @@ describe("computeFitnessAge", () => {
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.vo2Max).toBe(46);
- expect(r.value.band).toBe("green");
- expect(r.value.referenceBand).toEqual({ low: 35, high: 45 });
+ // Age 40 sits between the 30s (centre 34.5 → {39,49}) and 40s (centre
+ // 44.5 → {35,45}) VO2max bands; the fractional-age lookup interpolates
+ // to {36.8, 46.8} rather than reading the flat 40s bracket.
+ expect(r.value.referenceBand).toEqual({ low: 36.8, high: 46.8 });
+ // 46 < the interpolated upper edge (46.8) → not "excellent" green.
+ expect(r.value.band).toBe("yellow");
// ≥3 readings → trend = 46 - 44 = 2
expect(r.value.trendDelta).toBe(2);
- expect(r.value.fitnessAgeDeltaYears).toBe(-6); // midpoint 40, 46 → -6
+ // midpoint 41.8, 46 → -round(4.2) = -4
+ expect(r.value.fitnessAgeDeltaYears).toBe(-4);
}
});
diff --git a/src/lib/insights/derived/__tests__/hrv-balance.test.ts b/src/lib/insights/derived/__tests__/hrv-balance.test.ts
index ddfde884..1af07fbe 100644
--- a/src/lib/insights/derived/__tests__/hrv-balance.test.ts
+++ b/src/lib/insights/derived/__tests__/hrv-balance.test.ts
@@ -76,6 +76,8 @@ describe("computeHrvBalance", () => {
expect(r.value.baselineLow).toBe(40);
expect(r.value.baselineHigh).toBe(80);
expect(r.value.sampleDays).toBe(21);
+ // Sparkline series reuses the day-mean read (no extra query).
+ expect(r.value.series).toEqual([58, 62, 60]);
}
});
diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts
index ed08b0ea..4b1a1197 100644
--- a/src/lib/insights/derived/__tests__/norms.test.ts
+++ b/src/lib/insights/derived/__tests__/norms.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
-import { lookupNormalRange, hasSharpenedNorm } from "../norms";
+import {
+ lookupNormalRange,
+ hasSharpenedNorm,
+ predictSixMinuteWalkDistance,
+} from "../norms";
describe("lookupNormalRange — the age/sex reference-range enabler", () => {
it("returns a sex-specific VO2max band for a registered metric", () => {
@@ -46,6 +50,37 @@ describe("lookupNormalRange — the age/sex reference-range enabler", () => {
expect(old).not.toBeNull();
});
+ it("interpolates a fractional age between adjacent brackets (no hard step)", () => {
+ // Male VO2max: 30s centre 34.5 → {39,49}; 40s centre 44.5 → {35,45}.
+ // At the lower centre the band equals the 30s band exactly.
+ const at345 = lookupNormalRange("VO2_MAX", 34.5, "MALE");
+ expect(at345).toEqual({ low: 39, high: 49 });
+ // Midway between the two centres → halfway between the two bands.
+ const at395 = lookupNormalRange("VO2_MAX", 39.5, "MALE");
+ expect(at395).toEqual({ low: 37, high: 47 });
+ // Just into the 40s the band must move only slightly, not jump.
+ const at40 = lookupNormalRange("VO2_MAX", 40, "MALE")!;
+ expect(at40.low).toBeGreaterThan(35);
+ expect(at40.low).toBeLessThan(37);
+ expect(at40.high).toBeGreaterThan(45);
+ expect(at40.high).toBeLessThan(47);
+ });
+
+ it("the band changes smoothly across a bracket boundary, not in a step", () => {
+ // Either side of the 30s/40s boundary (age 40) the bands are close — a
+ // hard bracket lookup would have returned two different fixed bands.
+ const justBelow = lookupNormalRange("VO2_MAX", 39.9, "MALE")!;
+ const justAbove = lookupNormalRange("VO2_MAX", 40.1, "MALE")!;
+ expect(Math.abs(justAbove.low - justBelow.low)).toBeLessThan(0.5);
+ expect(Math.abs(justAbove.high - justBelow.high)).toBeLessThan(0.5);
+ });
+
+ it("holds the youngest band flat below the first bracket centre", () => {
+ // Age below the youngest centre clamps to the youngest cited band.
+ const young = lookupNormalRange("VO2_MAX", 21, "MALE");
+ expect(young).toEqual({ low: 42, high: 53 });
+ });
+
it("rejects negative / non-finite ages", () => {
expect(lookupNormalRange("RESTING_HEART_RATE", -5, null)).toBeNull();
expect(lookupNormalRange("RESTING_HEART_RATE", Number.NaN, null)).toBeNull();
@@ -59,3 +94,39 @@ describe("hasSharpenedNorm", () => {
expect(hasSharpenedNorm("STEPS", 35, "MALE")).toBe(false);
});
});
+
+describe("predictSixMinuteWalkDistance — Enright & Sherrill 1998", () => {
+ it("matches the published male equation", () => {
+ // 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m
+ expect(predictSixMinuteWalkDistance(40, 180, 80, "MALE")).toBeCloseTo(
+ 712,
+ 6,
+ );
+ });
+
+ it("matches the published female equation", () => {
+ // 2.11·165 − 2.29·65 − 5.78·40 + 667 = 635.1 m
+ expect(predictSixMinuteWalkDistance(40, 165, 65, "FEMALE")).toBeCloseTo(
+ 635.1,
+ 6,
+ );
+ });
+
+ it("returns null without a usable sex (the equations differ by sex)", () => {
+ expect(predictSixMinuteWalkDistance(40, 180, 80, null)).toBeNull();
+ });
+
+ it("returns null when weight is absent (no silently-dropped term)", () => {
+ expect(predictSixMinuteWalkDistance(40, 180, null, "MALE")).toBeNull();
+ });
+
+ it("returns null without height", () => {
+ expect(predictSixMinuteWalkDistance(40, null, 80, "MALE")).toBeNull();
+ });
+
+ it("returns null for non-adult / non-finite ages", () => {
+ expect(predictSixMinuteWalkDistance(12, 150, 45, "MALE")).toBeNull();
+ expect(predictSixMinuteWalkDistance(Number.NaN, 180, 80, "MALE")).toBeNull();
+ expect(predictSixMinuteWalkDistance(null, 180, 80, "MALE")).toBeNull();
+ });
+});
diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts
index 0dece59a..55cac977 100644
--- a/src/lib/insights/derived/__tests__/readiness.test.ts
+++ b/src/lib/insights/derived/__tests__/readiness.test.ts
@@ -12,6 +12,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
}));
+vi.mock("@/lib/tz/resolver", () => ({
+ resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
+}));
import { prisma } from "@/lib/db";
import {
diff --git a/src/lib/insights/derived/__tests__/six-minute-walk.test.ts b/src/lib/insights/derived/__tests__/six-minute-walk.test.ts
new file mode 100644
index 00000000..df310195
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/six-minute-walk.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const findMany = vi.fn();
+const findFirst = vi.fn();
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ measurement: {
+ findMany: (...a: unknown[]) => findMany(...a),
+ findFirst: (...a: unknown[]) => findFirst(...a),
+ },
+ },
+}));
+
+import {
+ computeSixMinuteWalkBand,
+ placeSixMinuteWalkBand,
+} from "../six-minute-walk";
+
+const NOW = new Date("2026-06-02T07:00:00Z");
+// Enright male, 40 yr, 180 cm, 80 kg →
+// 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m predicted.
+const MALE_40 = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
+const NO_DEMO = { ageYears: null, sex: null, heightCm: null };
+const NO_WEIGHT_PROFILE = MALE_40;
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("placeSixMinuteWalkBand", () => {
+ it("green at/above 80% of predicted", () => {
+ expect(placeSixMinuteWalkBand(80)).toBe("green");
+ expect(placeSixMinuteWalkBand(100)).toBe("green");
+ });
+ it("yellow between 60 and 80%", () => {
+ expect(placeSixMinuteWalkBand(60)).toBe("yellow");
+ expect(placeSixMinuteWalkBand(79)).toBe("yellow");
+ });
+ it("red below 60%", () => {
+ expect(placeSixMinuteWalkBand(59)).toBe("red");
+ });
+ it("null without a percent", () => {
+ expect(placeSixMinuteWalkBand(null)).toBeNull();
+ });
+});
+
+describe("computeSixMinuteWalkBand", () => {
+ it("insufficient when no 6MWT reading exists", async () => {
+ findMany.mockResolvedValueOnce([]);
+ const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
+ expect(r.status).toBe("insufficient");
+ if (r.status === "insufficient") {
+ expect(r.reason).toBe("no_readings_in_window");
+ }
+ // No reading → never reach the weight read.
+ expect(findFirst).not.toHaveBeenCalled();
+ });
+
+ it("ok with a band + percent-of-predicted when full demographics + weight exist", async () => {
+ // 3 readings → trend = 712 - 700 = 12; distance 712 vs predicted 712 → 100%.
+ findMany.mockResolvedValueOnce([
+ { value: 712 },
+ { value: 700 },
+ { value: 690 },
+ ]);
+ findFirst.mockResolvedValueOnce({ value: 80 });
+ const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
+ expect(r.status).toBe("ok");
+ if (r.status === "ok") {
+ expect(r.value.distanceM).toBe(712);
+ expect(r.value.predictedM).toBe(712);
+ expect(r.value.percentOfPredicted).toBe(100);
+ expect(r.value.band).toBe("green");
+ expect(r.value.trendDelta).toBe(12);
+ // Sparkline series: readings oldest → newest (rows are read desc).
+ expect(r.value.series).toEqual([690, 700, 712]);
+ }
+ });
+
+ it("bands red on a low percent of predicted", async () => {
+ // 400 / 712 = 56% → red.
+ findMany.mockResolvedValueOnce([{ value: 400 }]);
+ findFirst.mockResolvedValueOnce({ value: 80 });
+ const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
+ expect(r.status).toBe("ok");
+ if (r.status === "ok") {
+ expect(r.value.percentOfPredicted).toBe(56);
+ expect(r.value.band).toBe("red");
+ }
+ });
+
+ it("ok but band/percent null without demographics", async () => {
+ findMany.mockResolvedValueOnce([{ value: 500 }]);
+ findFirst.mockResolvedValueOnce({ value: 80 });
+ const r = await computeSixMinuteWalkBand("u1", NO_DEMO, { now: NOW });
+ expect(r.status).toBe("ok");
+ if (r.status === "ok") {
+ expect(r.value.distanceM).toBe(500);
+ expect(r.value.predictedM).toBeNull();
+ expect(r.value.percentOfPredicted).toBeNull();
+ expect(r.value.band).toBeNull();
+ // Single reading → trend suppressed.
+ expect(r.value.trendDelta).toBeNull();
+ }
+ });
+
+ it("ok but band null when weight is absent (no fabricated placement)", async () => {
+ findMany.mockResolvedValueOnce([{ value: 600 }]);
+ findFirst.mockResolvedValueOnce(null); // no recent weight
+ const r = await computeSixMinuteWalkBand("u1", NO_WEIGHT_PROFILE, {
+ now: NOW,
+ });
+ expect(r.status).toBe("ok");
+ if (r.status === "ok") {
+ expect(r.value.distanceM).toBe(600);
+ expect(r.value.predictedM).toBeNull();
+ expect(r.value.percentOfPredicted).toBeNull();
+ expect(r.value.band).toBeNull();
+ }
+ });
+});
diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts
index 335ceaba..b63dc534 100644
--- a/src/lib/insights/derived/__tests__/sleep-score.test.ts
+++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts
@@ -3,8 +3,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
}));
+vi.mock("@/lib/tz/resolver", () => ({
+ resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
+}));
import { prisma } from "@/lib/db";
+import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
computeSleepScore,
blendSleepSubScores,
@@ -149,6 +153,27 @@ describe("reconstructNights", () => {
expect(nights[0].remMinutes).toBe(90);
expect(nights[0].deepMinutes).toBe(60);
});
+
+ it("expresses the midpoint in UTC by default", () => {
+ // Earliest 01:00Z, latest 05:00Z → midpoint 03:00Z = 180 min-of-day.
+ const rows = night("2026-06-02", [
+ ["CORE", 120, "01:00"],
+ ["CORE", 120, "05:00"],
+ ]);
+ const nights = reconstructNights(rows);
+ expect(nights[0].midpoint).toBe(180);
+ });
+
+ it("expresses the midpoint in the user's timezone when one is passed", () => {
+ // Same 03:00Z midpoint, read in Asia/Kolkata (UTC+5:30, no DST) →
+ // 08:30 local = 510 min-of-day, not 180.
+ const rows = night("2026-06-02", [
+ ["CORE", 120, "01:00"],
+ ["CORE", 120, "05:00"],
+ ]);
+ const nights = reconstructNights(rows, "Asia/Kolkata");
+ expect(nights[0].midpoint).toBe(8 * 60 + 30);
+ });
});
describe("computeSleepScore", () => {
@@ -214,4 +239,29 @@ describe("computeSleepScore", () => {
expect(result.coverage.missing).toContain("composition");
}
});
+
+ it("threads an explicit tz into the midpoint without resolving the user zone", async () => {
+ const rows = [
+ ...night("2026-05-31", [
+ ["CORE", 240, "03:00"],
+ ["DEEP", 60, "04:00"],
+ ]),
+ ...night("2026-06-01", [
+ ["CORE", 250, "03:10"],
+ ["DEEP", 55, "04:10"],
+ ]),
+ ...night("2026-06-02", [
+ ["CORE", 235, "03:05"],
+ ["DEEP", 65, "04:05"],
+ ]),
+ ];
+ findMany.mockResolvedValue(rows);
+ // A pinned tz must score without falling back to the user-zone resolver.
+ const result = await computeSleepScore("u1", PROFILE, {
+ now: NOW,
+ tz: "Asia/Kolkata",
+ });
+ expect(result.status).toBe("ok");
+ expect(resolveUserTimezone).not.toHaveBeenCalled();
+ });
});
diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts
index 438b67f9..ffc105b4 100644
--- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts
+++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts
@@ -62,6 +62,8 @@ describe("computeWellnessScore", () => {
// 72 - mean(60, 64) = 72 - 62 = 10
expect(v.trendDelta).toBe(10);
expect(v.daysInWindow).toBe(3);
+ // Sparkline series: window rows oldest → newest (rows are read desc).
+ expect(v.series).toEqual([64, 60, 72]);
}
});
diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts
index aa46ba12..f2835e1a 100644
--- a/src/lib/insights/derived/baseline.ts
+++ b/src/lib/insights/derived/baseline.ts
@@ -42,7 +42,11 @@ import {
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
-import type { Derived, DerivedProvenanceSource } from "./types";
+import {
+ SPARKLINE_MAX_POINTS,
+ type Derived,
+ type DerivedProvenanceSource,
+} from "./types";
/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */
const DEFAULT_MAD_K = 3;
@@ -67,6 +71,12 @@ export interface VitalsBaselineValue {
sampleDays: number;
/** k used for the band (echoed for transparency). */
k: number;
+ /**
+ * Trailing per-day mean series (oldest → newest), capped to the last
+ * `SPARKLINE_MAX_POINTS`. Drives the tile sparkline; reuses the rows the
+ * band is already computed from (no extra read).
+ */
+ series: number[];
}
/** Caller-supplied profile (read once per request, never re-fetched here). */
@@ -159,7 +169,7 @@ export function medianAbsoluteDeviation(values: number[]): number {
export function buildBaselineBand(
dayMeans: number[],
k: number = DEFAULT_MAD_K,
-): Omit | null {
+): Omit | null {
if (dayMeans.length === 0) return null;
const center = median(dayMeans);
const mad = medianAbsoluteDeviation(dayMeans);
@@ -332,8 +342,14 @@ export async function computeVitalsBaseline(
fullHistoryDays: windowDays,
});
+ // Trailing per-day mean series for the inline sparkline — the same DAY
+ // means the band is built from, capped to the recent window.
+ const series = points
+ .slice(-SPARKLINE_MAX_POINTS)
+ .map((p) => p.mean);
+
return buildOk({
- value: { type, ...band },
+ value: { type, ...band, series },
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
diff --git a/src/lib/insights/derived/bmi.ts b/src/lib/insights/derived/bmi.ts
index 6c19e2cc..0766f01e 100644
--- a/src/lib/insights/derived/bmi.ts
+++ b/src/lib/insights/derived/bmi.ts
@@ -32,7 +32,7 @@ import {
nowProvenanceTimestamp,
} from "./coverage";
import type { BaselineProfile } from "./baseline";
-import type { Derived } from "./types";
+import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
const WEIGHT_TYPE: MeasurementType = "WEIGHT";
/** A weight reading older than this no longer reflects current BMI. */
@@ -55,6 +55,12 @@ export interface BmiValue {
weightKg: number;
/** The height used (cm). */
heightCm: number;
+ /**
+ * Trailing BMI series (oldest → newest), capped to the last
+ * `SPARKLINE_MAX_POINTS`. Derived from the recent weight readings at the
+ * fixed profile height — a bounded read, not a per-day rollup.
+ */
+ series: number[];
}
/** WHO category + band for a BMI value. Pure. */
@@ -104,7 +110,9 @@ export async function computeBmi(
}
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
- const latest = await prisma.measurement.findFirst({
+ // Bounded read of the recent weights — the latest backs the BMI value, the
+ // trailing set backs the inline sparkline (a small cap, not a per-day walk).
+ const weights = await prisma.measurement.findMany({
where: {
userId,
type: WEIGHT_TYPE,
@@ -112,8 +120,10 @@ export async function computeBmi(
measuredAt: { gte: since },
},
orderBy: { measuredAt: "desc" },
+ take: SPARKLINE_MAX_POINTS,
select: { value: true },
});
+ const latest = weights[0] ?? null;
if (!latest) {
// Height present but no recent weight → missing the weight input only.
@@ -141,6 +151,12 @@ export async function computeBmi(
const bmi = weightKg / (heightM * heightM);
const { category, band } = classifyBmi(bmi);
+ // BMI series from the recent weights at the fixed height; rows are
+ // newest-first, the sparkline wants oldest → newest.
+ const series = weights
+ .map((w) => Math.round((w.value / (heightM * heightM)) * 10) / 10)
+ .reverse();
+
const { coverage, confidence } = deriveCoverage({
requiredInputs: 2,
presentInputs: 2,
@@ -156,6 +172,7 @@ export async function computeBmi(
band,
weightKg,
heightCm,
+ series,
},
coverage,
confidence,
diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts
index 518857dd..e347f975 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -42,6 +42,13 @@ import type { Derived, DerivedProvenanceSource } from "./types";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_DAYS = 30;
+/**
+ * Row cap for the latest-day mean read — see `readiness.ts`. A dense intra-day
+ * day can hold hundreds of rows; the latest-day mean only needs a bounded
+ * sample of the most-recent rows (the dense-intraday retention reasoning). The
+ * common single-reading-per-day case is unaffected.
+ */
+const MAX_LATEST_DAY_ROWS = 50;
/** ≥ this many out-of-band vitals on a day fires the flag. */
export const COINCIDENT_FIRE_THRESHOLD = 2;
/** Need ≥ this many banded vitals before the flag can even coincide. */
@@ -118,7 +125,7 @@ async function readLatestDayMean(
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "desc" },
- take: 50,
+ take: MAX_LATEST_DAY_ROWS,
select: { value: true, measuredAt: true },
});
if (rows.length === 0) return null;
diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts
index a5080a56..1e2f226f 100644
--- a/src/lib/insights/derived/dispatch.ts
+++ b/src/lib/insights/derived/dispatch.ts
@@ -23,6 +23,7 @@ import {
type BaselineProfile,
} from "./baseline";
import { computeFitnessAge } from "./fitness-age";
+import { computeSixMinuteWalkBand } from "./six-minute-walk";
import { computeVascularAgeDelta } from "./vascular-age";
import { computeHrvBalance } from "./hrv-balance";
import { computeBmi } from "./bmi";
@@ -146,6 +147,36 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise>;
+ case "WRIST_TEMPERATURE_BASELINE":
+ case "STAIR_ASCENT_SPEED_BASELINE":
+ case "STAIR_DESCENT_SPEED_BASELINE": {
+ // v1.10.3 additive HealthKit baselines — each pins ONE fixed type and
+ // reuses the type-generic baseline engine unchanged (median ± k·MAD).
+ // Wrist temperature is a personal-DEVIATION band (no illness/cycle
+ // inference); stair ascent/descent are personal trend bands (no
+ // population cutoff — stair pace is geometry-confounded). The fixed
+ // type is the metric's single registered input.
+ const baselineType: Record<
+ | "WRIST_TEMPERATURE_BASELINE"
+ | "STAIR_ASCENT_SPEED_BASELINE"
+ | "STAIR_DESCENT_SPEED_BASELINE",
+ MeasurementType
+ > = {
+ WRIST_TEMPERATURE_BASELINE: "WRIST_TEMPERATURE",
+ STAIR_ASCENT_SPEED_BASELINE: "STAIR_ASCENT_SPEED",
+ STAIR_DESCENT_SPEED_BASELINE: "STAIR_DESCENT_SPEED",
+ };
+ return computeVitalsBaseline(args.userId, args.profile, {
+ type: baselineType[args.metric],
+ windowDays: args.windowDays,
+ now,
+ }) as Promise>;
+ }
+ case "SIX_MINUTE_WALK_BAND":
+ return computeSixMinuteWalkBand(args.userId, args.profile, {
+ windowDays: args.windowDays,
+ now,
+ }) as Promise>;
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
diff --git a/src/lib/insights/derived/hrv-balance.ts b/src/lib/insights/derived/hrv-balance.ts
index abff1c47..5ffee9ce 100644
--- a/src/lib/insights/derived/hrv-balance.ts
+++ b/src/lib/insights/derived/hrv-balance.ts
@@ -44,7 +44,7 @@ import {
readDayMeanSeries,
type BaselineProfile,
} from "./baseline";
-import { isDerivedOk } from "./types";
+import { isDerivedOk, SPARKLINE_MAX_POINTS } from "./types";
import type { Derived } from "./types";
const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY";
@@ -69,6 +69,12 @@ export interface HrvBalanceValue {
band: HrvBalanceBand;
/** Distinct days that backed the baseline. */
sampleDays: number;
+ /**
+ * Trailing per-day SDNN mean series (oldest → newest), capped to the last
+ * `SPARKLINE_MAX_POINTS`. Reuses the day-mean read already done for the
+ * recent average — no extra query.
+ */
+ series: number[];
}
/**
@@ -165,6 +171,9 @@ export async function computeHrvBalance(
baselineHigh: baseline.value.high,
band,
sampleDays: baseline.value.sampleDays,
+ series: recent.points
+ .slice(-SPARKLINE_MAX_POINTS)
+ .map((p) => p.mean),
},
coverage,
confidence,
diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts
index 658f7c97..2b699085 100644
--- a/src/lib/insights/derived/index.ts
+++ b/src/lib/insights/derived/index.ts
@@ -49,7 +49,11 @@ export type {
DerivedArchetype,
} from "./registry";
-export { lookupNormalRange, hasSharpenedNorm } from "./norms";
+export {
+ lookupNormalRange,
+ hasSharpenedNorm,
+ predictSixMinuteWalkDistance,
+} from "./norms";
export type { NormRange, NormSex } from "./norms";
// ── server-only compute engines (do NOT value-import from a client component) ──
@@ -77,6 +81,15 @@ export {
} from "./fitness-age";
export type { FitnessAgeValue, FitnessBand } from "./fitness-age";
+export {
+ computeSixMinuteWalkBand,
+ placeSixMinuteWalkBand,
+} from "./six-minute-walk";
+export type {
+ SixMinuteWalkValue,
+ SixMinuteWalkBand,
+} from "./six-minute-walk";
+
export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age";
export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age";
diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts
index 358023f7..71710202 100644
--- a/src/lib/insights/derived/norms.ts
+++ b/src/lib/insights/derived/norms.ts
@@ -24,6 +24,13 @@
* 377(9770):1011–1018, normal-range HR centiles by age).
* - Respiratory rate by age — clinical reference (WHO/PALS adult vs
* paediatric ranges).
+ * - Six-minute-walk distance — a published REGRESSION (not a bracket
+ * table): Enright & Sherrill 1998, "Reference Equations for the
+ * Six-Minute Walk in Healthy Adults", Am J Respir Crit Care Med
+ * 158(5):1384–1387; the test itself standardised by ATS 2002,
+ * "ATS Statement: Guidelines for the Six-Minute Walk Test",
+ * Am J Respir Crit Care Med 166(1):111–117. Surfaced as a
+ * percent-of-predicted re-frame, never a HealthLog-derived equation.
*
* Client-safe — pure data + a pure lookup, no server imports.
*/
@@ -105,15 +112,49 @@ const NORM_TABLES: Partial> = {
RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS,
};
+/** The age a bracket row is anchored at for interpolation — its centre. */
+function bracketCentre(row: NormRow): number {
+ return (row.minAge + row.maxAge) / 2;
+}
+
+/**
+ * Resolve the candidate rows for a profile sex: the sex-specific rows when
+ * an exact match exists, else the sex-agnostic rows, else none. Returns the
+ * rows ordered by bracket centre so the caller can interpolate across them.
+ * `null` (rather than an empty array) signals "no honest band for this sex"
+ * — a sex-specific-only table with no profile sex.
+ */
+function resolveSexRows(rows: NormRow[], sex: NormSex): NormRow[] | null {
+ const exact = sex ? rows.filter((row) => row.sex === sex) : [];
+ if (exact.length > 0) {
+ return [...exact].sort((a, b) => bracketCentre(a) - bracketCentre(b));
+ }
+ const agnostic = rows.filter((row) => row.sex === null);
+ if (agnostic.length > 0) {
+ return [...agnostic].sort((a, b) => bracketCentre(a) - bracketCentre(b));
+ }
+ return null;
+}
+
/**
* Resolve the age/sex-adjusted reference band for a metric, or `null`
* when no sharper band applies (unsupported metric, or demographics
* absent). On `null` the caller keeps the existing flat `normalRange`
* anchor — the enabler is strictly additive.
*
- * Sex matching: a sex-specific table prefers the row matching the
- * profile sex; when the profile sex is absent it falls back to the
- * row's `null`-sex variant if present, else the first matching-age row.
+ * Sex matching: a sex-specific table prefers the rows matching the profile
+ * sex; when the profile sex is absent it falls back to the sex-agnostic
+ * rows; a sex-specific-only table with no profile sex yields no honest band.
+ *
+ * Age handling: the bracket tables are coarse (decade / paediatric bands),
+ * so a fractional age that lands near a bracket edge would otherwise read a
+ * hard step at the boundary (e.g. 39.9 → 30s band, 40.0 → 40s band). Instead
+ * each band is anchored at its bracket CENTRE and a fractional age is linearly
+ * interpolated between the two adjacent centres — the band moves smoothly with
+ * age. Below the youngest centre / above the oldest centre the nearest band is
+ * held flat (clamp, never extrapolate), so the interpolated band always lies
+ * within the span of the two cited bracket bands and the standard's provenance
+ * stays accurate.
*/
export function lookupNormalRange(
metricId: MetricStatusMetricId,
@@ -126,23 +167,89 @@ export function lookupNormalRange(
const table = NORM_TABLES[metricId];
if (!table) return null;
- const ageRows = table.filter(
- (row) => ageYears >= row.minAge && ageYears <= row.maxAge,
- );
- if (ageRows.length === 0) return null;
+ const rows = resolveSexRows(table, sex);
+ if (!rows || rows.length === 0) return null;
- // Prefer an exact sex match; then a sex-agnostic row; then any row in
- // the age bracket (so a sex-specific-only table still yields a band for
- // a profile with no sex by averaging is avoided — we pick a defined
- // band rather than fabricate).
- const exact = sex ? ageRows.find((row) => row.sex === sex) : undefined;
- if (exact) return { ...exact.range };
+ // Single band — no neighbour to interpolate against.
+ if (rows.length === 1) return { ...rows[0].range };
- const agnostic = ageRows.find((row) => row.sex === null);
- if (agnostic) return { ...agnostic.range };
+ // Clamp below the youngest centre / above the oldest centre: hold the
+ // nearest cited band flat rather than extrapolate past the table.
+ const first = rows[0];
+ const last = rows[rows.length - 1];
+ if (ageYears <= bracketCentre(first)) return { ...first.range };
+ if (ageYears >= bracketCentre(last)) return { ...last.range };
- // Sex-specific-only table but no profile sex: no honest single band.
- return null;
+ // Find the two adjacent brackets whose centres bracket the age, then blend
+ // their ranges by the fractional position between the centres.
+ for (let i = 0; i < rows.length - 1; i++) {
+ const lo = rows[i];
+ const hi = rows[i + 1];
+ const loCentre = bracketCentre(lo);
+ const hiCentre = bracketCentre(hi);
+ if (ageYears >= loCentre && ageYears <= hiCentre) {
+ const span = hiCentre - loCentre;
+ const fraction = span > 0 ? (ageYears - loCentre) / span : 0;
+ // Round to one decimal: the cited brackets are integer-valued and the
+ // interpolated band reads tidily in the prompt + tile without losing the
+ // smoothing across the boundary.
+ const round1 = (n: number) => Math.round(n * 10) / 10;
+ return {
+ low: round1(lo.range.low + (hi.range.low - lo.range.low) * fraction),
+ high: round1(lo.range.high + (hi.range.high - lo.range.high) * fraction),
+ };
+ }
+ }
+
+ // Defensive — the clamp + loop above cover every finite age in range.
+ return { ...last.range };
+}
+
+/**
+ * Enright & Sherrill 1998 predicted six-minute-walk distance (metres) for a
+ * healthy adult. A published linear regression on age, height, weight, sex —
+ * NOT a HealthLog-derived model:
+ * - Men: 6MWD = 7.57·height_cm − 5.02·age − 1.76·weight_kg − 309
+ * - Women: 6MWD = 2.11·height_cm − 2.29·weight_kg − 5.78·age + 667
+ *
+ * Returns `null` when the inputs the equation needs are absent, so the caller
+ * surfaces the raw distance + trend without a fabricated placement (the
+ * `fitness-age.ts` `band: null` discipline). Sex is required (the two
+ * coefficient sets differ); height is required (the dominant term). Weight is
+ * required for the published full equation — when it is missing we return
+ * `null` rather than silently dropping the weight term, since the omission
+ * would inflate the predicted distance.
+ *
+ * Pure. The equation is for adults; for ages below 18 the reference does not
+ * apply and we return `null`.
+ */
+export function predictSixMinuteWalkDistance(
+ ageYears: number | null | undefined,
+ heightCm: number | null | undefined,
+ weightKg: number | null | undefined,
+ sex: NormSex,
+): number | null {
+ if (sex !== "MALE" && sex !== "FEMALE") return null;
+ if (
+ ageYears == null ||
+ !Number.isFinite(ageYears) ||
+ ageYears < 18 ||
+ ageYears > 120
+ ) {
+ return null;
+ }
+ if (heightCm == null || !Number.isFinite(heightCm) || heightCm <= 0) {
+ return null;
+ }
+ if (weightKg == null || !Number.isFinite(weightKg) || weightKg <= 0) {
+ return null;
+ }
+ const predicted =
+ sex === "MALE"
+ ? 7.57 * heightCm - 5.02 * ageYears - 1.76 * weightKg - 309
+ : 2.11 * heightCm - 2.29 * weightKg - 5.78 * ageYears + 667;
+ // A non-positive prediction is physically meaningless — treat as no band.
+ return predicted > 0 ? predicted : null;
}
/** `true` when an age/sex-sharpened band exists for the metric+profile. */
diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts
index e1908a9c..88ce49b7 100644
--- a/src/lib/insights/derived/readiness.ts
+++ b/src/lib/insights/derived/readiness.ts
@@ -52,6 +52,15 @@ import type { Derived, DerivedProvenanceSource } from "./types";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_DAYS = 30;
+/**
+ * Row cap for the latest-day mean read. A dense intra-day day (high-frequency
+ * RHR/HRV) can hold hundreds of rows; the deviation only needs the latest day's
+ * mean, so a bounded sample of the most-recent rows is enough — mirrors the
+ * dense-intraday retention reasoning (`measurements/dense-intraday-retention.ts`)
+ * that keeps the recent window dense but caps the read. The common single-
+ * reading-per-day case is unaffected.
+ */
+const MAX_LATEST_DAY_ROWS = 50;
/** Minimum present components before a headline is produced (no 1-of-N). */
export const READINESS_MIN_COMPONENTS = 2;
@@ -167,7 +176,7 @@ async function readLatestDayMean(
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "desc" },
- take: 50,
+ take: MAX_LATEST_DAY_ROWS,
select: { value: true, measuredAt: true },
});
if (rows.length === 0) return null;
diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts
index 73620d5e..4e27b419 100644
--- a/src/lib/insights/derived/registry.ts
+++ b/src/lib/insights/derived/registry.ts
@@ -46,7 +46,27 @@ export type DerivedMetricId =
/** Persisted nightly stress composite (passthrough read of COMPUTED rows). */
| "STRESS_SCORE"
/** Persisted nightly strain composite (passthrough read of COMPUTED rows). */
- | "STRAIN_SCORE";
+ | "STRAIN_SCORE"
+ /** v1.10.3: overnight wrist-temperature personal-deviation band (baseline engine). */
+ | "WRIST_TEMPERATURE_BASELINE"
+ /** v1.10.3: stair-ascent-speed personal trend band (baseline engine). */
+ | "STAIR_ASCENT_SPEED_BASELINE"
+ /** v1.10.3: stair-descent-speed personal trend band (baseline engine). */
+ | "STAIR_DESCENT_SPEED_BASELINE"
+ /** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
+ | "SIX_MINUTE_WALK_BAND";
+
+// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
+// trend-only with NO derived band, on purpose —
+// - FALL_COUNT is a zero-inflated discrete safety EVENT (target = zero); a
+// median ± k·MAD band on a near-constant-zero series is meaningless and a
+// band would imply a smooth metric where there is none.
+// - BREATHING_DISTURBANCES is a regulated sleep-apnea SCREENING signal (Apple
+// publishes only NotElevated/Elevated, no numeric cutoff); a self-derived
+// band would read as a HealthLog verdict on a screening signal and imply a
+// diagnosis. The fired BREATHING_DISTURBANCE_EVENT carries the device's own
+// classification in the awareness card; the continuous index stays a plain
+// trend with the generic assessment.
/** Archetype of a derived metric — drives shaping + the QA inventory. */
export type DerivedArchetype =
@@ -210,6 +230,42 @@ const REGISTRY: Record = {
minInputs: 1,
implemented: true,
},
+ WRIST_TEMPERATURE_BASELINE: {
+ id: "WRIST_TEMPERATURE_BASELINE",
+ displayName: "Wrist-temperature baseline",
+ archetype: "any-user-baseline",
+ inputs: ["WRIST_TEMPERATURE"],
+ minHistoryDays: 7,
+ minInputs: 1,
+ implemented: true,
+ },
+ STAIR_ASCENT_SPEED_BASELINE: {
+ id: "STAIR_ASCENT_SPEED_BASELINE",
+ displayName: "Stair-ascent-speed baseline",
+ archetype: "any-user-baseline",
+ inputs: ["STAIR_ASCENT_SPEED"],
+ minHistoryDays: 7,
+ minInputs: 1,
+ implemented: true,
+ },
+ STAIR_DESCENT_SPEED_BASELINE: {
+ id: "STAIR_DESCENT_SPEED_BASELINE",
+ displayName: "Stair-descent-speed baseline",
+ archetype: "any-user-baseline",
+ inputs: ["STAIR_DESCENT_SPEED"],
+ minHistoryDays: 7,
+ minInputs: 1,
+ implemented: true,
+ },
+ SIX_MINUTE_WALK_BAND: {
+ id: "SIX_MINUTE_WALK_BAND",
+ displayName: "Estimated 6-minute-walk band",
+ archetype: "passthrough-reframe",
+ inputs: ["SIX_MINUTE_WALK_DISTANCE"],
+ minHistoryDays: 1,
+ minInputs: 1,
+ implemented: true,
+ },
};
/** Closed set of ids the generic route accepts (Zod enum source). */
diff --git a/src/lib/insights/derived/six-minute-walk.ts b/src/lib/insights/derived/six-minute-walk.ts
new file mode 100644
index 00000000..1ef7bdd7
--- /dev/null
+++ b/src/lib/insights/derived/six-minute-walk.ts
@@ -0,0 +1,214 @@
+/**
+ * v1.10.3 — additive HealthKit signal: estimated six-minute-walk distance,
+ * re-framed against a published reference equation.
+ *
+ * `computeSixMinuteWalkBand(userId, profile, opts)` takes the device's
+ * ESTIMATED 6-minute-walk distance (HealthKit `sixMinuteWalkTestDistance` —
+ * an estimate, NOT a supervised in-clinic 6MWT) and re-frames it as a
+ * percent-of-predicted against the Enright & Sherrill 1998 reference
+ * equation for healthy adults. It NEVER recomputes the distance and NEVER
+ * derives its own equation — this is the `passthrough-reframe` archetype,
+ * the same posture `fitness-age.ts` takes against VO₂max.
+ *
+ * - **percentOfPredicted** — distance ÷ Enright-predicted × 100, placed in
+ * a green/yellow/red band (≥ 80% green; 60–80% yellow; < 60% red),
+ * `null` when the demographics the equation needs are absent (sex,
+ * adult age, height, AND weight — the published full equation). With no
+ * band the metric still surfaces the raw distance + trend, never a
+ * fabricated placement.
+ * - **trend** — signed latest-vs-prior delta, surfaced only once ≥ 3
+ * readings exist (the catalogue's trend gate).
+ *
+ * Standard: Enright & Sherrill 1998 (reference equations) + ATS 2002 (the
+ * test standard) — cited in `norms.ts`. Apple's value is framed as
+ * "estimated", surfaced + trended, never re-derived. No diagnosis: a low
+ * percent is a functional-capacity awareness signal, not a verdict.
+ *
+ * Server-only — reads the latest `SIX_MINUTE_WALK_DISTANCE` + `WEIGHT` rows
+ * via Prisma; age/height/sex come from the caller's profile. The placement
+ * helper is exported pure for the unit tests.
+ */
+import type { MeasurementType } from "@/generated/prisma/client";
+import { prisma } from "@/lib/db";
+import {
+ buildInsufficient,
+ buildOk,
+ deriveCoverage,
+ nowProvenanceTimestamp,
+} from "./coverage";
+import { predictSixMinuteWalkDistance } from "./norms";
+import type { BaselineProfile } from "./baseline";
+import {
+ SPARKLINE_MAX_POINTS,
+ type Derived,
+ type DerivedProvenanceSource,
+} from "./types";
+
+/** Band placement, same green/yellow/red vocabulary the tokens speak. */
+export type SixMinuteWalkBand = "green" | "yellow" | "red";
+
+const SIX_MINUTE_WALK_TYPE: MeasurementType = "SIX_MINUTE_WALK_DISTANCE";
+const WEIGHT_TYPE: MeasurementType = "WEIGHT";
+/** Readings needed before the latest-vs-prior trend delta is surfaced. */
+const MIN_TREND_READINGS = 3;
+/**
+ * The estimate refreshes at most every few days; widen the read window so the
+ * latest value + trend survive sparse cadence (matches `fitness-age.ts`).
+ */
+const DEFAULT_WINDOW_DAYS = 180;
+/** A weight reading older than this no longer reflects current capacity. */
+const WEIGHT_WINDOW_DAYS = 90;
+
+/** Percent-of-predicted band thresholds (% of Enright-predicted). */
+const GREEN_PCT = 80;
+const YELLOW_PCT = 60;
+
+/** The successful `value` payload for the 6-minute-walk band. */
+export interface SixMinuteWalkValue {
+ /** The latest device-estimated 6-minute-walk distance (m). */
+ distanceM: number;
+ /** Enright-predicted distance for the profile (m), or `null` without demographics. */
+ predictedM: number | null;
+ /** Distance ÷ predicted × 100, rounded; `null` without a prediction. */
+ percentOfPredicted: number | null;
+ /** Green/yellow/red placement, or `null` without a prediction. */
+ band: SixMinuteWalkBand | null;
+ /** Signed latest-vs-prior delta (m); `null` until ≥ 3 readings. */
+ trendDelta: number | null;
+ /** Distinct readings in the window. */
+ readingCount: number;
+ /**
+ * Trailing distance series (oldest → newest), capped to the last
+ * `SPARKLINE_MAX_POINTS`. Reuses the window rows already read — no extra
+ * query.
+ */
+ series: number[];
+}
+
+/**
+ * Place a percent-of-predicted in a band. Pure. ≥ 80% green; 60–80% yellow;
+ * < 60% red; `null` when the percent is absent.
+ */
+export function placeSixMinuteWalkBand(
+ percentOfPredicted: number | null,
+): SixMinuteWalkBand | null {
+ if (percentOfPredicted == null) return null;
+ if (percentOfPredicted >= GREEN_PCT) return "green";
+ if (percentOfPredicted >= YELLOW_PCT) return "yellow";
+ return "red";
+}
+
+/**
+ * Estimated six-minute-walk band — passthrough re-frame of
+ * `SIX_MINUTE_WALK_DISTANCE`. Returns `insufficient` when no reading exists;
+ * otherwise an `ok` value carrying the latest distance, its
+ * percent-of-predicted placement (when demographics allow), and a trend once
+ * ≥ 3 readings exist. With incomplete demographics the band is `null` and the
+ * value + trend still surface — never a fabricated placement.
+ */
+export async function computeSixMinuteWalkBand(
+ userId: string,
+ profile: BaselineProfile,
+ opts?: { windowDays?: number; now?: Date },
+): Promise> {
+ const now = opts?.now ?? new Date();
+ const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS;
+ const computedAt = nowProvenanceTimestamp(now);
+ const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
+
+ const rows = await prisma.measurement.findMany({
+ where: {
+ userId,
+ type: SIX_MINUTE_WALK_TYPE,
+ deletedAt: null,
+ measuredAt: { gte: since },
+ },
+ orderBy: { measuredAt: "desc" },
+ select: { value: true },
+ });
+
+ if (rows.length === 0) {
+ const { coverage } = deriveCoverage({
+ requiredInputs: 1,
+ presentInputs: 0,
+ historyDays: 0,
+ missing: [SIX_MINUTE_WALK_TYPE],
+ fullHistoryDays: windowDays,
+ });
+ return buildInsufficient({
+ coverage,
+ provenance: {
+ inputs: [SIX_MINUTE_WALK_TYPE],
+ source: "none",
+ windowDays,
+ computedAt,
+ },
+ reason: "no_readings_in_window",
+ });
+ }
+
+ const distanceM = rows[0].value;
+
+ // The Enright equation needs a recent weight; read the latest in a tighter
+ // window. Absent → predicted is null and the band is suppressed.
+ const weightSince = new Date(
+ now.getTime() - WEIGHT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
+ );
+ const weightRow = await prisma.measurement.findFirst({
+ where: {
+ userId,
+ type: WEIGHT_TYPE,
+ deletedAt: null,
+ measuredAt: { gte: weightSince },
+ },
+ orderBy: { measuredAt: "desc" },
+ select: { value: true },
+ });
+
+ const predictedM = predictSixMinuteWalkDistance(
+ profile.ageYears,
+ profile.heightCm ?? null,
+ weightRow?.value ?? null,
+ profile.sex,
+ );
+ const percentOfPredicted =
+ predictedM != null && predictedM > 0
+ ? Math.round((distanceM / predictedM) * 100)
+ : null;
+ const band = placeSixMinuteWalkBand(percentOfPredicted);
+ const trendDelta =
+ rows.length >= MIN_TREND_READINGS ? rows[0].value - rows[1].value : null;
+
+ const source: DerivedProvenanceSource = "live";
+ const { coverage, confidence } = deriveCoverage({
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: rows.length,
+ missing: [],
+ fullHistoryDays: MIN_TREND_READINGS,
+ });
+
+ return buildOk({
+ value: {
+ distanceM,
+ predictedM: predictedM != null ? Math.round(predictedM) : null,
+ percentOfPredicted,
+ band,
+ trendDelta,
+ readingCount: rows.length,
+ // rows are newest-first; the sparkline wants oldest → newest, capped.
+ series: rows
+ .slice(0, SPARKLINE_MAX_POINTS)
+ .map((r) => r.value)
+ .reverse(),
+ },
+ coverage,
+ confidence,
+ provenance: {
+ inputs: [SIX_MINUTE_WALK_TYPE],
+ source,
+ windowDays,
+ computedAt,
+ },
+ });
+}
diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts
index e3e3359f..9d9c588b 100644
--- a/src/lib/insights/derived/sleep-score.ts
+++ b/src/lib/insights/derived/sleep-score.ts
@@ -34,6 +34,8 @@
*/
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
+import { getLocalDateParts } from "@/lib/timezone";
+import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
buildInsufficient,
buildOk,
@@ -317,8 +319,16 @@ export interface NightSummary {
* Group raw stage rows into per-night summaries. A "night" is keyed by the
* latest stage row's calendar day (the wake day). Pure — the caller does
* the bounded DB read and passes rows in.
+ *
+ * `tz` is the IANA zone the midpoint is expressed against: a sleeper's
+ * 03:00-local midpoint must read as minutes-of-day in THEIR wall clock, not
+ * UTC, so a non-UTC user's consistency / timing sub-scores don't drift with
+ * the offset. Defaults to UTC for back-compatible pure use.
*/
-export function reconstructNights(rows: SleepRow[]): NightSummary[] {
+export function reconstructNights(
+ rows: SleepRow[],
+ tz: string = "UTC",
+): NightSummary[] {
// Bucket rows by the wake-day key (UTC day of the row's measuredAt).
const byNight = new Map();
for (const row of rows) {
@@ -369,7 +379,7 @@ export function reconstructNights(rows: SleepRow[]): NightSummary[] {
const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null;
const midpoint =
Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest
- ? minutesOfDay(new Date((earliest + latest) / 2))
+ ? minutesOfDay(new Date((earliest + latest) / 2), tz)
: null;
nights.push({
night,
@@ -385,8 +395,10 @@ export function reconstructNights(rows: SleepRow[]): NightSummary[] {
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
}
-function minutesOfDay(d: Date): number {
- return d.getUTCHours() * 60 + d.getUTCMinutes();
+function minutesOfDay(d: Date, tz: string): number {
+ if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes();
+ const { hour, minute } = getLocalDateParts(d, tz);
+ return hour * 60 + minute;
}
// ── compute ─────────────────────────────────────────────────────────────
@@ -394,6 +406,11 @@ function minutesOfDay(d: Date): number {
export interface SleepScoreOpts {
windowDays?: number;
now?: Date;
+ /**
+ * IANA zone the sleep midpoint is expressed against. Omit to resolve the
+ * user's stored zone (the production path); pass explicitly in tests.
+ */
+ tz?: string;
}
/**
@@ -409,6 +426,10 @@ export async function computeSleepScore(
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const now = opts.now ?? new Date();
const computedAt = nowProvenanceTimestamp(now);
+ // The midpoint is the user's wall-clock minutes-of-day, not UTC's, so a
+ // non-UTC sleeper's consistency / timing sub-scores don't drift with the
+ // offset. Resolve the stored zone unless a caller pins one.
+ const tz = opts.tz ?? (await resolveUserTimezone(userId));
const inputs = ["SLEEP_DURATION"];
const required = 1;
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
@@ -439,7 +460,7 @@ export async function computeSleepScore(
});
}
- const nights = reconstructNights(rows).filter(
+ const nights = reconstructNights(rows, tz).filter(
(n) => n.asleepMinutes > 0,
);
const scorableNights = nights.filter(
diff --git a/src/lib/insights/derived/types.ts b/src/lib/insights/derived/types.ts
index 905fffe8..5d4398ba 100644
--- a/src/lib/insights/derived/types.ts
+++ b/src/lib/insights/derived/types.ts
@@ -90,6 +90,14 @@ export interface DerivedInsufficient {
reason: string;
}
+/**
+ * Cap for a trailing `series` carried on a `Derived` value — the inline
+ * sparkline a tile renders. The window readers already bound their reads;
+ * this caps the points handed to the chart so a dense window never ships a
+ * thousand-point array to the client. The tile only needs the recent shape.
+ */
+export const SPARKLINE_MAX_POINTS = 30;
+
export type Derived = DerivedOk | DerivedInsufficient;
/** Narrowing type guard — `true` when the value computed successfully. */
diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts
index 7aa7e460..3dca29a7 100644
--- a/src/lib/insights/derived/wellness-scores.ts
+++ b/src/lib/insights/derived/wellness-scores.ts
@@ -16,7 +16,7 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
import type { BaselineProfile } from "./baseline";
-import type { Derived } from "./types";
+import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
* higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */
@@ -32,6 +32,12 @@ export interface WellnessScoreValue {
daysInWindow: number;
/** ISO timestamp of the latest score's `measuredAt`. */
asOf: string;
+ /**
+ * Trailing score series (oldest → newest), capped to the last
+ * `SPARKLINE_MAX_POINTS`. Reuses the window rows already read — no extra
+ * query.
+ */
+ series: number[];
}
/** The three persisted score types this engine serves. */
@@ -132,6 +138,11 @@ export async function computeWellnessScore(
trendDelta,
daysInWindow: rows.length,
asOf: latest.measuredAt.toISOString(),
+ // rows are newest-first; the sparkline wants oldest → newest, capped.
+ series: rows
+ .slice(0, SPARKLINE_MAX_POINTS)
+ .map((r) => r.value)
+ .reverse(),
},
coverage: {
requiredInputs: 1,
diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts
index 3bc69f47..06d924bc 100644
--- a/src/lib/insights/strain-score.ts
+++ b/src/lib/insights/strain-score.ts
@@ -23,12 +23,26 @@
* Tanaka, Monahan & Seals 2001, J. Am. Coll. Cardiol. 37(1):153–156.
*
* The per-workout TRIMP values are summed across the scored day to a
- * day-total TRIMP, then mapped to the 0–100 scale by a saturating curve
- * anchored at a reference daily load (`STRAIN_TRIMP_REFERENCE` ≈ a hard
- * hour). Days with NO usable HR series but WITH active energy fall back to
- * an active-energy-only proxy so a "logged but no series" workout still
- * registers some strain (clearly the weaker signal — the provenance records
- * which path produced the score).
+ * day-total TRIMP, then mapped to the 0–100 scale by a saturating curve.
+ *
+ * v1.10.3 — PERSONAL-RELATIVE ANCHOR. The 0–100 map is anchored to the
+ * USER'S OWN recent training-day load, not a fixed population reference: the
+ * reference that maps to score ≈ 63 is the EWMA-smoothed 75th percentile of
+ * the user's own training-day (TRIMP > 0) day-total TRIMP over a 42-day
+ * chronic window (the acute-vs-chronic framing Garmin Training Load uses,
+ * the personal-zone idea WHOOP uses). This makes the score meaningful for a
+ * deconditioned or chronic-condition user, who would otherwise be pinned near
+ * 0 against the population anchor regardless of how hard *they* worked. Below
+ * a 7-training-day cold-start floor the engine falls back to the fixed
+ * population anchor (`STRAIN_TRIMP_REFERENCE` ≈ a hard hour), recorded as
+ * `anchor: "population"`. The anchor is derived from the TRIMP INPUT, never
+ * from the 0–100 OUTPUT, so there is no circular feedback.
+ *
+ * Days with NO usable HR series but WITH active energy fall back to an
+ * active-energy-only proxy so a "logged but no series" workout still
+ * registers some strain (clearly the weaker signal, and with no personal
+ * intensity distribution it stays on the population anchor — the provenance
+ * records which path + which anchor produced the score).
*
* Honest confidence: the score is only stored when the day carried at least
* one workout HR series with usable samples OR a non-trivial active-energy
@@ -43,8 +57,10 @@
* - `externalId = strain:YYYY-MM-DD`
*
* The score is descriptive — a daily training-load proxy, NOT a clinical
- * assessment, and it is excluded from the doctor PDF. Server-only — runs
- * from the nightly pg-boss job in `src/lib/jobs/strain-score.ts`.
+ * assessment. In the doctor PDF it is segregated into a clearly-labelled,
+ * disclaimed Wellness-summary section, kept out of the clinical-vitals body.
+ * Server-only — runs from the nightly pg-boss job in
+ * `src/lib/jobs/strain-score.ts`.
*/
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
@@ -76,6 +92,59 @@ export const STRAIN_TRIMP_REFERENCE = 150;
*/
export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600;
+/**
+ * v1.10.3 — personal-relative anchor.
+ *
+ * The fixed `STRAIN_TRIMP_REFERENCE` is a POPULATION anchor: for a fit user a
+ * hard hour ≈ 150 TRIMP → score ≈ 63, but for a deconditioned / chronic user
+ * whose hardest realistic effort is, say, 25 TRIMP the score saturates near 15
+ * — they gave *their* maximum and the headline reads 15. STRESS + RECOVERY are
+ * already personal-relative; this brings STRAIN into line by anchoring the
+ * 0–100 map to the USER'S OWN recent training-day load.
+ *
+ * The anchor is the EWMA-smoothed 75th percentile of the user's own
+ * training-day (TRIMP > 0) day-total TRIMP over a 42-day chronic window — the
+ * acute-vs-chronic framing Garmin Training Load uses, the personal-zone idea
+ * WHOOP uses, gated like Oura's "a few days before it appears". We anchor on
+ * the TRIMP INPUT, never on the 0–100 OUTPUT, so there is no circularity.
+ */
+
+/** Chronic window (days) the personal training-day distribution is read over. */
+export const STRAIN_CHRONIC_WINDOW_DAYS = 42;
+
+/** Percentile of the user's own training-day TRIMP that maps to score ≈ 63. */
+export const STRAIN_PERSONAL_REF_PERCENTILE = 75;
+
+/**
+ * EWMA effective span (Nₑ) for smoothing the personal reference across nights,
+ * α = 2/(Nₑ+1) ≈ 0.13. Mirrors Garmin's chronic moving average — the anchor
+ * tracks fitness changes over weeks without day-to-day jitter.
+ */
+export const STRAIN_EWMA_N = 14;
+
+/** EWMA smoothing factor derived from {@link STRAIN_EWMA_N}. */
+export const STRAIN_EWMA_ALPHA = 2 / (STRAIN_EWMA_N + 1);
+
+/**
+ * Cold-start floor: distinct training days (TRIMP > 0) the user must have in
+ * the chronic window before the personal anchor activates. Below it the engine
+ * falls back to the population anchor, labelled lower-confidence. Counting
+ * TRAINING days (not calendar days) is deliberate — the anchor describes how
+ * hard the user trains *when they train*, so a twice-a-week trainer needs
+ * ~3.5 weeks to qualify, which is the correct semantics.
+ */
+export const STRAIN_MIN_TRAINING_DAYS = 7;
+
+/**
+ * Floor on the personal reference (TRIMP). Guards against a near-zero anchor
+ * (a user whose training days are all very light) inflating every trivial day
+ * toward 100.
+ */
+export const STRAIN_PERSONAL_REF_FLOOR = 10;
+
+/** Which anchor produced a day's score. */
+export type StrainAnchor = "personal" | "population";
+
/**
* The UTC calendar day a Strain run scores — the PREVIOUS day relative to
* `now`. The cron fires in the small hours; scoring the just-ended day is what
@@ -161,6 +230,105 @@ export function saturateToScore(load: number, reference: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}
+/**
+ * The `p`-th percentile (0–100) of a numeric sample via linear interpolation
+ * between order statistics. Returns 0 for an empty sample. Pure — exported for
+ * unit testing.
+ */
+export function percentile(values: readonly number[], p: number): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ if (sorted.length === 1) return sorted[0];
+ const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
+ const lo = Math.floor(rank);
+ const hi = Math.ceil(rank);
+ if (lo === hi) return sorted[lo];
+ const frac = rank - lo;
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
+}
+
+/** The training-day TRIMP history backing the personal anchor. */
+export interface StrainAnchorHistory {
+ /** Day-total TRIMP for each TRAINING day (TRIMP > 0) in the chronic window. */
+ trainingDayTrimps: readonly number[];
+ /** The EWMA reference persisted by the previous night's run, or null on first run. */
+ priorRefPersonal: number | null;
+}
+
+/** The resolved reference + the provenance the score row's cache should record. */
+export interface ResolvedStrainReference {
+ /** The reference to feed `saturateToScore` for the TRIMP path. */
+ reference: number;
+ /** Which anchor was used. */
+ anchor: StrainAnchor;
+ /** Distinct training days seen in the chronic window. */
+ trainingDays: number;
+ /**
+ * The EWMA-smoothed personal reference to persist for the next night. Always
+ * the personal reference even when the population anchor was used this night
+ * (so the EWMA keeps warming up toward activation); null only when there is
+ * no training-day history at all.
+ */
+ refPersonalToPersist: number | null;
+}
+
+/**
+ * Resolve the day's strain reference from the user's own training-day TRIMP
+ * history. Below the cold-start floor → the fixed population anchor (labelled
+ * `population`); at or above it → the EWMA-smoothed P75 of the user's own
+ * training-day TRIMP (labelled `personal`), floored at
+ * {@link STRAIN_PERSONAL_REF_FLOOR}.
+ *
+ * The EWMA blends this window's P75 with the prior night's reference
+ * (`ref = α·P75 + (1−α)·prev`); on the first night with any history it seeds
+ * from the window P75. Anchors on the TRIMP INPUT, never the score OUTPUT, so
+ * there is no circular feedback. Pure — no Prisma. Exported for unit testing.
+ */
+export function resolvePersonalReference(
+ history: StrainAnchorHistory,
+ populationReference: number = STRAIN_TRIMP_REFERENCE,
+): ResolvedStrainReference {
+ const trimps = history.trainingDayTrimps.filter((t) => t > 0);
+ const trainingDays = trimps.length;
+
+ // The EWMA reference warms up regardless of the cold-start gate so it is
+ // ready the night the user crosses the floor.
+ let refPersonalToPersist: number | null = null;
+ if (trainingDays > 0) {
+ const windowP75 = Math.max(
+ STRAIN_PERSONAL_REF_FLOOR,
+ percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE),
+ );
+ refPersonalToPersist =
+ history.priorRefPersonal != null
+ ? STRAIN_EWMA_ALPHA * windowP75 +
+ (1 - STRAIN_EWMA_ALPHA) * history.priorRefPersonal
+ : windowP75;
+ // Keep the persisted EWMA above the floor too (a long stretch of light
+ // days should never drag the anchor below it).
+ refPersonalToPersist = Math.max(
+ STRAIN_PERSONAL_REF_FLOOR,
+ refPersonalToPersist,
+ );
+ }
+
+ if (trainingDays < STRAIN_MIN_TRAINING_DAYS) {
+ return {
+ reference: populationReference,
+ anchor: "population",
+ trainingDays,
+ refPersonalToPersist,
+ };
+ }
+
+ return {
+ reference: refPersonalToPersist as number,
+ anchor: "personal",
+ trainingDays,
+ refPersonalToPersist,
+ };
+}
+
export interface StrainComputeResult {
/** The 0..100 score to persist, or null when the inputs gate. */
score: number | null;
@@ -176,6 +344,17 @@ export interface StrainComputeResult {
dayActiveEnergy: number;
/** Workouts with a usable HR series on the day. */
workoutsWithSeries: number;
+ /**
+ * v1.10.3 — which anchor the score was mapped against. `personal` once the
+ * user has ≥ `STRAIN_MIN_TRAINING_DAYS` training days of history (TRIMP
+ * path only); `population` during cold start and always for the
+ * active-energy fallback (which has no personal intensity distribution).
+ */
+ anchor: StrainAnchor;
+ /** Distinct training days (TRIMP > 0) in the chronic window. */
+ trainingDays: number;
+ /** EWMA personal reference to persist for the next night, or null. */
+ refPersonalToPersist: number | null;
}
export interface StrainProfile {
@@ -196,6 +375,10 @@ export async function computeStrainScore(
profile: StrainProfile,
hrRest: number | null,
now: Date,
+ anchorHistory: StrainAnchorHistory = {
+ trainingDayTrimps: [],
+ priorRefPersonal: null,
+ },
): Promise {
const dayKey = strainDayKey(now);
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
@@ -240,19 +423,37 @@ export async function computeStrainScore(
}
}
+ // v1.10.3 — resolve the personal-vs-population reference from the user's
+ // own training-day TRIMP history. Below the cold-start floor (or with no
+ // history) this returns the population anchor labelled `population`. The
+ // scored day's own TRIMP is included in the distribution it is judged
+ // against — the anchor is "your typical hard day", today included.
+ const ref = resolvePersonalReference({
+ trainingDayTrimps:
+ dayTrimp > 0
+ ? [...anchorHistory.trainingDayTrimps, dayTrimp]
+ : anchorHistory.trainingDayTrimps,
+ priorRefPersonal: anchorHistory.priorRefPersonal,
+ });
+
if (dayTrimp > 0) {
return {
- score: saturateToScore(dayTrimp, STRAIN_TRIMP_REFERENCE),
+ score: saturateToScore(dayTrimp, ref.reference),
reason: "trimp",
dayTrimp,
dayActiveEnergy,
workoutsWithSeries,
+ anchor: ref.anchor,
+ trainingDays: ref.trainingDays,
+ refPersonalToPersist: ref.refPersonalToPersist,
};
}
// No usable HR series. Fall back to the active-energy-only proxy when
// there is a non-trivial active-energy total. A profile with no HRmax /
- // resting HR can still get this fallback because it needs neither.
+ // resting HR can still get this fallback because it needs neither. The
+ // fallback has no personal intensity distribution, so it stays on the
+ // population anchor (honestly labelled `population`).
if (dayActiveEnergy > 0) {
return {
score: saturateToScore(dayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE),
@@ -260,6 +461,9 @@ export async function computeStrainScore(
dayTrimp: 0,
dayActiveEnergy,
workoutsWithSeries: 0,
+ anchor: "population",
+ trainingDays: ref.trainingDays,
+ refPersonalToPersist: ref.refPersonalToPersist,
};
}
@@ -274,6 +478,9 @@ export async function computeStrainScore(
dayTrimp: 0,
dayActiveEnergy,
workoutsWithSeries: 0,
+ anchor: "population",
+ trainingDays: ref.trainingDays,
+ refPersonalToPersist: ref.refPersonalToPersist,
};
}
@@ -311,10 +518,101 @@ export async function loadStrainInputs(
return { profile, hrRest: rhr?.value ?? null };
}
+/**
+ * v1.10.3 — read the trailing training-day TRIMP distribution + the prior
+ * EWMA reference from the server-internal `strainTrimpCache`. Reads cheap
+ * cached day-total TRIMP instead of re-integrating 42 days of HR series. The
+ * cache populates itself forward (no backfill); a user with no cache rows yet
+ * returns an empty history → the cold-start population anchor.
+ *
+ * `priorRefPersonal` = the most recent cached row's `refPersonal` STRICTLY
+ * before the scored day, so a re-run for the same day (idempotent recompute)
+ * blends against the prior night, never against its own previous write.
+ */
+export async function loadStrainAnchorHistory(
+ prisma: PrismaClient,
+ userId: string,
+ now: Date,
+): Promise {
+ const dayKey = strainDayKey(now);
+ const windowStart = new Date(
+ new Date(`${dayKey}T00:00:00.000Z`).getTime() -
+ STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY,
+ )
+ .toISOString()
+ .slice(0, 10);
+
+ // The chronic window of cached rows, excluding the scored day itself (its
+ // TRIMP is added at compute time so the distribution always reflects the
+ // freshly-computed day).
+ const rows = await prisma.strainTrimpCache.findMany({
+ where: { userId, day: { gte: windowStart, lt: dayKey } },
+ select: {
+ day: true,
+ dayTrimp: true,
+ refPersonal: true,
+ trainingDays: true,
+ },
+ orderBy: { day: "desc" },
+ });
+
+ const trainingDayTrimps = rows
+ .map((r) => r.dayTrimp)
+ .filter((t) => t > 0);
+ // The prior EWMA = the most recent cached row that actually carried personal
+ // training history (`trainingDays > 0`). A row written on a pure rest /
+ // energy-only day stores the population seed only to keep the column
+ // non-null; adopting it would pollute the EWMA and stall personal
+ // activation, so it is skipped — the seed stays unset (null) until a real
+ // training day has warmed the reference.
+ const warmedRow = rows.find((r) => r.trainingDays > 0);
+ const priorRefPersonal = warmedRow ? warmedRow.refPersonal : null;
+
+ return { trainingDayTrimps, priorRefPersonal };
+}
+
+/**
+ * v1.10.3 — upsert the scored day's Strain anchor cache row (day-total TRIMP +
+ * the EWMA reference + the anchor used + the training-day count). Idempotent on
+ * `(userId, day)` so a re-fired nightly tick overwrites in place. Server-only.
+ */
+export async function upsertStrainTrimpCache(
+ prisma: PrismaClient,
+ args: {
+ userId: string;
+ now: Date;
+ dayTrimp: number;
+ refPersonal: number;
+ anchor: StrainAnchor;
+ trainingDays: number;
+ },
+): Promise {
+ const day = strainDayKey(args.now);
+ await prisma.strainTrimpCache.upsert({
+ where: { userId_day: { userId: args.userId, day } },
+ create: {
+ userId: args.userId,
+ day,
+ dayTrimp: args.dayTrimp,
+ refPersonal: args.refPersonal,
+ anchor: args.anchor,
+ trainingDays: args.trainingDays,
+ },
+ update: {
+ dayTrimp: args.dayTrimp,
+ refPersonal: args.refPersonal,
+ anchor: args.anchor,
+ trainingDays: args.trainingDays,
+ },
+ });
+}
+
export interface PersistStrainResult {
outcome: "stored" | "insufficient";
score: number | null;
reason: StrainComputeResult["reason"];
+ /** v1.10.3 — which anchor produced the score (drives the honesty label). */
+ anchor: StrainAnchor;
}
/**
@@ -328,16 +626,20 @@ export async function persistStrainScore(
now: Date,
): Promise {
const { profile, hrRest } = await loadStrainInputs(prisma, userId, now);
- const { score, reason } = await computeStrainScore(
+ const anchorHistory = await loadStrainAnchorHistory(prisma, userId, now);
+ const result = await computeStrainScore(
prisma,
userId,
profile,
hrRest,
now,
+ anchorHistory,
);
+ const { score, reason, dayTrimp, anchor, trainingDays, refPersonalToPersist } =
+ result;
if (score === null) {
- return { outcome: "insufficient", score: null, reason };
+ return { outcome: "insufficient", score: null, reason, anchor };
}
await upsertScoreRow(prisma, {
@@ -348,5 +650,21 @@ export async function persistStrainScore(
now,
});
- return { outcome: "stored", score, reason };
+ // Persist the day's TRIMP + the EWMA reference so the next night's chronic
+ // window reads cheap cached values. Only the TRIMP path contributes a
+ // training day; the active-energy fallback (dayTrimp === 0) still writes a
+ // row so the cache reflects a non-training day, but it never counts toward
+ // the personal distribution. `refPersonalToPersist` is null only when there
+ // is no training history at all — fall back to the population reference for
+ // the stored EWMA seed so the column stays non-null.
+ await upsertStrainTrimpCache(prisma, {
+ userId,
+ now,
+ dayTrimp,
+ refPersonal: refPersonalToPersist ?? STRAIN_TRIMP_REFERENCE,
+ anchor,
+ trainingDays,
+ });
+
+ return { outcome: "stored", score, reason, anchor };
}
diff --git a/src/lib/jobs/__tests__/recovery-score.test.ts b/src/lib/jobs/__tests__/recovery-score.test.ts
index 47243367..ac2b423d 100644
--- a/src/lib/jobs/__tests__/recovery-score.test.ts
+++ b/src/lib/jobs/__tests__/recovery-score.test.ts
@@ -26,14 +26,14 @@ import {
const NOW = new Date("2026-06-02T08:00:00Z");
function makePrisma(userIds: string[]) {
- const findMany = vi
+ const groupBy = vi
.fn()
.mockResolvedValue(userIds.map((userId) => ({ userId })));
return {
- prisma: { measurement: { findMany } } as unknown as Parameters<
+ prisma: { measurement: { groupBy } } as unknown as Parameters<
typeof runRecoveryScore
>[0],
- findMany,
+ groupBy,
};
}
@@ -43,11 +43,12 @@ beforeEach(() => {
describe("findRecoveryScoreCandidates", () => {
it("queries live recovery-input rows inside the recency window", async () => {
- const { prisma, findMany } = makePrisma(["a", "b"]);
+ const { prisma, groupBy } = makePrisma(["a", "b"]);
const ids = await findRecoveryScoreCandidates(prisma, NOW, 100);
expect(ids).toEqual(["a", "b"]);
- const where = findMany.mock.calls[0][0].where;
+ const arg = groupBy.mock.calls[0][0];
+ const where = arg.where;
expect(where.type.in).toEqual(
expect.arrayContaining([
"RESTING_HEART_RATE",
@@ -62,7 +63,13 @@ describe("findRecoveryScoreCandidates", () => {
NOW.getTime() - RECOVERY_SCORE_RECENCY_DAYS * 24 * 60 * 60 * 1000,
);
expect(where.measuredAt.gte.getTime()).toBe(expectedSince.getTime());
- expect(findMany.mock.calls[0][0].distinct).toEqual(["userId"]);
+ expect(arg.by).toEqual(["userId"]);
+ // Deterministic recency-under-cap: newest input first, userId tiebreak.
+ expect(arg.orderBy).toEqual([
+ { _max: { measuredAt: "desc" } },
+ { userId: "asc" },
+ ]);
+ expect(arg.take).toBe(100);
});
});
diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts
index 08918f27..e0b3808e 100644
--- a/src/lib/jobs/recovery-score.ts
+++ b/src/lib/jobs/recovery-score.ts
@@ -67,7 +67,14 @@ export async function findRecoveryScoreCandidates(
const since = new Date(now.getTime() - RECOVERY_SCORE_RECENCY_DAYS * MS_PER_DAY);
// The input-type set is a closed compile-time list of enum members —
// splice-free; Prisma binds the `type IN (...)` array as parameters.
- const rows = await prisma.measurement.findMany({
+ //
+ // Deterministic recency-under-cap: group by user, order by each user's
+ // newest input first, with `userId` as the stable tiebreak so the capped
+ // set is reproducible. `distinct` + `take` without an order picks an
+ // arbitrary set when more than `cap` users qualify; `groupBy` makes the
+ // cap take the most-recently-active users every run.
+ const rows = await prisma.measurement.groupBy({
+ by: ["userId"],
where: {
type: {
in: [
@@ -80,8 +87,7 @@ export async function findRecoveryScoreCandidates(
deletedAt: null,
measuredAt: { gte: since },
},
- select: { userId: true },
- distinct: ["userId"],
+ orderBy: [{ _max: { measuredAt: "desc" } }, { userId: "asc" }],
take: cap,
});
return rows.map((r) => r.userId);
diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts
index 4415ffb4..3104f1ec 100644
--- a/src/lib/jobs/strain-score.ts
+++ b/src/lib/jobs/strain-score.ts
@@ -57,27 +57,38 @@ export async function findStrainScoreCandidates(
cap: number,
): Promise {
const since = new Date(now.getTime() - STRAIN_SCORE_RECENCY_DAYS * MS_PER_DAY);
+ // Deterministic recency-under-cap on both source queries: group by user,
+ // newest activity first, `userId` tiebreak. `distinct` + `take` without an
+ // order picked an arbitrary set when more than `cap` users qualified; the
+ // ordered `groupBy` makes each side take the most-recently-active users, and
+ // the merge below is order-stable.
const [workoutUsers, energyUsers] = await Promise.all([
- prisma.workout.findMany({
+ prisma.workout.groupBy({
+ by: ["userId"],
where: { startedAt: { gte: since } },
- select: { userId: true },
- distinct: ["userId"],
+ orderBy: [{ _max: { startedAt: "desc" } }, { userId: "asc" }],
take: cap,
}),
- prisma.measurement.findMany({
+ prisma.measurement.groupBy({
+ by: ["userId"],
where: {
type: "ACTIVE_ENERGY_BURNED",
deletedAt: null,
measuredAt: { gte: since },
},
- select: { userId: true },
- distinct: ["userId"],
+ orderBy: [{ _max: { measuredAt: "desc" } }, { userId: "asc" }],
take: cap,
}),
]);
+ // Interleave the two recency-ordered lists so the merged cap favours the
+ // most-recently-active users across both sources rather than draining one
+ // list first. Both inputs are already deterministic, so the merge is too.
const ids = new Set();
- for (const r of workoutUsers) ids.add(r.userId);
- for (const r of energyUsers) ids.add(r.userId);
+ const maxLen = Math.max(workoutUsers.length, energyUsers.length);
+ for (let i = 0; i < maxLen && ids.size < cap; i++) {
+ if (i < workoutUsers.length) ids.add(workoutUsers[i].userId);
+ if (ids.size < cap && i < energyUsers.length) ids.add(energyUsers[i].userId);
+ }
return Array.from(ids).slice(0, cap);
}
diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts
index 2064a9dd..e555253b 100644
--- a/src/lib/jobs/stress-score.ts
+++ b/src/lib/jobs/stress-score.ts
@@ -58,14 +58,18 @@ export async function findStressScoreCandidates(
cap: number,
): Promise {
const since = new Date(now.getTime() - STRESS_SCORE_RECENCY_DAYS * MS_PER_DAY);
- const rows = await prisma.measurement.findMany({
+ // Deterministic recency-under-cap: group by user, newest HRV first, `userId`
+ // tiebreak. `distinct` + `take` without an order picks an arbitrary set when
+ // more than `cap` users qualify; `groupBy` makes the cap take the
+ // most-recently-active users every run.
+ const rows = await prisma.measurement.groupBy({
+ by: ["userId"],
where: {
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
measuredAt: { gte: since },
},
- select: { userId: true },
- distinct: ["userId"],
+ orderBy: [{ _max: { measuredAt: "desc" } }, { userId: "asc" }],
take: cap,
});
return rows.map((r) => r.userId);
diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts
index a2099648..d0590e5f 100644
--- a/src/lib/openapi/routes.ts
+++ b/src/lib/openapi/routes.ts
@@ -1388,7 +1388,7 @@ const derivedMetricResponse = z
.record(z.string(), z.unknown())
.nullable()
.describe(
- "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } for VITALS_BASELINE); null when 'insufficient'.",
+ "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.",
),
coverage: derivedCoverage,
confidence: derivedConfidence