diff --git a/messages/de.json b/messages/de.json
index 7ed78a685..dccc6e5f9 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "Faktoren",
"contributorValue": "{value} · {weight}% Gewicht",
"contributorMissing": "keine Daten",
- "disclaimer": "Richtwert — keine klinische Bewertung.",
"insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "Schlaf",
"respiratory": "Atemfrequenz",
"mood": "Stimmungsstabilität"
- },
- "caveat": "Ein Wellness-Proxy, kein Erholungs- oder klinischer Wert."
+ }
},
"VITALS_BASELINE": {
"method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm — ein Wert im Band ist also typisch für dich."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen — nie spontan neu berechnet.",
- "caveat": "Ein beschreibender Wellness-Proxy, kein trainings- oder klinikgerechtes Erholungsmaß.",
"description": "Ein nächtlicher Erholungs-Proxy aus HRV, Ruhepuls und Schlaf."
},
"STRESS_SCORE": {
"method": "Ein nächtlicher 0–100-Stress-Proxy aus dem Verlauf deiner HRV über den Tag gegenüber deiner Baseline. Höher heißt mehr physiologische Belastung.",
- "caveat": "Ein aus der HRV abgeleiteter Proxy, KEINE EDA-/Hautleitwert-Stressmessung — deine Uhr hat keinen EDA-Sensor. Beschreibend, nicht klinisch.",
"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.",
"anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.",
"anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.",
- "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.",
"description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "Erholungs-Score",
"recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen",
"stress": "Stress",
- "strain": "Belastung"
+ "strain": "Belastung",
+ "historyTitle": "Verlauf",
+ "historyWindow": "Letzte {days} Tage"
},
"coincident": {
"label": "Mehrere Vitalwerte außerhalb der Baseline",
diff --git a/messages/en.json b/messages/en.json
index 62f080bed..154461567 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "Contributors",
"contributorValue": "{value} · {weight}% weight",
"contributorMissing": "no data",
- "disclaimer": "Indicative — not a clinical assessment.",
"insufficient": "Not enough data yet to score this. Keep tracking and it will appear here."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "Sleep",
"respiratory": "Respiratory rate",
"mood": "Mood stability"
- },
- "caveat": "A wellness proxy, not a recovery or clinical score."
+ }
},
"VITALS_BASELINE": {
"method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm — so a value inside the band is typical for you."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here — never recomputed on the fly.",
- "caveat": "A descriptive wellness proxy, not a training-grade or clinical recovery measure.",
"description": "A nightly recovery proxy from your HRV, resting heart rate and sleep."
},
"STRESS_SCORE": {
"method": "A nightly 0–100 stress proxy from the shape of your intra-day HRV against your baseline. Higher means more physiological load.",
- "caveat": "An HRV-derived proxy, NOT an EDA / galvanic-skin stress measurement — your watch has no EDA sensor. Descriptive, not clinical.",
"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.",
"anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.",
"anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.",
- "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, relative to your typical effort."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "Recovery score",
"recoveryCrossLink": "See your wearable's recovery & strain data",
"stress": "Stress",
- "strain": "Strain"
+ "strain": "Strain",
+ "historyTitle": "History",
+ "historyWindow": "Last {days} days"
},
"coincident": {
"label": "Several vitals off baseline",
diff --git a/messages/es.json b/messages/es.json
index b0ba7065f..ce8949c7b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "Factores",
"contributorValue": "{value} · {weight}% de peso",
"contributorMissing": "sin datos",
- "disclaimer": "Indicativo — no es una evaluación clínica.",
"insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "Sueño",
"respiratory": "Frecuencia respiratoria",
"mood": "Estabilidad del ánimo"
- },
- "caveat": "Un indicador de bienestar, no una puntuación de recuperación ni clínica."
+ }
},
"VITALS_BASELINE": {
"method": "Tu rango típico personal es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta mediana, escalada). Sigue TUS lecturas, no una norma poblacional, así que un valor dentro de la banda es típico para ti."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "Un indicador nocturno de recuperación de 0 a 100 que combina tu VFC, frecuencia cardíaca en reposo y sueño frente a tu línea base. Más alto significa mejor recuperado. Se calcula una vez por noche y aquí solo se lee, nunca se recalcula al vuelo.",
- "caveat": "Un indicador de bienestar descriptivo, no una medida de recuperación clínica ni de nivel de entrenamiento.",
"description": "Un indicador nocturno de recuperación a partir de tu VFC, frecuencia en reposo y sueño."
},
"STRESS_SCORE": {
"method": "Un indicador nocturno de estrés de 0 a 100 a partir de la forma de tu VFC a lo largo del día frente a tu línea base. Más alto significa más carga fisiológica.",
- "caveat": "Un indicador derivado de la VFC, NO una medición de estrés por EDA / actividad electrodérmica: tu reloj no tiene sensor EDA. Descriptivo, no clínico.",
"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.",
"anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.",
"anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.",
- "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, relativo a tu esfuerzo habitual."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "Puntuación de recuperación",
"recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable",
"stress": "Estrés",
- "strain": "Esfuerzo"
+ "strain": "Esfuerzo",
+ "historyTitle": "Evolución",
+ "historyWindow": "Últimos {days} días"
},
"coincident": {
"label": "Varias constantes fuera de la base",
diff --git a/messages/fr.json b/messages/fr.json
index 6f8adf4a5..2d7beeb73 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "Facteurs",
"contributorValue": "{value} · {weight}% du poids",
"contributorMissing": "aucune donnée",
- "disclaimer": "Indicatif — pas une évaluation clinique.",
"insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "Sommeil",
"respiratory": "Fréquence respiratoire",
"mood": "Stabilité de l’humeur"
- },
- "caveat": "Un indicateur de bien-être, pas un score de récupération ni clinique."
+ }
},
"VITALS_BASELINE": {
"method": "Votre plage typique personnelle est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS mesures, pas une norme de population : une valeur dans la bande est donc typique pour vous."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "Un indice de récupération nocturne de 0 à 100 combinant votre VFC, votre fréquence cardiaque au repos et votre sommeil par rapport à votre référence. Plus élevé signifie mieux récupéré. Calculé une fois par nuit et seulement relu ici, jamais recalculé à la volée.",
- "caveat": "Un indicateur de bien-être descriptif, pas une mesure de récupération clinique ou de niveau d’entraînement.",
"description": "Un indice de récupération nocturne à partir de votre VFC, fréquence au repos et sommeil."
},
"STRESS_SCORE": {
"method": "Un indice de stress nocturne de 0 à 100 à partir de la forme de votre VFC au cours de la journée par rapport à votre référence. Plus élevé signifie une charge physiologique plus forte.",
- "caveat": "Un indicateur dérivé de la VFC, PAS une mesure de stress par EDA / activité électrodermale : votre montre n’a pas de capteur EDA. Descriptif, non clinique.",
"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.",
"anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.",
"anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.",
- "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, relatif à votre effort habituel."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "Score de récupération",
"recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable",
"stress": "Stress",
- "strain": "Effort"
+ "strain": "Effort",
+ "historyTitle": "Évolution",
+ "historyWindow": "{days} derniers jours"
},
"coincident": {
"label": "Plusieurs constantes hors référence",
diff --git a/messages/it.json b/messages/it.json
index a3fa178ae..824a5b12a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "Fattori",
"contributorValue": "{value} · {weight}% di peso",
"contributorMissing": "nessun dato",
- "disclaimer": "Indicativo — non è una valutazione clinica.",
"insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "Sonno",
"respiratory": "Frequenza respiratoria",
"mood": "Stabilità dell’umore"
- },
- "caveat": "Un indicatore di benessere, non un punteggio di recupero né clinico."
+ }
},
"VITALS_BASELINE": {
"method": "Il tuo intervallo tipico personale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture, non una norma di popolazione: un valore dentro la banda è quindi tipico per te."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "Un indicatore notturno di recupero da 0 a 100 che combina VFC, frequenza cardiaca a riposo e sonno rispetto alla tua linea di base. Più alto significa più recuperato. Calcolato una volta a notte e qui solo riletto, mai ricalcolato al volo.",
- "caveat": "Un indicatore di benessere descrittivo, non una misura di recupero clinica o da allenamento.",
"description": "Un indicatore notturno di recupero da VFC, frequenza a riposo e sonno."
},
"STRESS_SCORE": {
"method": "Un indicatore notturno di stress da 0 a 100 dalla forma della tua VFC nel corso della giornata rispetto alla tua linea di base. Più alto significa maggiore carico fisiologico.",
- "caveat": "Un indicatore derivato dalla VFC, NON una misura di stress da EDA / attività elettrodermica: il tuo orologio non ha un sensore EDA. Descrittivo, non clinico.",
"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.",
"anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.",
"anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.",
- "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, relativo al tuo sforzo abituale."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "Punteggio di recupero",
"recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable",
"stress": "Stress",
- "strain": "Sforzo"
+ "strain": "Sforzo",
+ "historyTitle": "Andamento",
+ "historyWindow": "Ultimi {days} giorni"
},
"coincident": {
"label": "Più parametri fuori dalla base",
diff --git a/messages/ko.json b/messages/ko.json
index fa7c8f328..a8fb4aa52 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "구성 요소",
"contributorValue": "{value} · 가중치 {weight}%",
"contributorMissing": "데이터 없음",
- "disclaimer": "참고용이에요 — 임상 평가가 아니에요.",
"insufficient": "아직 점수를 낼 데이터가 부족해요. 계속 기록하면 여기에 표시돼요."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "수면",
"respiratory": "호흡수",
"mood": "기분 안정성"
- },
- "caveat": "회복도나 임상 점수가 아니라 컨디션을 가늠하는 지표예요."
+ }
},
"VITALS_BASELINE": {
"method": "개인 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 인구 평균이 아니라 내 측정값을 따라가요 — 그래서 이 범위 안이면 나에게는 평소 수준이에요."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "심박변이도, 안정 시 심박수, 수면을 내 기준값과 비교해 섞은 밤마다의 0–100 회복 지표예요. 높을수록 더 잘 회복된 거예요. 매일 밤 한 번 계산해 두고 여기서 그대로 읽어와요.",
- "caveat": "설명용 컨디션 지표예요. 훈련용이나 임상용 회복 측정값이 아니에요.",
"description": "심박변이도, 안정 시 심박수, 수면으로 매일 밤 계산하는 회복 지표예요."
},
"STRESS_SCORE": {
"method": "하루 중 심박변이도의 흐름을 기준값과 비교해 매일 밤 내는 0–100 스트레스 지표예요. 높을수록 생리적 부담이 큰 거예요.",
- "caveat": "심박변이도에서 뽑은 지표이고 EDA / 피부 전기 반응 측정이 아니에요 — 시계에 EDA 센서가 없어요. 설명용이고 임상용이 아니에요.",
"description": "하루 중 심박변이도의 흐름으로 매일 밤 계산하는 스트레스 지표예요."
},
"STRAIN_SCORE": {
"method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.",
"anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.",
"anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.",
- "caveat": "설명용 활동 부하 지표예요. 임상용이나 훈련용 부하 측정값이 아니에요.",
"description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "회복 점수",
"recoveryCrossLink": "웨어러블의 회복·부하 데이터 보기",
"stress": "스트레스",
- "strain": "부하"
+ "strain": "부하",
+ "historyTitle": "추이",
+ "historyWindow": "최근 {days}일"
},
"coincident": {
"label": "여러 지표가 기준값 밖",
diff --git a/messages/pl.json b/messages/pl.json
index 8fc97f260..a4642ee23 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4282,7 +4282,6 @@
"contributorsLabel": "Czynniki",
"contributorValue": "{value} · {weight}% wagi",
"contributorMissing": "brak danych",
- "disclaimer": "Orientacyjne — to nie ocena kliniczna.",
"insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj."
},
"composite": {
@@ -4308,8 +4307,7 @@
"sleep": "Sen",
"respiratory": "Częstość oddechu",
"mood": "Stabilność nastroju"
- },
- "caveat": "Wskaźnik dobrostanu, nie ocena regeneracji ani wynik kliniczny."
+ }
},
"VITALS_BASELINE": {
"method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną — wartość w obrębie pasma jest więc typowa dla Ciebie."
@@ -4334,19 +4332,16 @@
},
"RECOVERY_SCORE": {
"method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany — nigdy nieprzeliczany na bieżąco.",
- "caveat": "Opisowy wskaźnik dobrostanu, nie kliniczna ani treningowa miara regeneracji.",
"description": "Nocny wskaźnik regeneracji z Twojego HRV, tętna spoczynkowego i snu."
},
"STRESS_SCORE": {
"method": "Nocny wskaźnik stresu 0–100 na podstawie kształtu Twojego HRV w ciągu dnia względem linii bazowej. Wyższy oznacza większe obciążenie fizjologiczne.",
- "caveat": "Wskaźnik pochodzący z HRV, a NIE pomiar stresu metodą EDA / reakcji skórno-galwanicznej — Twój zegarek nie ma czujnika EDA. Opisowy, nie kliniczny.",
"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ń.",
"anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.",
"anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.",
- "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, względem Twojego typowego wysiłku."
},
"WRIST_TEMPERATURE_BASELINE": {
@@ -4382,7 +4377,9 @@
"recovery": "Wynik regeneracji",
"recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia",
"stress": "Stres",
- "strain": "Obciążenie"
+ "strain": "Obciążenie",
+ "historyTitle": "Przebieg",
+ "historyWindow": "Ostatnie {days} dni"
},
"coincident": {
"label": "Kilka parametrów poza bazą",
diff --git a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx
index 910255c6a..928d734ab 100644
--- a/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx
+++ b/src/components/insights/derived/__tests__/composite-score-anatomy-coach.test.tsx
@@ -42,6 +42,13 @@ vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: () =>
,
}));
+// The score's course renders a live chart for the stored scores; this deck is
+// about the assessment's hand-off, so the course is a probe here.
+vi.mock("../score-history", () => ({
+ ScoreHistoryChart: () => ,
+ ScoreHistoryCard: () => ,
+}));
+
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
import type { AnatomyMetricId } from "../composite-score-anatomy";
diff --git a/src/components/insights/derived/__tests__/score-history-wiring.test.tsx b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx
new file mode 100644
index 000000000..6ccf806fe
--- /dev/null
+++ b/src/components/insights/derived/__tests__/score-history-wiring.test.tsx
@@ -0,0 +1,145 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+/**
+ * v1.42 — every score's detail page shows its course right under the score
+ * card, and the warning-coloured "descriptive proxy, not clinical" line no
+ * longer rides above the method.
+ *
+ * The stored nightly scores (recovery, stress, strain) get the full metric
+ * chart over their measurement type; the two composites draw the course their
+ * value carries, and nothing when it carries fewer than two points. The chart
+ * and the card are stubbed to probes that state what they were given.
+ */
+
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({ t: (key: string) => key }),
+}));
+
+const derived = { current: {} as Record };
+vi.mock("../use-derived-metric", () => ({
+ useDerivedMetric: () => derived.current,
+}));
+
+vi.mock("@/components/insights/insight-status-card", () => ({
+ InsightStatusCard: () => ,
+}));
+
+vi.mock("../score-anatomy-view", () => ({
+ ScoreAnatomyView: ({ method }: { method: React.ReactNode }) => (
+ {method}
+ ),
+}));
+
+vi.mock("../score-history", () => ({
+ ScoreHistoryChart: ({ type }: { type: string }) => (
+
+ ),
+ ScoreHistoryCard: ({
+ series,
+ windowDays,
+ }: {
+ series: number[];
+ windowDays: number;
+ }) => (
+
+ ),
+}));
+
+import {
+ CompositeScoreAnatomy,
+ type AnatomyMetricId,
+} from "../composite-score-anatomy";
+
+function render(metric: AnatomyMetricId, series?: number[]): string {
+ derived.current = {
+ isLoading: false,
+ isError: false,
+ data: {
+ status: "ok",
+ value: {
+ score: 70,
+ band: "yellow",
+ subScores: [],
+ components: [],
+ series,
+ },
+ coverage: {},
+ confidence: null,
+ provenance: { windowDays: 30 },
+ assessment: null,
+ },
+ };
+ return renderToStaticMarkup();
+}
+
+describe("score detail history", () => {
+ it.each(["RECOVERY_SCORE", "STRESS_SCORE", "STRAIN_SCORE"] as const)(
+ "%s gets the full chart over its stored type",
+ (metric) => {
+ const html = render(metric, [60, 70]);
+ expect(html).toContain(
+ `data-slot="history-chart-probe" data-type="${metric}"`,
+ );
+ expect(html).not.toContain("history-card-probe");
+ // Under the score card, not somewhere else on the page.
+ expect(html.indexOf("history-chart-probe")).toBeGreaterThan(
+ html.indexOf("anatomy-probe"),
+ );
+ },
+ );
+
+ it.each(["SLEEP_SCORE", "READINESS"] as const)(
+ "%s draws the course its value carries, over its window",
+ (metric) => {
+ const html = render(metric, [55, 61, 70]);
+ expect(html).toContain('data-points="3"');
+ expect(html).toContain('data-window="30"');
+ expect(html).not.toContain("history-chart-probe");
+ },
+ );
+
+ it("an account without a score gets no empty chart under the insufficient card", () => {
+ derived.current = {
+ isLoading: false,
+ isError: false,
+ data: {
+ status: "insufficient",
+ value: null,
+ coverage: {},
+ confidence: null,
+ provenance: { windowDays: 14 },
+ assessment: null,
+ },
+ };
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("anatomy-probe");
+ expect(html).not.toContain("history-chart-probe");
+ });
+
+ it("a composite with fewer than two points draws no course", () => {
+ expect(render("READINESS", [70])).not.toContain("history-card-probe");
+ expect(render("READINESS")).not.toContain("history-card-probe");
+ });
+
+ it("no score page carries the caveat line above its method", () => {
+ for (const metric of [
+ "SLEEP_SCORE",
+ "READINESS",
+ "RECOVERY_SCORE",
+ "STRESS_SCORE",
+ "STRAIN_SCORE",
+ ] as const) {
+ const html = render(metric, [60, 70]);
+ expect(html).toContain(`insights.derived.composite.${metric}.method`);
+ expect(html).not.toContain(".caveat");
+ expect(html).not.toContain("text-warning");
+ }
+ });
+});
diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx
index e96cae1f5..1b55ca633 100644
--- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx
+++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx
@@ -135,4 +135,37 @@ describe("", () => {
expect(hidden).not.toContain('data-metric="STRAIN_SCORE"');
expect(hidden).toContain('data-metric="READINESS"');
});
+
+ it("draws each tile's course under its band word, naming the window", () => {
+ const html = render(
+ ,
+ );
+ const recovery = html.slice(
+ html.indexOf('data-metric="RECOVERY_SCORE"'),
+ html.indexOf('data-metric="STRESS_SCORE"'),
+ );
+ expect(recovery).toContain('data-slot="wellness-score-history"');
+ expect(recovery).toContain("Last 30 days");
+ // A score without a course draws no empty line.
+ const stress = html.slice(html.indexOf('data-metric="STRESS_SCORE"'));
+ expect(stress).not.toContain('data-slot="wellness-score-history"');
+ });
+
+ it("reserves the history band in the loading skeleton", () => {
+ const html = render();
+ const tile = html.slice(
+ html.indexOf('data-slot="wellness-score-tile-skeleton"'),
+ );
+ expect(tile).toContain("h-10 w-full");
+ });
});
diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx
index 43f35a545..7f6bee80d 100644
--- a/src/components/insights/derived/composite-score-anatomy.tsx
+++ b/src/components/insights/derived/composite-score-anatomy.tsx
@@ -19,12 +19,13 @@ import {
import type { RingHue } from "./ring-hues";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { METRIC_PROVENANCE } from "./standards";
+import { ScoreHistoryCard, ScoreHistoryChart } from "./score-history";
/**
* v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived
* score and renders the reusable `ScoreAnatomyView`. It owns the per-metric
* presentation map (title, contributor labels, the cited standard, the
- * plain-language method + honesty caveat) so the anatomy view itself stays
+ * plain-language method) so the anatomy view itself stays
* metric-agnostic.
*
* Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` —
@@ -32,12 +33,10 @@ import { METRIC_PROVENANCE } from "./standards";
* three persisted nightly scores (`RECOVERY_SCORE`, `STRESS_SCORE`,
* `STRAIN_SCORE`). The persisted scores carry no sub-decomposition, so they
* render the ring + coverage + the provenance surface (method + cited
- * standard + caveat) with no contributor rows — the acceptance-criterion fix
- * that gives every wellness ring a provenance surface instead of a read-only
- * dead-end. STRESS surfaces its "HRV-derived proxy, not an EDA/galvanic
- * measurement" caveat via the standards map.
+ * standard) with no contributor rows — the acceptance-criterion fix that gives
+ * every wellness ring a provenance surface instead of a read-only dead-end.
*
- * The standard + method/caveat keys come from the single `METRIC_PROVENANCE`
+ * The standard + method keys come from the single `METRIC_PROVENANCE`
* source map so the citation a metric exposes never drifts across surfaces.
*
* Client-only: `import type` for the value shapes (no server graph leaks);
@@ -147,16 +146,12 @@ export function CompositeScoreAnatomy({
}
}
- // Method copy carries an optional honesty caveat above it (STRESS proxy,
- // descriptive-not-clinical, …) so the caveat reaches the user, not just
- // the engine header.
+ // v1.42 — the per-score "descriptive proxy, not clinical" line that sat
+ // above the method in warning colour is gone: it repeated on every score
+ // page what the method and the page explainer already say, and read as an
+ // alarm. The method copy closes the card on its own.
const method = (
<>
- {meta.caveatKey ? (
-
- {t(meta.caveatKey)}
-
- ) : null}
{t(meta.methodKey)}
{anchorLine ? {anchorLine} : null}
>
@@ -268,6 +263,27 @@ export function CompositeScoreAnatomy({
// the card always renders its prose rather than the no-provider fallback.
const assessment = data.assessment;
+ // v1.42 — the score's course, right under its card. A stored nightly score
+ // gets the full chart; a composite draws the course its value carries.
+ const series =
+ data.status === "ok" && data.value
+ ? (data.value as { series?: number[] }).series
+ : undefined;
+ // Only beside a score: an account without one gets the honest
+ // insufficient card, not an empty chart under it.
+ const history =
+ data.status !== "ok" ? null : metric === "RECOVERY_SCORE" ||
+ metric === "STRESS_SCORE" ||
+ metric === "STRAIN_SCORE" ? (
+
+ ) : series && series.length >= 2 ? (
+
+ ) : null;
+
return (
+ {history}
{assessment ? (
-
+
{/* The comparison curve sits UNDER the live one so today's line is
never occluded by the reference it is being judged against. */}
{hasComparison ? (
diff --git a/src/components/insights/derived/score-history.tsx b/src/components/insights/derived/score-history.tsx
new file mode 100644
index 000000000..59744484d
--- /dev/null
+++ b/src/components/insights/derived/score-history.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { History } from "lucide-react";
+
+import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
+import { TileHeader } from "@/components/insights/tile-header";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { useAuth } from "@/hooks/use-auth";
+import { useTranslations } from "@/lib/i18n/context";
+import { RING_GRADIENT, type RingHue } from "./ring-hues";
+
+/**
+ * v1.42 — a score's course over time on its detail page, directly under the
+ * score card.
+ *
+ * The three nightly scores (recovery, stress, strain) are stored readings, so
+ * they get the full metric chart every measured page carries: the 7 / 30 / 90
+ * / All switch, the tooltip, and each day opening its day view. The two
+ * composites (sleep score, readiness) are computed rather than stored, so the
+ * course they carry on their value is drawn as a compact line over its window.
+ */
+
+const DeltaSparkline = dynamic(
+ () =>
+ import("@/components/charts/chart-runtime").then((mod) => ({
+ default: mod.DeltaSparkline,
+ })),
+ { ssr: false, loading: () => null },
+);
+
+export function ScoreHistoryChart({
+ type,
+ hue,
+}: {
+ /** The stored measurement type the score is written as. */
+ type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE";
+ hue: RingHue;
+}) {
+ const { t } = useTranslations();
+ const { user } = useAuth();
+ return (
+
+
+
+ );
+}
+
+export function ScoreHistoryCard({
+ series,
+ windowDays,
+ hue,
+}: {
+ series: number[];
+ windowDays: number;
+ hue: RingHue;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+
+ {t("insights.derived.scores.historyWindow", { days: windowDays })}
+
+ }
+ />
+
+
+
+ ({ i, v }))}
+ strokeVar={RING_GRADIENT[hue][1]}
+ domain={[0, 100]}
+ />
+
+
+
+ );
+}
diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts
index 39d1e3c12..a41a04394 100644
--- a/src/components/insights/derived/standards.ts
+++ b/src/components/insights/derived/standards.ts
@@ -111,7 +111,6 @@ export const METRIC_PROVENANCE: Record
=
name: "Plews et al. 2013, Sports Medicine",
url: "https://doi.org/10.1007/s40279-013-0071-8",
},
- caveatKey: "insights.derived.composite.READINESS.caveat",
},
RECOVERY_SCORE: {
methodKey: "insights.derived.composite.RECOVERY_SCORE.method",
@@ -120,7 +119,6 @@ export const METRIC_PROVENANCE: Record =
name: "Buchheit 2014, Front. Physiol.",
url: "https://doi.org/10.3389/fphys.2014.00073",
},
- caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat",
},
STRESS_SCORE: {
methodKey: "insights.derived.composite.STRESS_SCORE.method",
@@ -129,7 +127,6 @@ export const METRIC_PROVENANCE: Record =
name: "Kim et al. 2018, Psychiatry Investig.",
url: "https://doi.org/10.30773/pi.2017.08.17",
},
- caveatKey: "insights.derived.composite.STRESS_SCORE.caveat",
},
STRAIN_SCORE: {
methodKey: "insights.derived.composite.STRAIN_SCORE.method",
@@ -138,7 +135,6 @@ export const METRIC_PROVENANCE: Record =
name: "Banister 1991, TRIMP model",
url: "https://en.wikipedia.org/wiki/Training_Impulse",
},
- caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx
index 29759f462..2ed39c544 100644
--- a/src/components/insights/derived/wellness-scores.tsx
+++ b/src/components/insights/derived/wellness-scores.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, type ComponentType } from "react";
+import dynamic from "next/dynamic";
import Link from "next/link";
import {
Activity,
@@ -16,7 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
import { ScoreRing } from "./score-ring";
-import { TILE_HUE, type RingHue } from "./ring-hues";
+import { RING_GRADIENT, TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
import type { ScoreBand } from "./band-tokens";
// Type-only — the compute payloads never drag the server graph into the bundle.
@@ -42,6 +43,17 @@ import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores"
* rollup/persisted values; nothing warms on visit.
*/
+// The tile's history line loads through the shared chart-runtime boundary, so
+// the strip carries no recharts in its own chunk. The tile owns the fixed
+// 40 px band, so the async gap paints an empty band of the same size.
+const DeltaSparkline = dynamic(
+ () =>
+ import("@/components/charts/chart-runtime").then((mod) => ({
+ default: mod.DeltaSparkline,
+ })),
+ { ssr: false, loading: () => null },
+);
+
interface ScoreStripProps {
/** The batched-read selector from the parent dashboard's one query. */
read: DerivedBatchRead;
@@ -97,7 +109,14 @@ function RingTile({
index,
animate,
baseline,
+ history,
}: {
+ /**
+ * v1.42 — the score's course (oldest → newest) and the window it covers,
+ * drawn under the band word so each tile shows where the number came from.
+ * Fewer than two points draws nothing.
+ */
+ history?: { series: number[]; windowDays: number } | null;
score: number;
band: ScoreBand;
label: string;
@@ -165,10 +184,60 @@ function RingTile({
{bandWord}
+ {history && history.series.length >= 2 ? (
+
+ ) : null}
);
}
+/**
+ * The tile's history: a fixed 40 px line of the score over its window, with
+ * the window named beneath it. Decorative to assistive tech — the tile's link
+ * leads to the detail page, where the full course is readable.
+ */
+function ScoreHistoryLine({
+ series,
+ windowDays,
+ strokeVar,
+}: {
+ series: number[];
+ windowDays: number;
+ strokeVar: string;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+ ({ i, v }))}
+ strokeVar={strokeVar}
+ domain={[0, 100]}
+ />
+
+
+ {t("insights.derived.scores.historyWindow", { days: windowDays })}
+
+
+ );
+}
+
+/** The course a score value carries, if any, paired with its window. */
+function historyOf(
+ read: {
+ value: { series?: number[] } | null;
+ provenance: { windowDays: number };
+ } | null,
+): { series: number[]; windowDays: number } | null {
+ const series = read?.value?.series;
+ if (!read || !series || series.length < 2) return null;
+ return { series, windowDays: read.provenance.windowDays };
+}
+
/**
* Loading placeholder mirroring `RingTile`'s footprint (icon+label row, the
* `size="sm"` 120 px ring, band word) so the strip reserves its final height
@@ -191,6 +260,11 @@ function WellnessScoreTileSkeleton() {
+ {/* The history line's band, so a tile that gains one does not grow. */}
+
+
+
+
);
}
@@ -322,6 +396,7 @@ export function WellnessScores({
metricSlot="READINESS"
hue="readiness"
icon={Gauge}
+ history={historyOf(readiness)}
index={tiles.length}
animate={play}
baseline={null}
@@ -340,6 +415,7 @@ export function WellnessScores({
metricSlot="RECOVERY_SCORE"
hue="recovery"
icon={HeartPulse}
+ history={historyOf(recovery)}
index={tiles.length}
animate={play}
baseline={baselineOf(recovery.value.series)}
@@ -358,6 +434,7 @@ export function WellnessScores({
metricSlot="SLEEP_SCORE"
hue="sleep"
icon={Moon}
+ history={historyOf(sleep)}
index={tiles.length}
animate={play}
baseline={null}
@@ -376,6 +453,7 @@ export function WellnessScores({
metricSlot="STRESS_SCORE"
hue="stress"
icon={Activity}
+ history={historyOf(stress)}
index={tiles.length}
animate={play}
baseline={baselineOf(stress.value.series)}
@@ -397,6 +475,7 @@ export function WellnessScores({
metricSlot="STRAIN_SCORE"
hue="strain"
icon={Flame}
+ history={historyOf(strain)}
index={tiles.length}
animate={play}
baseline={baselineOf(strain.value.series)}
diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts
index e1df67c5a..2aba3a4aa 100644
--- a/src/lib/insights/derived/__tests__/derived-assessment.test.ts
+++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts
@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
{ key: "composition", value: 88, weight: 0.15 },
],
windowNights: 20,
+ series: [78, 82],
};
const recovery: WellnessScoreValue = {
diff --git a/src/lib/insights/derived/__tests__/readiness-history.test.ts b/src/lib/insights/derived/__tests__/readiness-history.test.ts
new file mode 100644
index 000000000..d8f6cc27f
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/readiness-history.test.ts
@@ -0,0 +1,46 @@
+/**
+ * The readiness course is read, never recomputed: the nightly job persists
+ * this blend as the COMPUTED recovery row, and a device's own recovery number
+ * (WHOOP, Oura, Polar) is not this blend, so it never enters the series.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
+vi.mock("@/lib/db", () => ({
+ prisma: { measurement: { findMany } },
+}));
+
+import { readReadinessHistory } from "../readiness";
+
+const NOW = new Date("2026-06-02T07:00:00Z");
+
+beforeEach(() => findMany.mockReset());
+
+describe("readReadinessHistory", () => {
+ it("reads only the persisted blend inside the window, oldest first", async () => {
+ // Newest first, as the query orders them.
+ findMany.mockResolvedValue([
+ { value: 71.6 },
+ { value: 64.2 },
+ { value: 80 },
+ ]);
+
+ const series = await readReadinessHistory("u1", NOW, 30);
+
+ expect(series).toEqual([80, 64, 72]);
+ const where = findMany.mock.calls[0][0].where;
+ expect(where).toMatchObject({
+ userId: "u1",
+ type: "RECOVERY_SCORE",
+ source: "COMPUTED",
+ deletedAt: null,
+ });
+ expect(where.measuredAt.lte).toEqual(NOW);
+ expect(where.measuredAt.gte).toEqual(new Date("2026-05-03T07:00:00Z"));
+ });
+
+ it("answers an empty course when the job never ran", async () => {
+ findMany.mockResolvedValue([]);
+ expect(await readReadinessHistory("u1", NOW, 30)).toEqual([]);
+ });
+});
diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts
index 9fb3da3aa..c464868a3 100644
--- a/src/lib/insights/derived/__tests__/sleep-score.test.ts
+++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts
@@ -326,6 +326,38 @@ describe("computeSleepScore", () => {
}
});
+ it("carries the score of every night in the window, ending on the headline", async () => {
+ const rows = [
+ ...night("2026-05-31", [
+ ["IN_BED", 480, "06:00"],
+ ["REM", 90, "02:00"],
+ ["CORE", 240, "03:00"],
+ ["DEEP", 60, "04:00"],
+ ]),
+ // A short night, so the course is not flat.
+ ...night("2026-06-01", [
+ ["IN_BED", 300, "06:10"],
+ ["REM", 40, "02:10"],
+ ["CORE", 200, "03:10"],
+ ["DEEP", 20, "04:10"],
+ ]),
+ ...night("2026-06-02", [
+ ["IN_BED", 480, "06:05"],
+ ["REM", 95, "02:05"],
+ ["CORE", 235, "03:05"],
+ ["DEEP", 65, "04:05"],
+ ]),
+ ];
+ findMany.mockResolvedValue(rows);
+ const result = await computeSleepScore("u1", PROFILE, { now: NOW });
+ expect(result.status).toBe("ok");
+ if (result.status === "ok") {
+ expect(result.value.series).toHaveLength(3);
+ expect(result.value.series.at(-1)).toBe(result.value.score);
+ expect(result.value.series[1]).toBeLessThan(result.value.series[0]);
+ }
+ });
+
it("reweights around composition on a legacy ASLEEP-only night", async () => {
const rows = [
...night("2026-05-31", [["ASLEEP", 420, "06:00"]]),
diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts
index 7487914f6..f91de1a29 100644
--- a/src/lib/insights/derived/dispatch.ts
+++ b/src/lib/insights/derived/dispatch.ts
@@ -27,7 +27,7 @@ import { computeVascularAgeDelta } from "./vascular-age";
import { computeHrvBalance } from "./hrv-balance";
import { computeBmi } from "./bmi";
import { computeSleepScore } from "./sleep-score";
-import { computeReadiness } from "./readiness";
+import { computeReadiness, readReadinessHistory } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
import { computeTrajectory } from "./trajectory";
import { computeSameTimeBaseline } from "./same-time-baseline";
@@ -141,11 +141,22 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise>;
- case "READINESS":
- return computeReadiness(args.userId, args.profile, {
+ case "READINESS": {
+ const readiness = await computeReadiness(args.userId, args.profile, {
windowDays: args.windowDays,
now,
- }) as Promise>;
+ });
+ // The blend's course rides beside today's score for the surfaces that
+ // draw it; the nightly job's own compute never pays for the read.
+ if (readiness.status === "ok" && readiness.value) {
+ readiness.value.series = await readReadinessHistory(
+ args.userId,
+ now,
+ readiness.provenance.windowDays,
+ );
+ }
+ return readiness as Derived;
+ }
case "COINCIDENT_DEVIATION":
return computeCoincidentDeviation(args.userId, args.profile, {
windowDays: args.windowDays,
diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts
index 87e1c5603..37003c5d4 100644
--- a/src/lib/insights/derived/readiness.ts
+++ b/src/lib/insights/derived/readiness.ts
@@ -50,7 +50,11 @@ import {
} from "./coverage";
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { computeSleepScore } from "./sleep-score";
-import type { Derived, DerivedProvenanceSource } from "./types";
+import {
+ SPARKLINE_MAX_POINTS,
+ type Derived,
+ type DerivedProvenanceSource,
+} from "./types";
const DEFAULT_WINDOW_DAYS = 30;
/**
@@ -89,6 +93,45 @@ export interface ReadinessValue {
score: number;
band: "green" | "yellow" | "red";
components: ReadinessComponent[];
+ /**
+ * v1.42 — the blend's own course over the window (oldest → newest, capped
+ * to `SPARKLINE_MAX_POINTS`), read from what the nightly job persisted; see
+ * `readReadinessHistory`. Attached by the derived dispatch for the surfaces
+ * that show it, absent on the job's own compute. Additive.
+ */
+ series?: number[];
+}
+
+/**
+ * The readiness blend's history, without recomputing a single day.
+ *
+ * The nightly recovery job persists exactly this blend — `computeRecoveryScore`
+ * delegates to `computeReadiness` verbatim — as the `COMPUTED`-source
+ * `RECOVERY_SCORE` row. Those rows ARE the readiness score of each past night.
+ * A device-native recovery row (WHOOP, Oura, Polar) shares the type but is the
+ * vendor's number, not this blend, so the read is held to `COMPUTED`.
+ */
+export async function readReadinessHistory(
+ userId: string,
+ now: Date,
+ windowDays: number,
+): Promise {
+ const rows = await prisma.measurement.findMany({
+ where: {
+ userId,
+ type: "RECOVERY_SCORE" satisfies MeasurementType,
+ source: "COMPUTED",
+ deletedAt: null,
+ measuredAt: {
+ gte: new Date(now.getTime() - windowDays * MS_PER_DAY),
+ lte: now,
+ },
+ },
+ select: { value: true },
+ orderBy: { measuredAt: "desc" },
+ take: SPARKLINE_MAX_POINTS,
+ });
+ return rows.map((row) => Math.round(row.value)).reverse();
}
// ── pure deviation scorers (exported for tests) ────────────────────────
diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts
index cc34c711c..78a5d3799 100644
--- a/src/lib/insights/derived/sleep-score.ts
+++ b/src/lib/insights/derived/sleep-score.ts
@@ -52,7 +52,7 @@ import {
nowProvenanceTimestamp,
} from "./coverage";
import type { BaselineProfile } from "./baseline";
-import type { Derived } from "./types";
+import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Default trailing window for the consistency/timing baseline (days). */
@@ -93,6 +93,14 @@ export interface SleepScoreValue {
subScores: SleepSubScore[];
/** Nights in the window that backed the consistency/timing baseline. */
windowNights: number;
+ /**
+ * v1.42 — the score of every scorable night in the window (oldest → newest,
+ * capped to `SPARKLINE_MAX_POINTS`), so a surface can show the score's
+ * course rather than one night. Each night is scored by the same blend as
+ * the headline, against the same window's habitual midpoint, so the last
+ * point IS `score`. Additive; older clients ignore it.
+ */
+ series: number[];
}
// ── pure scorers (exported for tests) ─────────────────────────────────
@@ -530,6 +538,26 @@ export async function computeSleepScore(
};
const { score, subScores } = blendSleepSubScores(raw);
+
+ // The same blend over every scorable night in the window. Consistency is a
+ // window-level figure and timing reads against the window's habitual
+ // midpoint, so every night is judged by the yardstick the headline uses.
+ const consistency = scoreConsistency(midpoints);
+ const series = scorableNights.slice(-SPARKLINE_MAX_POINTS).map(
+ (night) =>
+ blendSleepSubScores({
+ sufficiency: scoreSufficiency(night.asleepMinutes, needMinutes),
+ efficiency: scoreEfficiency(night.asleepMinutes, night.inBedMinutes),
+ consistency,
+ timing: scoreTiming(night.midpoint, habitualMidpoint, midpoints.length),
+ composition: scoreComposition(
+ night.remMinutes,
+ night.deepMinutes,
+ night.asleepMinutes,
+ night.hasStageBreakdown,
+ ),
+ }).score,
+ );
const presentCount = subScores.filter((s) => s.value !== null).length;
const missing = subScores.filter((s) => s.value === null).map((s) => s.key);
@@ -557,6 +585,7 @@ export async function computeSleepScore(
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
subScores,
windowNights: scorableNights.length,
+ series,
},
coverage,
confidence,