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 })} + + } + /> + + + + + + ); +} 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 ( +
+ +

+ {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,