From 635e64ace75f4327960ee311393fde5770790e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:01:10 +0200 Subject: [PATCH] feat(healthkit): ingest seven additive HealthKit signals end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire seven previously-deferred HealthKit quantity identifiers through the full pipeline — enum, mapping, plausibility range, category, list-UI metadata, FHIR coding, and the generic metric-status assessment: CARDIO_RECOVERY heartRateRecoveryOneMinute bpm WRIST_TEMPERATURE appleSleepingWristTemperature celsius FALL_COUNT numberOfTimesFallen count SIX_MINUTE_WALK_DISTANCE sixMinuteWalkTestDistance m STAIR_ASCENT_SPEED stairAscentSpeed m/s STAIR_DESCENT_SPEED stairDescentSpeed m/s BREATHING_DISTURBANCES appleSleepingBreathingDisturbances count Each value rides raw on the wire (identity conversion). Fall count is cumulative (daily SUM); the stair speeds drain to a daily mean; the rest store as latest per-workout / per-night spot readings. Wrist temperature and breathing disturbances are flagged privacy-sensitive. The category sleep-apnea and walking-steadiness EVENT identifiers stay deferred — they are events, not quantities. Each new metric gains an Insights sub-page on the shared scaffold with the generic assessment card, localised across all six locales. Cardio fitness (VO2 max) — ingested since v1.4.23 but assessment-less — gains its own /insights/cardio-fitness page so it picks up the assessment instead of riding as a bare chart-row on the pulse page. Normal-range anchors are cited where defensible (six-minute walk 400-700 m, ATS guideline + population reference); the rest defer to the user's own baseline rather than fabricate a band. Migration 0104 appends the enum values additively (IF NOT EXISTS, forward-only). --- docs/api/openapi.yaml | 58 +++++++++ messages/de.json | 109 ++++++++++++++++- messages/en.json | 109 ++++++++++++++++- messages/es.json | 109 ++++++++++++++++- messages/fr.json | 109 ++++++++++++++++- messages/it.json | 109 ++++++++++++++++- messages/pl.json | 109 ++++++++++++++++- .../migration.sql | 36 ++++++ prisma/schema.prisma | 12 ++ .../insights/breathing-disturbances/page.tsx | 31 +++++ src/app/insights/cardio-fitness/page.tsx | 32 +++++ src/app/insights/cardio-recovery/page.tsx | 32 +++++ src/app/insights/falls/page.tsx | 30 +++++ src/app/insights/six-minute-walk/page.tsx | 31 +++++ src/app/insights/stair-ascent-speed/page.tsx | 31 +++++ src/app/insights/stair-descent-speed/page.tsx | 31 +++++ src/app/insights/wrist-temperature/page.tsx | 32 +++++ .../insights/insights-tab-strip.tsx | 30 +++++ .../measurements/measurement-list-meta.ts | 32 +++++ .../measurement-type-enum-coverage.test.ts | 23 +++- src/lib/dashboard-layout.ts | 9 ++ src/lib/fhir/loinc-map.ts | 47 ++++++++ .../insights/__tests__/metric-status.test.ts | 25 ++++ src/lib/insights/chart-tokens.ts | 18 +++ src/lib/insights/metric-availability.ts | 12 +- src/lib/insights/metric-status-registry.ts | 97 ++++++++++++++- src/lib/insights/sub-page-metric.ts | 29 +++++ .../__tests__/apple-health-mapping.test.ts | 86 ++++++++++++- src/lib/measurements/apple-health-mapping.ts | 114 ++++++++++++++++-- src/lib/measurements/categories.ts | 16 +++ .../__tests__/pr-detection-worker.test.ts | 11 +- src/lib/personal-records/pr-direction.ts | 17 +++ src/lib/validations/measurement.ts | 54 +++++++++ 33 files changed, 1596 insertions(+), 34 deletions(-) create mode 100644 prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql create mode 100644 src/app/insights/breathing-disturbances/page.tsx create mode 100644 src/app/insights/cardio-fitness/page.tsx create mode 100644 src/app/insights/cardio-recovery/page.tsx create mode 100644 src/app/insights/falls/page.tsx create mode 100644 src/app/insights/six-minute-walk/page.tsx create mode 100644 src/app/insights/stair-ascent-speed/page.tsx create mode 100644 src/app/insights/stair-descent-speed/page.tsx create mode 100644 src/app/insights/wrist-temperature/page.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 08f8aa46d..d25cba861 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -376,6 +376,13 @@ paths: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES - in: query name: from schema: @@ -509,6 +516,13 @@ paths: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES value: type: number measuredAt: @@ -2069,6 +2083,13 @@ paths: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -2312,6 +2333,13 @@ paths: - AUDIO_EXPOSURE_HEADPHONE - AUDIO_EXPOSURE_EVENT - SLEEP_DURATION + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here." required: true @@ -3165,17 +3193,25 @@ components: - double-support-time - step-length - walking-speed + - cardio-fitness + - falls + - six-minute-walk + - stair-ascent-speed + - stair-descent-speed - sleep + - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age + - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature + - wrist-temperature - mood - medications - blutdruck @@ -3715,6 +3751,13 @@ components: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES value: type: number unit: @@ -4005,6 +4048,13 @@ components: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES value: type: number unit: @@ -7766,17 +7816,25 @@ components: - double-support-time - step-length - walking-speed + - cardio-fitness + - falls + - six-minute-walk + - stair-ascent-speed + - stair-descent-speed - sleep + - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age + - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature + - wrist-temperature - mood - medications - blutdruck diff --git a/messages/de.json b/messages/de.json index cec242b40..76ca8c928 100644 --- a/messages/de.json +++ b/messages/de.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} Einzelwerte)", "expandDay": "Einzelwerte dieses Tages anzeigen", "collapseDay": "Einzelwerte dieses Tages ausblenden", - "loadError": "Messwerte konnten nicht geladen werden." + "loadError": "Messwerte konnten nicht geladen werden.", + "typeCardioRecovery": "Erholungspuls", + "typeWristTemperature": "Handgelenktemperatur", + "typeFallCount": "Stürze", + "typeSixMinuteWalkDistance": "6-Minuten-Gehstrecke", + "typeStairAscentSpeed": "Treppensteig-Tempo", + "typeStairDescentSpeed": "Treppab-Tempo", + "typeBreathingDisturbances": "Atemaussetzer im Schlaf" }, "mood": { "title": "Stimmung", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Blutzucker", "bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.", "skinTemperatureTitle": "Hauttemperatur", - "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln." + "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.", + "cardioFitnessTitle": "Cardiofitness (VO₂max)", + "cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der aussagekräftigste Laborwert für die Ausdauerfitness; ein höherer oder steigender Wert spricht für eine gute Herz-Kreislauf-Gesundheit.", + "cardioRecoveryTitle": "Erholungspuls", + "cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.", + "wristTemperatureTitle": "Handgelenktemperatur", + "wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen. Apple zeigt sie als Abweichung von deinem persönlichen Ausgangswert; ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.", + "fallsTitle": "Stürze", + "fallsBody": "Hier werden die von deiner Apple Watch erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.", + "sixMinuteWalkTitle": "6-Minuten-Gehstrecke", + "sixMinuteWalkBody": "Dies ist Apples Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Eine gleichbleibende oder steigende Strecke ist ein gutes Zeichen.", + "stairAscentSpeedTitle": "Treppensteig-Tempo", + "stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.", + "stairDescentSpeedTitle": "Treppab-Tempo", + "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", + "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", + "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden." }, "stats": { "label": "Zusammenfassende Statistik", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Fitness", + "description": "Deine VO₂max im Zeitverlauf — das wichtigste Maß für die Ausdauerfitness.", + "chartTitle": "VO₂max (mL/(kg·min))", + "emptyState": { + "title": "Noch keine Fitnessdaten", + "description": "VO₂max-Werte erscheinen hier, sobald Apple Health sie aus deinen Workouts synchronisiert." + } + }, + "navCardioFitness": "Fitness", + "cardioRecovery": { + "title": "Erholungspuls", + "description": "Wie stark dein Puls eine Minute nach maximaler Belastung abfällt.", + "chartTitle": "Erholungspuls (bpm)", + "emptyState": { + "title": "Noch keine Erholungspuls-Daten", + "description": "Erholungspuls-Werte erscheinen hier, sobald Apple Health sie aus einem Workout synchronisiert." + } + }, + "navCardioRecovery": "Erholungspuls", + "wristTemperature": { + "title": "Handgelenktemperatur", + "description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.", + "chartTitle": "Handgelenktemperatur (°C)", + "emptyState": { + "title": "Noch keine Handgelenktemperatur-Daten", + "description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert." + } + }, + "navWristTemperature": "Handgelenk-Temp.", + "falls": { + "title": "Stürze", + "description": "Von deiner Apple Watch erkannte schwere Stürze, pro Tag gezählt.", + "chartTitle": "Stürze pro Tag", + "emptyState": { + "title": "Noch keine Sturzdaten", + "description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel." + } + }, + "navFalls": "Stürze", + "sixMinuteWalk": { + "title": "6-Minuten-Gehtest", + "description": "Apples Schätzung, wie weit du in sechs Minuten gehen könntest.", + "chartTitle": "6-Minuten-Gehstrecke (m)", + "emptyState": { + "title": "Noch keine 6-Minuten-Gehtest-Daten", + "description": "Schätzwerte erscheinen hier, sobald Apple Health synchronisiert." + } + }, + "navSixMinuteWalk": "6-Min-Gehtest", + "stairAscentSpeed": { + "title": "Treppensteig-Tempo", + "description": "Wie schnell du Treppen hinaufgehst, gemessen von iPhone oder Watch.", + "chartTitle": "Treppensteig-Tempo (m/s)", + "emptyState": { + "title": "Noch keine Daten zum Treppensteig-Tempo", + "description": "Werte erscheinen hier, sobald Apple Health synchronisiert." + } + }, + "navStairAscentSpeed": "Treppe hoch", + "stairDescentSpeed": { + "title": "Treppab-Tempo", + "description": "Wie schnell du Treppen hinabgehst, gemessen von iPhone oder Watch.", + "chartTitle": "Treppab-Tempo (m/s)", + "emptyState": { + "title": "Noch keine Daten zum Treppab-Tempo", + "description": "Werte erscheinen hier, sobald Apple Health synchronisiert." + } + }, + "navStairDescentSpeed": "Treppe runter", + "breathingDisturbances": { + "title": "Atemaussetzer", + "description": "Wie oft deine Atmung im Schlaf gestört war.", + "chartTitle": "Atemaussetzer im Schlaf", + "emptyState": { + "title": "Noch keine Daten zu Atemaussetzern", + "description": "Werte erscheinen hier, sobald Apple Health sie von einer unterstützten Apple Watch synchronisiert." + } + }, + "navBreathingDisturbances": "Atmung" }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/en.json b/messages/en.json index a59731193..813bd8806 100644 --- a/messages/en.json +++ b/messages/en.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} samples)", "expandDay": "Show samples for this day", "collapseDay": "Hide samples for this day", - "loadError": "Could not load measurements." + "loadError": "Could not load measurements.", + "typeCardioRecovery": "Cardio recovery", + "typeWristTemperature": "Wrist temperature", + "typeFallCount": "Falls", + "typeSixMinuteWalkDistance": "Six-minute walk distance", + "typeStairAscentSpeed": "Stair ascent speed", + "typeStairDescentSpeed": "Stair descent speed", + "typeBreathingDisturbances": "Sleep breathing disturbances" }, "mood": { "title": "Mood", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Blood glucose", "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.", "skinTemperatureTitle": "Skin temperature", - "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase." + "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.", + "cardioFitnessTitle": "Cardio fitness (VO₂ max)", + "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort. It is the single best lab marker of aerobic fitness, and a higher or rising value is a strong sign of cardiovascular health.", + "cardioRecoveryTitle": "Cardio recovery", + "cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.", + "wristTemperatureTitle": "Wrist temperature", + "wristTemperatureBody": "Wrist temperature is measured while you sleep. Apple shows it as the difference from your personal baseline; a steady reading is normal, while a sustained rise can hint at illness, intense training, or a cycle change.", + "fallsTitle": "Falls", + "fallsBody": "This counts the hard falls your Apple Watch detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.", + "sixMinuteWalkTitle": "Six-minute walk distance", + "sixMinuteWalkBody": "This is Apple's estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. A steady or rising distance is a good sign.", + "stairAscentSpeedTitle": "Stair ascent speed", + "stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.", + "stairDescentSpeedTitle": "Stair descent speed", + "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", + "breathingDisturbancesTitle": "Sleep breathing disturbances", + "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician." }, "stats": { "label": "Summary statistics", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Cardio Fitness", + "description": "Your VO₂ max over time — the headline measure of aerobic fitness.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "No cardio-fitness data yet", + "description": "VO₂ max readings appear here once Apple Health syncs from your workouts." + } + }, + "navCardioFitness": "Cardio Fitness", + "cardioRecovery": { + "title": "Cardio Recovery", + "description": "How far your heart rate drops one minute after peak exercise.", + "chartTitle": "Cardio recovery (bpm)", + "emptyState": { + "title": "No cardio-recovery data yet", + "description": "Cardio-recovery readings appear here once Apple Health syncs from a workout." + } + }, + "navCardioRecovery": "Cardio Recovery", + "wristTemperature": { + "title": "Wrist Temperature", + "description": "Your overnight wrist temperature, tracked against your own baseline.", + "chartTitle": "Wrist temperature (°C)", + "emptyState": { + "title": "No wrist-temperature data yet", + "description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear." + } + }, + "navWristTemperature": "Wrist Temp", + "falls": { + "title": "Falls", + "description": "Hard falls your Apple Watch detected, counted per day.", + "chartTitle": "Falls per day", + "emptyState": { + "title": "No fall data yet", + "description": "Detected falls appear here once Apple Health syncs. No falls is the goal." + } + }, + "navFalls": "Falls", + "sixMinuteWalk": { + "title": "Six-Minute Walk", + "description": "Apple's estimate of how far you could walk in six minutes.", + "chartTitle": "Six-minute walk distance (m)", + "emptyState": { + "title": "No six-minute-walk data yet", + "description": "Six-minute-walk estimates appear here once Apple Health syncs." + } + }, + "navSixMinuteWalk": "6-Min Walk", + "stairAscentSpeed": { + "title": "Stair Ascent Speed", + "description": "How fast you climb stairs, measured by your iPhone or Watch.", + "chartTitle": "Stair ascent speed (m/s)", + "emptyState": { + "title": "No stair-ascent-speed data yet", + "description": "Stair-ascent-speed readings appear here once Apple Health syncs." + } + }, + "navStairAscentSpeed": "Stair Ascent", + "stairDescentSpeed": { + "title": "Stair Descent Speed", + "description": "How fast you go down stairs, measured by your iPhone or Watch.", + "chartTitle": "Stair descent speed (m/s)", + "emptyState": { + "title": "No stair-descent-speed data yet", + "description": "Stair-descent-speed readings appear here once Apple Health syncs." + } + }, + "navStairDescentSpeed": "Stair Descent", + "breathingDisturbances": { + "title": "Breathing Disturbances", + "description": "How often your breathing was disturbed during sleep.", + "chartTitle": "Sleep breathing disturbances", + "emptyState": { + "title": "No breathing-disturbance data yet", + "description": "Sleep-breathing readings appear here once Apple Health syncs from a supported Apple Watch." + } + }, + "navBreathingDisturbances": "Breathing" }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/es.json b/messages/es.json index 5cc0d1f34..97b5ae0d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} muestras)", "expandDay": "Mostrar las muestras de este día", "collapseDay": "Ocultar las muestras de este día", - "loadError": "No se pudieron cargar las mediciones." + "loadError": "No se pudieron cargar las mediciones.", + "typeCardioRecovery": "Recuperación cardiaca", + "typeWristTemperature": "Temperatura de muñeca", + "typeFallCount": "Caídas", + "typeSixMinuteWalkDistance": "Distancia en marcha de 6 minutos", + "typeStairAscentSpeed": "Velocidad subiendo escaleras", + "typeStairDescentSpeed": "Velocidad bajando escaleras", + "typeBreathingDisturbances": "Alteraciones respiratorias del sueño" }, "mood": { "title": "Estado de ánimo", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Glucosa en sangre", "bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.", "skinTemperatureTitle": "Temperatura de la piel", - "skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual." + "skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.", + "cardioFitnessTitle": "Condición cardiovascular (VO₂ máx)", + "cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo. Es el mejor marcador de laboratorio de la forma aeróbica, y un valor más alto o en aumento es una buena señal de salud cardiovascular.", + "cardioRecoveryTitle": "Recuperación cardiaca", + "cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.", + "wristTemperatureTitle": "Temperatura de muñeca", + "wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes. Apple la muestra como la diferencia respecto a tu valor de referencia; un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.", + "fallsTitle": "Caídas", + "fallsBody": "Esto cuenta las caídas fuertes que detecta tu Apple Watch. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.", + "sixMinuteWalkTitle": "Distancia en marcha de 6 minutos", + "sixMinuteWalkBody": "Es la estimación de Apple de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. Una distancia estable o en aumento es una buena señal.", + "stairAscentSpeedTitle": "Velocidad subiendo escaleras", + "stairAscentSpeedBody": "Es lo rápido que subes un tramo de escaleras. Es un marcador sensible de fuerza de piernas y movilidad: un ritmo estable o en aumento es buena señal y uno que baja conviene vigilar.", + "stairDescentSpeedTitle": "Velocidad bajando escaleras", + "stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.", + "breathingDisturbancesTitle": "Alteraciones respiratorias del sueño", + "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional." }, "stats": { "label": "Estadísticas resumidas", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Condición física", + "description": "Tu VO₂ máx a lo largo del tiempo, la medida principal de la forma aeróbica.", + "chartTitle": "VO₂ máx (mL/(kg·min))", + "emptyState": { + "title": "Aún no hay datos de condición física", + "description": "Los valores de VO₂ máx aparecen aquí cuando Apple Health los sincroniza desde tus entrenamientos." + } + }, + "navCardioFitness": "Condición física", + "cardioRecovery": { + "title": "Recuperación cardiaca", + "description": "Cuánto baja tu frecuencia cardiaca un minuto después del esfuerzo máximo.", + "chartTitle": "Recuperación cardiaca (lpm)", + "emptyState": { + "title": "Aún no hay datos de recuperación cardiaca", + "description": "Los valores de recuperación cardiaca aparecen aquí cuando Apple Health los sincroniza desde un entrenamiento." + } + }, + "navCardioRecovery": "Recuperación cardiaca", + "wristTemperature": { + "title": "Temperatura de muñeca", + "description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.", + "chartTitle": "Temperatura de muñeca (°C)", + "emptyState": { + "title": "Aún no hay datos de temperatura de muñeca", + "description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno." + } + }, + "navWristTemperature": "Temp. muñeca", + "falls": { + "title": "Caídas", + "description": "Caídas fuertes que detectó tu Apple Watch, contadas por día.", + "chartTitle": "Caídas por día", + "emptyState": { + "title": "Aún no hay datos de caídas", + "description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo." + } + }, + "navFalls": "Caídas", + "sixMinuteWalk": { + "title": "Marcha de 6 minutos", + "description": "La estimación de Apple de cuánto podrías caminar en seis minutos.", + "chartTitle": "Distancia en 6 minutos (m)", + "emptyState": { + "title": "Aún no hay datos de marcha de 6 minutos", + "description": "Las estimaciones aparecen aquí cuando Apple Health se sincroniza." + } + }, + "navSixMinuteWalk": "Marcha 6 min", + "stairAscentSpeed": { + "title": "Velocidad subiendo escaleras", + "description": "Lo rápido que subes escaleras, medido por tu iPhone o Watch.", + "chartTitle": "Velocidad de subida (m/s)", + "emptyState": { + "title": "Aún no hay datos de subida de escaleras", + "description": "Los valores aparecen aquí cuando Apple Health se sincroniza." + } + }, + "navStairAscentSpeed": "Subir escaleras", + "stairDescentSpeed": { + "title": "Velocidad bajando escaleras", + "description": "Lo rápido que bajas escaleras, medido por tu iPhone o Watch.", + "chartTitle": "Velocidad de bajada (m/s)", + "emptyState": { + "title": "Aún no hay datos de bajada de escaleras", + "description": "Los valores aparecen aquí cuando Apple Health se sincroniza." + } + }, + "navStairDescentSpeed": "Bajar escaleras", + "breathingDisturbances": { + "title": "Alteraciones respiratorias", + "description": "Con qué frecuencia se alteró tu respiración durante el sueño.", + "chartTitle": "Alteraciones respiratorias del sueño", + "emptyState": { + "title": "Aún no hay datos de alteraciones respiratorias", + "description": "Los valores aparecen aquí cuando Apple Health los sincroniza desde un Apple Watch compatible." + } + }, + "navBreathingDisturbances": "Respiración" }, "thresholds": { "defaultLabel": "Por defecto", diff --git a/messages/fr.json b/messages/fr.json index 5b6d1d20a..4cfede66c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} échantillons)", "expandDay": "Afficher les échantillons de ce jour", "collapseDay": "Masquer les échantillons de ce jour", - "loadError": "Impossible de charger les mesures." + "loadError": "Impossible de charger les mesures.", + "typeCardioRecovery": "Récupération cardiaque", + "typeWristTemperature": "Température du poignet", + "typeFallCount": "Chutes", + "typeSixMinuteWalkDistance": "Distance de marche de 6 minutes", + "typeStairAscentSpeed": "Vitesse en montée d'escaliers", + "typeStairDescentSpeed": "Vitesse en descente d'escaliers", + "typeBreathingDisturbances": "Troubles respiratoires du sommeil" }, "mood": { "title": "Humeur", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Glycémie", "bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.", "skinTemperatureTitle": "Température cutanée", - "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel." + "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.", + "cardioFitnessTitle": "Capacité cardio (VO₂ max)", + "cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur marqueur de laboratoire de la forme aérobie, et une valeur plus élevée ou en hausse est un bon signe de santé cardiovasculaire.", + "cardioRecoveryTitle": "Récupération cardiaque", + "cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.", + "wristTemperatureTitle": "Température du poignet", + "wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil. Apple l'affiche comme l'écart par rapport à votre valeur de référence ; une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.", + "fallsTitle": "Chutes", + "fallsBody": "Ceci compte les chutes lourdes détectées par votre Apple Watch. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.", + "sixMinuteWalkTitle": "Distance de marche de 6 minutes", + "sixMinuteWalkBody": "C'est l'estimation d'Apple de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. Une distance stable ou en hausse est un bon signe.", + "stairAscentSpeedTitle": "Vitesse en montée d'escaliers", + "stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.", + "stairDescentSpeedTitle": "Vitesse en descente d'escaliers", + "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", + "breathingDisturbancesTitle": "Troubles respiratoires du sommeil", + "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel." }, "stats": { "label": "Statistiques récapitulatives", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Condition physique", + "description": "Votre VO₂ max au fil du temps — la principale mesure de la forme aérobie.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "Pas encore de données de condition physique", + "description": "Les valeurs de VO₂ max apparaissent ici dès qu'Apple Santé les synchronise depuis vos entraînements." + } + }, + "navCardioFitness": "Condition physique", + "cardioRecovery": { + "title": "Récupération cardiaque", + "description": "De combien votre fréquence cardiaque baisse une minute après l'effort maximal.", + "chartTitle": "Récupération cardiaque (bpm)", + "emptyState": { + "title": "Pas encore de données de récupération cardiaque", + "description": "Les valeurs de récupération cardiaque apparaissent ici dès qu'Apple Santé les synchronise depuis un entraînement." + } + }, + "navCardioRecovery": "Récupération cardiaque", + "wristTemperature": { + "title": "Température du poignet", + "description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.", + "chartTitle": "Température du poignet (°C)", + "emptyState": { + "title": "Pas encore de données de température du poignet", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne." + } + }, + "navWristTemperature": "Temp. poignet", + "falls": { + "title": "Chutes", + "description": "Chutes lourdes détectées par votre Apple Watch, comptées par jour.", + "chartTitle": "Chutes par jour", + "emptyState": { + "title": "Pas encore de données de chutes", + "description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute." + } + }, + "navFalls": "Chutes", + "sixMinuteWalk": { + "title": "Marche de 6 minutes", + "description": "L'estimation d'Apple de la distance que vous pourriez parcourir en six minutes.", + "chartTitle": "Distance sur 6 minutes (m)", + "emptyState": { + "title": "Pas encore de données de marche de 6 minutes", + "description": "Les estimations apparaissent ici dès qu'Apple Santé se synchronise." + } + }, + "navSixMinuteWalk": "Marche 6 min", + "stairAscentSpeed": { + "title": "Vitesse en montée d'escaliers", + "description": "À quelle vitesse vous montez les escaliers, mesurée par votre iPhone ou Watch.", + "chartTitle": "Vitesse de montée (m/s)", + "emptyState": { + "title": "Pas encore de données de montée d'escaliers", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise." + } + }, + "navStairAscentSpeed": "Montée escaliers", + "stairDescentSpeed": { + "title": "Vitesse en descente d'escaliers", + "description": "À quelle vitesse vous descendez les escaliers, mesurée par votre iPhone ou Watch.", + "chartTitle": "Vitesse de descente (m/s)", + "emptyState": { + "title": "Pas encore de données de descente d'escaliers", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise." + } + }, + "navStairDescentSpeed": "Descente escaliers", + "breathingDisturbances": { + "title": "Troubles respiratoires", + "description": "À quelle fréquence votre respiration a été perturbée pendant le sommeil.", + "chartTitle": "Troubles respiratoires du sommeil", + "emptyState": { + "title": "Pas encore de données de troubles respiratoires", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé les synchronise depuis une Apple Watch compatible." + } + }, + "navBreathingDisturbances": "Respiration" }, "thresholds": { "defaultLabel": "Par défaut", diff --git a/messages/it.json b/messages/it.json index 0905faeff..95d1fba57 100644 --- a/messages/it.json +++ b/messages/it.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} campioni)", "expandDay": "Mostra i campioni di questa giornata", "collapseDay": "Nascondi i campioni di questa giornata", - "loadError": "Impossibile caricare le misurazioni." + "loadError": "Impossibile caricare le misurazioni.", + "typeCardioRecovery": "Recupero cardiaco", + "typeWristTemperature": "Temperatura del polso", + "typeFallCount": "Cadute", + "typeSixMinuteWalkDistance": "Distanza nel cammino di 6 minuti", + "typeStairAscentSpeed": "Velocità in salita scale", + "typeStairDescentSpeed": "Velocità in discesa scale", + "typeBreathingDisturbances": "Disturbi respiratori nel sonno" }, "mood": { "title": "Umore", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Glicemia", "bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.", "skinTemperatureTitle": "Temperatura cutanea", - "skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale." + "skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.", + "cardioFitnessTitle": "Forma cardio (VO₂ max)", + "cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo. È il miglior marcatore di laboratorio della forma aerobica e un valore più alto o in aumento è un buon segno di salute cardiovascolare.", + "cardioRecoveryTitle": "Recupero cardiaco", + "cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.", + "wristTemperatureTitle": "Temperatura del polso", + "wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi. Apple la mostra come differenza dal tuo valore di riferimento; un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.", + "fallsTitle": "Cadute", + "fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo Apple Watch. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.", + "sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti", + "sixMinuteWalkBody": "È la stima di Apple di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Una distanza stabile o in aumento è un buon segno.", + "stairAscentSpeedTitle": "Velocità in salita scale", + "stairAscentSpeedBody": "È la velocità con cui sali una rampa di scale. È un marcatore sensibile di forza delle gambe e mobilità: un ritmo stabile o in aumento è un buon segno, mentre uno in calo va tenuto d'occhio.", + "stairDescentSpeedTitle": "Velocità in discesa scale", + "stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.", + "breathingDisturbancesTitle": "Disturbi respiratori nel sonno", + "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico." }, "stats": { "label": "Statistiche riepilogative", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Forma fisica", + "description": "Il tuo VO₂ max nel tempo: la misura principale della forma aerobica.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "Nessun dato sulla forma fisica", + "description": "I valori di VO₂ max compaiono qui quando Apple Health li sincronizza dai tuoi allenamenti." + } + }, + "navCardioFitness": "Forma fisica", + "cardioRecovery": { + "title": "Recupero cardiaco", + "description": "Di quanto cala la frequenza cardiaca un minuto dopo lo sforzo massimo.", + "chartTitle": "Recupero cardiaco (bpm)", + "emptyState": { + "title": "Nessun dato sul recupero cardiaco", + "description": "I valori di recupero cardiaco compaiono qui quando Apple Health li sincronizza da un allenamento." + } + }, + "navCardioRecovery": "Recupero cardiaco", + "wristTemperature": { + "title": "Temperatura del polso", + "description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.", + "chartTitle": "Temperatura del polso (°C)", + "emptyState": { + "title": "Nessun dato sulla temperatura del polso", + "description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno." + } + }, + "navWristTemperature": "Temp. polso", + "falls": { + "title": "Cadute", + "description": "Cadute forti rilevate dal tuo Apple Watch, contate al giorno.", + "chartTitle": "Cadute al giorno", + "emptyState": { + "title": "Nessun dato sulle cadute", + "description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo." + } + }, + "navFalls": "Cadute", + "sixMinuteWalk": { + "title": "Cammino di 6 minuti", + "description": "La stima di Apple di quanto potresti camminare in sei minuti.", + "chartTitle": "Distanza in 6 minuti (m)", + "emptyState": { + "title": "Nessun dato sul cammino di 6 minuti", + "description": "Le stime compaiono qui quando Apple Health si sincronizza." + } + }, + "navSixMinuteWalk": "Cammino 6 min", + "stairAscentSpeed": { + "title": "Velocità in salita scale", + "description": "Quanto velocemente sali le scale, misurato da iPhone o Watch.", + "chartTitle": "Velocità in salita (m/s)", + "emptyState": { + "title": "Nessun dato sulla salita delle scale", + "description": "I valori compaiono qui quando Apple Health si sincronizza." + } + }, + "navStairAscentSpeed": "Salita scale", + "stairDescentSpeed": { + "title": "Velocità in discesa scale", + "description": "Quanto velocemente scendi le scale, misurato da iPhone o Watch.", + "chartTitle": "Velocità in discesa (m/s)", + "emptyState": { + "title": "Nessun dato sulla discesa delle scale", + "description": "I valori compaiono qui quando Apple Health si sincronizza." + } + }, + "navStairDescentSpeed": "Discesa scale", + "breathingDisturbances": { + "title": "Disturbi respiratori", + "description": "Quanto spesso la tua respirazione è stata disturbata durante il sonno.", + "chartTitle": "Disturbi respiratori nel sonno", + "emptyState": { + "title": "Nessun dato sui disturbi respiratori", + "description": "I valori compaiono qui quando Apple Health li sincronizza da un Apple Watch compatibile." + } + }, + "navBreathingDisturbances": "Respirazione" }, "thresholds": { "defaultLabel": "Predefinito", diff --git a/messages/pl.json b/messages/pl.json index 73256201c..db5d8c5a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} pomiarów)", "expandDay": "Pokaż pomiary z tego dnia", "collapseDay": "Ukryj pomiary z tego dnia", - "loadError": "Nie udało się załadować pomiarów." + "loadError": "Nie udało się załadować pomiarów.", + "typeCardioRecovery": "Regeneracja serca", + "typeWristTemperature": "Temperatura nadgarstka", + "typeFallCount": "Upadki", + "typeSixMinuteWalkDistance": "Dystans marszu 6-minutowego", + "typeStairAscentSpeed": "Prędkość wchodzenia po schodach", + "typeStairDescentSpeed": "Prędkość schodzenia po schodach", + "typeBreathingDisturbances": "Zaburzenia oddychania we śnie" }, "mood": { "title": "Nastrój", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Poziom glukozy we krwi", "bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.", "skinTemperatureTitle": "Temperatura skóry", - "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego." + "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.", + "cardioFitnessTitle": "Wydolność krążeniowa (VO₂ max)", + "cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, a wyższa lub rosnąca wartość świadczy o dobrym zdrowiu układu krążenia.", + "cardioRecoveryTitle": "Regeneracja serca", + "cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.", + "wristTemperatureTitle": "Temperatura nadgarstka", + "wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu. Apple pokazuje ją jako różnicę względem Twojej wartości bazowej; stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.", + "fallsTitle": "Upadki", + "fallsBody": "Tutaj liczone są poważne upadki wykryte przez Apple Watch. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.", + "sixMinuteWalkTitle": "Dystans marszu 6-minutowego", + "sixMinuteWalkBody": "To szacunek Apple, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Stały lub rosnący dystans to dobry znak.", + "stairAscentSpeedTitle": "Prędkość wchodzenia po schodach", + "stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.", + "stairDescentSpeedTitle": "Prędkość schodzenia po schodach", + "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", + "breathingDisturbancesTitle": "Zaburzenia oddychania we śnie", + "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem." }, "stats": { "label": "Statystyki podsumowujące", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Wydolność", + "description": "Twoje VO₂ max w czasie — główna miara wydolności tlenowej.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "Brak danych o wydolności", + "description": "Wartości VO₂ max pojawią się tutaj, gdy Apple Health zsynchronizuje je z Twoich treningów." + } + }, + "navCardioFitness": "Wydolność", + "cardioRecovery": { + "title": "Regeneracja serca", + "description": "O ile spada tętno minutę po szczytowym wysiłku.", + "chartTitle": "Regeneracja serca (bpm)", + "emptyState": { + "title": "Brak danych o regeneracji serca", + "description": "Wartości regeneracji serca pojawią się tutaj, gdy Apple Health zsynchronizuje je z treningu." + } + }, + "navCardioRecovery": "Regeneracja serca", + "wristTemperature": { + "title": "Temperatura nadgarstka", + "description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.", + "chartTitle": "Temperatura nadgarstka (°C)", + "emptyState": { + "title": "Brak danych o temperaturze nadgarstka", + "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie." + } + }, + "navWristTemperature": "Temp. nadgarstka", + "falls": { + "title": "Upadki", + "description": "Poważne upadki wykryte przez Apple Watch, liczone na dzień.", + "chartTitle": "Upadki na dzień", + "emptyState": { + "title": "Brak danych o upadkach", + "description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków." + } + }, + "navFalls": "Upadki", + "sixMinuteWalk": { + "title": "Marsz 6-minutowy", + "description": "Szacunek Apple, jak daleko możesz przejść w sześć minut.", + "chartTitle": "Dystans w 6 minut (m)", + "emptyState": { + "title": "Brak danych o marszu 6-minutowym", + "description": "Szacunki pojawią się tutaj, gdy Apple Health się zsynchronizuje." + } + }, + "navSixMinuteWalk": "Marsz 6 min", + "stairAscentSpeed": { + "title": "Prędkość wchodzenia po schodach", + "description": "Jak szybko wchodzisz po schodach, mierzone przez iPhone'a lub Watch.", + "chartTitle": "Prędkość wchodzenia (m/s)", + "emptyState": { + "title": "Brak danych o wchodzeniu po schodach", + "description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje." + } + }, + "navStairAscentSpeed": "Wchodzenie", + "stairDescentSpeed": { + "title": "Prędkość schodzenia po schodach", + "description": "Jak szybko schodzisz po schodach, mierzone przez iPhone'a lub Watch.", + "chartTitle": "Prędkość schodzenia (m/s)", + "emptyState": { + "title": "Brak danych o schodzeniu po schodach", + "description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje." + } + }, + "navStairDescentSpeed": "Schodzenie", + "breathingDisturbances": { + "title": "Zaburzenia oddychania", + "description": "Jak często Twój oddech był zakłócany podczas snu.", + "chartTitle": "Zaburzenia oddychania we śnie", + "emptyState": { + "title": "Brak danych o zaburzeniach oddychania", + "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje je z obsługiwanego Apple Watch." + } + }, + "navBreathingDisturbances": "Oddychanie" }, "thresholds": { "defaultLabel": "Domyślne", diff --git a/prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql b/prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql new file mode 100644 index 000000000..02f6c64dc --- /dev/null +++ b/prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql @@ -0,0 +1,36 @@ +-- v1.10.0 — extend `measurement_type` with seven additive HealthKit +-- signals that previously sat in the deferred set. +-- +-- Background: each identifier below was emitted by the iOS client but +-- mapped to no `MeasurementType`, so the batch route ack-200'd a +-- per-entry `skipped:"unmappable_identifier"` and the sample was lost. +-- They are now wired end-to-end (mapping + plausibility range + the +-- generic metric-status assessment surface). +-- +-- CARDIO_RECOVERY ← HKQuantityTypeIdentifierHeartRateRecoveryOneMinute +-- WRIST_TEMPERATURE ← HKQuantityTypeIdentifierAppleSleepingWristTemperature +-- FALL_COUNT ← HKQuantityTypeIdentifierNumberOfTimesFallen +-- SIX_MINUTE_WALK_DISTANCE ← HKQuantityTypeIdentifierSixMinuteWalkTestDistance +-- STAIR_ASCENT_SPEED ← HKQuantityTypeIdentifierStairAscentSpeed +-- STAIR_DESCENT_SPEED ← HKQuantityTypeIdentifierStairDescentSpeed +-- BREATHING_DISTURBANCES ← HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances +-- +-- Unit convention: every one of these ships raw on the wire and passes +-- through `convertToDbUnit` as identity — none is a 0..1 fraction, so the +-- ×100 percent-scaling path the gait-percent metrics use does NOT apply. +-- The convention block at the top of +-- `src/lib/measurements/apple-health-mapping.ts` spells the split out. +-- +-- Pure additive — no existing row carries any of these values. The +-- `IF NOT EXISTS` guard keeps the migration idempotent so the runner can +-- replay it on a partially-applied environment without failing on the +-- duplicate-enum-value error code. Forward-only; enum values cannot be +-- dropped in PostgreSQL, so there is no automatic down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_RECOVERY'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WRIST_TEMPERATURE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FALL_COUNT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SIX_MINUTE_WALK_DISTANCE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_ASCENT_SPEED'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_DESCENT_SPEED'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCES'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82065d908..2e7e26b7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -532,6 +532,18 @@ enum MeasurementType { // and pass through `convertToDbUnit` as identity. WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m. WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s. + // v1.10.0 — additive HealthKit signals (WX-A) + // Seven previously-deferred quantity identifiers, wired end-to-end with + // the generic metric-status assessment treatment. Each rides the same + // server-side `convertToDbUnit` + plausibility-range path as the rest of + // the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`. + CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better). + WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot). + FALL_COUNT // count — HealthKit `numberOfTimesFallen`. Hard-fall detections; cumulative daily count, lower-better. + SIX_MINUTE_WALK_DISTANCE // metres — HealthKit `sixMinuteWalkTestDistance`. Apple's estimated 6-minute-walk-test distance; mobility + cardiopulmonary endurance signal, higher-better. + STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better. + STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better. + BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better. @@map("measurement_type") } diff --git a/src/app/insights/breathing-disturbances/page.tsx b/src/app/insights/breathing-disturbances/page.tsx new file mode 100644 index 000000000..a692ae338 --- /dev/null +++ b/src/app/insights/breathing-disturbances/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Wind } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/breathing-disturbances`. + * + * BREATHING_DISTURBANCES sub-page. Apple's per-night sleep-breathing + * index (iOS 18+), classified as NotElevated / Elevated; fewer is better. + * The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsBreathingDisturbancesPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/cardio-fitness/page.tsx b/src/app/insights/cardio-fitness/page.tsx new file mode 100644 index 000000000..d4fbe4897 --- /dev/null +++ b/src/app/insights/cardio-fitness/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/cardio-fitness`. + * + * VO2_MAX sub-page. VO₂ max previously rode as a bare chart-row on the + * pulse page with no plain-language assessment; this dedicated page gives + * it the generic metric-status assessment treatment alongside the chart. + * The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsCardioFitnessPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/cardio-recovery/page.tsx b/src/app/insights/cardio-recovery/page.tsx new file mode 100644 index 000000000..e859c4558 --- /dev/null +++ b/src/app/insights/cardio-recovery/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { HeartPulse } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/cardio-recovery`. + * + * CARDIO_RECOVERY sub-page. The heart-rate drop one minute after peak + * exercise; a larger drop signals fitter autonomic recovery. The series + * arrives from Apple Health sync after qualifying workouts — no + * manual-entry CTA. + */ +export default function InsightsCardioRecoveryPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any cardio-recovery data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/falls/page.tsx b/src/app/insights/falls/page.tsx new file mode 100644 index 000000000..34c32ad46 --- /dev/null +++ b/src/app/insights/falls/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { PersonStanding } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/falls`. + * + * FALL_COUNT sub-page. Hard-fall detections, summed per day; fewer is + * better. The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsFallsPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any fall data yet — what does this metric tell me about my health, and how do I lower my fall risk?" + /> + ); +} diff --git a/src/app/insights/six-minute-walk/page.tsx b/src/app/insights/six-minute-walk/page.tsx new file mode 100644 index 000000000..991e453c5 --- /dev/null +++ b/src/app/insights/six-minute-walk/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Footprints } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/six-minute-walk`. + * + * SIX_MINUTE_WALK_DISTANCE sub-page. Apple's estimated six-minute-walk-test + * distance; a mobility + cardiopulmonary endurance signal, higher-better. + * The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsSixMinuteWalkPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any six-minute-walk data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/stair-ascent-speed/page.tsx b/src/app/insights/stair-ascent-speed/page.tsx new file mode 100644 index 000000000..18ec8d6aa --- /dev/null +++ b/src/app/insights/stair-ascent-speed/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/stair-ascent-speed`. + * + * STAIR_ASCENT_SPEED sub-page. Gait speed measured while climbing stairs; + * a faster pace is the fitter signal. The series arrives from Apple Health + * sync — no manual-entry CTA. + */ +export default function InsightsStairAscentSpeedPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any stair-ascent-speed data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/stair-descent-speed/page.tsx b/src/app/insights/stair-descent-speed/page.tsx new file mode 100644 index 000000000..d7066413c --- /dev/null +++ b/src/app/insights/stair-descent-speed/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/stair-descent-speed`. + * + * STAIR_DESCENT_SPEED sub-page. Gait speed measured while descending + * stairs; the companion to ascent speed. The series arrives from Apple + * Health sync — no manual-entry CTA. + */ +export default function InsightsStairDescentSpeedPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any stair-descent-speed data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/wrist-temperature/page.tsx b/src/app/insights/wrist-temperature/page.tsx new file mode 100644 index 000000000..7376b6457 --- /dev/null +++ b/src/app/insights/wrist-temperature/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Thermometer } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/wrist-temperature`. + * + * WRIST_TEMPERATURE sub-page. Overnight wrist temperature; Apple frames + * it as a deviation from a personal baseline, so the assessment leans on + * the user's own trend rather than a fixed band. The series arrives from + * Apple Health sync — no manual-entry CTA. + */ +export default function InsightsWristTemperaturePage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 77613690f..bf59446f2 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -196,8 +196,28 @@ const SUB_PAGE_TABS: Record< labelKey: "insights.navWalkingSpeed", metric: "WALKING_SPEED", }, + // v1.10.0 — cardio fitness (VO2 max) + Apple-Health Mobility additions. + "cardio-fitness": { labelKey: "insights.navCardioFitness", metric: "VO2_MAX" }, + falls: { labelKey: "insights.navFalls", metric: "FALL_COUNT" }, + "six-minute-walk": { + labelKey: "insights.navSixMinuteWalk", + metric: "SIX_MINUTE_WALK_DISTANCE", + }, + "stair-ascent-speed": { + labelKey: "insights.navStairAscentSpeed", + metric: "STAIR_ASCENT_SPEED", + }, + "stair-descent-speed": { + labelKey: "insights.navStairDescentSpeed", + metric: "STAIR_DESCENT_SPEED", + }, // ── sleep ── sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" }, + // v1.10.0 — sleep breathing-disturbance index. + "breathing-disturbances": { + labelKey: "insights.navBreathingDisturbances", + metric: "BREATHING_DISTURBANCES", + }, // ── cardiovascular ── "resting-pulse": { labelKey: "insights.navRestingHr", @@ -212,6 +232,11 @@ const SUB_PAGE_TABS: Record< labelKey: "insights.navVascularAge", metric: "VASCULAR_AGE", }, + // v1.10.0 — cardio recovery (post-exercise HR drop). + "cardio-recovery": { + labelKey: "insights.navCardioRecovery", + metric: "CARDIO_RECOVERY", + }, // ── hearing ── "environmental-audio": { labelKey: "insights.navAudioExposureEnv", @@ -239,6 +264,11 @@ const SUB_PAGE_TABS: Record< labelKey: "insights.navSkinTemperature", metric: "SKIN_TEMPERATURE", }, + // v1.10.0 — overnight wrist temperature. + "wrist-temperature": { + labelKey: "insights.navWristTemperature", + metric: "WRIST_TEMPERATURE", + }, // ── mood ── mood: { labelKey: "insights.navMood", metric: "MOOD" }, // ── events ── diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 2686c99c0..1d270b042 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -33,6 +33,7 @@ import { Volume2, Headphones, Sun, + PersonStanding, type LucideIcon, } from "lucide-react"; @@ -81,6 +82,14 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength", WALKING_SPEED: "measurements.typeWalkingSpeed", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + CARDIO_RECOVERY: "measurements.typeCardioRecovery", + WRIST_TEMPERATURE: "measurements.typeWristTemperature", + FALL_COUNT: "measurements.typeFallCount", + SIX_MINUTE_WALK_DISTANCE: "measurements.typeSixMinuteWalkDistance", + STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed", + STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed", + BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -145,6 +154,19 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // WALKING_STEADINESS). WALKING_STEP_LENGTH: Footprints, WALKING_SPEED: Gauge, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // HeartPulse carries the post-exercise cardiac-recovery signal; + // Thermometer the overnight wrist reading; PersonStanding the + // fall-detection tally; Gauge the gait-speed family (stairs + 6MWT); + // Wind rounds out the sleep-breathing signal (SpO2 + resp rate + // already use it). + CARDIO_RECOVERY: HeartPulse, + WRIST_TEMPERATURE: Thermometer, + FALL_COUNT: PersonStanding, + SIX_MINUTE_WALK_DISTANCE: Footprints, + STAIR_ASCENT_SPEED: Gauge, + STAIR_DESCENT_SPEED: Gauge, + BREATHING_DISTURBANCES: Wind, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -203,4 +225,14 @@ export const MEASUREMENT_TYPE_COLORS: Record = { // visual group on Insights. WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2", WALKING_SPEED: "bg-chart-2/20 text-chart-2", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family), + // chart-5 (sleep-breathing, shares the SpO2 family). + CARDIO_RECOVERY: "bg-chart-3/20 text-chart-3", + WRIST_TEMPERATURE: "bg-chart-4/20 text-chart-4", + FALL_COUNT: "bg-chart-2/20 text-chart-2", + SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/20 text-chart-2", + STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2", + STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2", + BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index f644ffabe..5693adf6d 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -59,10 +59,18 @@ const EXPECTED_TYPES = [ // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── "WALKING_STEP_LENGTH", "WALKING_SPEED", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 38 canonical measurement types", () => { + it("exposes the 45 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -130,6 +138,19 @@ describe("measurementTypeEnum coverage", () => { // same clinical-layout deferral as the rest of the Mobility set. "WALKING_STEP_LENGTH", "WALKING_SPEED", + // v1.10.0 — additive HealthKit signals (WX-A) held under the same + // v1.5+ clinical-layout PDF gate as the rest of the Apple-Health + // additions. Cardio recovery, wrist temperature, falls, six-minute + // walk, the stair gait speeds, and the sleep-breathing index each + // surface on their Insights sub-page; the doctor PDF picks them up + // once a clinical layout + reference ranges land. + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index d393bbfdd..139814d2f 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -216,6 +216,15 @@ export const CHART_OVERLAY_KEYS = [ "walkingDoubleSupport", "walkingStepLength", "walkingSpeed", + // v1.10.0 — additive HealthKit signals (WX-A). One overlay-prefs slot + // per new chart surface; camel-case keys mirror the existing convention. + "cardioRecovery", + "wristTemperature", + "fallCount", + "sixMinuteWalkDistance", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 5e662a244..c54f2afdf 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -226,6 +226,53 @@ export const MEASUREMENT_LOINC: Record = { unit: "1", category: "vital-signs", }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // No stable LOINC term for these Apple-specific quantities, so each + // routes through the shared HEALTHKIT_CODESYSTEM with the raw + // HKQuantityTypeIdentifier… string as the code (a non-LOINC code under + // the LOINC namespace would fail FHIR conformance). + CARDIO_RECOVERY: { + loinc: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + display: "Cardio recovery (1-minute heart-rate recovery)", + unit: "/min", + category: "vital-signs", + }, + WRIST_TEMPERATURE: { + loinc: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + display: "Sleeping wrist temperature", + unit: "Cel", + category: "vital-signs", + }, + FALL_COUNT: { + loinc: "HKQuantityTypeIdentifierNumberOfTimesFallen", + display: "Number of times fallen", + unit: "{falls}", + category: "activity", + }, + SIX_MINUTE_WALK_DISTANCE: { + loinc: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + display: "Six-minute walk test distance", + unit: "m", + category: "activity", + }, + STAIR_ASCENT_SPEED: { + loinc: "HKQuantityTypeIdentifierStairAscentSpeed", + display: "Stair ascent speed", + unit: "m/s", + category: "vital-signs", + }, + STAIR_DESCENT_SPEED: { + loinc: "HKQuantityTypeIdentifierStairDescentSpeed", + display: "Stair descent speed", + unit: "m/s", + category: "vital-signs", + }, + BREATHING_DISTURBANCES: { + loinc: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + display: "Sleeping breathing disturbances", + unit: "{count}", + category: "vital-signs", + }, }; /** BP panel + component LOINC codes. */ diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index db98c42ec..769b2ae13 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -93,6 +93,31 @@ describe("metric-status registry", () => { "ACTIVE_ENERGY_BURNED", ); }); + + it("registers the v1.10.0 additive HealthKit signals with the expected direction", () => { + const cases = [ + ["CARDIO_RECOVERY", "higher-better"], + ["WRIST_TEMPERATURE", "target-band"], + ["FALL_COUNT", "lower-better"], + ["SIX_MINUTE_WALK_DISTANCE", "higher-better"], + ["STAIR_ASCENT_SPEED", "higher-better"], + ["STAIR_DESCENT_SPEED", "higher-better"], + ["BREATHING_DISTURBANCES", "lower-better"], + ] as const; + for (const [id, direction] of cases) { + const meta = getMetricStatusMeta(id); + expect(meta, `${id} missing from registry`).not.toBeNull(); + expect(meta!.measurementType).toBe(id); + expect(meta!.direction).toBe(direction); + expect(METRIC_STATUS_IDS).toContain(id); + } + }); + + it("anchors a six-minute-walk normal range from the population reference", () => { + expect(getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange).toEqual( + { low: 400, high: 700 }, + ); + }); }); describe("archetype prompt templates", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index c51b1f2f8..cf9c2f131 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -79,6 +79,16 @@ export const ALLOWED_CHART_TOKENS = [ // ride the existing card-shelf renderer. "metric:WALKING_STEP_LENGTH", "metric:WALKING_SPEED", + // v1.10.0 — additive HealthKit signals (WX-A). Same posture: the LLM + // can reference these in prose; the chart components ride the existing + // sub-page scaffold. + "metric:CARDIO_RECOVERY", + "metric:WRIST_TEMPERATURE", + "metric:FALL_COUNT", + "metric:SIX_MINUTE_WALK_DISTANCE", + "metric:STAIR_ASCENT_SPEED", + "metric:STAIR_DESCENT_SPEED", + "metric:BREATHING_DISTURBANCES", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -166,6 +176,14 @@ const ORPHAN_ENUMS = [ // v1.5.5 iOS-coord follow-up — raw-SI gait pair, same shape. "WALKING_STEP_LENGTH", "WALKING_SPEED", + // v1.10.0 — additive HealthKit signals (WX-A), same shape. + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 566ee5157..923e35bcf 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -89,7 +89,17 @@ export type InsightMetric = | "WALKING_ASYMMETRY" | "WALKING_DOUBLE_SUPPORT" | "WALKING_STEP_LENGTH" - | "WALKING_SPEED"; + | "WALKING_SPEED" + // v1.10.0 — additive HealthKit signals (WX-A). Each key equals its + // MeasurementType string so the generic `hasMetricData` fallback gates + // it on `summaries[metric].count > 0` (auto-light-up on first reading). + | "CARDIO_RECOVERY" + | "WRIST_TEMPERATURE" + | "FALL_COUNT" + | "SIX_MINUTE_WALK_DISTANCE" + | "STAIR_ASCENT_SPEED" + | "STAIR_DESCENT_SPEED" + | "BREATHING_DISTURBANCES"; /** * Inputs the gating helper consumes. The `summaries` shape mirrors diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index eac071d10..4ce666921 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -109,7 +109,15 @@ export type MetricStatusMetricId = | "AUDIO_EXPOSURE_ENV" | "AUDIO_EXPOSURE_HEADPHONE" | "AUDIO_EXPOSURE_EVENT" - | "SLEEP_DURATION"; + | "SLEEP_DURATION" + // v1.10.0 — additive HealthKit signals (WX-A). + | "CARDIO_RECOVERY" + | "WRIST_TEMPERATURE" + | "FALL_COUNT" + | "SIX_MINUTE_WALK_DISTANCE" + | "STAIR_ASCENT_SPEED" + | "STAIR_DESCENT_SPEED" + | "BREATHING_DISTURBANCES"; /** * The registry. Keyed by the HealthKit metric id. Each entry's @@ -400,6 +408,93 @@ const REGISTRY: Record = { normalRange: { low: 420, high: 540 }, archetype: "sleep", }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery — bpm drop one minute after peak exercise. A larger + // drop is the fitter, healthier signal (higher-better). No fixed band: + // recovery scales with the peak HR + fitness, so the user's own + // baseline leads. A blunted HRR1 (the long-cited Cole et al. NEJM 1999 + // ≤12 bpm marker) is a mortality predictor, but the absolute placement + // depends on the workout intensity HealthLog does not capture here, so + // we defer wholly to the user's trend rather than anchor a band. + CARDIO_RECOVERY: { + id: "CARDIO_RECOVERY", + measurementType: "CARDIO_RECOVERY", + displayName: "Cardio recovery", + unit: "bpm", + direction: "higher-better", + archetype: "physiological-vital", + }, + // Wrist temperature — overnight skin-side reading. Apple frames it as a + // baseline deviation; we store the absolute °C and treat it as a + // target-band metric whose anchor is the user's own nightly baseline. + // No fixed population band: wrist (skin) temperature runs cooler than + // core and varies by room temperature + bedding, so a hard band would + // misplace it. The user's own series leads (same posture as + // SKIN_TEMPERATURE). + WRIST_TEMPERATURE: { + id: "WRIST_TEMPERATURE", + measurementType: "WRIST_TEMPERATURE", + displayName: "Wrist temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + }, + // Fall count — fewer is better. Zero is the target; any sustained + // non-zero count is a mobility-risk signal Apple surfaces prominently. + FALL_COUNT: { + id: "FALL_COUNT", + measurementType: "FALL_COUNT", + displayName: "Falls", + unit: "falls/day", + direction: "lower-better", + archetype: "mobility-gait", + }, + // Six-minute-walk distance — population reference for healthy adults is + // ~400–700 m (ATS 2002 guideline + general-population reference + // standards, Casanova et al. ERJ 2011 / population samples). The band + // is a coarse placement aid; age, height, and sex shift it, so the + // user's own baseline still leads. + SIX_MINUTE_WALK_DISTANCE: { + id: "SIX_MINUTE_WALK_DISTANCE", + measurementType: "SIX_MINUTE_WALK_DISTANCE", + displayName: "Six-minute walk distance", + unit: "m", + direction: "higher-better", + normalRange: { low: 400, high: 700 }, + archetype: "mobility-gait", + }, + // Stair ascent speed — faster is the fitter signal. No fixed band: + // stair pace depends on stair geometry + leg length; the user's own + // trend leads (same posture as WALKING_STEP_LENGTH). + STAIR_ASCENT_SPEED: { + id: "STAIR_ASCENT_SPEED", + measurementType: "STAIR_ASCENT_SPEED", + displayName: "Stair ascent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + }, + // Stair descent speed — gait companion to ascent speed. + STAIR_DESCENT_SPEED: { + id: "STAIR_DESCENT_SPEED", + measurementType: "STAIR_DESCENT_SPEED", + displayName: "Stair descent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + }, + // Breathing disturbances — per-night sleep-breathing index. Fewer is + // better. Apple classifies the index as NotElevated / Elevated rather + // than publishing a numeric cutoff, so no fixed band is encoded; the + // direction + the user's own baseline carry the placement. + BREATHING_DISTURBANCES: { + id: "BREATHING_DISTURBANCES", + measurementType: "BREATHING_DISTURBANCES", + displayName: "Sleep breathing disturbances", + unit: "count", + direction: "lower-better", + archetype: "sleep", + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 8b9a2be53..f91bb6b6d 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -80,14 +80,28 @@ const SUB_PAGE_METRIC = { "double-support-time": ["WALKING_DOUBLE_SUPPORT"], "step-length": ["WALKING_STEP_LENGTH"], "walking-speed": ["WALKING_SPEED"], + // v1.10.0 — VO2 max (cardio fitness) gains a dedicated sub-page so it + // carries the generic metric-status assessment instead of riding as a + // bare chart-row on the pulse page. + "cardio-fitness": ["VO2_MAX"], + // v1.10.0 — Apple-Health Mobility additions. + falls: ["FALL_COUNT"], + "six-minute-walk": ["SIX_MINUTE_WALK_DISTANCE"], + "stair-ascent-speed": ["STAIR_ASCENT_SPEED"], + "stair-descent-speed": ["STAIR_DESCENT_SPEED"], // ── sleep ── sleep: ["SLEEP_DURATION"], + // v1.10.0 — sleep breathing-disturbance index joins the sleep cluster. + "breathing-disturbances": ["BREATHING_DISTURBANCES"], // ── cardiovascular ── "resting-pulse": ["RESTING_HEART_RATE"], hrv: ["HEART_RATE_VARIABILITY"], // v1.7.0 — Withings cardiovascular risk markers. "pulse-wave-velocity": ["PULSE_WAVE_VELOCITY"], "vascular-age": ["VASCULAR_AGE"], + // v1.10.0 — cardio recovery (post-exercise HR drop) joins the + // cardiovascular cluster. + "cardio-recovery": ["CARDIO_RECOVERY"], // ── hearing (v1.7.0) — audio-exposure cluster ── "environmental-audio": ["AUDIO_EXPOSURE_ENV"], "headphone-audio": ["AUDIO_EXPOSURE_HEADPHONE"], @@ -97,6 +111,8 @@ const SUB_PAGE_METRIC = { // ── metabolic (v1.7.0) ── "blood-glucose": ["BLOOD_GLUCOSE"], "skin-temperature": ["SKIN_TEMPERATURE"], + // v1.10.0 — overnight wrist temperature joins the metabolic cluster. + "wrist-temperature": ["WRIST_TEMPERATURE"], // ── mood ── mood: ["MOOD"], // ── events (medication adherence is event-driven; no measurement series) ── @@ -165,6 +181,19 @@ export const SUB_PAGE_GROUP: Partial> = { // metabolic (v1.7.0) "blood-glucose": "metabolic", "skin-temperature": "metabolic", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // activity / mobility cluster + "cardio-fitness": "activity", + falls: "activity", + "six-minute-walk": "activity", + "stair-ascent-speed": "activity", + "stair-descent-speed": "activity", + // vitals cluster (breathing sits with respiratory rate) + "breathing-disturbances": "vitals", + // cardiovascular cluster + "cardio-recovery": "cardiovascular", + // metabolic cluster (overnight wrist reading) + "wrist-temperature": "metabolic", }; /** diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 0c30f9b4d..84fddec66 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -61,6 +61,11 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { "HKCategoryTypeIdentifierSleepAnalysis", "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", "HKQuantityTypeIdentifierVO2Max", + // v1.10.0 — overnight wrist temperature + the sleep-breathing + // index ride behind an explicit Health-share consent, same as + // HRV / VO2 max / sleep analysis. + "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", ].sort(), ); // Audio exposure + time-in-daylight are environment / lifestyle @@ -134,8 +139,9 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility — `AppleWalkingSteadiness` moved into the mapped - // set in v1.4.30. The remaining identifiers stay deferred. - "HKQuantityTypeIdentifierNumberOfTimesFallen", + // set in v1.4.30; `NumberOfTimesFallen` moved into the mapped set + // in v1.10.0 (FALL_COUNT). The walking-steadiness EVENT stays + // deferred (a category-type event, not a quantity). "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary "HKQuantityTypeIdentifierForcedExpiratoryVolume1", @@ -619,6 +625,8 @@ describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { "HKQuantityTypeIdentifierFlightsClimbed", "HKQuantityTypeIdentifierDistanceWalkingRunning", "HKQuantityTypeIdentifierTimeInDaylight", + // v1.10.0 — hard-fall detections accumulate across the day. + "HKQuantityTypeIdentifierNumberOfTimesFallen", ].sort(), ); }); @@ -654,6 +662,10 @@ describe("HIGH_FREQUENCY_MEAN_TYPES (v1.7.0)", () => { "WALKING_DOUBLE_SUPPORT", "WALKING_STEADINESS", "WALKING_HEART_RATE_AVERAGE", + // v1.10.0 — stair gait speeds arrive per-climb at sampling + // granularity; the per-day mean is the right consolidation. + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", ].sort(), ); }); @@ -675,3 +687,73 @@ describe("HIGH_FREQUENCY_MEAN_TYPES (v1.7.0)", () => { } }); }); + +describe("v1.10.0 additive HealthKit signals (WX-A)", () => { + const WXA = { + HKQuantityTypeIdentifierHeartRateRecoveryOneMinute: { + type: "CARDIO_RECOVERY", + dbUnit: "bpm", + }, + HKQuantityTypeIdentifierAppleSleepingWristTemperature: { + type: "WRIST_TEMPERATURE", + dbUnit: "celsius", + }, + HKQuantityTypeIdentifierNumberOfTimesFallen: { + type: "FALL_COUNT", + dbUnit: "count", + }, + HKQuantityTypeIdentifierSixMinuteWalkTestDistance: { + type: "SIX_MINUTE_WALK_DISTANCE", + dbUnit: "m", + }, + HKQuantityTypeIdentifierStairAscentSpeed: { + type: "STAIR_ASCENT_SPEED", + dbUnit: "m/s", + }, + HKQuantityTypeIdentifierStairDescentSpeed: { + type: "STAIR_DESCENT_SPEED", + dbUnit: "m/s", + }, + HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances: { + type: "BREATHING_DISTURBANCES", + dbUnit: "count", + }, + } as const; + + it("maps each new identifier to its canonical type + unit with identity conversion", () => { + for (const [hk, expected] of Object.entries(WXA)) { + const mapping = APPLE_HEALTH_TYPE_MAP[hk]; + expect(mapping, `${hk} mapping missing`).toBeDefined(); + expect(mapping!.measurementType).toBe(expected.type); + expect(mapping!.dbUnit).toBe(expected.dbUnit); + // None of the WX-A signals ships a 0..1 fraction — every one + // passes through `convertToDbUnit` as identity. + expect(mapping!.convertToDbUnit(42)).toBe(42); + } + }); + + it("removes each wired identifier from the deferred set", () => { + for (const hk of Object.keys(WXA)) { + expect( + HK_QUANTITY_TYPE_DEFERRED.has(hk), + `${hk} should no longer be deferred`, + ).toBe(false); + } + }); + + it("round-trips a cardio-recovery sample through mapAppleHealthEntry", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + value: 35, + unit: "count/min", + startDate: "2026-06-02T08:00:00.000Z", + endDate: "2026-06-02T08:01:00.000Z", + }); + expect(out).toEqual({ + type: "CARDIO_RECOVERY", + value: 35, + unit: "bpm", + takenAt: new Date("2026-06-02T08:01:00.000Z"), + }); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 4839bc4c5..813eb1a3b 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -470,6 +470,88 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: (v) => v, aggregation: "mean", }, + + // ── v1.10.0 — additive HealthKit signals (WX-A) ───────────── + // Seven previously-deferred quantity identifiers wired end-to-end. + // Each one ships raw on the wire (no 0..1 fraction), so every entry + // takes the identity `convertToDbUnit` path — the ×100 percent + // scaling the gait-percent metrics use does not apply here. + + // Cardio recovery — the heart-rate drop one minute after peak + // exercise. iOS 16+. A larger drop is the fitter signal; we store + // the raw bpm delta. One sample per qualifying workout. + HKQuantityTypeIdentifierHeartRateRecoveryOneMinute: { + hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + measurementType: "CARDIO_RECOVERY", + hkUnit: "count/min", + dbUnit: "bpm", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + // Sleeping wrist temperature — iOS 16+ overnight reading. Apple's + // Health app frames it as a deviation from a personal baseline; we + // store the absolute °C reading and let the user's own series carry + // the baseline. One sample per night. + HKQuantityTypeIdentifierAppleSleepingWristTemperature: { + hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + measurementType: "WRIST_TEMPERATURE", + hkUnit: "degC", + dbUnit: "celsius", + convertToDbUnit: (v) => v, + aggregation: "latest", + isPrivacySensitive: true, + }, + // Fall count — hard-fall detections. Cumulative daily tally; the + // drain treats it like the other cumulative HK counts. + HKQuantityTypeIdentifierNumberOfTimesFallen: { + hkIdentifier: "HKQuantityTypeIdentifierNumberOfTimesFallen", + measurementType: "FALL_COUNT", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + // Six-minute-walk-test distance — Apple's estimated 6MWT distance in + // metres. Mobility + cardiopulmonary endurance signal. Near-daily + // rollup; latest reading leads. + HKQuantityTypeIdentifierSixMinuteWalkTestDistance: { + hkIdentifier: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + measurementType: "SIX_MINUTE_WALK_DISTANCE", + hkUnit: "m", + dbUnit: "m", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + // Stair ascent speed — raw metres-per-second measured while climbing. + HKQuantityTypeIdentifierStairAscentSpeed: { + hkIdentifier: "HKQuantityTypeIdentifierStairAscentSpeed", + measurementType: "STAIR_ASCENT_SPEED", + hkUnit: "m/s", + dbUnit: "m/s", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Stair descent speed — gait companion to ascent speed. + HKQuantityTypeIdentifierStairDescentSpeed: { + hkIdentifier: "HKQuantityTypeIdentifierStairDescentSpeed", + measurementType: "STAIR_DESCENT_SPEED", + hkUnit: "m/s", + dbUnit: "m/s", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Breathing disturbances — iOS 18+ per-night sleep-breathing index + // Apple classifies as NotElevated / Elevated. Stored as the raw + // unitless count; one sample per night. + HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances: { + hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + measurementType: "BREATHING_DISTURBANCES", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: (v) => v, + aggregation: "latest", + isPrivacySensitive: true, + }, }; /** @@ -493,6 +575,10 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = new Set = new Set([ // The remaining identifiers below stay deferred until a sibling // MeasurementType lands. "HKQuantityTypeIdentifierHeight", // already on User.heightCm - "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", - "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + // v1.10.0 — `HeartRateRecoveryOneMinute` + `AppleSleepingWristTemperature` + // moved into the mapping table (CARDIO_RECOVERY + WRIST_TEMPERATURE). "HKQuantityTypeIdentifierBasalEnergyBurned", "HKQuantityTypeIdentifierAppleExerciseTime", // implied by Workout rows "HKQuantityTypeIdentifierAppleStandTime", // implied by Workout rows @@ -626,9 +718,9 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Running / walking form — v1.5+ // v1.5.5 — `WalkingStepLength` + `WalkingSpeed` moved into the // mapping table (raw SI on the wire — metres and m/s respectively). - "HKQuantityTypeIdentifierStairAscentSpeed", - "HKQuantityTypeIdentifierStairDescentSpeed", - "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + // v1.10.0 — `StairAscentSpeed`, `StairDescentSpeed`, and + // `SixMinuteWalkTestDistance` moved into the mapping table + // (STAIR_ASCENT_SPEED / STAIR_DESCENT_SPEED / SIX_MINUTE_WALK_DISTANCE). "HKQuantityTypeIdentifierRunningSpeed", "HKQuantityTypeIdentifierRunningPower", "HKQuantityTypeIdentifierRunningStrideLength", @@ -655,7 +747,10 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierWorkoutEffortScore", "HKQuantityTypeIdentifierPhysicalEffort", // Sleep apnea + breathing (iOS 18) — v1.5+ - "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + // v1.10.0 — `AppleSleepingBreathingDisturbances` moved into the + // mapping table (BREATHING_DISTURBANCES). The category-type sleep-apnea + // EVENT stays deferred — it belongs in the event class, not the + // quantity map. "HKCategoryTypeIdentifierSleepApneaEvent", // Nutrition — Marc directive, indefinite hold "HKQuantityTypeIdentifierDietaryWater", @@ -697,9 +792,10 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the - // mapping table in v1.4.30 (R-F T1.5). The remaining identifiers - // stay deferred until a wellness sub-page surface lands. - "HKQuantityTypeIdentifierNumberOfTimesFallen", + // mapping table in v1.4.30 (R-F T1.5); `NumberOfTimesFallen` moved + // into the mapping table in v1.10.0 (FALL_COUNT). The walking- + // steadiness EVENT stays deferred — it is a category-type event, not + // a quantity, so it belongs in the event class rather than this map. "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical // bucket; no Measurement enum mapping today. diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 5ee820e9c..15956c8df 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -124,6 +124,22 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Metabolic ── ["BLOOD_GLUCOSE", "metabolic"], ["SKIN_TEMPERATURE", "metabolic"], + // v1.10.0 — wrist temperature is an overnight skin-side reading; + // it sits with skin temperature in the metabolic cluster. + ["WRIST_TEMPERATURE", "metabolic"], + + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery is the post-exercise HR-drop autonomic-fitness + // marker — it joins the derived cardiovascular surface. + ["CARDIO_RECOVERY", "cardiovascular"], + // Fall count, six-minute-walk distance, and the stair gait speeds are + // Apple-Health Mobility-section signals alongside walking steadiness. + ["FALL_COUNT", "activity"], + ["SIX_MINUTE_WALK_DISTANCE", "activity"], + ["STAIR_ASCENT_SPEED", "activity"], + ["STAIR_DESCENT_SPEED", "activity"], + // Breathing disturbances is a per-night sleep-breathing index. + ["BREATHING_DISTURBANCES", "sleep"], ]); /** diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts index b51f9f29c..d11298dbe 100644 --- a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -18,7 +18,7 @@ import { __test__, detectPersonalRecordsForUser, } from "../pr-detection-worker"; -import { isPRTrackable } from "../pr-direction"; +import { getPRDirection, isPRTrackable } from "../pr-direction"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { @@ -891,7 +891,14 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)", (r) => r.metricType === type && r.metricSlot === null, ); expect(pr, `expected PR for ${type}`).toBeDefined(); - expect(pr?.value, `${type} day-sum`).toBe(500); + // The PR is the winning day's SUM. For a MAX-direction cumulative + // type (steps, energy, …) the record is the biggest day (500). For + // a MIN-direction cumulative type (v1.10.0 FALL_COUNT — fewest + // falls is the record) the winning day is the smallest day-sum, so + // the seed days at 100 hold the record over the 500 day. + const expectedValue = + getPRDirection(type) === PersonalRecordDirection.MIN ? 100 : 500; + expect(pr?.value, `${type} day-sum`).toBe(expectedValue); } }); }); diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 95ee21890..27f37ca57 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -69,6 +69,14 @@ export function getPRDirection( // overall mobility resilience. MAX direction matches Apple's // own Mobility-section framing. case "WALKING_SPEED": + // v1.10.0 — cardio recovery (a larger one-minute HR drop is the + // fitter result), six-minute-walk distance (the classic endurance + // achievement), and the stair gait speeds (faster climb/descent = + // more leg strength + mobility) all read MAX. + case "CARDIO_RECOVERY": + case "SIX_MINUTE_WALK_DISTANCE": + case "STAIR_ASCENT_SPEED": + case "STAIR_DESCENT_SPEED": return PersonalRecordDirection.MAX; // MIN direction — lower value is the record. @@ -85,6 +93,11 @@ export function getPRDirection( // symmetric gait + shorter double-support fraction = healthier). case "WALKING_ASYMMETRY": case "WALKING_DOUBLE_SUPPORT": + // v1.10.0 — fall count and the sleep-breathing-disturbance index + // both read MIN: the "record" is the day/night with the fewest + // events, and fewer is unambiguously the goal. + case "FALL_COUNT": + case "BREATHING_DISTURBANCES": return PersonalRecordDirection.MIN; // Explicitly no PersonalRecord — see comment block above. @@ -118,6 +131,10 @@ export function getPRDirection( // direction; the future worker can still surface trend deltas // without claiming a record direction. case "WALKING_STEP_LENGTH": + // v1.10.0 — wrist temperature is homeostatic and Apple frames it as + // a baseline deviation, not a goal axis; neither a higher nor a + // lower reading is an achievement. Defer to a null PR direction. + case "WRIST_TEMPERATURE": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 114212994..ae9e7e616 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -47,6 +47,17 @@ export const measurementTypeEnum = z.enum([ // the convention block in `apple-health-mapping.ts`. "WALKING_STEP_LENGTH", "WALKING_SPEED", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Seven previously-deferred quantity identifiers; each ships raw on + // the wire (identity conversion). See the convention block in + // `apple-health-mapping.ts`. + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ]); export const glucoseContextEnum = z.enum([ @@ -132,6 +143,22 @@ const unitMap: Record = { // match HealthKit's `m` / `m/s` defaults. WALKING_STEP_LENGTH: "m", WALKING_SPEED: "m/s", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery is the bpm drop one minute after peak exercise. + CARDIO_RECOVERY: "bpm", + // Overnight wrist temperature in °C (absolute reading; Apple's own + // display frames it as a baseline deviation, we store the reading). + WRIST_TEMPERATURE: "celsius", + // Hard-fall detections — a plain count. + FALL_COUNT: "count", + // Apple's estimated six-minute-walk-test distance in metres. + SIX_MINUTE_WALK_DISTANCE: "m", + // Stair gait speeds — raw metres-per-second (no scaling). + STAIR_ASCENT_SPEED: "m/s", + STAIR_DESCENT_SPEED: "m/s", + // Per-night breathing-disturbance index — a unitless count Apple + // classifies as NotElevated / Elevated. + BREATHING_DISTURBANCES: "count", }; export function getUnitForType(type: string): string { @@ -239,6 +266,33 @@ const VALUE_RANGES: Record = { // run. The 0.1 floor captures a very slow shuffle; 3.0 covers // race-walking record territory. WALKING_SPEED: { min: 0.1, max: 3.0 }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery (bpm) — the one-minute HR drop after peak exertion. + // Trained athletes can drop 50+ bpm; an unfit or autonomically + // impaired recovery sits in the single digits. 0 is a flat (poor) + // recovery; 100 is a generous ceiling beyond any plausible reading. + CARDIO_RECOVERY: { min: 0, max: 100 }, + // Wrist temperature (°C) — overnight skin-side reading. Runs cooler + // than core; the 30–42 band covers the plausible nighttime range and + // rejects obvious sensor glitches. Same shape as SKIN_TEMPERATURE. + WRIST_TEMPERATURE: { min: 30, max: 42 }, + // Fall count — a daily tally. A handful in a bad day is plausible; + // 50 is a generous ceiling that still catches a runaway sensor. + FALL_COUNT: { min: 0, max: 50 }, + // Six-minute-walk distance (metres). Healthy adults walk ~400–700 m; + // the 50 m floor captures severe impairment and the 1000 m ceiling + // covers an unusually fit reading without admitting sensor noise. + SIX_MINUTE_WALK_DISTANCE: { min: 50, max: 1000 }, + // Stair gait speed (m/s) — slower than level walking. Adult stair + // ascent sits around 0.4–0.7 m/s, descent slightly faster. The 0.05 + // floor captures a very slow climb; 2.0 is well above any sustained + // stair pace and rejects a free-fall artefact. + STAIR_ASCENT_SPEED: { min: 0.05, max: 2.0 }, + STAIR_DESCENT_SPEED: { min: 0.05, max: 2.0 }, + // Breathing-disturbance index (count). A per-night index; 0 is an + // undisturbed night and the 1000 ceiling is generous headroom over + // any plausible severe-apnea night. + BREATHING_DISTURBANCES: { min: 0, max: 1000 }, }; export function validateMeasurementRange(