diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f9ee19..0e642c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.10.3] — 2026-06-03 — Personalised strain, a daily signal card, deeper derived metrics + +### Added + +- **A "Today's signal" card.** The coincident-deviation read — which notices when several of your vitals sit off their personal baseline at once — now leads the Insights overview as a calm daily card instead of a flag buried in the grid. It shows an all-clear on a normal day, names the vitals to keep an eye on when a few drift, and always frames them as possible factors, never a cause or a diagnosis. +- **Derived bands for more of what your watch measures.** Overnight wrist temperature and stair-climbing and stair-descent pace each get a personal typical-range band, and your device's estimated six-minute-walk distance is placed against a published reference for your age, height, weight and sex. Each is shown with its method and its cited standard, and only once there is enough history. They appear under a new "Mobility & body" group. +- **Trailing trend sparklines** on the derived tiles, drawn from the readings the tile already uses. + +### Changed + +- **The Strain score is now anchored to your own training load.** It reads how hard a day was relative to your own recent training-day effort rather than a fixed population figure, so a genuinely hard day reads high even while you are building back up; with too little history it falls back to a general reference and says so. +- Age-banded reference norms now interpolate across bracket boundaries instead of stepping, and the sleep midpoint is computed in your own timezone. + ## [1.10.2] — 2026-06-03 — Honest AI connection test, consistent insights, retention re-enabled ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d4873dfe..07014f7e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.10.2 + version: 1.10.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2466,6 +2466,10 @@ paths: - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE + - WRIST_TEMPERATURE_BASELINE + - STAIR_ASCENT_SPEED_BASELINE + - STAIR_DESCENT_SPEED_BASELINE + - SIX_MINUTE_WALK_BAND description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`." @@ -7769,6 +7773,10 @@ components: - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE + - WRIST_TEMPERATURE_BASELINE + - STAIR_ASCENT_SPEED_BASELINE + - STAIR_DESCENT_SPEED_BASELINE + - SIX_MINUTE_WALK_BAND description: Echoes the requested derived-metric id (tags the union). status: type: string @@ -7784,8 +7792,9 @@ components: type: string additionalProperties: {} - type: "null" - description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } - for VITALS_BASELINE); null when 'insufficient'. + description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, + series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); + null when 'insufficient'. coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: diff --git a/messages/de.json b/messages/de.json index 10928eb9..f790fc6f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2550,7 +2550,14 @@ "normal": "Normalbereich (WHO)", "overweight": "Übergewicht (WHO)", "obese": "Adipositas (WHO)" - } + }, + "sixMinuteBand": { + "green": "{percent}% des für dein Profil vorhergesagten Werts", + "yellow": "{percent}% des für dein Profil vorhergesagten Werts", + "red": "{percent}% des für dein Profil vorhergesagten Werts" + }, + "sixMinuteNoBand": "Ergänze Alter, Größe, Gewicht und Geschlecht, um dies einzuordnen", + "mobilitySectionTitle": "Mobilität & Körper" }, "anatomy": { "outOf": "/100", @@ -2618,9 +2625,25 @@ "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.", + "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. Die Skala ist an deine eigene jüngste Trainingstagslast angelehnt — relativ zu deiner typischen Anstrengung —, sodass ein für dich wirklich harter Tag hoch ausfällt. Solange du noch Verlauf aufbaust, wird stattdessen ein allgemeiner Referenzwert verwendet.", "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", - "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung." + "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." + }, + "WRIST_TEMPERATURE_BASELINE": { + "method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.", + "caveat": "Nur ein persönliches Abweichungsband — beschreibend, kein Anzeichen für Krankheit, Fieber oder Zyklusphase. Du interpretierst es selbst." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Dein typisches Treppensteig-Tempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", + "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Dein typisches Treppen-Abstiegstempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", + "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts — hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.", + "caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung — ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose." } }, "scores": { @@ -2632,7 +2655,15 @@ "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}" + "vitals": "Außerhalb des Bereichs: {list}", + "cardTitle": "Signal des Tages", + "allClear": "Alle deine Vitalwerte liegen heute in ihrem persönlichen Bereich.", + "allClearMeta": "{count} deiner Vitalwerte gegen deinen eigenen typischen Bereich geprüft.", + "watch": "Ein Vitalwert liegt heute außerhalb seines üblichen Bereichs.", + "watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.", + "firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.", + "factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.", + "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies." } }, "cardioFitness": { diff --git a/messages/en.json b/messages/en.json index 84e8cfc5..f9c78cba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2550,7 +2550,14 @@ "normal": "Normal range (WHO)", "overweight": "Overweight (WHO)", "obese": "Obese (WHO)" - } + }, + "sixMinuteBand": { + "green": "{percent}% of predicted for your profile", + "yellow": "{percent}% of predicted for your profile", + "red": "{percent}% of predicted for your profile" + }, + "sixMinuteNoBand": "Add your age, height, weight and sex to frame this", + "mobilitySectionTitle": "Mobility & body" }, "anatomy": { "outOf": "/100", @@ -2618,9 +2625,25 @@ "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.", + "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. The scale is anchored to your own recent training-day load — relative to your typical effort — so a genuinely hard day for you reads high. While you are still building history it uses a general reference instead.", "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." + "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort." + }, + "WRIST_TEMPERATURE_BASELINE": { + "method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.", + "caveat": "A personal-deviation band only — descriptive, not an illness, fever or cycle-phase signal. Interpret it yourself." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Your typical stair-climbing pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", + "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Your typical stair-descent pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", + "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate — never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.", + "caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis." } }, "scores": { @@ -2632,7 +2655,15 @@ "coincident": { "label": "Several vitals off baseline", "summary": "{count} of your vitals sat outside their personal range today.", - "vitals": "Outside range: {list}" + "vitals": "Outside range: {list}", + "cardTitle": "Today's signal", + "allClear": "All your vitals are within their personal range today.", + "allClearMeta": "Checked {count} of your vitals against your own typical range.", + "watch": "One vital is outside its usual range today.", + "watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.", + "firedHeadline": "{count} of your vitals are outside their personal range today.", + "factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.", + "building": "Building your personal baselines — track a few more days and this will appear." } }, "cardioFitness": { diff --git a/messages/es.json b/messages/es.json index e6741d04..4d7b1d04 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2550,7 +2550,14 @@ "normal": "Rango normal (OMS)", "overweight": "Sobrepeso (OMS)", "obese": "Obesidad (OMS)" - } + }, + "sixMinuteBand": { + "green": "{percent}% de lo previsto para tu perfil", + "yellow": "{percent}% de lo previsto para tu perfil", + "red": "{percent}% de lo previsto para tu perfil" + }, + "sixMinuteNoBand": "Añade tu edad, altura, peso y sexo para encuadrar esto", + "mobilitySectionTitle": "Movilidad y cuerpo" }, "anatomy": { "outOf": "/100", @@ -2618,9 +2625,25 @@ "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.", + "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. La escala se ancla a tu propia carga reciente de días de entrenamiento —relativa a tu esfuerzo habitual—, de modo que un día realmente duro para ti aparece alto. Mientras aún estás acumulando historial usa una referencia general en su lugar.", "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." + "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual." + }, + "WRIST_TEMPERATURE_BASELINE": { + "method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.", + "caveat": "Solo una banda de desviación personal: descriptiva, no una señal de enfermedad, fiebre ni fase del ciclo. Tú la interpretas." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Tu ritmo típico al subir escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.", + "caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Tu ritmo típico al bajar escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.", + "caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "La distancia ESTIMADA de marcha de seis minutos de tu dispositivo situada frente a la referencia de Enright y Sherrill para tu edad, estatura, peso y sexo, como porcentaje de lo previsto. La distancia es la estimación del dispositivo: nunca se recalcula aquí. Sin tu edad, estatura, peso y sexo se oculta el porcentaje y solo se muestran la distancia y la tendencia.", + "caveat": "Una estimación reformulada frente a una referencia publicada: una señal de capacidad funcional, no una prueba clínica de marcha de seis minutos ni un diagnóstico." } }, "scores": { @@ -2632,7 +2655,15 @@ "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}" + "vitals": "Fuera de rango: {list}", + "cardTitle": "Señal de hoy", + "allClear": "Hoy todas tus constantes están dentro de su rango personal.", + "allClearMeta": "Se revisaron {count} de tus constantes frente a tu propio rango habitual.", + "watch": "Hoy una constante está fuera de su rango habitual.", + "watchVital": "Fuera de rango: {vital}. A menudo es algo puntual; conviene vigilarlo los próximos días.", + "firedHeadline": "Hoy, {count} de tus constantes están fuera de su rango personal.", + "factors": "Posibles factores, nunca una causa: un entrenamiento intenso, dormir mal, alcohol, altitud, estrés o una enfermedad. Es un aviso a partir de tus propias bases, no un diagnóstico.", + "building": "Construyendo tus bases personales: registra unos días más y esto aparecerá." } }, "cardioFitness": { diff --git a/messages/fr.json b/messages/fr.json index 79b4a319..ba70c8a3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2550,7 +2550,14 @@ "normal": "Plage normale (OMS)", "overweight": "Surpoids (OMS)", "obese": "Obésité (OMS)" - } + }, + "sixMinuteBand": { + "green": "{percent}% de la valeur prédite pour votre profil", + "yellow": "{percent}% de la valeur prédite pour votre profil", + "red": "{percent}% de la valeur prédite pour votre profil" + }, + "sixMinuteNoBand": "Ajoutez votre âge, taille, poids et sexe pour cadrer ceci", + "mobilitySectionTitle": "Mobilité et corps" }, "anatomy": { "outOf": "/100", @@ -2618,9 +2625,25 @@ "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.", + "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. L’échelle est calée sur votre propre charge récente des jours d’entraînement — relative à votre effort habituel —, de sorte qu’une journée vraiment difficile pour vous ressort élevée. Tant que vous constituez encore votre historique, elle utilise plutôt une référence générale.", "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." + "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel." + }, + "WRIST_TEMPERATURE_BASELINE": { + "method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.", + "caveat": "Une simple bande d’écart personnel — descriptive, ni un signe de maladie, de fièvre ou de phase du cycle. À vous de l’interpréter." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Votre allure typique en montant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", + "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Votre allure typique en descendant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", + "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil — jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.", + "caveat": "Une estimation reformulée par rapport à une référence publiée — un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic." } }, "scores": { @@ -2632,7 +2655,15 @@ "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}" + "vitals": "Hors plage : {list}", + "cardTitle": "Signal du jour", + "allClear": "Aujourd’hui, toutes vos constantes sont dans leur plage personnelle.", + "allClearMeta": "{count} de vos constantes vérifiées par rapport à votre propre plage habituelle.", + "watch": "Aujourd’hui, une constante est hors de sa plage habituelle.", + "watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.", + "firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.", + "factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.", + "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra." } }, "cardioFitness": { diff --git a/messages/it.json b/messages/it.json index be4f3266..883ac29a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2550,7 +2550,14 @@ "normal": "Intervallo normale (OMS)", "overweight": "Sovrappeso (OMS)", "obese": "Obesità (OMS)" - } + }, + "sixMinuteBand": { + "green": "{percent}% del valore previsto per il tuo profilo", + "yellow": "{percent}% del valore previsto per il tuo profilo", + "red": "{percent}% del valore previsto per il tuo profilo" + }, + "sixMinuteNoBand": "Aggiungi età, altezza, peso e sesso per inquadrare questo dato", + "mobilitySectionTitle": "Mobilità e corpo" }, "anatomy": { "outOf": "/100", @@ -2618,9 +2625,25 @@ "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.", + "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. La scala è ancorata al tuo carico recente nei giorni di allenamento — relativo al tuo sforzo abituale —, così che una giornata davvero dura per te risulti alta. Finché stai ancora costruendo lo storico usa invece un riferimento generale.", "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." + "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale." + }, + "WRIST_TEMPERATURE_BASELINE": { + "method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.", + "caveat": "Solo una banda di scostamento personale — descrittiva, non un segnale di malattia, febbre o fase del ciclo. Sei tu a interpretarla." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Il tuo passo tipico in salita sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", + "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Il tuo passo tipico in discesa sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", + "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo — mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.", + "caveat": "Una stima riformulata rispetto a un riferimento pubblicato — un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi." } }, "scores": { @@ -2632,7 +2655,15 @@ "coincident": { "label": "Più parametri fuori dalla base", "summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.", - "vitals": "Fuori intervallo: {list}" + "vitals": "Fuori intervallo: {list}", + "cardTitle": "Segnale di oggi", + "allClear": "Oggi tutti i tuoi parametri vitali sono nel loro intervallo personale.", + "allClearMeta": "Controllati {count} dei tuoi parametri rispetto al tuo intervallo abituale.", + "watch": "Oggi un parametro è fuori dal suo intervallo abituale.", + "watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.", + "firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.", + "factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.", + "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà." } }, "cardioFitness": { diff --git a/messages/pl.json b/messages/pl.json index acbf8933..a1da6cf7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2550,7 +2550,14 @@ "normal": "Zakres prawidłowy (WHO)", "overweight": "Nadwaga (WHO)", "obese": "Otyłość (WHO)" - } + }, + "sixMinuteBand": { + "green": "{percent}% wartości przewidywanej dla Twojego profilu", + "yellow": "{percent}% wartości przewidywanej dla Twojego profilu", + "red": "{percent}% wartości przewidywanej dla Twojego profilu" + }, + "sixMinuteNoBand": "Dodaj wiek, wzrost, wagę i płeć, aby to ująć w kontekście", + "mobilitySectionTitle": "Mobilność i ciało" }, "anatomy": { "outOf": "/100", @@ -2618,9 +2625,25 @@ "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ń.", + "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ń. Skala jest zakotwiczona w Twoim własnym niedawnym obciążeniu z dni treningowych — względem Twojego typowego wysiłku — więc naprawdę ciężki dzień dla Ciebie wypada wysoko. Dopóki dopiero budujesz historię, używa zamiast tego ogólnego punktu odniesienia.", "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." + "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku." + }, + "WRIST_TEMPERATURE_BASELINE": { + "method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.", + "caveat": "Tylko osobiste pasmo odchylenia — opisowe, nie sygnał choroby, gorączki ani fazy cyklu. Interpretujesz je samodzielnie." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Twoje typowe tempo wchodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", + "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Twoje typowe tempo schodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", + "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia — nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.", + "caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza." } }, "scores": { @@ -2632,7 +2655,15 @@ "coincident": { "label": "Kilka parametrów poza bazą", "summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.", - "vitals": "Poza zakresem: {list}" + "vitals": "Poza zakresem: {list}", + "cardTitle": "Sygnał dnia", + "allClear": "Dziś wszystkie Twoje parametry życiowe mieszczą się w osobistym zakresie.", + "allClearMeta": "Sprawdzono {count} Twoich parametrów względem Twojego typowego zakresu.", + "watch": "Dziś jeden parametr jest poza swoim zwykłym zakresem.", + "watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.", + "firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.", + "factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.", + "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi." } }, "cardioFitness": { diff --git a/package.json b/package.json index 7a7689f1..86a4a942 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.10.2", + "version": "1.10.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql b/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql new file mode 100644 index 00000000..e7ca80e2 --- /dev/null +++ b/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql @@ -0,0 +1,48 @@ +-- v1.10.3 — Strain personal-anchor cache. +-- +-- A server-internal per-(user, day) cache that lets the nightly Strain engine +-- anchor the 0–100 map to the user's OWN recent training-day load (an +-- EWMA-smoothed P75 of day-total TRIMP over a 42-day chronic window) instead +-- of the fixed population reference. Persisting the day-total TRIMP + the +-- running EWMA reference means the chronic window reads cheap cached values +-- rather than re-integrating 42 days of HR series every night. +-- +-- Purely additive: one new table, no enum changes, no backfill. The nightly +-- idempotent recompute populates the cache forward; until a user accrues +-- enough personal training history the engine falls back to the population +-- anchor, so behaviour matches today until the cache fills in — a clean, +-- self-healing rollout. +-- +-- NOT a Measurement: never read through the derived registry, never on a +-- user-facing surface, never in the doctor PDF or FHIR bundle, never ingested +-- from a client. A dedicated table keeps it server-internal and out of every +-- MeasurementType completeness wall. +-- +-- Reversibility: forward-only; dropping the table loses only the cache, and +-- the nightly job rebuilds it (falling back to the population anchor in the +-- interim). + +CREATE TABLE IF NOT EXISTS "strain_trimp_cache" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "day" TEXT NOT NULL, + "day_trimp" DOUBLE PRECISION NOT NULL, + "ref_personal" DOUBLE PRECISION NOT NULL, + "anchor" TEXT NOT NULL, + "training_days" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "strain_trimp_cache_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_key" + ON "strain_trimp_cache" ("user_id", "day"); + +CREATE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_idx" + ON "strain_trimp_cache" ("user_id", "day" DESC); + +ALTER TABLE "strain_trimp_cache" + ADD CONSTRAINT "strain_trimp_cache_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f47c76dd..14a888a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -404,6 +404,11 @@ model User { // retires rows older than 90 days (see `handlePushAttemptCleanup` in // `reminder-worker.ts`). pushAttempts PushAttempt[] + // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total + // TRIMP + running EWMA reference) so the nightly Strain engine anchors + // the 0–100 map to the user's own training load without re-integrating + // the 42-day chronic window of HR series each night. + strainTrimpCache StrainTrimpCache[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -933,6 +938,50 @@ model WorkoutSamples { @@map("workout_samples") } +// ─── Strain personal anchor cache (v1.10.3) ───────────────── +// +// Server-internal per-(user, day) cache that lets the nightly Strain +// engine anchor the 0–100 map to the USER'S OWN recent training-day load +// instead of a fixed population reference. Each row records the scored +// day's day-total Banister TRIMP plus the running EWMA-smoothed personal +// reference and which anchor produced that night's score (personal once +// the cold-start floor is met, population before). Persisting day-total +// TRIMP means the 42-day chronic window reads cheap cached values instead +// of re-integrating 42 days of HR series every night. +// +// NOT a Measurement: it is never read through the derived registry, never +// surfaced on any user-facing surface, never in the doctor PDF or FHIR +// bundle, and never ingested from a client. Reusing the Measurement enum +// would have pulled an internal cache type through every MeasurementType +// completeness wall (chart tokens, categories, PR direction); a dedicated +// table keeps it cleanly server-internal. Idempotent on `(userId, day)` so +// a re-fired nightly tick overwrites the day's cache row in place. +model StrainTrimpCache { + id String @id @default(cuid()) + userId String @map("user_id") + /// The scored UTC calendar day (`YYYY-MM-DD`), matching the score row's day key. + day String + /// Day-total Banister TRIMP for the day (0 when no usable HR series). + dayTrimp Float @map("day_trimp") + /// EWMA-smoothed personal reference TRIMP in effect for this day's score. + refPersonal Float @map("ref_personal") + /// Which anchor produced this day's score: `personal` once enough personal + /// training history accrued, `population` during cold start. + anchor String + /// Distinct training days (TRIMP > 0) seen in the chronic window — drives + /// the cold-start gate + the confidence label. + trainingDays Int @map("training_days") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, day]) + @@index([userId, day(sort: Desc)]) + @@map("strain_trimp_cache") +} + // ─── Personal records (v1.4.25 W8d — schema only) ────────── // // MAX vs MIN direction is stored on the row so the future detection diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 1302c484..5e13eae8 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -97,6 +97,28 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); +// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from +// a buried below-the-fold tile to the top-of-overview daily read (the +// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it +// owns its own derived-metric query and renders four calm states. The chunk +// loader's skeleton shares the card's `min-h-48` footprint (and the in-card +// `CardSkeleton` matches it too) so the top of the page does not shift across +// loading → any resolved state. Decorative → `aria-hidden`. +const CoincidentDeviationCard = dynamic( + () => + import("@/components/insights/coincident-deviation-card").then((mod) => ({ + default: mod.CoincidentDeviationCard, + })), + { + ssr: false, + loading: () => ( +