mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): surface inputs, method and a cited standard for all 11 derived metrics
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.
This commit is contained in:
1 parent
b92d5cec82
commit
551d352581
15 files changed
+960
-250
No files matched your search
+43
-1
@@ -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": {
|
||||
|
||||
+43
-1
@@ -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": {
|
||||
|
||||
+43
-1
@@ -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": {
|
||||
|
||||
+43
-1
@@ -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": {
|
||||
|
||||
+43
-1
@@ -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": {
|
||||
|
||||
+43
-1
@@ -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": {
|
||||
|
||||
@@ -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/<metric>` — the composite score-anatomy
|
||||
* detail surface.
|
||||
* v1.10.0 — `/insights/scores/<metric>` — 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<string, CompositeMetricId> = {
|
||||
const SLUG_TO_METRIC: Record<string, AnatomyMetricId> = {
|
||||
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 (
|
||||
<SubPageShell
|
||||
title={t(`insights.derived.composite.${metric}.title`)}
|
||||
description={t(`insights.derived.composite.${metric}.description`)}
|
||||
title={t(header.title)}
|
||||
description={t(header.description)}
|
||||
coachLaunch
|
||||
>
|
||||
<CompositeScoreAnatomy metric={metric} />
|
||||
|
||||
@@ -48,7 +48,8 @@ describe("<CoverageMeter>", () => {
|
||||
const html = render(<CoverageMeter coverage={FULL} confidence={HIGH} />);
|
||||
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("<CoverageMeter>", () => {
|
||||
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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -47,23 +47,44 @@ describe("<SparklineDeltaTile>", () => {
|
||||
/>,
|
||||
);
|
||||
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(<SparklineDeltaTile {...base} value={null} />);
|
||||
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(
|
||||
<SparklineDeltaTile {...base} value={58} series={[58]} />,
|
||||
);
|
||||
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(
|
||||
<SparklineDeltaTile {...base} value={58} series={[60, 59, 58]} />,
|
||||
);
|
||||
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(
|
||||
<SparklineDeltaTile
|
||||
{...base}
|
||||
value={58}
|
||||
provenance={<button data-slot="test-prov">i</button>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sparkline-delta-tile-provenance"');
|
||||
expect(html).toContain('data-slot="test-prov"');
|
||||
});
|
||||
|
||||
it("surfaces the stale caption when staleDays > 7", () => {
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
|
||||
function ok(value: Record<string, unknown>): { data: Resp; isLoading: false } {
|
||||
function ok(value: Record<string, unknown>): 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: <T,>(token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse<T>,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("<VitalsDashboard>", () => {
|
||||
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("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
it("hides an absent vital entirely (no tile)", () => {
|
||||
useDerivedMetric.mockReturnValue(insufficient("no_readings_in_window"));
|
||||
mockBatch(() => insufficient("no_readings_in_window"));
|
||||
const html = render(<VitalsDashboard />);
|
||||
// The grid + heading still render, but no value tile.
|
||||
expect(html).toContain('data-slot="vitals-dashboard-grid"');
|
||||
@@ -78,8 +100,8 @@ describe("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
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("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
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("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
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("<VitalsDashboard>", () => {
|
||||
});
|
||||
|
||||
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(<VitalsDashboard />);
|
||||
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(<VitalsDashboard />);
|
||||
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(<VitalsDashboard />);
|
||||
expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"');
|
||||
});
|
||||
});
|
||||
@@ -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<CompositeMetricId, ProvenanceStandard> = {
|
||||
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<SleepScoreValue>("SLEEP_SCORE", {
|
||||
enabled: metric === "SLEEP_SCORE",
|
||||
});
|
||||
const readiness = useDerivedMetric<ReadinessValue>("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 ? (
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : 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;
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
{provenance ? (
|
||||
<span
|
||||
data-slot="sparkline-delta-tile-provenance"
|
||||
className="shrink-0"
|
||||
>
|
||||
{provenance}
|
||||
</span>
|
||||
) : null}
|
||||
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
|
||||
@@ -149,9 +166,11 @@ export function SparklineDeltaTile({
|
||||
</div>
|
||||
|
||||
{/* Inline sparkline — fixed 40px height so the tile never reflows.
|
||||
Suppressed (with the height reserved) when there is no series. */}
|
||||
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
|
||||
{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 ? (
|
||||
<div className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={sparkData}
|
||||
@@ -175,13 +194,8 @@ export function SparklineDeltaTile({
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div
|
||||
className="border-border/40 h-full w-full rounded border border-dashed"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Framing + delta caption row. */}
|
||||
<div className="mt-2 flex min-h-[18px] items-baseline justify-between gap-2">
|
||||
|
||||
@@ -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.<ID>.*`
|
||||
* (extending the shape SLEEP_SCORE + READINESS already use) so the i18n
|
||||
* bundle carries one consistent namespace.
|
||||
*/
|
||||
export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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 ? (
|
||||
<span className="text-warning block font-medium">{t(meta.caveatKey)}</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<VitalsBaselineValue>(
|
||||
"VITALS_BASELINE",
|
||||
{ type, enabled },
|
||||
);
|
||||
const data = read<VitalsBaselineValue>({ 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={
|
||||
<MetricProvenance metric="VITALS_BASELINE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<FitnessAgeValue>("FITNESS_AGE", {
|
||||
enabled,
|
||||
});
|
||||
const data = read<FitnessAgeValue>({ 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={
|
||||
<MetricProvenance metric="FITNESS_AGE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<VascularAgeDeltaValue>(
|
||||
"VASCULAR_AGE_DELTA",
|
||||
{ enabled },
|
||||
);
|
||||
const data = read<VascularAgeDeltaValue>({ 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={
|
||||
<MetricProvenance
|
||||
metric="VASCULAR_AGE_DELTA"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HrvBalanceValue>("HRV_BALANCE", {
|
||||
enabled,
|
||||
});
|
||||
const data = read<HrvBalanceValue>({ 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={
|
||||
<MetricProvenance metric="HRV_BALANCE" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** BMI tile (weight + height fallback). */
|
||||
function BmiTile({ enabled }: { enabled: boolean }) {
|
||||
function BmiTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<BmiValue>("BMI", { enabled });
|
||||
const data = read<BmiValue>({ 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={
|
||||
<MetricProvenance metric="BMI" provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CoincidentDeviationValue>({
|
||||
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 (
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="COINCIDENT_DEVIATION"
|
||||
data-state="fired"
|
||||
className="bg-card border-warning/40 flex h-full w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.coincident.label")}
|
||||
</span>
|
||||
<MetricProvenance
|
||||
metric="COINCIDENT_DEVIATION"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
<AlertTriangle className="text-warning h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm" data-slot="coincident-summary">
|
||||
{t("insights.derived.coincident.summary", {
|
||||
count: v.contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
|
||||
<WellnessScores enabled={enabled} />
|
||||
<WellnessScores read={read} isLoading={isLoading} />
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
@@ -301,14 +462,15 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
data-slot="vitals-dashboard-grid"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<FitnessAgeTile enabled={enabled} />
|
||||
<VascularAgeTile enabled={enabled} />
|
||||
<HrvBalanceTile enabled={enabled} />
|
||||
<BmiTile enabled={enabled} />
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} enabled={enabled} />
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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/<slug>`); 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/<slug>`) 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 = (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ScoreRing score={score} band={band} size="sm" label={label} />
|
||||
</div>
|
||||
);
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
data-slot="wellness-score-tile"
|
||||
data-metric={metricSlot}
|
||||
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{ring}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
href={href}
|
||||
data-slot="wellness-score-tile"
|
||||
data-metric={metricSlot}
|
||||
className="bg-card border-border flex items-center justify-center rounded-xl border p-4"
|
||||
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{ring}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<ScoreRing score={score} band={band} size="sm" label={label} />
|
||||
<span
|
||||
data-slot="wellness-score-band-word"
|
||||
className="text-muted-foreground text-xs font-medium"
|
||||
>
|
||||
{bandWord}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessTile({ enabled }: ScoreTileProps) {
|
||||
export function WellnessScores({ read, isLoading, className }: ScoreStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<ReadinessValue>("READINESS", {
|
||||
enabled,
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
return (
|
||||
<RingTile
|
||||
score={data.value.score}
|
||||
band={data.value.band}
|
||||
label={t("insights.derived.composite.READINESS.title")}
|
||||
href="/insights/scores/readiness"
|
||||
metricSlot="READINESS"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SleepTile({ enabled }: ScoreTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useDerivedMetric<SleepScoreValue>("SLEEP_SCORE", {
|
||||
enabled,
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
return (
|
||||
<RingTile
|
||||
score={data.value.score}
|
||||
band={data.value.band}
|
||||
label={t("insights.derived.composite.SLEEP_SCORE.title")}
|
||||
href="/insights/scores/sleep"
|
||||
metricSlot="SLEEP_SCORE"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const readiness = read<ReadinessValue>({ metric: "READINESS" });
|
||||
const sleep = read<SleepScoreValue>({ metric: "SLEEP_SCORE" });
|
||||
const recovery = read<WellnessScoreValue>({ metric: "RECOVERY_SCORE" });
|
||||
const stress = read<WellnessScoreValue>({ metric: "STRESS_SCORE" });
|
||||
const strain = read<WellnessScoreValue>({ 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<WellnessScoreValue>(metric, {
|
||||
enabled,
|
||||
});
|
||||
if (isLoading || !data || data.status !== "ok" || !data.value) return null;
|
||||
return (
|
||||
<RingTile
|
||||
score={data.value.score}
|
||||
band={data.value.band}
|
||||
label={t(labelKey)}
|
||||
metricSlot={metric}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const bandWord = (band: ScoreBand) =>
|
||||
t(`insights.derived.scoreRing.band.${band}`);
|
||||
|
||||
const tiles: React.ReactNode[] = [];
|
||||
|
||||
if (!isLoading && readiness?.status === "ok" && readiness.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="READINESS"
|
||||
score={readiness.value.score}
|
||||
band={readiness.value.band}
|
||||
label={t("insights.derived.composite.READINESS.title")}
|
||||
bandWord={bandWord(readiness.value.band)}
|
||||
href="/insights/scores/readiness"
|
||||
metricSlot="READINESS"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && recovery?.status === "ok" && recovery.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="RECOVERY_SCORE"
|
||||
score={recovery.value.score}
|
||||
band={recovery.value.band}
|
||||
label={t("insights.derived.scores.recovery")}
|
||||
bandWord={bandWord(recovery.value.band)}
|
||||
href="/insights/scores/recovery"
|
||||
metricSlot="RECOVERY_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && sleep?.status === "ok" && sleep.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="SLEEP_SCORE"
|
||||
score={sleep.value.score}
|
||||
band={sleep.value.band}
|
||||
label={t("insights.derived.composite.SLEEP_SCORE.title")}
|
||||
bandWord={bandWord(sleep.value.band)}
|
||||
href="/insights/scores/sleep"
|
||||
metricSlot="SLEEP_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && stress?.status === "ok" && stress.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRESS_SCORE"
|
||||
score={stress.value.score}
|
||||
band={stress.value.band}
|
||||
label={t("insights.derived.scores.stress")}
|
||||
bandWord={bandWord(stress.value.band)}
|
||||
href="/insights/scores/stress"
|
||||
metricSlot="STRESS_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (!isLoading && strain?.status === "ok" && strain.value) {
|
||||
tiles.push(
|
||||
<RingTile
|
||||
key="STRAIN_SCORE"
|
||||
score={strain.value.score}
|
||||
band={strain.value.band}
|
||||
label={t("insights.derived.scores.strain")}
|
||||
bandWord={bandWord(strain.value.band)}
|
||||
href="/insights/scores/strain"
|
||||
metricSlot="STRAIN_SCORE"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<section
|
||||
data-slot="wellness-scores"
|
||||
@@ -151,23 +168,7 @@ export function WellnessScores({
|
||||
data-slot="wellness-scores-grid"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
<ReadinessTile enabled={enabled} />
|
||||
<PersistedScoreTile
|
||||
metric="RECOVERY_SCORE"
|
||||
labelKey="insights.derived.scores.recovery"
|
||||
enabled={enabled}
|
||||
/>
|
||||
<SleepTile enabled={enabled} />
|
||||
<PersistedScoreTile
|
||||
metric="STRESS_SCORE"
|
||||
labelKey="insights.derived.scores.stress"
|
||||
enabled={enabled}
|
||||
/>
|
||||
<PersistedScoreTile
|
||||
metric="STRAIN_SCORE"
|
||||
labelKey="insights.derived.scores.strain"
|
||||
enabled={enabled}
|
||||
/>
|
||||
{tiles}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user