mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(insights): show every wellness score's course, drop the caveat line
Each score tile in "Your health scores" draws the score over its window under the band word (the persisted window for recovery, stress and strain; thirty days for the sleep score and readiness), on a fixed 0-100 scale so a few points do not read as a cliff. A score's detail page carries its history directly under the score card: the stored nightly scores get the full chart with the period switch, tooltip and day access every measured page has; the two composites draw the course their value carries. The data was already there for three of the five. The sleep score now returns the score of every scorable night in its window, judged by the same blend and the same window baseline as the headline, so the last point is the headline. Readiness reads its course from what the nightly job persisted, the COMPUTED recovery row that is this exact blend, without recomputing a single day. Both fields are additive. The warning-coloured "descriptive proxy, not clinical" line above the method is gone from all four score pages that carried it, with its keys in all seven locales and the unused anatomy disclaimer key.
This commit is contained in:
1 parent
4273af231e
commit
1a5eab9f7c
21 files changed
+591
-75
No files matched your search
+4
-7
@@ -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",
|
||||
|
||||
+4
-7
@@ -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",
|
||||
|
||||
+4
-7
@@ -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",
|
||||
|
||||
+4
-7
@@ -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",
|
||||
|
||||
+4
-7
@@ -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",
|
||||
|
||||
+4
-7
@@ -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": "여러 지표가 기준값 밖",
|
||||
|
||||
+4
-7
@@ -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ą",
|
||||
|
||||
@@ -42,6 +42,13 @@ vi.mock("../score-anatomy-view", () => ({
|
||||
ScoreAnatomyView: () => <div data-slot="score-anatomy-view-probe" />,
|
||||
}));
|
||||
|
||||
// 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: () => <div data-slot="score-history-probe" />,
|
||||
ScoreHistoryCard: () => <div data-slot="score-history-probe" />,
|
||||
}));
|
||||
|
||||
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
|
||||
import type { AnatomyMetricId } from "../composite-score-anatomy";
|
||||
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedMetric: () => derived.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/insights/insight-status-card", () => ({
|
||||
InsightStatusCard: () => <div data-slot="assessment-probe" />,
|
||||
}));
|
||||
|
||||
vi.mock("../score-anatomy-view", () => ({
|
||||
ScoreAnatomyView: ({ method }: { method: React.ReactNode }) => (
|
||||
<div data-slot="anatomy-probe">{method}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../score-history", () => ({
|
||||
ScoreHistoryChart: ({ type }: { type: string }) => (
|
||||
<div data-slot="history-chart-probe" data-type={type} />
|
||||
),
|
||||
ScoreHistoryCard: ({
|
||||
series,
|
||||
windowDays,
|
||||
}: {
|
||||
series: number[];
|
||||
windowDays: number;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="history-card-probe"
|
||||
data-points={series.length}
|
||||
data-window={windowDays}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<CompositeScoreAnatomy metric={metric} />);
|
||||
}
|
||||
|
||||
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(
|
||||
<CompositeScoreAnatomy metric="RECOVERY_SCORE" />,
|
||||
);
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -135,4 +135,37 @@ describe("<WellnessScores>", () => {
|
||||
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(
|
||||
<WellnessScores
|
||||
read={readFrom({
|
||||
RECOVERY_SCORE: ok({
|
||||
score: 70,
|
||||
band: "yellow",
|
||||
series: [60, 64, 70],
|
||||
}),
|
||||
STRESS_SCORE: ok({ score: 30, band: "green", series: [] }),
|
||||
})}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
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(<WellnessScores read={readFrom({})} isLoading />);
|
||||
const tile = html.slice(
|
||||
html.indexOf('data-slot="wellness-score-tile-skeleton"'),
|
||||
);
|
||||
expect(tile).toContain("h-10 w-full");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
{anchorLine ? <span className="mt-1 block">{anchorLine}</span> : 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" ? (
|
||||
<ScoreHistoryChart type={metric} hue={METRIC_HUE[metric]} />
|
||||
) : series && series.length >= 2 ? (
|
||||
<ScoreHistoryCard
|
||||
series={series}
|
||||
windowDays={data.provenance.windowDays}
|
||||
hue={METRIC_HUE[metric]}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ScoreAnatomyView
|
||||
@@ -284,6 +300,7 @@ export function CompositeScoreAnatomy({
|
||||
insufficient={insufficient}
|
||||
className={className}
|
||||
/>
|
||||
{history}
|
||||
{assessment ? (
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
|
||||
@@ -32,7 +32,14 @@ export function DeltaSparkline({
|
||||
data,
|
||||
strokeVar,
|
||||
comparisonLabel,
|
||||
domain,
|
||||
}: {
|
||||
/**
|
||||
* A fixed y-domain. Omitted, the line spans its own min..max, which suits a
|
||||
* vital read against itself; a bounded score passes its scale (`[0, 100]`)
|
||||
* so a night-to-night wobble of a few points is not drawn as a cliff.
|
||||
*/
|
||||
domain?: [number, number];
|
||||
/**
|
||||
* `{ i, v }` points, already length-guarded (≥2) by the tile. An optional
|
||||
* `b` on a point is the comparison series' value at the same index —
|
||||
@@ -62,7 +69,7 @@ export function DeltaSparkline({
|
||||
<stop offset="100%" stopColor={strokeVar} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["dataMin", "dataMax"]} />
|
||||
<YAxis hide domain={domain ?? ["dataMin", "dataMax"]} />
|
||||
{/* The comparison curve sits UNDER the live one so today's line is
|
||||
never occluded by the reference it is being judged against. */}
|
||||
{hasComparison ? (
|
||||
|
||||
@@ -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 (
|
||||
<div data-slot="score-history-chart" data-type={type}>
|
||||
<HealthChartDynamic
|
||||
types={[type]}
|
||||
title={t("insights.derived.scores.historyTitle")}
|
||||
titleIcon={History}
|
||||
colors={[RING_GRADIENT[hue][1]]}
|
||||
unit={t("insights.deviceScore.unitScore")}
|
||||
userTimezone={user?.timezone}
|
||||
dayLinks
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScoreHistoryCard({
|
||||
series,
|
||||
windowDays,
|
||||
hue,
|
||||
}: {
|
||||
series: number[];
|
||||
windowDays: number;
|
||||
hue: RingHue;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Card data-slot="score-history-card" className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={History}
|
||||
title={t("insights.derived.scores.historyTitle")}
|
||||
right={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.derived.scores.historyWindow", { days: windowDays })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-24 w-full" aria-hidden="true">
|
||||
<DeltaSparkline
|
||||
data={series.map((v, i) => ({ i, v }))}
|
||||
strokeVar={RING_GRADIENT[hue][1]}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -111,7 +111,6 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
|
||||
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<DerivedMetricId, MetricProvenanceMeta> =
|
||||
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<DerivedMetricId, MetricProvenanceMeta> =
|
||||
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<DerivedMetricId, MetricProvenanceMeta> =
|
||||
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",
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
</div>
|
||||
{history && history.series.length >= 2 ? (
|
||||
<ScoreHistoryLine
|
||||
series={history.series}
|
||||
windowDays={history.windowDays}
|
||||
strokeVar={RING_GRADIENT[hue][1]}
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div data-slot="wellness-score-history" className="space-y-1">
|
||||
<div className="h-10 w-full" aria-hidden="true">
|
||||
<DeltaSparkline
|
||||
data={series.map((v, i) => ({ i, v }))}
|
||||
strokeVar={strokeVar}
|
||||
domain={[0, 100]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("insights.derived.scores.historyWindow", { days: windowDays })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
<Skeleton className="size-[120px] rounded-full" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
{/* The history line's band, so a tile that gains one does not grow. */}
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="mx-auto h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
|
||||
{ key: "composition", value: 88, weight: 0.15 },
|
||||
],
|
||||
windowNights: 20,
|
||||
series: [78, 82],
|
||||
};
|
||||
|
||||
const recovery: WellnessScoreValue = {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"]]),
|
||||
|
||||
@@ -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<Derived<unknown>>;
|
||||
case "READINESS":
|
||||
return computeReadiness(args.userId, args.profile, {
|
||||
case "READINESS": {
|
||||
const readiness = await computeReadiness(args.userId, args.profile, {
|
||||
windowDays: args.windowDays,
|
||||
now,
|
||||
}) as Promise<Derived<unknown>>;
|
||||
});
|
||||
// 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<unknown>;
|
||||
}
|
||||
case "COINCIDENT_DEVIATION":
|
||||
return computeCoincidentDeviation(args.userId, args.profile, {
|
||||
windowDays: args.windowDays,
|
||||
|
||||
@@ -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<number[]> {
|
||||
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) ────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user