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; +}