From b04570349306985c819b5b439779ce7f9bba05c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 20:14:20 +0200 Subject: [PATCH] feat(apple-health): ingest the activity rings, mindfulness, State of Mind and AFib burden Six Apple Health signals leave the deferred list and ride POST /api/measurements/batch and the export.zip import end to end: EXERCISE_MINUTES, STAND_MINUTES and STAND_HOURS (the exercise and stand rings), MINDFUL_MINUTES, STATE_OF_MIND_VALENCE (iOS 18 State of Mind, -1..1) and AFIB_BURDEN (AFib History, percent of the week). The ring types and mindful minutes are cumulative, so the daily rollup is their sum; AFib burden is scaled strictly from Apple's fraction and a value above 1 is refused rather than guessed at. The archive import takes mindful minutes from the session's dates and skips idle stand hours. Every registry carries them: units, ranges, labels in all locales, categories, chart tokens, report catalogue (mindful minutes and the valence fenced as sensitive), signal registry with FHIR codings, and MCP exposure for the three ring types only. Workout GPS tracks are now an opt-in, off by default (PATCH /api/auth/me/workout-routes, a switch on Data & Privacy). While it is off, the workout batch route and the export import keep every workout and drop its track with the same per-entry status. With it on, the import reads the referenced GPX files from the archive one bounded member at a time, thins each to the point cap and stores it sealed. The climb a track carries is written to Workout.elevationM at write time, so the workout insight no longer opens the track, and the AI egress guard now freezes the set of files that read route geometry. --- docs/api/openapi.yaml | 158 ++++++++++- messages/de.json | 13 + messages/en.json | 13 + messages/es.json | 13 + messages/fr.json | 13 + messages/it.json | 13 + messages/ko.json | 13 + messages/nl.json | 13 + messages/pl.json | 13 + .../migration.sql | 21 ++ .../0432_store_workout_routes/migration.sql | 6 + prisma/schema.prisma | 16 ++ .../ai-egress-capability-guard.test.ts | 82 ++++++ ...doctor-report-control-gating-guard.test.ts | 4 +- .../me/workout-routes/__tests__/route.test.ts | 121 ++++++++ src/app/api/auth/me/workout-routes/route.ts | 109 +++++++ .../workouts/__tests__/batch-create.test.ts | 54 +++- src/app/api/workouts/batch/route.ts | 29 +- .../measurements/measurement-list-meta.ts | 15 + .../measurements/measurement-type-colors.ts | 9 + .../__tests__/report-selection-panel.test.tsx | 4 +- src/components/settings/privacy-section.tsx | 4 + .../settings/workout-routes-card.tsx | 83 ++++++ .../measurement-type-enum-coverage.test.ts | 10 +- .../__tests__/route-import.test.ts | 253 +++++++++++++++++ src/lib/apple-health/archive-stream.ts | 153 ++++++++-- src/lib/apple-health/route-import.ts | 266 ++++++++++++++++++ src/lib/data-wipe/wipe-plan.ts | 1 + src/lib/export/account-settings-backup.ts | 1 + src/lib/export/backup-plan.ts | 1 + src/lib/insights/chart-tokens.ts | 8 + src/lib/jobs/apple-health-import-worker.ts | 38 ++- src/lib/jobs/workout-insight-generate.ts | 5 - .../__tests__/apple-health-mapping.test.ts | 84 +++++- .../import-apple-health-export.test.ts | 66 ++++- src/lib/measurements/apple-health-mapping.ts | 96 ++++++- src/lib/measurements/categories.ts | 11 + src/lib/measurements/display-transform.ts | 5 + .../import-apple-health-export.ts | 71 +++++ src/lib/measurements/type-label-keys.ts | 7 + src/lib/measurements/unit-label.ts | 3 + src/lib/measurements/unit-map.ts | 10 + src/lib/measurements/value-domain.ts | 1 + src/lib/modules/measurement-scope.ts | 8 +- src/lib/openapi/routes/profile.ts | 61 ++++ src/lib/personal-records/pr-direction.ts | 11 + src/lib/query-keys/workouts.ts | 5 + .../__tests__/catalogue-guard.test.ts | 10 +- src/lib/report-selection/catalogue.ts | 16 ++ .../fixtures/expected-measurement-loinc.json | 36 +++ src/lib/signals/registry.ts | 136 +++++++++ src/lib/validations/backup.ts | 1 + src/lib/validations/measurement.ts | 20 ++ src/lib/validations/user-prefs.ts | 5 + .../__tests__/insight-evidence.test.ts | 72 +---- .../workouts/__tests__/route-climb.test.ts | 35 +++ src/lib/workouts/insight-evidence.ts | 34 +-- src/lib/workouts/route-climb.ts | 37 +++ 58 files changed, 2233 insertions(+), 163 deletions(-) create mode 100644 prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql create mode 100644 prisma/migrations/0432_store_workout_routes/migration.sql create mode 100644 src/app/api/auth/me/workout-routes/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/workout-routes/route.ts create mode 100644 src/components/settings/workout-routes-card.tsx create mode 100644 src/lib/apple-health/__tests__/route-import.test.ts create mode 100644 src/lib/apple-health/route-import.ts create mode 100644 src/lib/workouts/__tests__/route-climb.test.ts create mode 100644 src/lib/workouts/route-climb.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..b390c93b3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4315,6 +4315,12 @@ paths: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN - in: query name: from schema: @@ -9455,6 +9461,52 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/me/workout-routes: + get: + tags: + - Auth + summary: Read the workout-route opt-in + description: Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and + the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with + the route stored. No AI path ever reads a stored track. + responses: + "200": + description: The current flag. + content: + application/json: + schema: + $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Auth + summary: Set the workout-route opt-in + description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off + stops new tracks; tracks already stored stay until their workout is deleted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WorkoutRoutesPatchRequest" + responses: + "200": + description: The resolved next flag. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/me/use-central-codex: get: tags: @@ -25948,7 +26000,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 101 type: array items: type: string @@ -26008,7 +26060,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 101 type: array items: type: string @@ -26843,6 +26895,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN value: type: number measuredAt: @@ -28524,6 +28582,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN biomarkerId: type: string minLength: 1 @@ -29023,7 +29087,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 101 type: array items: type: string @@ -29358,6 +29422,14 @@ components: - labsLocalOcrEnabled description: Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a paper report because the image is transcribed on the device and only the TEXT is sent. + WorkoutRoutesPatchRequest: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + description: Keep or stop keeping the GPS track of new workouts. Off by default. UseCentralCodexPatchRequest: type: object properties: @@ -30186,6 +30258,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -38851,6 +38929,8 @@ components: type: boolean labsLocalOcrEnabled: type: boolean + storeWorkoutRoutes: + type: boolean onboardingCompletedAt: anyOf: - type: string @@ -38961,6 +39041,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN value: type: number valueMin: @@ -39701,6 +39787,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN - type: "null" biomarkerName: anyOf: @@ -42176,6 +42268,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -43354,6 +43452,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN - type: "null" intervalDays: anyOf: @@ -43566,6 +43670,12 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN metricSlot: type: - string @@ -55934,6 +56044,48 @@ components: - data - error additionalProperties: false + GetWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WorkoutRoutesResponse: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + additionalProperties: false + PatchWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetUseCentralCodexEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index ba1ca12ef..6b34c7498 100644 --- a/messages/de.json +++ b/messages/de.json @@ -911,6 +911,12 @@ "unitRating": "Stufe", "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert", + "typeExerciseMinutes": "Trainingsminuten", + "typeStandMinutes": "Stehminuten", + "typeStandHours": "Stehstunden", + "typeMindfulMinutes": "Achtsamkeitsminuten", + "typeStateOfMindValence": "Gemütszustand", + "typeAfibBurden": "VHF-Last", "durationReadAs": "Gelesen als {duration}", "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationHoursOnly": "{hours} Std.", @@ -3565,6 +3571,7 @@ "falls": "Stürze", "years": "Jahre", "breathingEvents": "Ereignisse", + "standHours": "Stunden", "respiratoryRate": "Atemzüge/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte", "modules": "Einträge der Module: {list}" }, + "workoutRoutes": { + "title": "Trainingsrouten", + "description": "Den GPS-Verlauf neuer Trainings aus der iPhone-App und dem Apple-Health-Export speichern.", + "detail": "Standardmäßig aus. Eine Route zeigt, wo du wohnst, arbeitest und trainierst, deshalb wird sie erst gespeichert, wenn du das einschaltest. Die Trainings selbst werden immer gespeichert, und keine KI-Funktion liest jemals eine Route. Ausschalten stoppt neue Routen; schon gespeicherte bleiben, bis du das Training löschst.", + "loadError": "Die Routen-Einstellung konnte nicht geladen werden." + }, "retention": { "title": "Aufbewahrung", "description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.", diff --git a/messages/en.json b/messages/en.json index 3a6dfe846..60fe5c6be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -911,6 +911,12 @@ "unitRating": "rating", "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score", + "typeExerciseMinutes": "Exercise minutes", + "typeStandMinutes": "Standing minutes", + "typeStandHours": "Stand hours", + "typeMindfulMinutes": "Mindful minutes", + "typeStateOfMindValence": "State of mind", + "typeAfibBurden": "AFib burden", "durationReadAs": "Read as {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "falls", "years": "years", "breathingEvents": "events", + "standHours": "hours", "respiratoryRate": "breaths/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Your profile, medical history and health-record details", "modules": "Records of each module: {list}" }, + "workoutRoutes": { + "title": "Workout routes", + "description": "Keep the GPS track of new workouts from the iPhone app and the Apple Health export.", + "detail": "Off by default. A route shows where you live, work and train, so it is only kept once you turn this on. Workouts are kept either way, and no AI feature ever reads a route. Turning it off stops new routes; routes already kept stay until you delete the workout.", + "loadError": "The route setting could not be loaded." + }, "retention": { "title": "Retention", "description": "How long time-bound records are kept before automatic cleanup.", diff --git a/messages/es.json b/messages/es.json index 0196a1f7b..fcfada1b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -911,6 +911,12 @@ "unitRating": "nivel", "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI", + "typeExerciseMinutes": "Minutos de ejercicio", + "typeStandMinutes": "Minutos de pie", + "typeStandHours": "Horas de pie", + "typeMindfulMinutes": "Minutos de atención plena", + "typeStateOfMindValence": "Estado de ánimo", + "typeAfibBurden": "Carga de FA", "durationReadAs": "Se lee como {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "caídas", "years": "años", "breathingEvents": "eventos", + "standHours": "horas", "respiratoryRate": "respiraciones/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud", "modules": "Registros de cada módulo: {list}" }, + "workoutRoutes": { + "title": "Rutas de entrenamiento", + "description": "Guardar el recorrido GPS de los entrenamientos nuevos de la app de iPhone y de la exportación de Apple Salud.", + "detail": "Desactivado por defecto. Una ruta muestra dónde vives, trabajas y entrenas, así que solo se guarda cuando lo activas. Los entrenamientos se guardan siempre y ninguna función de IA lee nunca una ruta. Al desactivarlo dejan de guardarse rutas nuevas; las ya guardadas se quedan hasta que borres el entrenamiento.", + "loadError": "No se pudo cargar el ajuste de rutas." + }, "retention": { "title": "Retención", "description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.", diff --git a/messages/fr.json b/messages/fr.json index d7109278c..843d5ebf6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -911,6 +911,12 @@ "unitRating": "niveau", "typeWho5Score": "Score WHO-5", "typeSciScore": "Score SCI", + "typeExerciseMinutes": "Minutes d’exercice", + "typeStandMinutes": "Minutes debout", + "typeStandHours": "Heures debout", + "typeMindfulMinutes": "Minutes de pleine conscience", + "typeStateOfMindValence": "État d’esprit", + "typeAfibBurden": "Charge de FA", "durationReadAs": "Compris comme {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "chutes", "years": "ans", "breathingEvents": "événements", + "standHours": "heures", "respiratoryRate": "respirations/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé", "modules": "Données de chaque module : {list}" }, + "workoutRoutes": { + "title": "Parcours d’entraînement", + "description": "Conserver le tracé GPS des nouveaux entraînements de l’app iPhone et de l’export Apple Santé.", + "detail": "Désactivé par défaut. Un parcours montre où vous habitez, travaillez et vous entraînez : il n’est donc conservé qu’une fois l’option activée. Les entraînements sont conservés dans tous les cas, et aucune fonction d’IA ne lit jamais un parcours. Désactiver l’option arrête les nouveaux parcours ; ceux déjà conservés restent jusqu’à la suppression de l’entraînement.", + "loadError": "Impossible de charger le réglage des parcours." + }, "retention": { "title": "Conservation", "description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.", diff --git a/messages/it.json b/messages/it.json index 91db78f4b..15d737a1c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -911,6 +911,12 @@ "unitRating": "livello", "typeWho5Score": "Punteggio WHO-5", "typeSciScore": "Punteggio SCI", + "typeExerciseMinutes": "Minuti di esercizio", + "typeStandMinutes": "Minuti in piedi", + "typeStandHours": "Ore in piedi", + "typeMindfulMinutes": "Minuti di consapevolezza", + "typeStateOfMindValence": "Stato d’animo", + "typeAfibBurden": "Carico di FA", "durationReadAs": "Letto come {duration}", "durationHoursMinutes": "{hours} h {minutes} min", "durationHoursOnly": "{hours} h", @@ -3565,6 +3571,7 @@ "falls": "cadute", "years": "anni", "breathingEvents": "eventi", + "standHours": "ore", "respiratoryRate": "respiri/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria", "modules": "Dati di ogni modulo: {list}" }, + "workoutRoutes": { + "title": "Percorsi degli allenamenti", + "description": "Conserva il tracciato GPS dei nuovi allenamenti dall’app per iPhone e dall’esportazione di Apple Salute.", + "detail": "Disattivato di default. Un percorso mostra dove vivi, lavori e ti alleni, quindi viene conservato solo dopo averlo attivato. Gli allenamenti vengono conservati comunque e nessuna funzione IA legge mai un percorso. Disattivandolo si fermano i nuovi percorsi; quelli già conservati restano finché non elimini l’allenamento.", + "loadError": "Impossibile caricare l’impostazione dei percorsi." + }, "retention": { "title": "Conservazione", "description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.", diff --git a/messages/ko.json b/messages/ko.json index 0708547d5..f23096369 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -911,6 +911,12 @@ "unitRating": "단계", "typeWho5Score": "WHO-5 점수", "typeSciScore": "SCI 점수", + "typeExerciseMinutes": "운동 시간(분)", + "typeStandMinutes": "서 있기 시간(분)", + "typeStandHours": "일어서기 시간", + "typeMindfulMinutes": "마음 챙기기 시간(분)", + "typeStateOfMindValence": "마음 상태", + "typeAfibBurden": "심방세동 부담", "durationReadAs": "{duration}(으)로 읽었어요", "durationHoursMinutes": "{hours}시간 {minutes}분", "durationHoursOnly": "{hours}시간", @@ -3565,6 +3571,7 @@ "falls": "회", "years": "세", "breathingEvents": "회", + "standHours": "시간", "respiratoryRate": "회/분" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "프로필, 병력, 건강 기록 정보", "modules": "모듈별 기록: {list}" }, + "workoutRoutes": { + "title": "운동 경로", + "description": "iPhone 앱과 Apple 건강 내보내기에서 새 운동의 GPS 경로를 저장합니다.", + "detail": "기본값은 꺼짐입니다. 경로는 사는 곳, 일하는 곳, 운동하는 곳을 보여 주므로 이 설정을 켠 뒤에만 저장됩니다. 운동 기록은 항상 저장되며, 어떤 AI 기능도 경로를 읽지 않습니다. 끄면 새 경로 저장이 멈추고, 이미 저장된 경로는 운동을 삭제할 때까지 남습니다.", + "loadError": "경로 설정을 불러오지 못했습니다." + }, "retention": { "title": "보관 기간", "description": "자동 정리 전까지 기간이 정해진 기록을 얼마나 보관하는지예요.", diff --git a/messages/nl.json b/messages/nl.json index c5e9d9a13..3f6fabe35 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -911,6 +911,12 @@ "unitRating": "beoordeling", "typeWho5Score": "WHO-5-score", "typeSciScore": "SCI-score", + "typeExerciseMinutes": "Trainingsminuten", + "typeStandMinutes": "Staminuten", + "typeStandHours": "Stauren", + "typeMindfulMinutes": "Mindfulnessminuten", + "typeStateOfMindValence": "Gemoedstoestand", + "typeAfibBurden": "AF-belasting", "durationReadAs": "Gelezen als {duration}", "durationHoursMinutes": "{hours} u {minutes} min", "durationHoursOnly": "{hours} u", @@ -3565,6 +3571,7 @@ "falls": "valpartijen", "years": "jaar", "breathingEvents": "gebeurtenissen", + "standHours": "uur", "respiratoryRate": "ademhalingen/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Je profiel, medische voorgeschiedenis en dossiergegevens", "modules": "Gegevens van elke module: {list}" }, + "workoutRoutes": { + "title": "Trainingsroutes", + "description": "Het GPS-spoor van nieuwe trainingen uit de iPhone-app en de Apple Gezondheid-export bewaren.", + "detail": "Standaard uit. Een route laat zien waar je woont, werkt en traint, dus hij wordt pas bewaard als je dit aanzet. Trainingen worden altijd bewaard en geen enkele AI-functie leest ooit een route. Uitzetten stopt nieuwe routes; al bewaarde routes blijven tot je de training verwijdert.", + "loadError": "De route-instelling kon niet worden geladen." + }, "retention": { "title": "Bewaartermijn", "description": "Hoe lang tijdgebonden gegevens worden bewaard voordat ze automatisch worden opgeschoond.", diff --git a/messages/pl.json b/messages/pl.json index 6d55a5d6d..66982c68e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -911,6 +911,12 @@ "unitRating": "poziom", "typeWho5Score": "Wynik WHO-5", "typeSciScore": "Wynik SCI", + "typeExerciseMinutes": "Minuty ćwiczeń", + "typeStandMinutes": "Minuty stania", + "typeStandHours": "Godziny stania", + "typeMindfulMinutes": "Minuty uważności", + "typeStateOfMindValence": "Stan umysłu", + "typeAfibBurden": "Obciążenie AF", "durationReadAs": "Odczytano jako {duration}", "durationHoursMinutes": "{hours} godz. {minutes} min", "durationHoursOnly": "{hours} godz.", @@ -3565,6 +3571,7 @@ "falls": "upadki", "years": "lata", "breathingEvents": "zdarzenia", + "standHours": "godz.", "respiratoryRate": "oddechów/min" }, "sleep": { @@ -7155,6 +7162,12 @@ "profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej", "modules": "Wpisy poszczególnych modułów: {list}" }, + "workoutRoutes": { + "title": "Trasy treningów", + "description": "Zapisuj ślad GPS nowych treningów z aplikacji na iPhone’a i z eksportu Apple Zdrowie.", + "detail": "Domyślnie wyłączone. Trasa pokazuje, gdzie mieszkasz, pracujesz i trenujesz, więc zapisujemy ją dopiero po włączeniu. Treningi są zapisywane zawsze, a żadna funkcja AI nigdy nie odczytuje trasy. Wyłączenie zatrzymuje nowe trasy; już zapisane zostają do usunięcia treningu.", + "loadError": "Nie udało się wczytać ustawienia tras." + }, "retention": { "title": "Retencja", "description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.", diff --git a/prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql b/prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql new file mode 100644 index 000000000..d48e5661c --- /dev/null +++ b/prisma/migrations/0431_apple_health_activity_mind_afib/migration.sql @@ -0,0 +1,21 @@ +-- Extend `measurement_type` with six Apple Health signals that sat in the +-- deferred list: the exercise and stand rings, mindful minutes, the State of +-- Mind valence, and AFib burden. +-- +-- EXERCISE_MINUTES <- HKQuantityTypeIdentifierAppleExerciseTime +-- STAND_MINUTES <- HKQuantityTypeIdentifierAppleStandTime +-- STAND_HOURS <- HKCategoryTypeIdentifierAppleStandHour +-- MINDFUL_MINUTES <- HKCategoryTypeIdentifierMindfulSession +-- STATE_OF_MIND_VALENCE <- HKDataTypeIdentifierStateOfMind (valence) +-- AFIB_BURDEN <- HKQuantityTypeIdentifierAtrialFibrillationBurden +-- +-- Purely additive: no existing row carries any of these values. IF NOT +-- EXISTS keeps a replay on a partially applied database harmless. Enum +-- values cannot be dropped in PostgreSQL, so there is no down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'EXERCISE_MINUTES'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_MINUTES'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_HOURS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MINDFUL_MINUTES'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STATE_OF_MIND_VALENCE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AFIB_BURDEN'; diff --git a/prisma/migrations/0432_store_workout_routes/migration.sql b/prisma/migrations/0432_store_workout_routes/migration.sql new file mode 100644 index 000000000..4ce5ea98f --- /dev/null +++ b/prisma/migrations/0432_store_workout_routes/migration.sql @@ -0,0 +1,6 @@ +-- A per-account opt-in for keeping workout GPS tracks. Off by default: a +-- route shows where a person lives, works and trains, so new tracks are +-- stored only once the person turns this on. Tracks already stored stay +-- where they are; the setting decides what new syncs and imports keep. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "store_workout_routes" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61e9e8270..82129aac4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -374,6 +374,12 @@ model User { // review screen remains the safety backstop. Default `false`; vision-capable // users keep the higher-accuracy native path and never pay the WASM download. labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled") + /// v1.44 — keep the GPS track of a workout. OFF by default: a route is + /// where a person lives, works and runs, so it is stored only after they + /// opt in. Read by the workout batch route and the export.zip import, which + /// drop the track while it is off (the workout itself is always kept). + /// Never read by any AI path; the AI egress guard freezes the route readers. + storeWorkoutRoutes Boolean @default(false) @map("store_workout_routes") // v1.7.0 unit pref — global metric/imperial display preference. // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". @@ -1703,6 +1709,16 @@ enum MeasurementType { // Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both. WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment. SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment. + // ── v1.44 — Apple Health activity rings, mindfulness, mood and AFib burden ── + // All five arrive over POST /api/measurements/batch and the export.zip import. + // The minute and hour types are cumulative (daily SUM); AFib burden and the + // State of Mind valence are spot readings. + EXERCISE_MINUTES // minutes — HealthKit `appleExerciseTime`. The activity ring's exercise minutes; cumulative, daily SUM. + STAND_MINUTES // minutes — HealthKit `appleStandTime`. Minutes spent standing; cumulative, daily SUM. + STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours. + MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM. + STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement. + AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis. @@map("measurement_type") } diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index ed12342c5..140c21f96 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -288,3 +288,85 @@ describe("the status chokepoint's capability argument", () => { expect(withCapability.capability).toBe("statusText"); }); }); + +/** + * Workout GPS tracks never reach a model. + * + * A route shows where a person lives, works and trains, and it is stored only + * after an opt-in. So the set of files that can open or write a track is + * frozen here, with the reason each needs it, and none of them may be a + * provider call site or sit in an AI module. The workout insight used to open + * the track to derive metres climbed; that figure is now filled into + * `Workout.elevationM` at write time (`route-climb.ts`), and the insight reads + * the column. A new reader fails the first check until someone decides, here, + * that it is not an AI path. + * + * Limit: the matcher looks for the cipher helpers, the sealed column and a + * `geometry: true` select. A read under some other spelling (a raw SQL + * `SELECT geometry_encrypted`, a backup dumping the model generically) would + * slip it. The generic backup dump and restore do carry the sealed column; + * they are not AI paths and hand it to no model. + */ +describe("workout route geometry never reaches an AI path", () => { + const ROUTE_READER = + /\breadRouteGeometry\s*\(|\bencryptRouteGeometry\s*\(|\bgeometryEncrypted\b|\bgeometry\s*:\s*true\b/; + + const ROUTE_READERS: Record = { + "app/api/workouts/[id]/route.ts": + "The workout detail read: serves the track to the person's own map.", + "app/api/workouts/batch/route.ts": + "Writes a kept track (opt-in) and derives its climb before storage.", + "lib/apple-health/route-import.ts": + "Writes a kept track from the Apple Health export (opt-in).", + "lib/crypto/encrypted-columns.ts": + "The encrypted-column registry the key rotation walks.", + "lib/jobs/free-text-encryption-backfill.ts": + "Seals legacy readable tracks in place.", + "lib/workouts/route-geometry-cipher.ts": "The cipher itself.", + }; + + /** Directories whose code feeds a model. */ + const AI_AREAS = [ + "lib/ai/", + "lib/insights/", + "lib/mcp/", + "app/mcp/", + "app/api/insights/", + "app/api/coach/", + ]; + + const found = sourceFiles().filter((rel) => ROUTE_READER.test(code(rel))); + + it("finds route readers at all (an empty matcher is a failure, not a pass)", () => { + expect(found.length).toBeGreaterThanOrEqual(4); + }); + + it("every file that opens or writes a track is named, with a reason", () => { + expect(found.filter((rel) => !(rel in ROUTE_READERS))).toEqual([]); + }); + + it("the list carries no file that no longer touches a track", () => { + expect( + Object.keys(ROUTE_READERS).filter((rel) => !found.includes(rel)), + ).toEqual([]); + }); + + it("no route reader calls a provider or lives in an AI module", () => { + for (const rel of Object.keys(ROUTE_READERS)) { + expect(rel in EGRESS_SITES, `${rel} is an egress site`).toBe(false); + expect(EGRESS_CALL.test(code(rel)), `${rel} calls a provider`).toBe( + false, + ); + expect( + AI_AREAS.some((area) => rel.startsWith(area)), + `${rel} sits in an AI module`, + ).toBe(false); + } + }); + + it("the workout insight job does not read the track", () => { + const job = code("lib/jobs/workout-insight-generate.ts"); + expect(job).not.toMatch(/\broute\s*:/); + expect(job).not.toMatch(ROUTE_READER); + }); +}); diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 36a3a0765..1c06583ae 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,8 +74,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(95); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); + expect(ALL_LEAF_IDS.length).toBe(101); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(83); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18); }); diff --git a/src/app/api/auth/me/workout-routes/__tests__/route.test.ts b/src/app/api/auth/me/workout-routes/__tests__/route.test.ts new file mode 100644 index 000000000..dad36584d --- /dev/null +++ b/src/app/api/auth/me/workout-routes/__tests__/route.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The workout-route opt-in: off by default, a hard-set PATCH, audit-logged, + * and a malformed body refused before anything is written. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/workout-routes", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +describe("/api/auth/me/workout-routes", () => { + it("reads off when the row carries no value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const res = await GET( + new Request("http://localhost/api/auth/me/workout-routes") as never, + { params: Promise.resolve({}) } as never, + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + data: { storeWorkoutRoutes: false }, + }); + }); + + it("sets the flag field by field and audits the change", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + storeWorkoutRoutes: false, + } as never); + const res = await PATCH( + mkPatch({ storeWorkoutRoutes: true }) as never, + { + params: Promise.resolve({}), + } as never, + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + data: { storeWorkoutRoutes: true }, + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { storeWorkoutRoutes: true }, + }); + expect(auditLog).toHaveBeenCalledWith( + "user.storeWorkoutRoutes.update", + expect.objectContaining({ + details: { previous: false, next: true }, + }), + ); + }); + + it("refuses a malformed body with 422 and writes nothing", async () => { + const res = await PATCH( + mkPatch({ storeWorkoutRoutes: "yes" }) as never, + { + params: Promise.resolve({}), + } as never, + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses an unauthenticated caller", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await PATCH( + mkPatch({ storeWorkoutRoutes: true }) as never, + { + params: Promise.resolve({}), + } as never, + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/workout-routes/route.ts b/src/app/api/auth/me/workout-routes/route.ts new file mode 100644 index 000000000..aea7aba69 --- /dev/null +++ b/src/app/api/auth/me/workout-routes/route.ts @@ -0,0 +1,109 @@ +/** + * Per-account opt-in for keeping workout GPS tracks. + * + * GET /api/auth/me/workout-routes — current flag. + * PATCH /api/auth/me/workout-routes — body `{ storeWorkoutRoutes: boolean }`. + * + * Off by default. While it is off, `POST /api/workouts/batch` and the + * Apple Health export import keep every workout but drop its track, so a + * route (where a person lives, works and trains) is stored only after the + * person asked for it. Turning it off later stops new tracks; tracks already + * stored stay until the workout or the account is deleted. + * + * Same per-user-scalar pattern as `labs-local-ocr`: 60/min rate limit, a + * bounded body, Zod safeParse → 422, field-by-field write, audit row, and + * the resolved next state in the response. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { workoutRoutesPatchSchema } from "@/lib/validations/user-prefs"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type WorkoutRoutesResponse = { + storeWorkoutRoutes: boolean; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.workoutRoutes.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { storeWorkoutRoutes: true }, + }); + const payload: WorkoutRoutesResponse = { + storeWorkoutRoutes: row?.storeWorkoutRoutes ?? false, + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `workout-routes:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = workoutRoutesPatchSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = parsed.data.storeWorkoutRoutes; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { storeWorkoutRoutes: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { storeWorkoutRoutes: next }, + }); + + await auditLog("user.storeWorkoutRoutes.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous?.storeWorkoutRoutes ?? false, + next, + }, + }); + + annotate({ + action: { name: "auth.me.workoutRoutes.patch" }, + meta: { storeWorkoutRoutes: next }, + }); + + const payload: WorkoutRoutesResponse = { storeWorkoutRoutes: next }; + return apiSuccess(payload); +}); diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 0a434f9a9..ecdc3b436 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -203,8 +203,11 @@ beforeEach(() => { // v1.4.43 — default to no per-user source-priority override so // the write-time picker walks the canonical default ladder. Tests // that exercise a custom ladder override this in-line. + // The route opt-in is on here so the route cases below exercise the + // write; the off case sets it explicitly. vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, + storeWorkoutRoutes: true, } as never); }); @@ -507,6 +510,55 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => { }); }); +describe("POST /api/workouts/batch — workout-route opt-in", () => { + const withRoute = () => + validWorkout("uuid-with-route", { + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + }); + + it("keeps the workout and drops the track while the opt-in is off", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + storeWorkoutRoutes: false, + } as never); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + mockInsertReturning([ + { id: "wkt-off", source: "APPLE_HEALTH", externalId: "uuid-with-route" }, + ]); + + const res = await POST(makeRequest({ workouts: [withRoute()] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + // Same per-entry status as a stored track: the client needs no branch. + expect(body.data.entries[0]?.status).toBe("inserted"); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); + + it("drops the track when the account row carries no opt-in at all", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + mockInsertReturning([ + { id: "wkt-none", source: "APPLE_HEALTH", externalId: "uuid-with-route" }, + ]); + + const res = await POST(makeRequest({ workouts: [withRoute()] })); + expect(res.status).toBe(200); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/workouts/batch — nested route attachment", () => { it("creates a WorkoutRoute row for a workout that ships a route", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); @@ -700,7 +752,7 @@ describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => expect(sourcePriorityReads).toHaveLength(1); expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { id: "user-1" }, - select: { sourcePriorityJson: true }, + select: { sourcePriorityJson: true, storeWorkoutRoutes: true }, }); }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 37ca6dfaf..90c88cb54 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -66,6 +66,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler"; +import { routeClimbM } from "@/lib/workouts/route-climb"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -259,10 +260,15 @@ async function postBatch(request: NextRequest): Promise { // time before the read-time picker ever ran. One indexed // findUnique per batch — well under the cost of letting the // duplicate persist and carrying it forward on every read. + // The same read carries the route opt-in: a GPS track is kept only when + // the person turned `storeWorkoutRoutes` on. While it is off the workout + // is stored and the track dropped, with the same per-entry status, so a + // client cannot tell the two apart and needs no new branch. const userPriority = await prisma.user.findUnique({ where: { id: user.id }, - select: { sourcePriorityJson: true }, + select: { sourcePriorityJson: true, storeWorkoutRoutes: true }, }); + const keepRoutes = userPriority?.storeWorkoutRoutes === true; // Content-Length pre-flight. The body cap exists to bound the // request-pipeline cost BEFORE Node has to read the full payload — @@ -440,12 +446,13 @@ async function postBatch(request: NextRequest): Promise { 0, Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000), ); - const route = w.route - ? { - geometry: w.route.geometry as unknown, - sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown, - } - : null; + const route = + w.route && keepRoutes + ? { + geometry: w.route.geometry as unknown, + sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown, + } + : null; const samples = w.samples ? { samples: w.samples as unknown, @@ -464,7 +471,9 @@ async function postBatch(request: NextRequest): Promise { maxHeartRate: w.maxHeartRate ?? null, minHeartRate: w.minHeartRate ?? null, stepCount: w.stepCount ?? null, - elevationM: w.elevationM ?? null, + // A kept track fills the climb when the source sent none, so the figure + // lives on the workout row and no reader has to open the track for it. + elevationM: w.elevationM ?? (route ? routeClimbM(route.geometry) : null), pauseDurationSec: w.pauseDurationSec ?? null, // `EXTERNAL` under a scoped credential, and nothing else: the guard // above refused any body that named a source, so nothing asserted is @@ -908,6 +917,10 @@ async function postBatch(request: NextRequest): Promise { enriched: enrichedCount, updated: updatedCount, skipped: skipped.length, + // Tracks the body carried but the opt-in told us not to keep. + routes_dropped: keepRoutes + ? 0 + : workouts.filter((w) => w.route != null).length, }, }); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index e190c189a..5be62c111 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -132,6 +132,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { PAIN_NRS: "measurements.typePainNrs", WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", WAIST_TO_HEIGHT: "measurements.typeWaistToHeight", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + STAND_HOURS: "measurements.typeStandHours", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", + AFIB_BURDEN: "measurements.typeAfibBurden", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -267,6 +274,14 @@ export const MEASUREMENT_TYPE_ICONS: Record = { PAIN_NRS: Activity, WAIST_CIRCUMFERENCE: Scale, WAIST_TO_HEIGHT: Scale, + // ── v1.44 — the rings take the activity glyphs, mindful minutes the + // breath, the valence the score dial, AFib burden the rhythm trace. + EXERCISE_MINUTES: Dumbbell, + STAND_MINUTES: PersonStanding, + STAND_HOURS: PersonStanding, + MINDFUL_MINUTES: Wind, + STATE_OF_MIND_VALENCE: Gauge, + AFIB_BURDEN: HeartPulse, }; // The colour map lives in its own icon-free module so a surface that only diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index 5f3386b6c..76a7d6803 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -129,4 +129,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + // ── v1.44 — the rings and mindful minutes join the activity family + // (chart-2), the valence the score family (chart-1), AFib burden the + // cardio family (chart-3). + EXERCISE_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAND_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAND_HOURS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", }; diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 394ee3b19..b3c162515 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -194,8 +194,8 @@ describe(" — the first run", () => { expect(groupChip(html, "medications")).toBe("0/4"); // ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS. expect(groupChip(html, "history")).toBe("0/5"); - expect(groupChip(html, "cardio")).toBe("0/14"); - expect(groupChip(html, "activity")).toBe("0/8"); + expect(groupChip(html, "cardio")).toBe("0/15"); + expect(groupChip(html, "activity")).toBe("0/11"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); expect(groupChip(html, "mobility")).toBe("0/11"); expect(groupChip(html, "environment")).toBe("0/4"); diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 193017392..393543b91 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -32,6 +32,7 @@ import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; import { SecurityActivityCard } from "@/components/settings/security-activity-card"; import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; +import { WorkoutRoutesCard } from "@/components/settings/workout-routes-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; @@ -130,6 +131,9 @@ export function PrivacySection() { + {/* Workout GPS tracks: kept only on opt-in. */} + + {/* Retention */} ({ + queryKey: queryKeys.workoutRoutesPref(), + queryFn: () => apiGet("/api/auth/me/workout-routes"), + staleTime: 60_000, + }); + const update = useMutation({ + mutationFn: (storeWorkoutRoutes) => + apiPatch("/api/auth/me/workout-routes", { + storeWorkoutRoutes, + }), + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.workoutRoutesPref(), data); + }, + }); + + return ( + + + update.mutate(checked, { + onSuccess: () => + toastWrittenOutcome("success", t("settings.saved")), + onError: (failure) => + showErrorToast(t("settings.savingError"), { + cause: failure, + }), + }) + } + aria-label={t("settings.privacy.workoutRoutes.title")} + /> + } + /> + {pref.isError ? ( + void pref.refetch()} + message={t("settings.privacy.workoutRoutes.loadError")} + /> + ) : ( +

{t("settings.privacy.workoutRoutes.detail")}

+ )} +
+ ); +} diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 99a5dc9a2..5ef5738b7 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -112,10 +112,18 @@ const EXPECTED_TYPES = [ // both server-derived from a completed assessment, higher = better) ── "WHO5_SCORE", "SCI_SCORE", + // ── v1.44 — Apple Health activity rings, mindfulness, State of Mind and + // AFib burden ── + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "AFIB_BURDEN", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 75 canonical measurement types", () => { + it("exposes the 83 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); diff --git a/src/lib/apple-health/__tests__/route-import.test.ts b/src/lib/apple-health/__tests__/route-import.test.ts new file mode 100644 index 000000000..e24f33fe6 --- /dev/null +++ b/src/lib/apple-health/__tests__/route-import.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; + +import { strToU8, zipSync } from "fflate"; + +vi.mock("@/lib/workouts/route-geometry-cipher", () => ({ + encryptRouteGeometry: (geometry: unknown) => + Buffer.from(JSON.stringify(geometry)), +})); + +import { + importAppleHealthWorkoutRoutes, + parseGpxTrack, + thinIndices, + trackToRoute, +} from "../route-import"; +import { routeMemberNameFromReference } from "../archive-stream"; +import { MAX_ROUTE_POINTS } from "@/lib/validations/workout"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +function archive(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "healthlog-route-import-")); + dirs.push(dir); + const path = join(dir, "export.zip"); + writeFileSync( + path, + zipSync( + Object.fromEntries( + Object.entries(files).map(([name, body]) => [name, strToU8(body)]), + ), + ), + ); + return path; +} + +function gpx(points: Array<[number, number, number?, string?]>): string { + return ` + +Route +${points + .map( + ([lat, lon, ele, time]) => + `${ele === undefined ? "" : `${ele}`}${time ? `` : ""}`, + ) + .join("\n")} +`; +} + +function fakePrisma( + workout: { + id: string; + elevationM: number | null; + route: { id: string } | null; + } | null, +) { + return { + workout: { + findUnique: vi.fn(async () => workout), + update: vi.fn(async () => ({})), + }, + workoutRoute: { createMany: vi.fn(async () => ({ count: 1 })) }, + }; +} + +describe("routeMemberNameFromReference", () => { + it("maps Apple's reference path onto the archive member", () => { + expect(routeMemberNameFromReference("/workout-routes/route_1.gpx")).toBe( + "apple_health_export/workout-routes/route_1.gpx", + ); + }); + + it("refuses anything that is not a direct route file", () => { + expect( + routeMemberNameFromReference("/workout-routes/../export.xml"), + ).toBeNull(); + expect( + routeMemberNameFromReference("/electrocardiograms/a.csv"), + ).toBeNull(); + expect(routeMemberNameFromReference("/workout-routes/a/b.gpx")).toBeNull(); + }); +}); + +describe("parseGpxTrack + trackToRoute", () => { + it("keeps altitude and time when every point carries them", async () => { + const track = await parseGpxTrack( + Readable.from([ + gpx([ + [50.1, 8.1, 100, "2026-05-14T06:00:00Z"], + [50.2, 8.2, 130, "2026-05-14T06:00:05Z"], + ]), + ]), + ); + const route = trackToRoute(track); + expect(route?.geometry.coordinates).toEqual([ + [8.1, 50.1, 100], + [8.2, 50.2, 130], + ]); + expect(route?.sampleTimestamps).toEqual([ + { t: "2026-05-14T06:00:00.000Z" }, + { t: "2026-05-14T06:00:05.000Z" }, + ]); + }); + + it("drops altitude and times rather than half-filling them", async () => { + const route = trackToRoute( + await parseGpxTrack( + Readable.from([ + gpx([ + [50.1, 8.1, 100, "2026-05-14T06:00:00Z"], + [50.2, 8.2], + ]), + ]), + ), + ); + expect(route?.geometry.coordinates).toEqual([ + [8.1, 50.1], + [8.2, 50.2], + ]); + expect(route?.sampleTimestamps).toBeNull(); + }); + + it("skips out-of-range points and refuses a track shorter than two", async () => { + const track = await parseGpxTrack( + Readable.from([ + gpx([ + [95, 8.1], + [50.2, 8.2], + ]), + ]), + ); + expect(track.lat).toEqual([50.2]); + expect(trackToRoute(track)).toBeNull(); + }); + + it("rejects malformed XML", async () => { + await expect( + parseGpxTrack(Readable.from([" { + const idx = thinIndices(MAX_ROUTE_POINTS * 3 + 7, MAX_ROUTE_POINTS); + expect(idx).toHaveLength(MAX_ROUTE_POINTS); + expect(idx[0]).toBe(0); + expect(idx.at(-1)).toBe(MAX_ROUTE_POINTS * 3 + 6); + expect(thinIndices(5, MAX_ROUTE_POINTS)).toEqual([0, 1, 2, 3, 4]); + }); +}); + +describe("importAppleHealthWorkoutRoutes", () => { + const files = { + "apple_health_export/export.xml": "", + "apple_health_export/workout-routes/route_1.gpx": gpx([ + [50.1, 8.1, 100], + [50.2, 8.2, 140], + [50.3, 8.3, 120], + ]), + "apple_health_export/workout-routes/route_2.gpx": gpx([[50.1, 8.1]]), + }; + + it("attaches a referenced track sealed and fills the climb", async () => { + const prisma = fakePrisma({ id: "w1", elevationM: null, route: null }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-1", path: "/workout-routes/route_1.gpx" }, + ], + prisma: prisma as never, + }); + expect(counts).toEqual({ + discovered: 1, + attached: 1, + skipped: 0, + failed: 0, + }); + const data = ( + prisma.workoutRoute.createMany.mock.calls[0] as unknown as [ + { data: Array<{ workoutId: string; geometryEncrypted: Buffer }> }, + ] + )[0].data[0]; + expect(data.workoutId).toBe("w1"); + expect(JSON.parse(data.geometryEncrypted.toString())).toMatchObject({ + type: "LineString", + }); + expect(prisma.workout.update).toHaveBeenCalledWith({ + where: { id: "w1" }, + data: { elevationM: 40 }, + }); + }); + + it("reads only referenced members, and leaves a stored track alone", async () => { + const prisma = fakePrisma({ id: "w1", elevationM: 12, route: { id: "r" } }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-1", path: "/workout-routes/route_1.gpx" }, + ], + prisma: prisma as never, + }); + expect(counts).toEqual({ + discovered: 1, + attached: 0, + skipped: 1, + failed: 0, + }); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); + + it("counts a too-short track as failed", async () => { + const prisma = fakePrisma({ id: "w2", elevationM: null, route: null }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-2", path: "/workout-routes/route_2.gpx" }, + ], + prisma: prisma as never, + }); + expect(counts.failed).toBe(1); + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + }); + + it("fails soft on a member over the byte cap", async () => { + const prisma = fakePrisma({ id: "w1", elevationM: null, route: null }); + const counts = await importAppleHealthWorkoutRoutes({ + archivePath: archive(files), + userId: "u1", + references: [ + { externalId: "ext-1", path: "/workout-routes/route_1.gpx" }, + ], + prisma: prisma as never, + limits: { + maxMembers: 100, + maxRouteMembers: 10, + maxMemberBytes: 64, + maxTotalRouteBytes: 1024, + maxCompressionRatio: 200, + }, + }); + expect(counts.attached).toBe(0); + expect(counts.failed).toBe(1); + }); +}); diff --git a/src/lib/apple-health/archive-stream.ts b/src/lib/apple-health/archive-stream.ts index b5718a78a..7af968af6 100644 --- a/src/lib/apple-health/archive-stream.ts +++ b/src/lib/apple-health/archive-stream.ts @@ -1,5 +1,6 @@ /** - * Bounded ZIP reader for Apple Health ECG CSV members. + * Bounded ZIP reader for Apple Health's auxiliary members: the ECG CSVs and + * the workout-route GPX files. * * ZIP entry names are treated as untrusted identifiers only: no archive member * is ever materialised on disk. Central-directory metadata is checked before @@ -37,6 +38,18 @@ const CENTRAL_SIGNATURE = 0x02014b50; const LOCAL_SIGNATURE = 0x04034b50; const MAX_EOCD_SEARCH = 65_535 + 22; const ECG_PREFIX = "apple_health_export/electrocardiograms/"; +const ROUTE_PREFIX = "apple_health_export/workout-routes/"; + +/** + * One family of auxiliary members: which names belong to it, the label its + * errors carry, and its own count and total-byte ceilings. + */ +interface MemberKind { + label: string; + matches: (name: string) => boolean; + maxMembers: number; + maxTotalBytes: number; +} function archiveError(message: string): Error { return new Error(`Unsafe Apple Health archive: ${message}`); @@ -95,19 +108,41 @@ function isUnsafeName(name: string): boolean { return segments.some((segment) => segment === "." || segment === ".."); } -function isEcgCsv(name: string): boolean { +function isDirectChild(name: string, prefix: string, extension: string) { return ( - name.startsWith(ECG_PREFIX) && - name.length > ECG_PREFIX.length && - name.toLowerCase().endsWith(".csv") && - !name.slice(ECG_PREFIX.length).includes("/") + name.startsWith(prefix) && + name.length > prefix.length && + name.toLowerCase().endsWith(extension) && + !name.slice(prefix.length).includes("/") ); } +function isEcgCsv(name: string): boolean { + return isDirectChild(name, ECG_PREFIX, ".csv"); +} + +/** A workout route as the archive names it: `workout-routes/.gpx`. */ +export function isWorkoutRouteGpx(name: string): boolean { + return isDirectChild(name, ROUTE_PREFIX, ".gpx"); +} + +/** + * The archive member a `` inside a `` + * points at. Apple writes the path from the export root + * (`/workout-routes/route_….gpx`); the member carries the export folder in + * front. Null for anything that is not a direct route file. + */ +export function routeMemberNameFromReference(path: string): string | null { + const trimmed = path.startsWith("/") ? path.slice(1) : path; + const name = `apple_health_export/${trimmed}`; + return isWorkoutRouteGpx(name) ? name : null; +} + async function readCentralDirectory( handle: FileHandle, fileSize: number, limits: AppleHealthArchiveLimits, + kind: MemberKind, ): Promise { if (fileSize < 22) throw archiveError("missing ZIP directory"); @@ -148,7 +183,7 @@ async function readCentralDirectory( const entries: ZipEntry[] = []; const names = new Set(); let cursor = centralOffset; - let ecgCount = 0; + let kindCount = 0; for (let index = 0; index < totalEntries; index += 1) { const header = await readExactly(handle, 46, cursor); @@ -177,23 +212,23 @@ async function readCentralDirectory( if (names.has(name)) throw archiveError("duplicate member name"); names.add(name); - if (isEcgCsv(name)) { - ecgCount += 1; - if (ecgCount > limits.maxEcgMembers) { - throw archiveError("ECG member count limit exceeded"); + if (kind.matches(name)) { + kindCount += 1; + if (kindCount > kind.maxMembers) { + throw archiveError(`${kind.label} member count limit exceeded`); } if ((flags & 0x1) !== 0) { - throw archiveError("encrypted ECG member is unsupported"); + throw archiveError(`encrypted ${kind.label} member is unsupported`); } if (method !== 0 && method !== 8) { - throw archiveError("unsupported ECG compression method"); + throw archiveError(`unsupported ${kind.label} compression method`); } if (uncompressedSize > limits.maxMemberBytes) { - throw archiveError("ECG member byte limit exceeded"); + throw archiveError(`${kind.label} member byte limit exceeded`); } const ratio = uncompressedSize / Math.max(1, compressedSize); if (ratio > limits.maxCompressionRatio) { - throw archiveError("ECG compression ratio limit exceeded"); + throw archiveError(`${kind.label} compression ratio limit exceeded`); } } @@ -218,6 +253,7 @@ async function decompressEntry( entry: ZipEntry, fileSize: number, limits: AppleHealthArchiveLimits, + kind: MemberKind, total: { bytes: number }, ): Promise { const local = await readExactly(handle, 30, entry.localHeaderOffset); @@ -260,16 +296,16 @@ async function decompressEntry( const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); memberBytes += chunk.byteLength; if (memberBytes > limits.maxMemberBytes) { - throw archiveError("actual ECG member byte limit exceeded"); + throw archiveError(`actual ${kind.label} member byte limit exceeded`); } - if (total.bytes + memberBytes > limits.maxTotalEcgBytes) { - throw archiveError("total ECG byte limit exceeded"); + if (total.bytes + memberBytes > kind.maxTotalBytes) { + throw archiveError(`total ${kind.label} byte limit exceeded`); } chunks.push(chunk); } } catch { for (const chunk of chunks) chunk.fill(0); - throw archiveError("ECG decompression or byte limit failure"); + throw archiveError(`${kind.label} decompression or byte limit failure`); } finally { compressed.fill(0); } @@ -279,31 +315,35 @@ async function decompressEntry( } /** - * Yield only Apple Health's ECG CSV members as in-memory bounded streams. - * The generator validates the complete central directory before yielding any - * health data, preventing partial processing of a structurally unsafe archive. + * Yield the members of one kind as in-memory bounded streams, one at a time. + * The complete central directory is validated before any health data is + * yielded, so a structurally unsafe archive is never partly processed. + * `wanted` narrows further (a route the export's XML actually references). */ -export async function* streamAppleHealthEcgMembers(input: { - archivePath: string; - limits: AppleHealthArchiveLimits; -}): AsyncGenerator { - assertPositiveLimits(input.limits); - const handle = await open(input.archivePath, "r"); +async function* streamMembers( + archivePath: string, + limits: AppleHealthArchiveLimits, + kind: MemberKind, + wanted: (name: string) => boolean = () => true, +): AsyncGenerator { + assertPositiveLimits(limits); + const handle = await open(archivePath, "r"); try { const stat = await handle.stat(); const fileSize = Number(stat.size); if (!Number.isSafeInteger(fileSize)) { throw archiveError("archive is too large to address safely"); } - const entries = await readCentralDirectory(handle, fileSize, input.limits); + const entries = await readCentralDirectory(handle, fileSize, limits, kind); const total = { bytes: 0 }; for (const entry of entries) { - if (!isEcgCsv(entry.name)) continue; + if (!kind.matches(entry.name) || !wanted(entry.name)) continue; const chunks = await decompressEntry( handle, entry, fileSize, - input.limits, + limits, + kind, total, ); yield { name: entry.name, stream: Readable.from(chunks) }; @@ -312,3 +352,54 @@ export async function* streamAppleHealthEcgMembers(input: { await handle.close(); } } + +/** Yield only Apple Health's ECG CSV members as in-memory bounded streams. */ +export async function* streamAppleHealthEcgMembers(input: { + archivePath: string; + limits: AppleHealthArchiveLimits; +}): AsyncGenerator { + yield* streamMembers(input.archivePath, input.limits, { + label: "ECG", + matches: isEcgCsv, + maxMembers: input.limits.maxEcgMembers, + maxTotalBytes: input.limits.maxTotalEcgBytes, + }); +} + +/** Ceilings for the route members; the shared ones keep their meaning. */ +export interface AppleHealthRouteArchiveLimits { + maxMembers: number; + maxRouteMembers: number; + maxMemberBytes: number; + maxTotalRouteBytes: number; + maxCompressionRatio: number; +} + +/** + * Yield the workout-route GPX members `wanted` names, one bounded buffer at + * a time: the caller parses and drops each before the next is inflated. + */ +export async function* streamAppleHealthRouteMembers(input: { + archivePath: string; + limits: AppleHealthRouteArchiveLimits; + wanted: (name: string) => boolean; +}): AsyncGenerator { + const { limits } = input; + yield* streamMembers( + input.archivePath, + { + maxMembers: limits.maxMembers, + maxEcgMembers: limits.maxRouteMembers, + maxMemberBytes: limits.maxMemberBytes, + maxTotalEcgBytes: limits.maxTotalRouteBytes, + maxCompressionRatio: limits.maxCompressionRatio, + }, + { + label: "route", + matches: isWorkoutRouteGpx, + maxMembers: limits.maxRouteMembers, + maxTotalBytes: limits.maxTotalRouteBytes, + }, + input.wanted, + ); +} diff --git a/src/lib/apple-health/route-import.ts b/src/lib/apple-health/route-import.ts new file mode 100644 index 000000000..c392d2dc0 --- /dev/null +++ b/src/lib/apple-health/route-import.ts @@ -0,0 +1,266 @@ +/** + * Workout GPS tracks from an Apple Health export, kept only on opt-in. + * + * `export.xml` names each workout's track as a `` to a GPX + * file under `workout-routes/`. The XML pass hands those references here + * (only when the account turned `storeWorkoutRoutes` on); this module then + * reads the referenced members one at a time, parses each GPX as a stream, + * thins it evenly to `MAX_ROUTE_POINTS`, and stores it sealed against the + * workout the XML pass wrote. + * + * Memory: one member is held at a time, inflated into a buffer the archive + * reader caps (`maxMemberBytes`), and its points are kept as flat number + * arrays until they are thinned. Nothing about the track goes into the job + * result, the logs or a wide event: only counts. + * + * A failure here never fails the import. The workouts and every measurement + * are already committed; a track that cannot be read is counted and skipped. + */ +import type { Readable } from "node:stream"; + +import sax from "sax"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { + routeMemberNameFromReference, + streamAppleHealthRouteMembers, + type AppleHealthRouteArchiveLimits, +} from "@/lib/apple-health/archive-stream"; +import { MAX_ROUTE_POINTS } from "@/lib/validations/workout"; +import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; +import { routeClimbM } from "@/lib/workouts/route-climb"; + +/** A workout the XML pass wrote, and the track file it referenced. */ +export interface WorkoutRouteReference { + /** The workout's `externalId` under `source = APPLE_HEALTH`. */ + externalId: string; + /** The `` as Apple wrote it. */ + path: string; +} + +export interface WorkoutRouteImportCounts { + /** Tracks the XML referenced that are present in the archive. */ + discovered: number; + /** Tracks stored against their workout. */ + attached: number; + /** Workout already had a track, or its row is gone. */ + skipped: number; + /** Unreadable, too short, or over a limit. */ + failed: number; +} + +/** Per-archive ceilings for the route members. */ +export const APPLE_HEALTH_ROUTE_ARCHIVE_LIMITS: AppleHealthRouteArchiveLimits = + { + maxMembers: 100_000, + maxRouteMembers: 20_000, + // A three-hour ride at 1 Hz is a few MB of GPX; 32 MB covers a 24-hour + // ultra with room to spare and bounds the one buffer held at a time. + maxMemberBytes: 32 * 1024 * 1024, + maxTotalRouteBytes: 4 * 1024 * 1024 * 1024, + maxCompressionRatio: 200, + }; + +/** The points of one track, flat: lon, lat, elevation (NaN when absent), time ms (NaN when absent). */ +interface ParsedTrack { + lon: number[]; + lat: number[]; + ele: number[]; + time: number[]; +} + +/** Stream a GPX document into flat point arrays. Rejects malformed XML. */ +export async function parseGpxTrack(stream: Readable): Promise { + const track: ParsedTrack = { lon: [], lat: [], ele: [], time: [] }; + const parser = sax.parser(true, { trim: true }); + let inPoint = false; + let field: "ele" | "time" | null = null; + let ele = Number.NaN; + let time = Number.NaN; + let lat = Number.NaN; + let lon = Number.NaN; + let failure: Error | null = null; + + parser.onerror = (err) => { + failure = err instanceof Error ? err : new Error(String(err)); + }; + parser.onopentag = (node) => { + const name = node.name.toLowerCase(); + if (name === "trkpt") { + const attrs = node.attributes as Record; + lat = Number.parseFloat(attrs.lat ?? ""); + lon = Number.parseFloat(attrs.lon ?? ""); + ele = Number.NaN; + time = Number.NaN; + inPoint = true; + } else if (inPoint && (name === "ele" || name === "time")) { + field = name; + } + }; + parser.ontext = (text) => { + if (!inPoint || field === null) return; + if (field === "ele") ele = Number.parseFloat(text); + else time = Date.parse(text); + }; + parser.onclosetag = (tagName) => { + const name = tagName.toLowerCase(); + if (name === "ele" || name === "time") field = null; + if (name !== "trkpt") return; + inPoint = false; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) { + return; + } + track.lon.push(lon); + track.lat.push(lat); + track.ele.push(Number.isFinite(ele) ? ele : Number.NaN); + track.time.push(Number.isFinite(time) ? time : Number.NaN); + }; + + for await (const chunk of stream) { + parser.write(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk); + if (failure) throw failure; + } + parser.close(); + if (failure) throw failure; + return track; +} + +/** + * Indices that thin `count` points evenly to at most `max`, always keeping + * the first and the last. + */ +export function thinIndices(count: number, max: number): number[] { + if (count <= max) return Array.from({ length: count }, (_, i) => i); + const out: number[] = []; + const step = (count - 1) / (max - 1); + for (let i = 0; i < max; i += 1) out.push(Math.round(i * step)); + return out; +} + +/** + * The stored shapes for a parsed track: a GeoJSON LineString (altitude only + * when every kept point has one) and, when every kept point is timed, the + * parallel sample list. Null for a track with fewer than two points. + */ +export function trackToRoute(track: ParsedTrack): { + geometry: { type: "LineString"; coordinates: number[][] }; + sampleTimestamps: Array<{ t: string }> | null; +} | null { + const indices = thinIndices(track.lon.length, MAX_ROUTE_POINTS); + if (indices.length < 2) return null; + const withEle = indices.every((i) => Number.isFinite(track.ele[i])); + const withTime = indices.every((i) => Number.isFinite(track.time[i])); + return { + geometry: { + type: "LineString", + coordinates: indices.map((i) => + withEle + ? [track.lon[i], track.lat[i], track.ele[i]] + : [track.lon[i], track.lat[i]], + ), + }, + sampleTimestamps: withTime + ? indices.map((i) => ({ t: new Date(track.time[i]).toISOString() })) + : null, + }; +} + +/** + * Store the referenced tracks. `references` comes from the XML pass; each + * names a workout the import wrote under `source = APPLE_HEALTH`. + */ +export async function importAppleHealthWorkoutRoutes(input: { + archivePath: string; + userId: string; + references: readonly WorkoutRouteReference[]; + prisma: Pick; + limits?: AppleHealthRouteArchiveLimits; +}): Promise { + const counts: WorkoutRouteImportCounts = { + discovered: 0, + attached: 0, + skipped: 0, + failed: 0, + }; + const byMember = new Map(); + for (const ref of input.references) { + const member = routeMemberNameFromReference(ref.path); + if (member) byMember.set(member, ref.externalId); + } + if (byMember.size === 0) return counts; + + try { + for await (const member of streamAppleHealthRouteMembers({ + archivePath: input.archivePath, + limits: input.limits ?? APPLE_HEALTH_ROUTE_ARCHIVE_LIMITS, + wanted: (name) => byMember.has(name), + })) { + counts.discovered += 1; + const externalId = byMember.get(member.name); + if (!externalId) continue; + try { + const route = trackToRoute(await parseGpxTrack(member.stream)); + if (!route) { + counts.failed += 1; + continue; + } + const workout = await input.prisma.workout.findUnique({ + where: { + userId_source_externalId: { + userId: input.userId, + source: "APPLE_HEALTH", + externalId, + }, + }, + select: { + id: true, + elevationM: true, + route: { select: { id: true } }, + }, + }); + if (!workout || workout.route) { + counts.skipped += 1; + continue; + } + await input.prisma.workoutRoute.createMany({ + data: [ + { + workoutId: workout.id, + geometryEncrypted: encryptRouteGeometry(route.geometry), + sampleTimestamps: route.sampleTimestamps ?? undefined, + }, + ], + skipDuplicates: true, + }); + // The climb lives on the workout row, so no reader opens the track + // for it (the same write-time derivation as the batch route). + if (workout.elevationM === null) { + const climb = routeClimbM(route.geometry); + if (climb !== null) { + await input.prisma.workout.update({ + where: { id: workout.id }, + data: { elevationM: climb }, + }); + } + } + counts.attached += 1; + } catch { + counts.failed += 1; + } + } + } catch { + // The archive itself is unreadable for this pass (a ZIP64 export, a + // limit hit in the directory). Every referenced track not yet handled + // counts as failed; the import stays done. + const handled = counts.attached + counts.skipped + counts.failed; + counts.failed += Math.max(0, byMember.size - handled); + } + return counts; +} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 93347dfcc..da5cdb6ac 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -427,6 +427,7 @@ export const USER_RESET = { coachLastSeenAt: null, documentsAutoAiRead: false, labsLocalOcrEnabled: false, + storeWorkoutRoutes: false, codexAccessTokenEncrypted: null, codexRefreshTokenEncrypted: null, codexTokenExpiresAt: null, diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index 2585a2c11..1ac7b794b 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -527,6 +527,7 @@ const ACCOUNT_SETTING_CODECS: Readonly< coachPrefsJson: json("coachPrefsJson"), documentsAutoAiRead: plain("documentsAutoAiRead"), labsLocalOcrEnabled: plain("labsLocalOcrEnabled"), + storeWorkoutRoutes: plain("storeWorkoutRoutes"), onboardingCompletedAt: instant("onboardingCompletedAt"), onboardingTourCompleted: plain("onboardingTourCompleted"), onboardingTourProgressJson: json("onboardingTourProgressJson"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 1d517711b..c86898c1a 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1038,6 +1038,7 @@ export const USER_COLUMN_BACKUP_CLASS = { coachPrefsJson: "SETTING", documentsAutoAiRead: "SETTING", labsLocalOcrEnabled: "SETTING", + storeWorkoutRoutes: "SETTING", // Where the person is in the app's introductions. Without these a restored // account is sent through setup and the tour again, and asked to accept the // disclaimer it already accepted (a newer disclaimer still asks, because the diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 56c0c470c..94df12acc 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -137,6 +137,14 @@ export const ALLOWED_CHART_TOKENS = [ // continuous-series contract, higher = better. "metric:WHO5_SCORE", "metric:SCI_SCORE", + // v1.44 — Apple Health activity rings, mindfulness, State of Mind and + // AFib burden. Charted through the generic series renderer. + "metric:EXERCISE_MINUTES", + "metric:STAND_MINUTES", + "metric:STAND_HOURS", + "metric:MINDFUL_MINUTES", + "metric:STATE_OF_MIND_VALENCE", + "metric:AFIB_BURDEN", "metric:GRIP_STRENGTH", "metric:PAIN_NRS", "metric:WAIST_CIRCUMFERENCE", diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index f90e09300..1023cc0f8 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -34,6 +34,11 @@ import { streamAppleHealthEcgMembers } from "@/lib/apple-health/archive-stream"; import { parseAppleHealthEcgCsv } from "@/lib/apple-health/ecg-csv"; import { importAppleHealthEcg } from "@/lib/apple-health/ecg-import"; import { extractExportXml } from "@/lib/import/unzip-export-xml"; +import { annotate } from "@/lib/logging/context"; +import { + importAppleHealthWorkoutRoutes, + type WorkoutRouteReference, +} from "@/lib/apple-health/route-import"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; @@ -428,8 +433,11 @@ export async function handleAppleHealthImport( // user has no preference set. const userRow = await prisma.user.findUnique({ where: { id: userId }, - select: { timezone: true }, + select: { timezone: true, storeWorkoutRoutes: true }, }); + // GPS tracks are read from the archive only when the person keeps them. + const keepRoutes = userRow?.storeWorkoutRoutes === true; + const routeReferences: WorkoutRouteReference[] = []; const userTimezone = userRow?.timezone && userRow.timezone.length > 0 ? userRow.timezone @@ -471,6 +479,9 @@ export async function handleAppleHealthImport( userId, userTimezone, prisma, + ...(keepRoutes + ? { onWorkoutRoute: (ref) => void routeReferences.push(ref) } + : {}), onProgress: async (snapshot) => { // The phase label here is what the polling endpoint surfaces; // map "parsing" / "upserting" through verbatim. @@ -536,6 +547,31 @@ export async function handleAppleHealthImport( ecg.failed += Math.max(0, ecg.discovered - completed); } + // Workout GPS tracks, on opt-in only. Fail-soft like the ECGs: the + // workouts are committed, a track that cannot be read is only counted. + // Read one member at a time from the archive; nothing about a track + // reaches the result but the count. + if (routeReferences.length > 0) { + const routes = await importAppleHealthWorkoutRoutes({ + archivePath: uploadPath, + userId, + references: routeReferences, + prisma, + }); + result.workouts.routesAttached = routes.attached; + annotate({ + action: { name: "apple_health.import.workout_routes" }, + meta: { + referenced: routeReferences.length, + discovered: routes.discovered, + attached: routes.attached, + skipped: routes.skipped, + failed: routes.failed, + }, + }); + routeReferences.length = 0; + } + // Done. Persist the terminal envelope. await prisma.importJob.update({ where: { id: importJobId }, diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 839d38eef..699a68aa7 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -66,7 +66,6 @@ import { meetsDurationFloor, workoutInsightInputHash, } from "@/lib/workouts/insight-gates"; -import { readRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeZones, @@ -272,7 +271,6 @@ export async function runWorkoutInsightGenerate( minHeartRate: true, elevationM: true, metadata: true, - route: { select: { geometry: true, geometryEncrypted: true } }, samples: { select: { samples: true } }, }, }); @@ -397,9 +395,6 @@ export async function runWorkoutInsightGenerate( tz, hrSeries, zones, - // Opened in memory only to derive metres climbed; the track itself - // never reaches the prompt. - routeGeometry: row.route ? readRouteGeometry(row.route) : null, history, }); const inputHash = workoutInsightInputHash( diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index fbdab574f..86afc5d32 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -140,6 +140,12 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { "HKCategoryTypeIdentifierLowHeartRateEvent", "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", "HKCategoryTypeIdentifierSleepApneaEvent", + // v1.44 — mindfulness and the State of Mind are mental-health + // self-care and self-report; AFib burden is the AFib History + // feature the person enabled on the watch. + "HKCategoryTypeIdentifierMindfulSession", + "HKDataTypeIdentifierStateOfMind", + "HKQuantityTypeIdentifierAtrialFibrillationBurden", ].sort(), ); // Audio exposure + time-in-daylight are environment / lifestyle @@ -209,8 +215,8 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { // skipped-but-known rather than unknown-and-dropped. it("covers the iOS-17 + iOS-18 long-tail identifiers", () => { const expectedLongTail = [ - // Cardiovascular / clinical - "HKQuantityTypeIdentifierAtrialFibrillationBurden", + // Cardiovascular / clinical (AFib burden moved into the mapped set in + // v1.44 as AFIB_BURDEN). "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility — `AppleWalkingSteadiness` moved into the mapped set in // v1.4.30. In v1.10.0 both remaining identifiers moved in too: @@ -772,11 +778,85 @@ describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { "HKQuantityTypeIdentifierTimeInDaylight", // v1.10.0 — hard-fall detections accumulate across the day. "HKQuantityTypeIdentifierNumberOfTimesFallen", + // v1.44 — the exercise and stand rings and mindful minutes. + "HKQuantityTypeIdentifierAppleExerciseTime", + "HKQuantityTypeIdentifierAppleStandTime", + "HKCategoryTypeIdentifierAppleStandHour", + "HKCategoryTypeIdentifierMindfulSession", ].sort(), ); }); }); +describe("v1.44 — activity rings, mindfulness, State of Mind, AFib burden", () => { + const sample = (hkIdentifier: string, value: number, unit: string) => + mapAppleHealthEntry({ + hkIdentifier, + value, + unit, + startDate: "2026-10-20T08:00:00.000Z", + endDate: "2026-10-20T08:30:00.000Z", + }); + + it("maps every identifier and removes it from the deferred set", () => { + const expected: Record = { + HKQuantityTypeIdentifierAppleExerciseTime: "EXERCISE_MINUTES", + HKQuantityTypeIdentifierAppleStandTime: "STAND_MINUTES", + HKCategoryTypeIdentifierAppleStandHour: "STAND_HOURS", + HKCategoryTypeIdentifierMindfulSession: "MINDFUL_MINUTES", + HKDataTypeIdentifierStateOfMind: "STATE_OF_MIND_VALENCE", + HKQuantityTypeIdentifierAtrialFibrillationBurden: "AFIB_BURDEN", + }; + for (const [hk, type] of Object.entries(expected)) { + expect(APPLE_HEALTH_TYPE_MAP[hk]?.measurementType).toBe(type); + expect(HK_QUANTITY_TYPE_DEFERRED.has(hk)).toBe(false); + expect(hkIdentifierForType(type)).toBe(hk); + } + }); + + it("passes the minute and hour values through", () => { + expect( + sample("HKQuantityTypeIdentifierAppleExerciseTime", 32, "min"), + ).toMatchObject({ type: "EXERCISE_MINUTES", value: 32, unit: "minutes" }); + expect( + sample("HKCategoryTypeIdentifierAppleStandHour", 1, "count"), + ).toMatchObject({ type: "STAND_HOURS", value: 1, unit: "hours" }); + expect( + sample("HKCategoryTypeIdentifierMindfulSession", 10, "min"), + ).toMatchObject({ type: "MINDFUL_MINUTES", value: 10, unit: "minutes" }); + }); + + it("stores the State of Mind valence as is", () => { + expect( + sample("HKDataTypeIdentifierStateOfMind", -0.4, "valence"), + ).toMatchObject({ type: "STATE_OF_MIND_VALENCE", value: -0.4 }); + }); + + it("scales AFib burden strictly, so a small burden stays small", () => { + // 0.5 % arrives as the fraction 0.005. A client that sends 2 for 2 % + // is not guessed at: it lands at 200 and the range guard refuses it. + expect( + sample("HKQuantityTypeIdentifierAtrialFibrillationBurden", 0.005, "%") + ?.value, + ).toBeCloseTo(0.5, 10); + expect( + sample("HKQuantityTypeIdentifierAtrialFibrillationBurden", 2, "%")?.value, + ).toBe(200); + expect(validateMeasurementRange("AFIB_BURDEN", 200)).not.toBeNull(); + expect(validateMeasurementRange("AFIB_BURDEN", 0.5)).toBeNull(); + }); + + it("bounds every new type", () => { + expect( + validateMeasurementRange("STATE_OF_MIND_VALENCE", 1.5), + ).not.toBeNull(); + expect(validateMeasurementRange("STATE_OF_MIND_VALENCE", -1)).toBeNull(); + expect(validateMeasurementRange("STAND_HOURS", 25)).not.toBeNull(); + expect(validateMeasurementRange("EXERCISE_MINUTES", 1441)).not.toBeNull(); + expect(validateMeasurementRange("MINDFUL_MINUTES", 20)).toBeNull(); + }); +}); + describe("HIGH_FREQUENCY_MEAN_TYPES (v1.7.0)", () => { it("is strictly disjoint from CUMULATIVE_HK_TYPES", () => { for (const type of HIGH_FREQUENCY_MEAN_TYPES) { diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 1fc88cdc7..5c994f5f2 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -637,9 +637,18 @@ describe("hashSampleKey", () => { expect(a).not.toBe(b); }); }); -const cumulativeMappings = Object.values(APPLE_HEALTH_TYPE_MAP).filter( +const allCumulativeMappings = Object.values(APPLE_HEALTH_TYPE_MAP).filter( (mapping) => CUMULATIVE_HK_TYPES.has(mapping.measurementType), ); +/** + * The cumulative quantities, whose `` carries its reading in + * `value`. The two cumulative category types (stand hours, mindful + * sessions) read theirs from a symbolic value or the dates and have their + * own cases below. + */ +const cumulativeMappings = allCumulativeMappings.filter((mapping) => + mapping.hkIdentifier.startsWith("HKQuantityTypeIdentifier"), +); function cumulativeExportXml( hkIdentifier: string, @@ -678,11 +687,60 @@ async function importCumulativeFixture(xml: string) { } describe("streamParseExportXml — cumulative source-day estimates", () => { - it("covers exactly the six cumulative HealthKit measurement types", () => { - expect(CUMULATIVE_HK_TYPES.size).toBe(6); + it("covers exactly the ten cumulative HealthKit measurement types", () => { + expect(CUMULATIVE_HK_TYPES.size).toBe(10); expect( - new Set(cumulativeMappings.map((mapping) => mapping.measurementType)), + new Set(allCumulativeMappings.map((mapping) => mapping.measurementType)), ).toEqual(CUMULATIVE_HK_TYPES); + expect(allCumulativeMappings.length - cumulativeMappings.length).toBe(2); + }); + + it("counts stood hours and never stores an idle one", async () => { + const hour = ( + h: number, + value: string, + ) => ` `; + const { prisma, result } = + await importCumulativeFixture(` + +${hour(8, "HKCategoryValueAppleStandHourStood")} +${hour(9, "HKCategoryValueAppleStandHourIdle")} +${hour(10, "HKCategoryValueAppleStandHourStood")} +${hour(11, "HKCategoryValueAppleStandHourStood")} +`); + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ + type: "STAND_HOURS", + value: 3, + unit: "hours", + }); + expect( + result.deferred["HKCategoryTypeIdentifierAppleStandHour::idle"], + ).toBe(1); + expect( + result.unknown["HKCategoryTypeIdentifierAppleStandHour::unparseable"], + ).toBeUndefined(); + }); + + it("takes mindful minutes from the session's own dates", async () => { + const { prisma } = await importCumulativeFixture(` + + + +`); + expect(prisma._measurements).toHaveLength(1); + expect(prisma._measurements[0]).toMatchObject({ + type: "MINDFUL_MINUTES", + value: 25, + unit: "minutes", + }); }); it("stores 8,526 rather than source-blind 15,608 for overlapping iPhone and Zepp steps", async () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 60824d9c8..6e8c9cf78 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -245,6 +245,12 @@ const UNIT_FIXED_AUDIO_SPL = "Sound-pressure level: Apple emits `dBASPL` only, and a decibel is logarithmic — a sibling unit would need its own formula, never a factor."; const UNIT_FIXED_COMPOSITE_VO2 = "Compound rate Apple emits verbatim as `mL/(kg*min)`; the Health app exposes no other spelling and the compound has no sibling unit to convert from."; +const UNIT_FIXED_STAND_HOUR = + "Stand hours are a tally of hour samples: the importer maps `Stood` to 1 and drops `Idle`, and a daily row carries the count, so no record unit can rescale it."; +const UNIT_FIXED_VALENCE = + "State of Mind valence is a dimensionless −1..1 self-report; HealthKit has no unit for it and nothing to convert from."; +const UNIT_FIXED_AFIB_FRACTION = + "Apple writes the percent unit but the value rides as a 0..1 fraction, scaled ×100 in `convertToDbUnit`; branching on the record unit would double-scale it."; const UNIT_FIXED_SLEEP_CATEGORY = "Sleep samples carry a symbolic category value, not a quantity: the importer derives the duration in minutes from the sample's own dates, so the record's unit attribute (empty in Apple's archive) says nothing about the reading."; @@ -479,6 +485,83 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { aggregation: "sum", }, + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + // The exercise and stand rings. Apple ships both as minute quantities; + // cumulative, so the daily SUM is the ring value (same posture as + // daylight minutes). + HKQuantityTypeIdentifierAppleExerciseTime: { + hkIdentifier: "HKQuantityTypeIdentifierAppleExerciseTime", + measurementType: "EXERCISE_MINUTES", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + HKQuantityTypeIdentifierAppleStandTime: { + hkIdentifier: "HKQuantityTypeIdentifierAppleStandTime", + measurementType: "STAND_MINUTES", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + // The stand ring's hours. A category sample per hour, `stood` (0) or + // `idle` (1); the wire `value` is the number of stood hours the row + // stands for: 1 for one stood-hour sample, the day's count on a + // `stats:` daily row. The archive importer turns `Stood` into 1 and + // skips `Idle`, so an idle hour is never a row. + HKCategoryTypeIdentifierAppleStandHour: { + hkIdentifier: "HKCategoryTypeIdentifierAppleStandHour", + measurementType: "STAND_HOURS", + hkUnit: "count", + unitFixedReason: UNIT_FIXED_STAND_HOUR, + dbUnit: "hours", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + // Mindful sessions. A category sample with no quantity: the value is the + // session length in minutes, from the sample's own dates (the archive + // importer derives it; the batch client sends it), the sleep contract. + HKCategoryTypeIdentifierMindfulSession: { + hkIdentifier: "HKCategoryTypeIdentifierMindfulSession", + measurementType: "MINDFUL_MINUTES", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + isPrivacySensitive: true, + }, + // State of Mind (iOS 18+). The valence of a logged emotion or daily mood, + // −1 (very unpleasant) to 1 (very pleasant), stored as is. Labels and + // associations stay on the device. + HKDataTypeIdentifierStateOfMind: { + hkIdentifier: "HKDataTypeIdentifierStateOfMind", + measurementType: "STATE_OF_MIND_VALENCE", + hkUnit: "valence", + unitFixedReason: UNIT_FIXED_VALENCE, + dbUnit: "valence", + convertToDbUnit: (v) => v, + aggregation: "mean", + isPrivacySensitive: true, + }, + // AFib burden (iOS 16+, AFib History): the weekly share of time the watch + // estimated in AFib. Apple ships a 0..1 fraction and the wire is pinned to + // it: scaled strictly ×100. `percentFromFraction` repairs shipped clients + // that pre-scale SpO2 and body fat; no client pre-scales this type, and a + // small burden is exactly where a pre-scaled value cannot be told from a + // fraction, so a value above 1 is refused by the range guard (it lands + // above 100) rather than guessed at. + HKQuantityTypeIdentifierAtrialFibrillationBurden: { + hkIdentifier: "HKQuantityTypeIdentifierAtrialFibrillationBurden", + measurementType: "AFIB_BURDEN", + hkUnit: "%", + unitFixedReason: UNIT_FIXED_AFIB_FRACTION, + dbUnit: "%", + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + isPrivacySensitive: true, + }, + // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──────────────── // Walking steadiness — iOS 15+ Mobility daily rollup. Apple ships // a 0..1 fraction; HealthLog stores 0..100 percent (same convention @@ -818,6 +901,12 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = // daily total is the meaningful reduction (SUM), matching the other // cumulative HK counts. "FALL_COUNT", + // v1.44 — the exercise and stand rings and mindful minutes are day + // totals of partial-day increments, like daylight minutes. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", ]); /** @@ -1095,9 +1184,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // 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 - "HKCategoryTypeIdentifierAppleStandHour", // implied by Workout rows // 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). @@ -1152,9 +1238,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierDietarySugar", "HKQuantityTypeIdentifierDietaryFiber", "HKQuantityTypeIdentifierDietarySodium", - // Mental-state / mindfulness — v1.5 (route to existing mood model) - "HKCategoryTypeIdentifierMindfulSession", - "HKDataTypeIdentifierStateOfMind", // Pregnancy bleeding — stays deferred (pregnancy-mode is a later // release). v1.15.0 promoted the cycle-tracking reproductive types // (MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality / @@ -1183,7 +1266,6 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Cardiovascular / clinical (iOS 16+) — no HealthLog counterpart yet; // clinical decision-support territory, defer behind a stricter user // opt-in than the existing Health-share prompt. - "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the // mapping table in v1.4.30 (R-F T1.5). In v1.10.0 both remaining diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 924d06d82..0bff7c895 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -219,6 +219,17 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // Waist circumference + waist-to-height are anthropometric → body cluster. ["WAIST_CIRCUMFERENCE", "body"], ["WAIST_TO_HEIGHT", "body"], + // ── v1.44 — Apple Health activity rings, mindfulness, mood, AFib ── + // The exercise and stand rings, and mindful minutes (time given to a + // practice, the same day-total shape), sit with the activity cluster. + ["EXERCISE_MINUTES", "activity"], + ["STAND_MINUTES", "activity"], + ["STAND_HOURS", "activity"], + ["MINDFUL_MINUTES", "activity"], + // The State of Mind valence is a self-report, like the WHO-5 total. + ["STATE_OF_MIND_VALENCE", "scores"], + // AFib burden joins the rhythm notifications on the cardiovascular surface. + ["AFIB_BURDEN", "cardiovascular"], ]); /** diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index ff5c371b9..03a4382cd 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -333,6 +333,11 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ // grain for these day-cumulative counts. "ACTIVE_ENERGY_BURNED", "TIME_IN_DAYLIGHT", + // v1.44 — the minute rings, mindful minutes and stood hours. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", ]); /** diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 1e3c3bab2..d91f34fac 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -227,6 +227,8 @@ interface PreparedWorkout { externalId: string; externalSourceVersion: string | null; metadata: Prisma.JsonValue | null; + /** The `` of the workout's GPS track, when it has one. */ + routePath?: string; } type CumulativeSourceSubtotals = Map; @@ -331,13 +333,49 @@ export function hashCumulativeSourceIdentity( * Returns `null` for unparseable values so the caller can skip the * row rather than poisoning the batch. */ +/** Apple's symbolic `HKCategoryValueAppleStandHour` values in `export.xml`. */ +const STAND_HOUR_STOOD = "HKCategoryValueAppleStandHourStood"; +const STAND_HOUR_IDLE = "HKCategoryValueAppleStandHourIdle"; + +/** An idle stand hour: skipped on purpose, never a row and never "unparseable". */ +export function isIdleStandHour( + hkIdentifier: string, + rawValue: string | undefined, +): boolean { + return ( + hkIdentifier === "HKCategoryTypeIdentifierAppleStandHour" && + rawValue === STAND_HOUR_IDLE + ); +} + +function minutesBetween(startDate: string, endDate: string): number | null { + const start = new Date(startDate); + const end = new Date(endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + return Math.max(0, (end.getTime() - start.getTime()) / 60_000); +} + export function parseRecordValue( hkIdentifier: string, rawValue: string | undefined, startDate: string, endDate: string, ): { value: number; sleepStage?: number } | null { + // Mindful sessions carry no quantity (Apple writes no `value`, or the + // not-applicable sentinel): the reading is the session length, minutes + // from the record's own dates, the same contract as sleep. + if (hkIdentifier === "HKCategoryTypeIdentifierMindfulSession") { + const minutes = minutesBetween(startDate, endDate); + return minutes === null ? null : { value: minutes }; + } if (rawValue === undefined) return null; + // A stood hour is one; an idle hour is dropped by the caller before it + // gets here (`isIdleStandHour`), so it never becomes a row. + if (hkIdentifier === "HKCategoryTypeIdentifierAppleStandHour") { + return rawValue === STAND_HOUR_STOOD ? { value: 1 } : null; + } if (hkIdentifier === "HKCategoryTypeIdentifierSleepAnalysis") { const stage = SLEEP_STAGE_NAME_TO_CODEPOINT[rawValue]; if (stage === undefined) return null; @@ -410,6 +448,14 @@ export interface StreamParseInput { * `WORKOUT_FLUSH_BATCH`. */ workoutBatchSize?: number; + /** + * Called once per workout whose `` references a GPX file, + * with the workout's `externalId` and the path Apple wrote. Only passed + * when the account keeps workout routes (`storeWorkoutRoutes`); without it + * the reference is not even read. The track is attached after the XML + * pass, from the archive (`src/lib/apple-health/route-import.ts`). + */ + onWorkoutRoute?: (ref: { externalId: string; path: string }) => void; } /** @@ -432,6 +478,7 @@ export async function streamParseExportXml( prisma, onProgress, onExportDate, + onWorkoutRoute, spotBatchSize = SPOT_FLUSH_BATCH, workoutBatchSize = WORKOUT_FLUSH_BATCH, } = input; @@ -1210,6 +1257,13 @@ export async function streamParseExportXml( return; } + if (isIdleStandHour(hkType, attrs.value)) { + // An hour without standing is the absence of a stood hour. Counted + // so the import summary still shows the archive carried it. + deferred[`${hkType}::idle`] = (deferred[`${hkType}::idle`] ?? 0) + 1; + return; + } + const parsedValue = parseRecordValue( hkType, attrs.value, @@ -1449,6 +1503,17 @@ export async function streamParseExportXml( return; } + if ( + name === "FileReference" && + onWorkoutRoute && + currentWorkout && + typeof attrs.path === "string" && + attrs.path.toLowerCase().endsWith(".gpx") + ) { + currentWorkout.routePath = attrs.path; + return; + } + if ( name === "Me" || name === "ActivitySummary" || @@ -1486,6 +1551,12 @@ export async function streamParseExportXml( } } if (tagName === "Workout" && currentWorkout) { + if (currentWorkout.routePath && onWorkoutRoute) { + onWorkoutRoute({ + externalId: currentWorkout.externalId, + path: currentWorkout.routePath, + }); + } workoutBatch.push(currentWorkout); currentWorkout = null; } diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index 32da24148..46a61d0a4 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -103,6 +103,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { // ── v1.27.9 — screening scores ── WHO5_SCORE: "measurements.typeWho5Score", SCI_SCORE: "measurements.typeSciScore", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + STAND_HOURS: "measurements.typeStandHours", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", + AFIB_BURDEN: "measurements.typeAfibBurden", GRIP_STRENGTH: "measurements.typeGripStrength", PAIN_NRS: "measurements.typePainNrs", WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", diff --git a/src/lib/measurements/unit-label.ts b/src/lib/measurements/unit-label.ts index c6373967a..f1f6fcfa2 100644 --- a/src/lib/measurements/unit-label.ts +++ b/src/lib/measurements/unit-label.ts @@ -25,6 +25,7 @@ const TYPE_UNIT_LABEL_KEYS: Readonly> = { FALL_COUNT: "insights.units.falls", BREATHING_DISTURBANCES: "insights.units.breathingEvents", RESPIRATORY_RATE: "insights.units.respiratoryRate", + STAND_HOURS: "insights.units.standHours", }; /** Word-shaped tokens that read the same whatever the type. */ @@ -52,6 +53,8 @@ const UNITLESS_UNIT_TOKENS: ReadonlySet = new Set([ "event", "level", "ratio", + // State of Mind valence: a bare −1..1 number. + "valence", "unknown", ]); diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index 307a7173d..71761e35c 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -156,6 +156,16 @@ const unitMap: Record = { PAIN_NRS: "score", WAIST_CIRCUMFERENCE: "cm", WAIST_TO_HEIGHT: "ratio", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "minutes", + STAND_MINUTES: "minutes", + // Stood hours, a whole-number tally of hours. + STAND_HOURS: "hours", + MINDFUL_MINUTES: "minutes", + // HealthKit's dimensionless −1..1 valence. + STATE_OF_MIND_VALENCE: "valence", + // Percent of the week after the ×100 scaling. + AFIB_BURDEN: "%", }; export function getUnitForType(type: string): string { diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts index 0f071e6f9..1ae492854 100644 --- a/src/lib/measurements/value-domain.ts +++ b/src/lib/measurements/value-domain.ts @@ -48,6 +48,7 @@ export const INTEGER_VALUED_MEASUREMENT_TYPES: ReadonlySet = new Set([ "ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "FALL_COUNT", + "STAND_HOURS", "BREATHING_DISTURBANCES", "SLEEP_DISTURBANCE_COUNT", // Category events — the row carries a fixed `value = 1` (one fired event). diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 2ff632964..7975be709 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -122,7 +122,8 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = * - Core clinical figures every account carries: weight, pulse, blood * pressure, BMI, body temperature. * - Body composition: fat / lean / muscle / bone / water / visceral. - * - Activity volume: steps, distance, flights, active energy. Owned by no + * - Activity volume: steps, distance, flights, active energy, and the + * exercise and stand rings. Owned by no * module — `workouts` gates workout SESSIONS, not ambient movement. * - Gait and mobility, incl. the fall / walk-test / stair metrics. * - Cardio-vascular readings with no module home: SpO2, respiratory rate, @@ -162,6 +163,11 @@ export const UNSCOPED_REVIEWED_TYPES: ReadonlySet = "FLIGHTS_CLIMBED", "ACTIVE_ENERGY_BURNED", "ENERGY_EXPENDITURE_KJ", + // v1.44 — the exercise and stand rings are ambient movement like steps, + // not workout sessions. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", // Gait / mobility. "WALKING_SPEED", "WALKING_STEADINESS", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index b2a953afa..a510c4fad 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -33,6 +33,7 @@ import { documentsAutoAiReadPatchSchema, injectionSitePrefsPatchSchema, labsLocalOcrPatchSchema, + workoutRoutesPatchSchema, unitPreferencePatchSchema, glucoseUnitPatchSchema, useCentralCodexPatchSchema, @@ -1000,6 +1001,16 @@ const labsLocalOcrResponse = z .object({ labsLocalOcrEnabled: z.boolean() }) .meta({ id: "LabsLocalOcrResponse" }); +const workoutRoutesPatchRequest = workoutRoutesPatchSchema.meta({ + id: "WorkoutRoutesPatchRequest", + description: + "Keep or stop keeping the GPS track of new workouts. Off by default.", +}); + +const workoutRoutesResponse = z + .object({ storeWorkoutRoutes: z.boolean() }) + .meta({ id: "WorkoutRoutesResponse" }); + const useCentralCodexPatchRequest = useCentralCodexPatchSchema.meta({ id: "UseCentralCodexPatchRequest", description: @@ -2254,6 +2265,56 @@ export const profilePaths: NonNullable = { }, }, }, + "/api/auth/me/workout-routes": { + get: { + tags: ["Auth"], + summary: "Read the workout-route opt-in", + description: + "Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with the route stored. No AI path ever reads a stored track.", + responses: { + "200": { + description: "The current flag.", + content: { + "application/json": { + schema: dataEnvelope( + workoutRoutesResponse, + "GetWorkoutRoutesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Set the workout-route opt-in", + description: + "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off stops new tracks; tracks already stored stay until their workout is deleted.", + requestBody: { + required: true, + content: { "application/json": { schema: workoutRoutesPatchRequest } }, + }, + responses: { + "200": { + description: "The resolved next flag.", + content: { + "application/json": { + schema: dataEnvelope( + workoutRoutesResponse, + "PatchWorkoutRoutesEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/use-central-codex": { get: { tags: ["Auth"], diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index ed0340fc5..dc8ecf4ad 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -103,6 +103,12 @@ export function getPRDirection( case "BONE_MASS": case "MUSCLE_MASS": case "TIME_IN_DAYLIGHT": + // v1.44 — the activity rings: more exercise, standing and mindful + // minutes (and more stood hours) are the goal, as Apple frames them. + case "EXERCISE_MINUTES": + case "STAND_MINUTES": + case "STAND_HOURS": + case "MINDFUL_MINUTES": // v1.4.30 — walking steadiness on a 0-100 scale; Apple's own // Mobility section treats higher as the achievement (the // recovery direction from a low-steadiness window). @@ -206,6 +212,11 @@ export function getPRDirection( // "personal record" for a health-notification event would be both // meaningless and alarming, so they explicitly have no PR direction. case "IRREGULAR_RHYTHM_NOTIFICATION": + // v1.44 — AFib burden is an awareness signal from a certified feature, + // and a State of Mind valence is a self-report of how a moment felt. + // Neither is a goal axis a record could celebrate. + case "AFIB_BURDEN": + case "STATE_OF_MIND_VALENCE": case "HIGH_HEART_RATE_EVENT": case "LOW_HEART_RATE_EVENT": case "WALKING_STEADINESS_EVENT": diff --git a/src/lib/query-keys/workouts.ts b/src/lib/query-keys/workouts.ts index de709a028..3bffcdfbc 100644 --- a/src/lib/query-keys/workouts.ts +++ b/src/lib/query-keys/workouts.ts @@ -22,4 +22,9 @@ export const workoutKeys = { sportType?: string; }) => ["workouts", "recent", opts] as const, workoutDetail: (id: string) => ["workouts", id] as const, + /** + * v1.44 — the workout-route opt-in. Its own root, so invalidating the + * workout caches after a sync never refetches the setting. + */ + workoutRoutesPref: () => ["workout-routes-pref"] as const, }; diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 6b270286d..4e3ba4439 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -53,9 +53,9 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { - expect(MEASUREMENT_LEAF_IDS).toHaveLength(77); + expect(MEASUREMENT_LEAF_IDS).toHaveLength(83); expect(STRUCTURED_LEAF_IDS).toHaveLength(18); - expect(ALL_LEAF_IDS).toHaveLength(95); + expect(ALL_LEAF_IDS).toHaveLength(101); }); it("resolves a label for every leaf", () => { @@ -145,13 +145,17 @@ describe("template purity", () => { expect(unknown).toEqual([]); }); - it("fences exactly the eight leaves the design names", () => { + it("fences exactly the ten leaves the design names", () => { expect([...SENSITIVE_LEAF_IDS].sort()).toEqual( [ "ANAMNESIS", "CYCLE", "FAMILY_HISTORY", "GAD7_SCORE", + // v1.44 — mindful minutes and the State of Mind valence are + // mental-health self-care and self-report. + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", "MOOD", "PHQ9_SCORE", "SCI_SCORE", diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index 8d622dd9f..f53f1287a 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -119,6 +119,7 @@ export const MEASUREMENT_LEAF_GROUP: Record = { IRREGULAR_RHYTHM_NOTIFICATION: "cardio", HIGH_HEART_RATE_EVENT: "cardio", LOW_HEART_RATE_EVENT: "cardio", + AFIB_BURDEN: "cardio", // ── body ── WEIGHT: "body", @@ -161,6 +162,9 @@ export const MEASUREMENT_LEAF_GROUP: Record = { STRAIN_SCORE: "activity", DAY_STRAIN: "activity", WORKOUT_STRAIN: "activity", + EXERCISE_MINUTES: "activity", + STAND_MINUTES: "activity", + STAND_HOURS: "activity", // ── mobility & gait ── WALKING_SPEED: "mobility", @@ -185,6 +189,11 @@ export const MEASUREMENT_LEAF_GROUP: Record = { BLOOD_GLUCOSE: "glucose", // ── fenced ── + // Mindful minutes and the State of Mind valence are mental-health + // self-care and self-report: fenced like the screening totals, so they + // reach a report only when the person picks them. + MINDFUL_MINUTES: "sensitive", + STATE_OF_MIND_VALENCE: "sensitive", PHQ9_SCORE: "sensitive", GAD7_SCORE: "sensitive", WHO5_SCORE: "sensitive", @@ -264,6 +273,9 @@ export const LEAF_MODULE: Partial> = { // Activity / movement series and the training-load scores. ACTIVITY_STEPS: "workouts", ACTIVE_ENERGY_BURNED: "workouts", + EXERCISE_MINUTES: "workouts", + STAND_MINUTES: "workouts", + STAND_HOURS: "workouts", ENERGY_EXPENDITURE_KJ: "workouts", FLIGHTS_CLIMBED: "workouts", WALKING_RUNNING_DISTANCE: "workouts", @@ -309,6 +321,10 @@ export const LEAF_MODULE: Partial> = { GAD7_SCORE: "mentalHealth", WHO5_SCORE: "mentalHealth", SCI_SCORE: "mentalHealth", + // The State of Mind valence and mindful minutes ride the mood module: a + // person who turned mood tracking off does not see them on a report. + STATE_OF_MIND_VALENCE: "mood", + MINDFUL_MINUTES: "mood", // Structured sections with an owning module. LAB_RESULTS: "labs", MOOD: "mood", diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index ef14ef361..905fa945d 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -262,5 +262,41 @@ "display": "Sleep Condition Indicator total score", "unit": "{score}", "category": "survey" + }, + "EXERCISE_MINUTES": { + "loinc": "HKQuantityTypeIdentifierAppleExerciseTime", + "display": "Exercise time", + "unit": "min", + "category": "activity" + }, + "STAND_MINUTES": { + "loinc": "HKQuantityTypeIdentifierAppleStandTime", + "display": "Stand time", + "unit": "min", + "category": "activity" + }, + "STAND_HOURS": { + "loinc": "HKCategoryTypeIdentifierAppleStandHour", + "display": "Stand hours", + "unit": "h", + "category": "activity" + }, + "MINDFUL_MINUTES": { + "loinc": "HKCategoryTypeIdentifierMindfulSession", + "display": "Mindful session", + "unit": "min", + "category": "activity" + }, + "STATE_OF_MIND_VALENCE": { + "loinc": null, + "display": "State of mind valence", + "unit": "1", + "category": "survey" + }, + "AFIB_BURDEN": { + "loinc": "HKQuantityTypeIdentifierAtrialFibrillationBurden", + "display": "Atrial fibrillation burden", + "unit": "%", + "category": "vital-signs" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 2b2c8ad4f..d889362a8 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1778,6 +1778,142 @@ export const SIGNALS: Record = { mcp: false, }, }, + // ── v1.44 — Apple Health activity rings, mindfulness, State of Mind, AFib ── + // The three ring metrics are read through MCP like steps and daylight. The + // mindful minutes, the State of Mind valence and AFib burden stay off every + // AI-facing surface (Coach, MCP, correlations): mental-health self-reports + // and an AFib awareness signal are shared only where the person asks. + EXERCISE_MINUTES: { + key: "EXERCISE_MINUTES", + kind: "measurement", + source: { measurementType: "EXERCISE_MINUTES" }, + displayName: "Exercise minutes", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + // Apple's exercise ring default and the WHO weekly 150 minutes / 7. + normalRange: { low: 22, high: 60 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleExerciseTime", + display: "Exercise time", + unit: "min", + category: "activity", + }, + }, + STAND_MINUTES: { + key: "STAND_MINUTES", + kind: "measurement", + source: { measurementType: "STAND_MINUTES" }, + displayName: "Standing minutes", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleStandTime", + display: "Stand time", + unit: "min", + category: "activity", + }, + }, + STAND_HOURS: { + key: "STAND_HOURS", + kind: "measurement", + source: { measurementType: "STAND_HOURS" }, + displayName: "Stand hours", + unit: "h/day", + direction: "higher-better", + archetype: "activity-fitness", + // Apple's stand-ring goal is 12 hours. + normalRange: { low: 12, high: 24 }, + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKCategoryTypeIdentifierAppleStandHour", + display: "Stand hours", + unit: "h", + category: "activity", + }, + }, + MINDFUL_MINUTES: { + key: "MINDFUL_MINUTES", + kind: "measurement", + source: { measurementType: "MINDFUL_MINUTES" }, + displayName: "Mindful minutes", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKCategoryTypeIdentifierMindfulSession", + display: "Mindful session", + unit: "min", + category: "activity", + }, + }, + STATE_OF_MIND_VALENCE: { + key: "STATE_OF_MIND_VALENCE", + kind: "score", + source: { measurementType: "STATE_OF_MIND_VALENCE" }, + displayName: "State of mind (valence)", + unit: "valence", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + // A self-report on HealthKit's −1..1 scale; no LOINC concept. + loinc: null, + display: "State of mind valence", + unit: "1", + category: "survey", + }, + }, + AFIB_BURDEN: { + key: "AFIB_BURDEN", + kind: "measurement", + source: { measurementType: "AFIB_BURDEN" }, + displayName: "AFib burden", + unit: "%", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAtrialFibrillationBurden", + display: "Atrial fibrillation burden", + unit: "%", + category: "vital-signs", + }, + }, }; /** Type guard: narrow an arbitrary string to a registered signal key. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 957caaf9e..658568fae 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -2175,6 +2175,7 @@ const accountSettingsBackupSchema = z coachPrefsJson: z.unknown().optional(), documentsAutoAiRead: z.boolean().optional(), labsLocalOcrEnabled: z.boolean().optional(), + storeWorkoutRoutes: z.boolean().optional(), onboardingCompletedAt: isoDateTime.nullable().optional(), onboardingTourCompleted: z.boolean().optional(), onboardingTourProgressJson: z.unknown().optional(), diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 9ac5f0ae8..7134461ac 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -145,6 +145,14 @@ export const measurementTypeEnum = z.enum([ // percentage (raw sum × 4); SCI stores the 0–32 total. "WHO5_SCORE", "SCI_SCORE", + // ── v1.44 — Apple Health activity rings, mindfulness, State of Mind and + // AFib burden. See the enum comments in `prisma/schema.prisma`. + "EXERCISE_MINUTES", + "STAND_MINUTES", + "STAND_HOURS", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "AFIB_BURDEN", ]); /** @@ -440,6 +448,18 @@ export const VALUE_RANGES: Record = { // SCI total 0–32 — both server-derived, higher = better. WHO5_SCORE: { min: 0, max: 100 }, SCI_SCORE: { min: 0, max: 32 }, + // ── v1.44 — the minute rings and mindful minutes are bounded by the day; + // a row is one sample or one day's total, never more than 1440 minutes. + EXERCISE_MINUTES: { min: 0, max: 1440 }, + STAND_MINUTES: { min: 0, max: 1440 }, + MINDFUL_MINUTES: { min: 0, max: 1440 }, + // Stood hours: 1 per hour sample, at most 24 on a day's total. + STAND_HOURS: { min: 0, max: 24 }, + // State of Mind valence: HealthKit's closed −1..1 scale. + STATE_OF_MIND_VALENCE: { min: -1, max: 1 }, + // AFib burden as a percent of the week, after the ×100 scaling. A client + // that already scaled sends 2 for 2 %, which lands at 200 and is refused. + AFIB_BURDEN: { min: 0, max: 100 }, // Grip strength kg — a child can read in the single digits, an elite athlete // tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise. GRIP_STRENGTH: { min: 0, max: 120 }, diff --git a/src/lib/validations/user-prefs.ts b/src/lib/validations/user-prefs.ts index 2f6200710..786926f62 100644 --- a/src/lib/validations/user-prefs.ts +++ b/src/lib/validations/user-prefs.ts @@ -41,6 +41,11 @@ export const labsLocalOcrPatchSchema = z.object({ labsLocalOcrEnabled: z.boolean(), }); +/** `PATCH /api/auth/me/workout-routes` — keep workout GPS tracks (opt-in). */ +export const workoutRoutesPatchSchema = z.object({ + storeWorkoutRoutes: z.boolean(), +}); + /** `PATCH /api/auth/me/use-central-codex` — the operator's shared Codex. */ export const useCentralCodexPatchSchema = z.object({ useCentralCodex: z.boolean(), diff --git a/src/lib/workouts/__tests__/insight-evidence.test.ts b/src/lib/workouts/__tests__/insight-evidence.test.ts index da3f7b11f..a19fcfceb 100644 --- a/src/lib/workouts/__tests__/insight-evidence.test.ts +++ b/src/lib/workouts/__tests__/insight-evidence.test.ts @@ -6,7 +6,6 @@ import { assertNumericLeaves, buildWorkoutInsightEvidence, narrowSportType, - routeClimbM, summariseHrShape, summariseOwnHistory, } from "../insight-evidence"; @@ -90,38 +89,6 @@ describe("summariseHrShape", () => { }); }); -describe("routeClimbM", () => { - it("sums only the positive altitude deltas", () => { - const geometry = { - type: "LineString", - coordinates: [ - [8, 50, 100], - [8, 50, 140], - [8, 50, 110], - [8, 50, 160], - ], - }; - // +40 then −30 then +50 → 90 climbed, not 60 net. - expect(routeClimbM(geometry)).toBe(90); - }); - - it("returns null when the geometry carries no altitude channel", () => { - // Withings ships static GPX with no altitudes. Reporting a confident 0 - // would be a claim about flat terrain that the data does not make. - expect( - routeClimbM({ - type: "LineString", - coordinates: [ - [8, 50], - [8.1, 50.1], - ], - }), - ).toBeNull(); - expect(routeClimbM(null)).toBeNull(); - expect(routeClimbM("not geometry")).toBeNull(); - }); -}); - describe("summariseOwnHistory", () => { const row = (durationSec: number, avgHeartRate: number | null) => ({ durationSec, @@ -176,7 +143,6 @@ describe("buildWorkoutInsightEvidence", () => { tz: "Europe/Berlin", hrSeries: null, zones: null, - routeGeometry: null, history: [], }; @@ -196,20 +162,7 @@ describe("buildWorkoutInsightEvidence", () => { expect(evidence.localDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); - it("prefers route-derived climb over the denormalised column", () => { - const evidence = buildWorkoutInsightEvidence({ - ...base, - routeGeometry: { - coordinates: [ - [8, 50, 100], - [8, 50, 130], - ], - }, - }); - expect(evidence.climbM).toBe(30); - }); - - it("falls back to the column when the route carries no altitudes", () => { + it("reads the climb from the stored column", () => { const evidence = buildWorkoutInsightEvidence(base); expect(evidence.climbM).toBe(210); }); @@ -240,7 +193,6 @@ describe("workoutInsightInputHash", () => { tz: "UTC", hrSeries: null, zones: null, - routeGeometry: null, history: [], }); @@ -372,7 +324,6 @@ describe("assertNumericLeaves", () => { }, ], }, - routeGeometry: null, history: [], }), ).toThrow(/non-numeric leaf/); @@ -394,15 +345,14 @@ describe("assertNumericLeaves", () => { tz: "UTC", hrSeries: series([120, 130, 140, 150, 145, 138]), zones: null, - routeGeometry: null, history: [], }); expect(evidence.hr).not.toBeNull(); expect(evidence.hr).not.toHaveProperty("source"); }); - it("never carries route geometry — only the one number derived from it", () => { - const evidence = buildWorkoutInsightEvidence({ + it("takes no route geometry at all", () => { + const input = { row: { sportType: "cycling", startedAt: new Date("2026-07-18T06:00:00.000Z"), @@ -412,19 +362,19 @@ describe("assertNumericLeaves", () => { avgHeartRate: null, maxHeartRate: null, minHeartRate: null, - elevationM: null, + elevationM: 50, }, tz: "UTC", hrSeries: null, zones: null, - routeGeometry: { - coordinates: [ - [8.1, 50.1, 100], - [8.2, 50.2, 150], - ], - }, history: [], - }); + }; + const withTrack = { + ...input, + // @ts-expect-error — the builder has no route input; a track cannot be handed to it. + routeGeometry: { coordinates: [[8.1, 50.1, 100]] }, + } satisfies Parameters[0]; + const evidence = buildWorkoutInsightEvidence(withTrack); // Location history is not a figure and may not ride a prompt. expect(JSON.stringify(evidence)).not.toContain("8.1"); expect(JSON.stringify(evidence)).not.toContain("coordinates"); diff --git a/src/lib/workouts/__tests__/route-climb.test.ts b/src/lib/workouts/__tests__/route-climb.test.ts new file mode 100644 index 000000000..f83681843 --- /dev/null +++ b/src/lib/workouts/__tests__/route-climb.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { routeClimbM } from "../route-climb"; + +describe("routeClimbM", () => { + it("sums only the positive altitude deltas", () => { + const geometry = { + type: "LineString", + coordinates: [ + [8, 50, 100], + [8, 50, 140], + [8, 50, 110], + [8, 50, 160], + ], + }; + // +40 then −30 then +50 → 90 climbed, not 60 net. + expect(routeClimbM(geometry)).toBe(90); + }); + + it("returns null when the geometry carries no altitude channel", () => { + // Withings ships static GPX with no altitudes. Reporting a confident 0 + // would be a claim about flat terrain that the data does not make. + expect( + routeClimbM({ + type: "LineString", + coordinates: [ + [8, 50], + [8.1, 50.1], + ], + }), + ).toBeNull(); + expect(routeClimbM(null)).toBeNull(); + expect(routeClimbM("not geometry")).toBeNull(); + }); +}); diff --git a/src/lib/workouts/insight-evidence.ts b/src/lib/workouts/insight-evidence.ts index ab68d2b91..3e47f8cce 100644 --- a/src/lib/workouts/insight-evidence.ts +++ b/src/lib/workouts/insight-evidence.ts @@ -221,34 +221,6 @@ export function summariseHrShape( }; } -/** - * Total metres climbed, summed from a GeoJSON LineString's altitude channel. - * - * Only positive deltas count — this is climb, not net elevation change, which - * is the figure a rider recognises. Returns null when the geometry carries no - * altitude channel at all (Withings ships static GPX without one), so the - * caller can fall back to the denormalised column instead of reporting a - * confident zero. - */ -export function routeClimbM(geometry: unknown): number | null { - if (!geometry || typeof geometry !== "object") return null; - const coords = (geometry as { coordinates?: unknown }).coordinates; - if (!Array.isArray(coords)) return null; - - let climb = 0; - let previous: number | null = null; - let seen = 0; - for (const point of coords) { - if (!Array.isArray(point) || point.length < 3) continue; - const alt = point[2]; - if (typeof alt !== "number" || !Number.isFinite(alt)) continue; - seen++; - if (previous !== null && alt > previous) climb += alt - previous; - previous = alt; - } - return seen >= 2 ? Math.round(climb) : null; -} - /** Slim row shape the own-history median reads. */ export interface OwnHistoryRow { durationSec: number; @@ -330,8 +302,6 @@ export interface BuildEvidenceInput { hrSeries: WorkoutHrSeries | null; /** Zones as the detail route computes them, or null. */ zones: WorkoutZones | null; - /** `WorkoutRoute.geometry`, or null when the source shipped no route. */ - routeGeometry: unknown; /** Same-sport sessions inside the lookback, excluding this workout. */ history: readonly OwnHistoryRow[]; } @@ -412,7 +382,9 @@ export function buildWorkoutInsightEvidence( localDate: userDayKey(row.startedAt, input.tz), durationSec: row.durationSec, distanceM: row.totalDistanceM, - climbM: routeClimbM(input.routeGeometry) ?? row.elevationM, + // The stored column only. A kept GPS track already filled it at write + // time (`route-climb.ts`); the track itself never reaches this builder. + climbM: row.elevationM, activeEnergyKcal: row.totalEnergyKcal, avgHr: row.avgHeartRate, maxHr: row.maxHeartRate, diff --git a/src/lib/workouts/route-climb.ts b/src/lib/workouts/route-climb.ts new file mode 100644 index 000000000..9e2b8bd03 --- /dev/null +++ b/src/lib/workouts/route-climb.ts @@ -0,0 +1,37 @@ +/** + * Metres climbed, derived from a workout's GPS track at write time. + * + * The workout batch route fills `Workout.elevationM` from a kept track when + * the source sent no elevation of its own, so the figure lives on the + * workout row. Readers, the workout insight among them, take the column and + * never open the track: no AI path reads route geometry (the egress guard + * freezes the readers). + */ + +/** + * Total metres climbed, summed from a GeoJSON LineString's altitude channel. + * + * Only positive deltas count — this is climb, not net elevation change, which + * is the figure a rider recognises. Returns null when the geometry carries no + * altitude channel at all (Withings ships static GPX without one), so the + * caller can fall back to the denormalised column instead of reporting a + * confident zero. + */ +export function routeClimbM(geometry: unknown): number | null { + if (!geometry || typeof geometry !== "object") return null; + const coords = (geometry as { coordinates?: unknown }).coordinates; + if (!Array.isArray(coords)) return null; + + let climb = 0; + let previous: number | null = null; + let seen = 0; + for (const point of coords) { + if (!Array.isArray(point) || point.length < 3) continue; + const alt = point[2]; + if (typeof alt !== "number" || !Number.isFinite(alt)) continue; + seen++; + if (previous !== null && alt > previous) climb += alt - previous; + previous = alt; + } + return seen >= 2 ? Math.round(climb) : null; +}