From 551d3525810b5058eb729d97e5065b05c4f68fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 00:00:14 +0200 Subject: [PATCH] feat(insights): surface inputs, method and a cited standard for all 11 derived metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Sleep and Readiness reached a provenance surface; the other nine shipped a number with the cited standard buried in a code comment. Give every derived metric a path to its inputs + method + cited standard: - Recovery, Stress and Strain get a ScoreAnatomyView detail page and a tappable ring (they were read-only dead-ends). Stress surfaces its 'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain cites the TRIMP load model; Recovery the HRV-guided recovery literature. - The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age) gain an info explainer affordance carrying the method + standard, since they render as tiles with no detail page. - The coincident-deviation flag gains a dashboard tile (shown only when it fired) with the same explainer (median ± k·MAD basis). - A single standards map (src/components/insights/derived/standards.ts) pins each metric's method copy key, standard name and a real linkable URL (DOI / WHO / Wikipedia) so the citation a metric exposes stays one source. Add the per-metric method/caveat/description copy across all six locales. Map storage units to display symbols so a baseline tile reads '36.6 °C', not '36.6 celsius'. Show the band word under the strip rings so a value reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline placeholder when no trailing series exists rather than reserving dead space, and give the tile a series + provenance slot for when one does. --- messages/de.json | 44 +++- messages/en.json | 44 +++- messages/es.json | 44 +++- messages/fr.json | 44 +++- messages/it.json | 44 +++- messages/pl.json | 44 +++- src/app/insights/scores/[metric]/page.tsx | 57 ++++- .../derived/__tests__/coverage-meter.test.tsx | 8 +- .../__tests__/sparkline-delta-tile.test.tsx | 33 ++- .../__tests__/vitals-dashboard.test.tsx | 129 +++++++--- .../derived/composite-score-anatomy.tsx | 105 +++++--- .../insights/derived/sparkline-delta-tile.tsx | 34 ++- src/components/insights/derived/standards.ts | 131 ++++++++++ .../insights/derived/vitals-dashboard.tsx | 224 ++++++++++++++--- .../insights/derived/wellness-scores.tsx | 225 +++++++++--------- 15 files changed, 960 insertions(+), 250 deletions(-) create mode 100644 src/components/insights/derived/standards.ts diff --git a/messages/de.json b/messages/de.json index d77e2a7d0..78bf46e5b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2579,7 +2579,44 @@ "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." + }, + "HRV_BALANCE": { + "method": "Dein jüngster HRV-(SDNN-)Durchschnitt im Vergleich zu deiner eigenen gleitenden Baseline (Median ± robuste Streuung). Ausgeglichen heißt: er liegt in deinem persönlichen Bereich; das Band stammt aus deinen Daten, nie aus einem Bevölkerungswert." + }, + "BMI": { + "method": "Gewicht ÷ Größe², eingeordnet nach den WHO-Kategorien für Erwachsene (Untergewicht / Normal / Übergewicht / Adipositas). Nur ein Screening-Verhältnis — es unterscheidet nicht Muskel von Fett." + }, + "FITNESS_AGE": { + "method": "Dein VO₂max im Vergleich zu alters- und geschlechtsbezogenen Perzentilen der kardiorespiratorischen Fitness, als Fitnessalter-Differenz. Das VO₂max ist die Schätzung deines Geräts — hier nie neu berechnet." + }, + "VASCULAR_AGE_DELTA": { + "method": "Die Gefäßalter- bzw. Pulswellengeschwindigkeits-Schätzung deines Geräts im Vergleich zu deinem chronologischen Alter, als Differenz in Jahren. Die Schätzung stammt vom Gerät; HealthLog ordnet sie nur ein.", + "caveat": "Die eigene Schätzung deines Geräts, nur eingeordnet — beschreibend, keine klinische Gefäßbewertung.", + "description": "Wie die Gefäßalter-Schätzung deines Geräts zu deinem chronologischen Alter steht." + }, + "COINCIDENT_DEVIATION": { + "method": "Ein Hinweis, wenn zwei oder mehr deiner Vitalwerte am selben Tag außerhalb ihres persönlichen Normalbereichs liegen. Jedes Band ist der robuste Median ± k·MAD deiner eigenen Verlaufsdaten und passt sich an dich an, nicht an einen Bevölkerungswert.", + "caveat": "Ein Hinweis aus deinen eigenen Baselines — beschreibend, keine Diagnose und kein klinischer Alarm." + }, + "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.", + "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", + "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Erholung", "stress": "Stress", "strain": "Belastung" + }, + "coincident": { + "label": "Mehrere Vitalwerte außerhalb der Baseline", + "summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.", + "vitals": "Außerhalb des Bereichs: {list}" } }, "cardioFitness": { diff --git a/messages/en.json b/messages/en.json index d43358aae..054d61a83 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2579,7 +2579,44 @@ "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." + }, + "HRV_BALANCE": { + "method": "Your recent HRV (SDNN) average placed against your own rolling baseline (median ± a robust spread). Balanced means it sits inside your personal range; the band is built from your data, never a population cut-off." + }, + "BMI": { + "method": "Weight ÷ height², classified against the WHO adult categories (underweight / normal / overweight / obese). A screening ratio only — it does not distinguish muscle from fat." + }, + "FITNESS_AGE": { + "method": "Your VO₂max placed against age- and sex-referenced cardiorespiratory-fitness percentiles, expressed as a fitness-age delta. The VO₂max is your device's estimate — never recomputed here." + }, + "VASCULAR_AGE_DELTA": { + "method": "Your device's vascular-age / pulse-wave-velocity estimate compared with your chronological age, expressed as a delta in years. The estimate is the device's; HealthLog only re-frames it.", + "caveat": "Your device's own estimate, re-framed — descriptive, not a clinical vascular assessment.", + "description": "How your device's vascular-age estimate sits against your chronological age." + }, + "COINCIDENT_DEVIATION": { + "method": "A flag raised when two or more of your vitals sit outside their personal typical range on the same day. Each band is the robust median ± k·MAD of your own history, so it adapts to you rather than to a population cut-off.", + "caveat": "An awareness flag from your own baselines — descriptive, not a diagnosis or a clinical alert." + }, + "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.", + "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." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Recovery", "stress": "Stress", "strain": "Strain" + }, + "coincident": { + "label": "Several vitals off baseline", + "summary": "{count} of your vitals sat outside their personal range today.", + "vitals": "Outside range: {list}" } }, "cardioFitness": { diff --git a/messages/es.json b/messages/es.json index acc539147..b16140457 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2579,7 +2579,44 @@ "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." + }, + "HRV_BALANCE": { + "method": "Tu media reciente de VFC (SDNN) frente a tu propia línea base móvil (mediana ± dispersión robusta). Equilibrada significa que está dentro de tu rango personal; la banda se construye con tus datos, nunca con un umbral poblacional." + }, + "BMI": { + "method": "Peso ÷ altura², clasificado según las categorías de la OMS para adultos (bajo peso / normal / sobrepeso / obesidad). Solo es una proporción de cribado: no distingue músculo de grasa." + }, + "FITNESS_AGE": { + "method": "Tu VO₂máx situado frente a percentiles de forma cardiorrespiratoria por edad y sexo, expresado como una diferencia de edad de forma física. El VO₂máx es la estimación de tu dispositivo, nunca se recalcula aquí." + }, + "VASCULAR_AGE_DELTA": { + "method": "La estimación de edad vascular / velocidad de onda de pulso de tu dispositivo comparada con tu edad cronológica, expresada como una diferencia en años. La estimación es del dispositivo; HealthLog solo la reinterpreta.", + "caveat": "La propia estimación de tu dispositivo, solo reinterpretada: descriptiva, no una evaluación vascular clínica.", + "description": "Cómo se sitúa la estimación de edad vascular de tu dispositivo frente a tu edad cronológica." + }, + "COINCIDENT_DEVIATION": { + "method": "Un aviso que aparece cuando dos o más de tus constantes quedan fuera de su rango típico personal el mismo día. Cada banda es la mediana robusta ± k·MAD de tu propio historial, así que se adapta a ti y no a un umbral poblacional.", + "caveat": "Un aviso a partir de tus propias líneas base: descriptivo, no un diagnóstico ni una alerta clínica." + }, + "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.", + "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." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Recuperación", "stress": "Estrés", "strain": "Esfuerzo" + }, + "coincident": { + "label": "Varias constantes fuera de la base", + "summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.", + "vitals": "Fuera de rango: {list}" } }, "cardioFitness": { diff --git a/messages/fr.json b/messages/fr.json index 98d0674ac..6479aa6df 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2579,7 +2579,44 @@ "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." + }, + "HRV_BALANCE": { + "method": "Votre moyenne récente de VFC (SDNN) comparée à votre propre référence glissante (médiane ± dispersion robuste). Équilibrée signifie qu’elle se situe dans votre plage personnelle ; la bande est construite à partir de vos données, jamais d’un seuil de population." + }, + "BMI": { + "method": "Poids ÷ taille², classé selon les catégories adultes de l’OMS (maigreur / normal / surpoids / obésité). Un simple ratio de dépistage : il ne distingue pas le muscle de la graisse." + }, + "FITNESS_AGE": { + "method": "Votre VO₂max situé par rapport aux percentiles de condition cardiorespiratoire selon l’âge et le sexe, exprimé comme un écart d’âge de forme. Le VO₂max est l’estimation de votre appareil, jamais recalculée ici." + }, + "VASCULAR_AGE_DELTA": { + "method": "L’estimation d’âge vasculaire / vitesse de l’onde de pouls de votre appareil comparée à votre âge chronologique, exprimée comme un écart en années. L’estimation vient de l’appareil ; HealthLog ne fait que la reformuler.", + "caveat": "La propre estimation de votre appareil, simplement reformulée : descriptive, pas une évaluation vasculaire clinique.", + "description": "Comment l’estimation d’âge vasculaire de votre appareil se situe par rapport à votre âge chronologique." + }, + "COINCIDENT_DEVIATION": { + "method": "Un indicateur qui se déclenche lorsque deux de vos constantes ou plus sortent de leur plage typique personnelle le même jour. Chaque bande est la médiane robuste ± k·MAD de votre propre historique : elle s’adapte à vous et non à un seuil de population.", + "caveat": "Un indicateur issu de vos propres références : descriptif, ni un diagnostic ni une alerte clinique." + }, + "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.", + "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." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Récupération", "stress": "Stress", "strain": "Effort" + }, + "coincident": { + "label": "Plusieurs constantes hors référence", + "summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.", + "vitals": "Hors plage : {list}" } }, "cardioFitness": { diff --git a/messages/it.json b/messages/it.json index 697eca6a7..e65233fa0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2579,7 +2579,44 @@ "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." + }, + "HRV_BALANCE": { + "method": "La tua media recente di VFC (SDNN) confrontata con la tua linea di base mobile (mediana ± dispersione robusta). Equilibrata significa che rientra nel tuo intervallo personale; la banda è costruita sui tuoi dati, mai su una soglia di popolazione." + }, + "BMI": { + "method": "Peso ÷ altezza², classificato secondo le categorie OMS per adulti (sottopeso / normale / sovrappeso / obesità). Solo un rapporto di screening: non distingue il muscolo dal grasso." + }, + "FITNESS_AGE": { + "method": "Il tuo VO₂max collocato rispetto ai percentili di fitness cardiorespiratoria per età e sesso, espresso come scarto di età di forma. Il VO₂max è la stima del tuo dispositivo, qui mai ricalcolata." + }, + "VASCULAR_AGE_DELTA": { + "method": "La stima di età vascolare / velocità dell’onda di polso del tuo dispositivo confrontata con la tua età anagrafica, espressa come scarto in anni. La stima è del dispositivo; HealthLog si limita a reinquadrarla.", + "caveat": "La stima del tuo dispositivo, solo reinquadrata: descrittiva, non una valutazione vascolare clinica.", + "description": "Come la stima di età vascolare del tuo dispositivo si colloca rispetto alla tua età anagrafica." + }, + "COINCIDENT_DEVIATION": { + "method": "Un avviso che compare quando due o più dei tuoi parametri vitali escono dal loro intervallo tipico personale nello stesso giorno. Ogni banda è la mediana robusta ± k·MAD del tuo storico, quindi si adatta a te e non a una soglia di popolazione.", + "caveat": "Un avviso dalle tue linee di base: descrittivo, non una diagnosi né un allarme clinico." + }, + "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.", + "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." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Recupero", "stress": "Stress", "strain": "Sforzo" + }, + "coincident": { + "label": "Più parametri fuori dalla base", + "summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.", + "vitals": "Fuori intervallo: {list}" } }, "cardioFitness": { diff --git a/messages/pl.json b/messages/pl.json index 2b850259b..c70c1c764 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2579,7 +2579,44 @@ "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." + }, + "HRV_BALANCE": { + "method": "Twoja niedawna średnia HRV (SDNN) na tle Twojej własnej ruchomej linii bazowej (mediana ± odporne rozproszenie). Zrównoważone oznacza, że mieści się w Twoim osobistym zakresie; pasmo budowane jest z Twoich danych, nigdy z progu populacyjnego." + }, + "BMI": { + "method": "Masa ÷ wzrost², sklasyfikowane według kategorii WHO dla dorosłych (niedowaga / norma / nadwaga / otyłość). To tylko wskaźnik przesiewowy — nie odróżnia mięśni od tłuszczu." + }, + "FITNESS_AGE": { + "method": "Twoje VO₂max odniesione do percentyli wydolności krążeniowo-oddechowej według wieku i płci, wyrażone jako różnica wieku sprawnościowego. VO₂max to szacunek Twojego urządzenia — tutaj nigdy nie jest przeliczany ponownie." + }, + "VASCULAR_AGE_DELTA": { + "method": "Szacunek wieku naczyniowego / prędkości fali tętna z Twojego urządzenia porównany z Twoim wiekiem metrykalnym, wyrażony jako różnica w latach. Szacunek pochodzi z urządzenia; HealthLog jedynie go przedstawia.", + "caveat": "Własny szacunek Twojego urządzenia, jedynie przedstawiony — opisowy, nie kliniczna ocena naczyniowa.", + "description": "Jak szacunek wieku naczyniowego Twojego urządzenia wypada na tle Twojego wieku metrykalnego." + }, + "COINCIDENT_DEVIATION": { + "method": "Sygnał pojawiający się, gdy co najmniej dwa Twoje parametry życiowe wykraczają tego samego dnia poza swój osobisty typowy zakres. Każde pasmo to odporna mediana ± k·MAD Twojej historii, więc dostosowuje się do Ciebie, a nie do progu populacyjnego.", + "caveat": "Sygnał z Twoich własnych linii bazowych — opisowy, nie diagnoza ani alarm kliniczny." + }, + "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ń.", + "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." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Regeneracja", "stress": "Stres", "strain": "Obciążenie" + }, + "coincident": { + "label": "Kilka parametrów poza bazą", + "summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.", + "vitals": "Poza zakresem: {list}" } }, "cardioFitness": { diff --git a/src/app/insights/scores/[metric]/page.tsx b/src/app/insights/scores/[metric]/page.tsx index 71802fdcc..fe7c630de 100644 --- a/src/app/insights/scores/[metric]/page.tsx +++ b/src/app/insights/scores/[metric]/page.tsx @@ -6,35 +6,68 @@ import { useTranslations } from "@/lib/i18n/context"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { CompositeScoreAnatomy, - type CompositeMetricId, + type AnatomyMetricId, } from "@/components/insights/derived/composite-score-anatomy"; /** - * v1.10.0 — `/insights/scores/` — the composite score-anatomy - * detail surface. + * v1.10.0 — `/insights/scores/` — the score-anatomy detail surface. * - * Reached on tap from a composite's dashboard / insights entry. Renders the - * Oura-style anatomy view (big ring + ranked contributor rows + cited - * standard) for one composite. The `[metric]` param is validated against the - * two W3 composites — an unknown slug falls back to the readiness view's - * insufficient state rather than 404-ing, so a stale link never dead-ends. + * Reached on tap from a score's dashboard / insights entry. Renders the + * Oura-style anatomy view (big ring + ranked contributor rows where the + * score decomposes + cited standard + method + honesty caveat) for one + * derived score — the two composites (sleep, readiness) AND the three + * persisted nightly scores (recovery, stress, strain), which previously had + * no detail page. The `[metric]` slug is validated against the known set; an + * unknown slug falls back to the readiness view's insufficient state rather + * than 404-ing, so a stale link never dead-ends. */ -const SLUG_TO_METRIC: Record = { +const SLUG_TO_METRIC: Record = { sleep: "SLEEP_SCORE", readiness: "READINESS", + recovery: "RECOVERY_SCORE", + stress: "STRESS_SCORE", + strain: "STRAIN_SCORE", +}; + +/** Localised page header per metric (title + one-line description). */ +const HEADER_KEYS: Record< + AnatomyMetricId, + { title: string; description: string } +> = { + SLEEP_SCORE: { + title: "insights.derived.composite.SLEEP_SCORE.title", + description: "insights.derived.composite.SLEEP_SCORE.description", + }, + READINESS: { + title: "insights.derived.composite.READINESS.title", + description: "insights.derived.composite.READINESS.description", + }, + RECOVERY_SCORE: { + title: "insights.derived.scores.recovery", + description: "insights.derived.composite.RECOVERY_SCORE.description", + }, + STRESS_SCORE: { + title: "insights.derived.scores.stress", + description: "insights.derived.composite.STRESS_SCORE.description", + }, + STRAIN_SCORE: { + title: "insights.derived.scores.strain", + description: "insights.derived.composite.STRAIN_SCORE.description", + }, }; export default function CompositeScorePage() { const params = useParams<{ metric: string }>(); const { t } = useTranslations(); const slug = typeof params.metric === "string" ? params.metric : ""; - const metric: CompositeMetricId = SLUG_TO_METRIC[slug] ?? "READINESS"; + const metric: AnatomyMetricId = SLUG_TO_METRIC[slug] ?? "READINESS"; + const header = HEADER_KEYS[metric]; return ( diff --git a/src/components/insights/derived/__tests__/coverage-meter.test.tsx b/src/components/insights/derived/__tests__/coverage-meter.test.tsx index f58ab2a51..b73ca2a1b 100644 --- a/src/components/insights/derived/__tests__/coverage-meter.test.tsx +++ b/src/components/insights/derived/__tests__/coverage-meter.test.tsx @@ -48,7 +48,8 @@ describe("", () => { const html = render(); const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; expect(lit).toBe(5); - expect(html).toContain("bg-dracula-green"); + // Semantic --success token — AA-safe on the white card in light mode. + expect(html).toContain("bg-success"); }); it("lights a partial number of dots and tints by band (provisional)", () => { @@ -56,7 +57,10 @@ describe("", () => { const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; expect(lit).toBeGreaterThan(0); expect(lit).toBeLessThan(5); - expect(html).toContain("bg-dracula-orange"); + // The four confidence bands collapse onto three semantic severities; + // low rides the caution --warning (medium↔low told apart by lit-dot + // count + percent, never colour alone). + expect(html).toContain("bg-warning"); expect(html).toContain('data-percent="35"'); }); diff --git a/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx index 484f45509..b051869b6 100644 --- a/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx +++ b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx @@ -47,23 +47,44 @@ describe("", () => { />, ); expect(html).toContain('data-slot="sparkline-delta-tile-delta"'); - expect(html).toContain("text-dracula-green"); + // Semantic --success token (AA-safe in light mode, identical in dark). + expect(html).toContain("text-success"); }); - it("renders the empty state for a null value (no value, dashed sparkline slot)", () => { + it("renders the empty state for a null value with no sparkline slot", () => { const html = render(); expect(html).toContain('data-slot="sparkline-delta-tile-value"'); expect(html).toContain("—"); - // No series → dashed placeholder, not an SVG. - expect(html).toContain("border-dashed"); + // No series → the sparkline row collapses entirely (no reserved empty + // dashed placeholder, which read as visual dead space across the grid). + expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"'); + expect(html).not.toContain("border-dashed"); }); - it("reserves the sparkline slot but omits the chart when series < 2 points", () => { + it("omits the sparkline row when series < 2 points", () => { const html = render( , ); + expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"'); + }); + + it("renders the sparkline chart when a series of >= 2 points is passed", () => { + const html = render( + , + ); expect(html).toContain('data-slot="sparkline-delta-tile-spark"'); - expect(html).toContain("border-dashed"); + }); + + it("renders the provenance affordance in the label row when passed", () => { + const html = render( + i} + />, + ); + expect(html).toContain('data-slot="sparkline-delta-tile-provenance"'); + expect(html).toContain('data-slot="test-prov"'); }); it("surfaces the stale caption when staleDays > 7", () => { diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx index 7ca853cd1..e30240d7e 100644 --- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx +++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx @@ -2,13 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; -import type { DerivedMetricResponse } from "../use-derived-metric"; +import type { + DerivedMetricResponse, + DerivedBatchToken, +} from "../use-derived-metric"; -// Drive the grid by stubbing the per-tile query hook; each tile renders the -// state the mock returns for its (metric, type) pair. -const useDerivedMetric = vi.fn(); +// The dashboard now reads the whole grid through ONE batched query. Drive it +// by stubbing `useDerivedBatch` and returning a `read(token)` selector that +// resolves each (metric, type) pair from a scenario map. +const useDerivedBatch = vi.fn(); vi.mock("../use-derived-metric", () => ({ - useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a), + useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a), +})); +// The auth hook gates the (mocked-away) query — stub it authenticated. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), })); import { VitalsDashboard } from "../vitals-dashboard"; @@ -21,41 +29,55 @@ function render(node: React.ReactNode) { type Resp = DerivedMetricResponse>; -function ok(value: Record): { data: Resp; isLoading: false } { +function ok(value: Record): Resp { return { - isLoading: false, - data: { - metric: "X", - status: "ok", - value, - coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, - confidence: { score: 90, band: "high" }, - provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, - reason: null, - }, + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, }; } -function insufficient(reason: string, historyDays = 0): { data: Resp; isLoading: false } { +function insufficient(reason: string, historyDays = 0): Resp { return { - isLoading: false, - data: { - metric: "X", - status: "insufficient", - value: null, - coverage: { requiredInputs: 1, presentInputs: reason === "no_readings_in_window" ? 0 : 1, historyDays, missing: [] }, - confidence: null, - provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, - reason, + metric: "X", + status: "insufficient", + value: null, + coverage: { + requiredInputs: 1, + presentInputs: reason === "no_readings_in_window" ? 0 : 1, + historyDays, + missing: [], }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason, }; } +/** + * Install a batch mock that resolves each requested token from `resolve`, + * defaulting unmatched tokens to the absent state so a scenario only has to + * name the tiles it cares about. + */ +function mockBatch( + resolve: (token: DerivedBatchToken) => Resp, + isLoading = false, +) { + useDerivedBatch.mockReturnValue({ + isLoading, + read: (token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse, + }); +} + beforeEach(() => vi.clearAllMocks()); describe("", () => { it("renders an available baseline vital as an ok tile", () => { - useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { - if (metric === "VITALS_BASELINE" && opts?.type === "RESTING_HEART_RATE") { + mockBatch((token) => { + if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") { return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); } return insufficient("no_readings_in_window"); @@ -69,7 +91,7 @@ describe("", () => { }); it("hides an absent vital entirely (no tile)", () => { - useDerivedMetric.mockReturnValue(insufficient("no_readings_in_window")); + mockBatch(() => insufficient("no_readings_in_window")); const html = render(); // The grid + heading still render, but no value tile. expect(html).toContain('data-slot="vitals-dashboard-grid"'); @@ -78,8 +100,8 @@ describe("", () => { }); it("shows a provisional baseline tile with the coverage meter, not a headline", () => { - useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { - if (metric === "VITALS_BASELINE" && opts?.type === "WEIGHT") { + mockBatch((token) => { + if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") { return insufficient("insufficient_history_for_band", 4); } return insufficient("no_readings_in_window"); @@ -92,8 +114,8 @@ describe("", () => { }); it("renders the fitness-age tile with its age framing", () => { - useDerivedMetric.mockImplementation((metric: string) => { - if (metric === "FITNESS_AGE") { + mockBatch((token) => { + if (token.metric === "FITNESS_AGE") { return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); } return insufficient("no_readings_in_window"); @@ -104,8 +126,8 @@ describe("", () => { }); it("renders the BMI tile with its WHO category", () => { - useDerivedMetric.mockImplementation((metric: string) => { - if (metric === "BMI") { + mockBatch((token) => { + if (token.metric === "BMI") { return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); } return insufficient("no_readings_in_window"); @@ -116,11 +138,44 @@ describe("", () => { }); it("hides BMI when there is no height/weight", () => { - useDerivedMetric.mockImplementation((metric: string) => { - if (metric === "BMI") return insufficient("no_height_on_profile"); + mockBatch((token) => { + if (token.metric === "BMI") return insufficient("no_height_on_profile"); return insufficient("no_readings_in_window"); }); const html = render(); expect(html).not.toContain('data-metric="BMI"'); }); + + it("surfaces the coincident-deviation flag when it fired", () => { + mockBatch((token) => { + if (token.metric === "COINCIDENT_DEVIATION") { + return ok({ + fired: true, + day: "2026-06-02", + vitals: [], + contributing: [ + { type: "RESTING_HEART_RATE", value: 70, center: 55, low: 48, high: 62, outside: true, direction: "above" }, + { type: "RESPIRATORY_RATE", value: 20, center: 14, low: 12, high: 16, outside: true, direction: "above" }, + ], + }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="COINCIDENT_DEVIATION"'); + expect(html).toContain('data-state="fired"'); + // The provenance affordance reaches the flag. + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + }); + + it("hides the coincident-deviation flag when it did not fire", () => { + mockBatch((token) => { + if (token.metric === "COINCIDENT_DEVIATION") { + return ok({ fired: false, day: "2026-06-02", vitals: [], contributing: [] }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"'); + }); }); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index b6fb4ac7e..f253b9f0c 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -6,44 +6,48 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { ReadinessValue, SleepScoreValue, + WellnessScoreValue, } from "@/lib/insights/derived"; import { ScoreAnatomyView, type AnatomyContributor, } from "./score-anatomy-view"; -import type { ProvenanceStandard } from "./provenance-explainer"; +import { METRIC_PROVENANCE } from "./standards"; /** - * v1.10.0 — the data-bound wrapper that fetches a composite derived metric - * and renders the reusable `ScoreAnatomyView`. It owns the per-metric + * 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) so the anatomy view itself stays metric-agnostic. + * plain-language method + honesty caveat) so the anatomy view itself stays + * metric-agnostic. * - * Supports the W3 composites: `SLEEP_SCORE` and `READINESS`. Each maps its - * `value` (sub-scores / components) onto the normalised `AnatomyContributor` - * list and supplies its cited standard + method copy from the i18n bundle. + * Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` — + * each maps its sub-scores onto ranked `AnatomyContributor` rows) AND the + * 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. + * + * The standard + method/caveat 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); * the fetch rides the same `/api/insights/derived` route + 8 s client * ceiling as the rest of the surface. */ -export type CompositeMetricId = "SLEEP_SCORE" | "READINESS"; - -/** The cited standard per composite (the explainer's external link). */ -const STANDARDS: Record = { - SLEEP_SCORE: { - name: "Hirshkowitz et al. 2015, Sleep Health", - url: "https://doi.org/10.1016/j.sleh.2014.12.010", - }, - READINESS: { - name: "Plews et al. 2013, Sports Medicine", - url: "https://doi.org/10.1007/s40279-013-0071-8", - }, -}; +export type AnatomyMetricId = + | "SLEEP_SCORE" + | "READINESS" + | "RECOVERY_SCORE" + | "STRESS_SCORE" + | "STRAIN_SCORE"; export interface CompositeScoreAnatomyProps { - metric: CompositeMetricId; + metric: AnatomyMetricId; className?: string; } @@ -53,14 +57,9 @@ export function CompositeScoreAnatomy({ }: CompositeScoreAnatomyProps) { const { t } = useTranslations(); - const sleep = useDerivedMetric("SLEEP_SCORE", { - enabled: metric === "SLEEP_SCORE", - }); - const readiness = useDerivedMetric("READINESS", { - enabled: metric === "READINESS", - }); - - const query = metric === "SLEEP_SCORE" ? sleep : readiness; + const query = useDerivedMetric< + SleepScoreValue | ReadinessValue | WellnessScoreValue + >(metric); if (query.isLoading) { return ( @@ -71,8 +70,22 @@ export function CompositeScoreAnatomy({ } const data = query.data; - const title = t(`insights.derived.composite.${metric}.title`); - const standard = STANDARDS[metric]; + const meta = METRIC_PROVENANCE[metric]; + const standard = meta.standard; + const title = titleFor(metric, t); + // 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. + const method = ( + <> + {meta.caveatKey ? ( + + {t(meta.caveatKey)} + + ) : null} + {t(meta.methodKey)} + + ); if (!data) { // Network/abort fallback — render the insufficient state honestly. @@ -94,7 +107,7 @@ export function CompositeScoreAnatomy({ windowDays: 0, computedAt: new Date().toISOString(), }} - method={t(`insights.derived.composite.${metric}.method`)} + method={method} standard={standard} insufficient className={className} @@ -120,7 +133,7 @@ export function CompositeScoreAnatomy({ weight: s.weight, })) .sort(rankByImpact); - } else { + } else if (metric === "READINESS") { const v = data.value as ReadinessValue; score = v.score; caption = t(`insights.derived.scoreRing.band.${v.band}`); @@ -132,6 +145,11 @@ export function CompositeScoreAnatomy({ weight: c.weight, })) .sort(rankByImpact); + } else { + // Persisted nightly score — no sub-decomposition; ring + provenance. + const v = data.value as WellnessScoreValue; + score = v.score; + caption = t(`insights.derived.scoreRing.band.${v.band}`); } } @@ -144,7 +162,7 @@ export function CompositeScoreAnatomy({ coverage={data.coverage} confidence={data.confidence} provenance={data.provenance} - method={t(`insights.derived.composite.${metric}.method`)} + method={method} standard={standard} insufficient={insufficient} className={className} @@ -152,6 +170,25 @@ export function CompositeScoreAnatomy({ ); } +/** Localised title per metric — composites use their existing copy keys; + * the persisted scores reuse the wellness-strip labels. */ +function titleFor( + metric: AnatomyMetricId, + t: (key: string) => string, +): string { + switch (metric) { + case "SLEEP_SCORE": + case "READINESS": + return t(`insights.derived.composite.${metric}.title`); + case "RECOVERY_SCORE": + return t("insights.derived.scores.recovery"); + case "STRESS_SCORE": + return t("insights.derived.scores.stress"); + case "STRAIN_SCORE": + return t("insights.derived.scores.strain"); + } +} + /** Rank contributors by impact: present-and-higher-weight first. */ function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number { const aPresent = a.value != null ? 1 : 0; diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index dda91b6ca..ddc1dada6 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -53,6 +53,14 @@ export interface SparklineDeltaTileProps { staleDays?: number | null; /** Decimal places for the value + delta. Defaults to 1. */ precision?: number; + /** + * Optional provenance affordance — the `ProvenanceExplainer` ⓘ trigger + * rendered in the label row so the method + cited standard reach the user + * on a tile that has no detail page (vitals baseline, HRV, BMI, + * fitness/vascular age). Plain element children — the caller wires the + * explainer; the tile just gives it a slot. + */ + provenance?: React.ReactNode; className?: string; } @@ -67,6 +75,7 @@ export function SparklineDeltaTile({ framing, staleDays = null, precision = 1, + provenance, className, }: SparklineDeltaTileProps) { const { t } = useTranslations(); @@ -119,6 +128,14 @@ export function SparklineDeltaTile({ > {label} + {provenance ? ( + + {provenance} + + ) : null} @@ -149,9 +166,11 @@ export function SparklineDeltaTile({ {/* Inline sparkline — fixed 40px height so the tile never reflows. - Suppressed (with the height reserved) when there is no series. */} -
- {sparkData ? ( + When there is no trailing series the row collapses entirely rather + than reserving an empty dashed placeholder (which read as visual + dead space across the whole grid). */} + {sparkData ? ( +
- ) : ( - +
+ ) : null} {/* Framing + delta caption row. */}
diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts new file mode 100644 index 000000000..f701fca1b --- /dev/null +++ b/src/components/insights/derived/standards.ts @@ -0,0 +1,131 @@ +/** + * v1.10.0 — the single derived-metric provenance source map. + * + * One place that pins, per derived metric, the cited standard (a real, + * linkable primary source — DOI / WHO / professional-society standard) and + * the i18n keys for the plain-language method copy + the honesty caveat the + * `ProvenanceExplainer` / `ScoreAnatomyView` surface. Every score surface + * (the dashboard tiles, the wellness rings, the anatomy detail pages, the + * coincident-deviation flag) reads from here so the method + cited standard + * a metric exposes stays one source — never a comment buried in the engine + * module, never two surfaces disagreeing on the citation. + * + * Client-safe: a string-keyed metadata map + i18n key names, no compute, no + * server imports — a `"use client"` surface value-imports it freely. + */ +import type { ProvenanceStandard } from "./provenance-explainer"; +import type { DerivedMetricId } from "@/lib/insights/derived/registry"; + +export interface MetricProvenanceMeta { + /** i18n key for the plain-language method copy (under composite.*.method). */ + methodKey: string; + /** The cited primary standard rendered as an external link. */ + standard: ProvenanceStandard; + /** + * Optional i18n key for an honesty caveat surfaced above the method — + * e.g. STRESS_SCORE must state it is an HRV-derived proxy, not an + * EDA/galvanic measurement. + */ + caveatKey?: string; +} + +/** + * The cited standard + method/caveat keys for every derived metric. The + * `method`/`caveat` keys live under `insights.derived.composite..*` + * (extending the shape SLEEP_SCORE + READINESS already use) so the i18n + * bundle carries one consistent namespace. + */ +export const METRIC_PROVENANCE: Record = { + VITALS_BASELINE: { + methodKey: "insights.derived.composite.VITALS_BASELINE.method", + standard: { + // Robust central-tendency / dispersion via the median ± k·MAD. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + }, + HRV_BALANCE: { + methodKey: "insights.derived.composite.HRV_BALANCE.method", + standard: { + // Task Force of the ESC / NASPE — HRV standards of measurement. + name: "Task Force ESC/NASPE 1996, Circulation", + url: "https://doi.org/10.1161/01.CIR.93.5.1043", + }, + }, + BMI: { + methodKey: "insights.derived.composite.BMI.method", + standard: { + // WHO Technical Report Series 894 — BMI classification. + name: "WHO Technical Report Series 894", + url: "https://iris.who.int/handle/10665/42330", + }, + }, + FITNESS_AGE: { + methodKey: "insights.derived.composite.FITNESS_AGE.method", + standard: { + // FRIEND registry CRF reference standards (Kaminsky et al.). + name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)", + url: "https://doi.org/10.1016/j.mayocp.2015.07.026", + }, + }, + VASCULAR_AGE_DELTA: { + methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method", + standard: { + // Reference values for arterial stiffness (PWV) collaboration. + name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.", + url: "https://doi.org/10.1093/eurheartj/ehq165", + }, + caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat", + }, + COINCIDENT_DEVIATION: { + methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method", + standard: { + // Hampel-identifier / median ± k·MAD outlier flagging. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat", + }, + SLEEP_SCORE: { + methodKey: "insights.derived.composite.SLEEP_SCORE.method", + standard: { + name: "Hirshkowitz et al. 2015, Sleep Health", + url: "https://doi.org/10.1016/j.sleh.2014.12.010", + }, + }, + READINESS: { + methodKey: "insights.derived.composite.READINESS.method", + standard: { + 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", + standard: { + // HRV-guided recovery / training-readiness framing. + 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", + standard: { + // Neurovisceral-integration / HRV-as-stress-index basis. + 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", + standard: { + // Banister TRIMP training-load model (HR-reserve weighting). + name: "Banister 1991, TRIMP model", + url: "https://en.wikipedia.org/wiki/Training_Impulse", + }, + caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat", + }, +}; diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index d1ec79f6c..c73c5656a 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { Gauge, HeartPulse, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; import { @@ -12,7 +13,13 @@ import { import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { CoverageMeter } from "./coverage-meter"; import { WellnessScores } from "./wellness-scores"; -import { useDerivedMetric } from "./use-derived-metric"; +import { ProvenanceExplainer } from "./provenance-explainer"; +import { METRIC_PROVENANCE } from "./standards"; +import { + useDerivedBatch, + type DerivedBatchRead, + type DerivedBatchToken, +} from "./use-derived-metric"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; @@ -20,6 +27,9 @@ import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age"; import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"; import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance"; import type { BmiValue } from "@/lib/insights/derived/bmi"; +import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; +import { AlertTriangle } from "lucide-react"; /** * v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid). @@ -33,10 +43,13 @@ import type { BmiValue } from "@/lib/insights/derived/bmi"; * - provisional (`insufficient_history_for_band`) → the tile renders with a * `CoverageMeter` that shows the honest "N of 7 days" state, never a * headline number from too-little history. - * - ok → the full value + framing line + sparkline placeholder. + * - ok → the full value + framing line + a provenance ⓘ affordance. * - * Each tile owns its own `useDerivedMetric` query (keyed off the centralised - * factory) so a sparse vital simply un-mounts rather than blocking the grid. + * The whole grid (every vital tile AND the wellness-score strip) reads from + * ONE batched `/api/insights/derived/batch` request, fanned out server-side + * under a bounded limiter with the profile loaded once. This replaces the + * cold-mount fan-out of 14+ independent requests sharing the Prisma pool — + * the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1). */ const SECTION_VITALS: string[] = [ @@ -59,6 +72,23 @@ const UP_BAD_VITALS = new Set([ /** Up-is-good for the vitals where a rise is favourable. */ const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]); +/** + * Storage-unit → display-symbol overrides. `getUnitForType` returns the + * canonical *storage* string (e.g. the literal "celsius"); a tile must show + * the symbol a user reads ("°C"). Only the units whose storage string is not + * already a display symbol need an entry — kg / bpm / ms / % / mg/dL render + * fine as-is. + */ +const DISPLAY_UNIT_SYMBOL: Record = { + celsius: "°C", + fahrenheit: "°F", +}; + +function displayUnit(type: string): string { + const storage = getUnitForType(type); + return DISPLAY_UNIT_SYMBOL[storage] ?? storage; +} + function vitalSentiment(type: string): TrendDirectionSentiment { if (UP_BAD_VITALS.has(type)) return "up-bad"; if (UP_GOOD_VITALS.has(type)) return "up-good"; @@ -70,14 +100,47 @@ interface DashboardProps { className?: string; } +/** The provenance ⓘ explainer for one derived metric, wired from the map. */ +function MetricProvenance({ + metric, + provenance, +}: { + metric: keyof typeof METRIC_PROVENANCE; + provenance: DerivedProvenance; +}) { + const { t } = useTranslations(); + const meta = METRIC_PROVENANCE[metric]; + const method = ( + <> + {meta.caveatKey ? ( + {t(meta.caveatKey)} + ) : null} + {t(meta.methodKey)} + + ); + return ( + + ); +} + +interface TileProps { + read: DerivedBatchRead; + isLoading: boolean; +} + /** A single personal-typical-range tile for one vital. */ -function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { +function BaselineTile({ + type, + read, + isLoading, +}: TileProps & { type: string }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading } = useDerivedMetric( - "VITALS_BASELINE", - { type, enabled }, - ); + const data = read({ metric: "VITALS_BASELINE", type }); if (isLoading || !data) return null; // Absent → don't render the tile at all. @@ -88,7 +151,7 @@ function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge; const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; const label = labelKey ? t(labelKey) : type; - const unit = getUnitForType(type); + const unit = displayUnit(type); // Provisional — building the band; show value-less coverage state. if (data.status === "insufficient") { @@ -131,17 +194,18 @@ function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { icon={Icon} framing={framing} directionSentiment={vitalSentiment(type)} + provenance={ + + } />
); } /** Cardio-fitness band tile (VO₂max passthrough re-frame). */ -function FitnessAgeTile({ enabled }: { enabled: boolean }) { +function FitnessAgeTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("FITNESS_AGE", { - enabled, - }); + const data = read({ metric: "FITNESS_AGE" }); if (isLoading || !data || data.status !== "ok" || !data.value) return null; const v = data.value; const framing = @@ -167,18 +231,18 @@ function FitnessAgeTile({ enabled }: { enabled: boolean }) { directionSentiment="up-good" framing={framing} precision={1} + provenance={ + + } />
); } /** Vascular-age delta tile (Withings passthrough re-frame). */ -function VascularAgeTile({ enabled }: { enabled: boolean }) { +function VascularAgeTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric( - "VASCULAR_AGE_DELTA", - { enabled }, - ); + const data = read({ metric: "VASCULAR_AGE_DELTA" }); if (isLoading || !data || data.status !== "ok" || !data.value) return null; const v = data.value; const framing = @@ -204,17 +268,21 @@ function VascularAgeTile({ enabled }: { enabled: boolean }) { directionSentiment="up-bad" framing={framing} precision={0} + provenance={ + + } /> ); } /** HRV (SDNN) balance tile (reuses the baseline engine). */ -function HrvBalanceTile({ enabled }: { enabled: boolean }) { +function HrvBalanceTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("HRV_BALANCE", { - enabled, - }); + const data = read({ metric: "HRV_BALANCE" }); if (isLoading || !data) return null; if (data.status === "insufficient" && data.reason === "no_readings_in_window") { return null; @@ -255,15 +323,18 @@ function HrvBalanceTile({ enabled }: { enabled: boolean }) { directionSentiment="up-good" framing={framing} precision={0} + provenance={ + + } /> ); } /** BMI tile (weight + height fallback). */ -function BmiTile({ enabled }: { enabled: boolean }) { +function BmiTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("BMI", { enabled }); + const data = read({ metric: "BMI" }); if (isLoading || !data || data.status !== "ok" || !data.value) return null; const v = data.value; const framing = t(`insights.derived.vitals.bmiCategory.${v.category}`); @@ -277,18 +348,108 @@ function BmiTile({ enabled }: { enabled: boolean }) { directionSentiment="neutral" framing={framing} precision={1} + provenance={ + + } /> ); } +/** + * Coincident-deviation flag tile — surfaces ONLY when the flag fired today + * (≥ N vitals outside their personal band on the same day). Lists the + * contributing vitals as plain text and carries the provenance affordance + * (the Hampel/Leys median ± k·MAD basis + the descriptive-not-clinical + * caveat). When the flag has not fired the tile un-mounts entirely — never + * an alarming empty card. + */ +function CoincidentDeviationTile({ read, isLoading }: TileProps) { + const { t } = useTranslations(); + const data = read({ + metric: "COINCIDENT_DEVIATION", + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + if (!v.fired || v.contributing.length === 0) return null; + + const names = v.contributing + .map((d) => { + const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type]; + return labelKey ? t(labelKey) : d.type; + }) + .join(", "); + + return ( +
+
+ + {t("insights.derived.coincident.label")} + + + +
+

+ {t("insights.derived.coincident.summary", { + count: v.contributing.length, + })} +

+

+ {t("insights.derived.coincident.vitals", { list: names })} +

+
+ ); +} + +/** + * The full set of tokens the dashboard reads in one batch — the five + * wellness scores + the four derived re-frames + the coincident-deviation + * flag + one baseline per vital (minus HRV, which has its own balance tile). + */ +function dashboardTokens(): DerivedBatchToken[] { + const tokens: DerivedBatchToken[] = [ + { metric: "READINESS" }, + { metric: "SLEEP_SCORE" }, + { metric: "RECOVERY_SCORE" }, + { metric: "STRESS_SCORE" }, + { metric: "STRAIN_SCORE" }, + { metric: "FITNESS_AGE" }, + { metric: "VASCULAR_AGE_DELTA" }, + { metric: "HRV_BALANCE" }, + { metric: "BMI" }, + { metric: "COINCIDENT_DEVIATION" }, + ]; + for (const type of SECTION_VITALS) { + if (type === "HEART_RATE_VARIABILITY") continue; + tokens.push({ metric: "VITALS_BASELINE", type }); + } + return tokens; +} + export function VitalsDashboard({ enabled = true, className }: DashboardProps) { const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); const vitals = useMemo(() => SECTION_VITALS, []); + const tokens = useMemo(() => dashboardTokens(), []); + + const batch = useDerivedBatch(tokens, { + enabled: enabled && isAuthenticated, + }); + const read = batch.read; + const isLoading = batch.isLoading; + const tileProps: TileProps = { read, isLoading }; return (
- +
- - - - + + + + + {vitals .filter((type) => type !== "HEART_RATE_VARIABILITY") .map((type) => ( - + ))}
diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 9b91b09b7..c39c9d75b 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { ScoreRing } from "./score-ring"; -import { useDerivedMetric } from "./use-derived-metric"; +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. import type { ReadinessValue } from "@/lib/insights/derived/readiness"; @@ -16,128 +16,145 @@ import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores" * * A row of score rings for the composite + persisted wellness scores, each * data-availability-gated: a score the user has no data for simply does not - * render (never an apologetic empty ring). The two composites with a detail - * surface (Readiness, Sleep) are tappable and route to their existing - * `ScoreAnatomyView` page (`/insights/scores/`); the persisted nightly - * scores (Recovery, Stress, Strain) render as read-only rings. + * render (never an apologetic empty ring). All five composites/scores now + * route to a `ScoreAnatomyView` detail page (`/insights/scores/`) so + * every ring carries its inputs + method + cited standard one tap away — the + * three persisted scores (Recovery, Stress, Strain) are no longer read-only + * dead-ends. The band word renders under each ring so a less-technical user + * reads "low 62", not a bare reddish 62. * - * Every ring reads the SAME `/api/insights/derived` route through the one - * `useDerivedMetric` hook — no per-screen recompute, no warm-on-mount fan-out - * (the reads are already-computed rollup/persisted values). + * Every ring reads from the ONE batched `/api/insights/derived/batch` + * request the parent dashboard owns (passed in via `read`) — no per-ring + * request, no cold-mount fan-out. The reads are already-computed + * rollup/persisted values; nothing warms on visit. */ -interface ScoreTileProps { - enabled: boolean; +interface ScoreStripProps { + /** The batched-read selector from the parent dashboard's one query. */ + read: DerivedBatchRead; + isLoading: boolean; + className?: string; } -/** A score ring optionally wrapped in a link to its anatomy detail page. */ +/** A score ring wrapped in a link to its anatomy detail page. */ function RingTile({ score, band, label, + bandWord, href, metricSlot, }: { score: number; band: ScoreBand; label: string; - href?: string; + bandWord: string; + href: string; metricSlot: string; }) { - const ring = ( -
- -
- ); - if (href) { - return ( - - {ring} - - ); - } return ( -
- {ring} -
+
+ + + {bandWord} + +
+ ); } -function ReadinessTile({ enabled }: ScoreTileProps) { +export function WellnessScores({ read, isLoading, className }: ScoreStripProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("READINESS", { - enabled, - }); - if (isLoading || !data || data.status !== "ok" || !data.value) return null; - return ( - - ); -} -function SleepTile({ enabled }: ScoreTileProps) { - const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("SLEEP_SCORE", { - enabled, - }); - if (isLoading || !data || data.status !== "ok" || !data.value) return null; - return ( - - ); -} + const readiness = read({ metric: "READINESS" }); + const sleep = read({ metric: "SLEEP_SCORE" }); + const recovery = read({ metric: "RECOVERY_SCORE" }); + const stress = read({ metric: "STRESS_SCORE" }); + const strain = read({ metric: "STRAIN_SCORE" }); -function PersistedScoreTile({ - metric, - labelKey, - enabled, -}: { - metric: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE"; - labelKey: string; - enabled: boolean; -}) { - const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric(metric, { - enabled, - }); - if (isLoading || !data || data.status !== "ok" || !data.value) return null; - return ( - - ); -} + const bandWord = (band: ScoreBand) => + t(`insights.derived.scoreRing.band.${band}`); + + const tiles: React.ReactNode[] = []; + + if (!isLoading && readiness?.status === "ok" && readiness.value) { + tiles.push( + , + ); + } + if (!isLoading && recovery?.status === "ok" && recovery.value) { + tiles.push( + , + ); + } + if (!isLoading && sleep?.status === "ok" && sleep.value) { + tiles.push( + , + ); + } + if (!isLoading && stress?.status === "ok" && stress.value) { + tiles.push( + , + ); + } + if (!isLoading && strain?.status === "ok" && strain.value) { + tiles.push( + , + ); + } + + // Whole strip un-mounts when no score has data — never an empty section. + if (tiles.length === 0) return null; -export function WellnessScores({ - enabled = true, - className, -}: { - enabled?: boolean; - className?: string; -}) { - const { t } = useTranslations(); return (
- - - - - + {tiles}
);