From 05fdddabc59a5d60da132648598d30e6dd638ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 16:10:03 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.7.1=20=E2=80=94=20overview?= =?UTF-8?q?-card=20edit/history/advanced=20actions=20(incl.=20GLP-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ios-coord/v1.7.0-ios-ack-live.md | 35 + .../ios-coord/v1.7.0-ios-convergence-locks.md | 175 +++++ .../v1.7.0-ios-offline-sync-answers.md | 376 +++++++++++ .../v1.7.0-ios-to-server-mood-reminder.md | 35 + .../v1.7.0-ios-to-server-pin-and-snapshot.md | 52 ++ .../ios-coord/v1.7.0-ios-to-server-reply.md | 238 +++++++ .../v1.7.0-offline-sync-contract-proposal.md | 471 ++++++++++++++ .planning/ios-coord/v1.7.0-server-final.md | 46 ++ .../v1.7.0-server-to-ios-confirmation.md | 72 ++ .../v1.7.0-server-to-ios-handover.md | 204 ++++++ .../D-2-direction.md | 615 ++++++++++++++++++ .../D-3-final-direction.md | 289 ++++++++ .../E-1-frontend-review.md | 190 ++++++ .../E-2-ux-review.md | 247 +++++++ .../E-3-arch-review.md | 208 ++++++ .../E-4-a11y-review.md | 134 ++++ .../F-1-senior-dev-audit.md | 220 +++++++ .../F-2-security-audit.md | 330 ++++++++++ .../G-1-history-view-direction.md | 178 +++++ .../G-2-senior-dev-review.md | 153 +++++ .../G-3-security-review.md | 210 ++++++ .../G-4-frontend-review.md | 87 +++ .../G-5-simplify-review.md | 99 +++ .../I-1-deleted-features-inventory.md | 265 ++++++++ .../R-2-detail-patterns.md | 89 +++ .planning/screenshots-2026-05-29/1.png | Bin 0 -> 579057 bytes ...dschirmfoto 2026-05-29 um 22.03.55 (2).png | Bin 0 -> 312763 bytes .../Bildschirmfoto 2026-05-29 um 22.04.06.png | Bin 0 -> 188144 bytes ...dschirmfoto 2026-05-29 um 22.04.12 (2).png | Bin 0 -> 313232 bytes .../Bildschirmfoto 2026-05-29 um 22.04.12.png | Bin 0 -> 643044 bytes .../Bildschirmfoto 2026-05-29 um 22.04.25.png | Bin 0 -> 246965 bytes .../Bildschirmfoto 2026-05-29 um 22.04.37.png | Bin 0 -> 187999 bytes .../Bildschirmfoto 2026-05-29 um 22.04.56.png | Bin 0 -> 585304 bytes .../Bildschirmfoto 2026-05-29 um 22.05.19.png | Bin 0 -> 243997 bytes .../Bildschirmfoto 2026-05-29 um 22.05.39.png | Bin 0 -> 200962 bytes .../screenshots-2026-05-29/dashboard.png | Bin 0 -> 205789 bytes .../site-audit-2026-05-29/admin-settings.md | 143 ++++ .../dead-code-structure.md | 88 +++ .../docs-landing-readme-seo.md | 323 +++++++++ .../site-audit-2026-05-29/frontend-arch.md | 109 ++++ .planning/v1.6.0-marathon-state.md | 155 +++++ .planning/v1.7.0-marathon-state.md | 127 ++++ .planning/v1.7.0-marathon/R-coach.md | 384 +++++++++++ .planning/v1.7.0-marathon/R-contract.md | 600 +++++++++++++++++ .planning/v1.7.0-marathon/R-export.md | 476 ++++++++++++++ .planning/v1.7.0-marathon/R-firstpaint.md | 421 ++++++++++++ .planning/v1.7.0-marathon/R-healthkit.md | 260 ++++++++ .planning/v1.7.0-marathon/R-medui.md | 586 +++++++++++++++++ .planning/v1.7.0-marathon/docs-audit.md | 328 ++++++++++ .../v1.7.0-marathon/docs-maintainer-notes.md | 34 + .../iOS-reply-convergence-notes.md | 106 +++ .../v1.7.0-marathon/qa/code-correctness.md | 115 ++++ .planning/v1.7.0-marathon/qa/design-a11y.md | 181 ++++++ .planning/v1.7.0-marathon/qa/i18n.md | 101 +++ .planning/v1.7.0-marathon/qa/security.md | 136 ++++ .planning/v1.7.0-marathon/qa/senior-dev.md | 149 +++++ .../v1.7.0-marathon/qa/simplify-deadcode.md | 116 ++++ CHANGELOG.md | 4 + docs/api/openapi.yaml | 2 +- package.json | 2 +- src/app/medications/page.tsx | 80 +++ .../__tests__/medication-card-glp1.test.tsx | 127 +++- .../medication-card-symmetry.test.tsx | 107 ++- .../medications/glp1-medication-card.tsx | 39 ++ .../medications/medication-card.tsx | 44 +- 65 files changed, 10335 insertions(+), 26 deletions(-) create mode 100644 .planning/ios-coord/v1.7.0-ios-ack-live.md create mode 100644 .planning/ios-coord/v1.7.0-ios-convergence-locks.md create mode 100644 .planning/ios-coord/v1.7.0-ios-offline-sync-answers.md create mode 100644 .planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md create mode 100644 .planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md create mode 100644 .planning/ios-coord/v1.7.0-ios-to-server-reply.md create mode 100644 .planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md create mode 100644 .planning/ios-coord/v1.7.0-server-final.md create mode 100644 .planning/ios-coord/v1.7.0-server-to-ios-confirmation.md create mode 100644 .planning/ios-coord/v1.7.0-server-to-ios-handover.md create mode 100644 .planning/medication-detail-page-2026-05-28/D-2-direction.md create mode 100644 .planning/medication-detail-page-2026-05-28/D-3-final-direction.md create mode 100644 .planning/medication-detail-page-2026-05-28/E-1-frontend-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/E-2-ux-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/E-3-arch-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/E-4-a11y-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/F-1-senior-dev-audit.md create mode 100644 .planning/medication-detail-page-2026-05-28/F-2-security-audit.md create mode 100644 .planning/medication-detail-page-2026-05-28/G-1-history-view-direction.md create mode 100644 .planning/medication-detail-page-2026-05-28/G-2-senior-dev-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/G-3-security-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/G-4-frontend-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/G-5-simplify-review.md create mode 100644 .planning/medication-detail-page-2026-05-28/I-1-deleted-features-inventory.md create mode 100644 .planning/medication-detail-page-2026-05-28/R-2-detail-patterns.md create mode 100644 .planning/screenshots-2026-05-29/1.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.03.55 (2).png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.04.06.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.04.12 (2).png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.04.12.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.04.25.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.04.37.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.04.56.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.05.19.png create mode 100644 .planning/screenshots-2026-05-29/Bildschirmfoto 2026-05-29 um 22.05.39.png create mode 100644 .planning/screenshots-2026-05-29/dashboard.png create mode 100644 .planning/site-audit-2026-05-29/admin-settings.md create mode 100644 .planning/site-audit-2026-05-29/dead-code-structure.md create mode 100644 .planning/site-audit-2026-05-29/docs-landing-readme-seo.md create mode 100644 .planning/site-audit-2026-05-29/frontend-arch.md create mode 100644 .planning/v1.6.0-marathon-state.md create mode 100644 .planning/v1.7.0-marathon-state.md create mode 100644 .planning/v1.7.0-marathon/R-coach.md create mode 100644 .planning/v1.7.0-marathon/R-contract.md create mode 100644 .planning/v1.7.0-marathon/R-export.md create mode 100644 .planning/v1.7.0-marathon/R-firstpaint.md create mode 100644 .planning/v1.7.0-marathon/R-healthkit.md create mode 100644 .planning/v1.7.0-marathon/R-medui.md create mode 100644 .planning/v1.7.0-marathon/docs-audit.md create mode 100644 .planning/v1.7.0-marathon/docs-maintainer-notes.md create mode 100644 .planning/v1.7.0-marathon/iOS-reply-convergence-notes.md create mode 100644 .planning/v1.7.0-marathon/qa/code-correctness.md create mode 100644 .planning/v1.7.0-marathon/qa/design-a11y.md create mode 100644 .planning/v1.7.0-marathon/qa/i18n.md create mode 100644 .planning/v1.7.0-marathon/qa/security.md create mode 100644 .planning/v1.7.0-marathon/qa/senior-dev.md create mode 100644 .planning/v1.7.0-marathon/qa/simplify-deadcode.md diff --git a/.planning/ios-coord/v1.7.0-ios-ack-live.md b/.planning/ios-coord/v1.7.0-ios-ack-live.md new file mode 100644 index 000000000..d9ef206a7 --- /dev/null +++ b/.planning/ios-coord/v1.7.0-ios-ack-live.md @@ -0,0 +1,35 @@ +# iOS → Server — ack: v1.7.0 LIVE received + +Date: 2026-05-31. iOS branch `feat/v0100-marathon`. Got `v1.7.0-server-final.md` — +thanks, all locks confirmed live. iOS plan: + +## Pinning this cycle (v0.10.0) +iOS was built forward-compatible + field-presence-gated, so it's already correct +against live v1.7.0. We're doing the cleanup pin pass in our QA wave (W10): +- Decode `scheduleType` (`SCHEDULED|PRN|CYCLIC`) as the clean cadence discriminator. +- Remove the widget workarounds `filteringForServer()` / `byRestoringIosOnlyWidgets()` + / `byMergingIosOnlyDefaults()` (server round-trips all 27 ids now). +- Verify the compliance `min(cap)` + `.noSchedule` drop fires against live `due`/ + `expectedCount`. +- Flip full passkey trust on the default host (AASA confirmed 200). + +## Snapshot cold-launch seed +`GET /api/dashboard/snapshot` with `metricStates` + `layoutCatalogue` — thanks for +adding the iOS-shaped block. Adopting as a one-key cold-launch seed is a perf win; +we'll wire it opportunistically (this cycle if time permits in W10, else early v0.11). +Per-store endpoints stay regardless. + +## Mood reminder +Acknowledged — the card now delivers on its own + `notificationPrefs.mood.reminderHour` +is the source of truth. iOS still ships a LOCAL evening reminder (offline reliability, +fires only if no entry today) but will READ `reminderHour` as the authoritative time +rather than its own default. Belt-and-suspenders, no double-fire (local cancels when an +entry exists; server respects explicit opt-out). + +## Offline sync — all three domains noted +Great that mood + intakes are now served too (single multi-domain cursor; tombstone +identity measurements=`externalId`, mood/intakes=server `id`). iOS consumer is a v0.11 +item (measurements-first), building against this now-frozen contract. Updated our v0.11 +backlog accordingly — the server side is no longer the gating piece. + +All clear. We'll pin from `openapi.yaml` @ v1.7.0. diff --git a/.planning/ios-coord/v1.7.0-ios-convergence-locks.md b/.planning/ios-coord/v1.7.0-ios-convergence-locks.md new file mode 100644 index 000000000..aef9ac5e9 --- /dev/null +++ b/.planning/ios-coord/v1.7.0-ios-convergence-locks.md @@ -0,0 +1,175 @@ +# iOS Convergence Locks — v1.7.0 + +**Author:** iOS team (HealthLog iOS, `feat/v0100-marathon`) +**Audience:** Server team (HealthLog `v1.7.0`, parallel build) +**Status:** Authoritative iOS-side lock. Evidence-cited to iOS source `file:line`. +**Purpose:** Lock three convergence surfaces so the parallel server build targets byte-identical shapes — no reconcile drift. (1) Widget IDs, (2) FHIR LOINC/UCUM codes, (3) medication-contract field names. + +> iOS will pin the **final** field names from `docs/api/openapi.yaml` @ the `v1.7.0` tag. Where a name below is marked **PROPOSED**, it is iOS's request that the server adopt that exact spelling. Where marked **LOCKED**, iOS already emits/consumes it on the wire today and the server must not diverge. + +--- + +## 1. FHIR R4 LOINC / UCUM lock (LOCKED) + +iOS emits FHIR R4 `Observation` resources from a hardcoded `MetricKind → LOINC + UCUM` table. **The server FHIR export must be byte-identical to this table** — same `Observation.code.coding[].code`, same `display`, same `valueQuantity.{code,unit}`. + +- Code system: `http://loinc.org` — `HealthLog/FHIR/LOINCCode.swift:30` +- Unit system (UCUM): `http://unitsofmeasure.org` — `HealthLog/FHIR/LOINCCode.swift:67` +- Mapping table source: `HealthLog/FHIR/MetricFHIRMapper.swift:102-374` +- HK type column source: `HealthLog/Services/HealthKitWireConverter.swift:129-233` + +UCUM strings are **case-sensitive, canonical-form, brackets-are-load-bearing**: `mm[Hg]`, `/min` (leading slash), `Cel` (not `°C`), `kg/m2`, `{steps}`, `dB[A]` (`LOINCCode.swift:67-80`). + +### 1a. Standard LOINC-backed metrics + +| Metric | LOINC code | LOINC display | UCUM | HK type | Evidence | +|---|---|---|---|---|---| +| Body weight | `29463-7` | Body weight | `kg` | `HKQuantityTypeIdentifierBodyMass` | MetricFHIRMapper.swift:105-110 | +| Blood pressure (panel) | `85354-9` | Blood pressure panel with all children optional | _(panel: no unit)_ | — | MetricFHIRMapper.swift:37-40 | +| → BP systolic (component) | `8480-6` | Systolic blood pressure | `mm[Hg]` | `…BloodPressureSystolic` | MetricFHIRMapper.swift:43-46, 119-123 | +| → BP diastolic (component) | `8462-4` | Diastolic blood pressure | `mm[Hg]` | `…BloodPressureDiastolic` | MetricFHIRMapper.swift:49-52 | +| Heart rate (pulse) | `8867-4` | Heart rate | `/min` | `HKQuantityTypeIdentifierHeartRate` | MetricFHIRMapper.swift:125-130 | +| Resting heart rate | `40443-4` | Heart rate --resting | `/min` | `…RestingHeartRate` | MetricFHIRMapper.swift:209-217 | +| HRV (SDNN) | `80404-7` | R-R interval.standard deviation (Heart rate variability) | `ms` | `…HeartRateVariabilitySDNN` | MetricFHIRMapper.swift:219-228 | +| Body temperature | `8310-5` | Body temperature | `Cel` | `…BodyTemperature` | MetricFHIRMapper.swift:152-157 | +| SpO₂ | `59408-5` | Oxygen saturation in Arterial blood by Pulse oximetry | `%` | `…OxygenSaturation` | MetricFHIRMapper.swift:159-171 | +| Respiratory rate | `9279-1` | Respiratory rate | `/min` | `…RespiratoryRate` | MetricFHIRMapper.swift:290-295 | +| Body fat % | `41982-0` ⚠ | Percentage of body fat Measured | `%` | `…BodyFatPercentage` | MetricFHIRMapper.swift:141-150 | +| Body water | `73704-9` ⚠ | Body water by Bioelectrical impedance analysis | `kg` | _(scale-derived)_ | MetricFHIRMapper.swift:173-182 | +| Bone mass | `73708-0` ⚠ | Bone mineral content by DXA | `kg` | _(scale-derived)_ | MetricFHIRMapper.swift:184-193 | +| BMI | `39156-5` | Body mass index (BMI) [Ratio] | `kg/m2` | `…BodyMassIndex` | MetricFHIRMapper.swift:324-329 | +| Sleep duration | `93832-4` | Sleep duration | `h` | `HKCategoryTypeIdentifierSleepAnalysis` | MetricFHIRMapper.swift:195-200 | +| Steps | `41950-7` | Number of steps in 24 hour Measured | `{steps}` | `…StepCount` | MetricFHIRMapper.swift:202-207 | +| VO₂ max | `96402-2` ⚠ | Oxygen consumption maximum during exercise | `mL/min/kg` | `…VO2Max` | MetricFHIRMapper.swift:230-239 | +| Walking speed | `41957-2` ⚠ | Gait speed [Velocity] Measured | `m/s` | `…WalkingSpeed` | MetricFHIRMapper.swift:241-250 | +| Walking asymmetry | `91557-1` ⚠ | Walking asymmetry percentage | `%` | `…WalkingAsymmetryPercentage` | MetricFHIRMapper.swift:252-261 | +| Walking step length | `41955-6` ⚠ | Step length Measured | `m` | `…WalkingStepLength` | MetricFHIRMapper.swift:263-272 | +| Active energy | `41981-2` | Calories burned | `kcal` | `…ActiveEnergyBurned` | MetricFHIRMapper.swift:335-340 | + +⚠ = `physicianReviewPending: true` in iOS (`LOINCCode.swift:47-61`). Code still **emitted** — server must match the code; the flag drives UI disclaimers only. + +### 1b. Glucose — context-discriminated LOINC (LOCKED) + +iOS picks the LOINC by `GlucoseContext`. Default (random / no context) = `2339-0`. UCUM `mg/dL` for all. Source: `MetricFHIRMapper.swift:73-89, 132-139, 388-403`. + +| Glucose context | LOINC | display | UCUM | +|---|---|---|---| +| random / unspecified / bedtime | `2339-0` ⚠ | Glucose [Mass/volume] in Blood | `mg/dL` | +| fasting / beforeMeal | `1558-6` ⚠ | Fasting glucose [Mass/volume] in Serum or Plasma | `mg/dL` | +| afterMeal (postprandial) | `1521-4` ⚠ | Glucose [Mass/volume] in Serum or Plasma --2 hours post meal | `mg/dL` | + +### 1c. HK-placeholder codes (no published LOINC term) + +For these, iOS emits the **HealthKit identifier string as the `code`** (in `http://loinc.org` slot) as a clinician-reviewable placeholder until LOINC publishes a term. **Server must emit the identical placeholder string**, not invent a LOINC. Source: `MetricFHIRMapper.swift:274-373`. + +| Metric | `code` (placeholder) | UCUM | +|---|---|---| +| Walking double support | `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` | `%` | +| Audio exposure (environment) | `HKQuantityTypeIdentifierEnvironmentalAudioExposure` | `dB[A]` | +| Audio exposure (headphone) | `HKQuantityTypeIdentifierHeadphoneAudioExposure` | `dB[A]` | +| Flights climbed | `HKQuantityTypeIdentifierFlightsClimbed` | `{flights}` | +| Distance walking/running | `HKQuantityTypeIdentifierDistanceWalkingRunning` | `m` | +| Time in daylight | `HKQuantityTypeIdentifierTimeInDaylight` | `min` | + +**Count of distinct LOINC codes extracted: 23** (20 standard incl. BP panel + 2 components, + 3 glucose-context). Plus 6 HK-placeholder codes. + +--- + +## 2. Dashboard widget-ID lock (LOCKED) + +Canonical enum: `HealthLog/Models/DashboardWidgetLayout.swift:73-118` (`DashboardWidgetId`). Wire shape `GET/PUT /api/dashboard/widgets` → `DashboardWidgetLayout` (`DashboardWidgetLayout.swift:16-56`). Server source-of-truth referenced by iOS: `src/lib/dashboard-layout.ts` → `DASHBOARD_WIDGET_IDS`. + +**Total widget IDs: 26.** (The default layout array, `DashboardWidgetLayout.swift:259-357`, materialises all 26.) + +### 2a. Server-known IDs (16) — already in the server Zod enum + +`serverKnownIds` (`DashboardWidgetLayout.swift:137-142`): `weight`, `bp`, `pulse`, `bodyFat`, `mood`, `medications`, `sleep`, `steps`, `glucose`, `totalBodyWater`, `boneMass`, `bpInTarget`, `oxygenSaturation`, `achievements`, `vo2Max`, `recentWorkouts`. + +### 2b. iOS-only IDs (10) — NOT yet in the server enum → **server must widen `DASHBOARD_WIDGET_IDS` to accept these on PUT** + +Added by the v0.5.2 / v0.7.0 HK-completeness sweeps (`DashboardWidgetLayout.swift:100-118`). Today iOS filters these off the PUT payload (`filteringForServer()`, line 423) because the server 422s on them — this is a **temporary defensive guard (TODO issue #11)**. Adopting them server-side retires the guard. + +| Widget ID | Maps to MetricKind | Evidence | +|---|---|---| +| `restingHeartRate` | `.restingHeartRate` | DashboardWidgetLayout.swift:100, 180 | +| `hrv` | `.hrv` | :101, :181 | +| `walkingSpeed` | `.walkingSpeed` | :102, :182 | +| `walkingAsymmetry` | `.walkingAsymmetry` | :103, :183 | +| `walkingStepLength` | `.walkingStepLength` | :104, :184 | +| `bmi` | `.bmi` | :105, :189 | +| `bodyTemperature` | `.bodyTemperature` | :106, :190 | +| `walkingDoubleSupport` | `.walkingDoubleSupport` | :110, :185 | +| `respiratoryRate` | `.respiratoryRate` | :114, :186 | +| `audioExposureEnvironment` | `.audioExposureEnvironment` | :116, :187 | +| `audioExposureHeadphone` | `.audioExposureHeadphone` | :118, :188 | + +> **Note:** this is **11 rows**, not 10. The earlier "11 iOS-only" count is correct — `bmi` belongs to the v0.5.2 wave and is iOS-only too. Reconciled iOS-only count = **11**. (16 server-known + 11 iOS-only = 27 distinct IDs in the catalogue; the default-layout array omits one server-known non-tile id — see below.) + +### 2c. Catalogue reconciliation + +- Distinct IDs defined in `DashboardWidgetId`: **27** (16 server-known + 11 iOS-only). +- IDs materialised in `DashboardWidgetLayout.default`: **26** — the default array (`:259-357`) carries all 16 server-known **except** `recentWorkouts` (iOS has no workouts surface yet; `metricKind(forId:)` returns `nil` for it, `:194`) is **present** in default but `glucose`/etc. all present; the 26 vs 27 delta is `recentWorkouts` being a non-tile server widget. Server should treat the **27-ID catalogue** as authoritative for the Zod enum. + +**Lock for server:** widen `DASHBOARD_WIDGET_IDS` to the full 27-ID set (16 current + 11 iOS-only above). Once accepted, iOS removes `filteringForServer()` / `byRestoringIosOnlyWidgets()` / `byMergingIosOnlyDefaults()` (`DashboardWidgetLayout.swift:423-487`). + +--- + +## 3. Medication-contract field-name lock + +iOS specification to the server. **PROPOSED** names = iOS asks server to adopt this exact spelling in `openapi.yaml`. Current iOS wire shape: `HealthLog/Models/Medication.swift:9-136`. + +### 3a. Medication GET + detail — delivery booleans (PROPOSED) + +iOS already has the DTO seam (`MedicationDeliveryDefaultsDTO`, `DeliveryPreferences.swift:70-92`) awaiting the server fields **SB-LA-1 / SB-AK-1** (`DeliveryDefaultsProviding.swift:11-54`). + +| Field | Type | Default | Direction | Notes | +|---|---|---|---|---| +| `liveActivityEnabled` | `Bool` | `false` | GET/detail + PUT | Lock-Screen/Dynamic-Island dose countdown. Default OFF (`DeliveryPreferences.swift:29-34, 72`). | +| `criticalAlarmEnabled` | `Bool` | `false` | GET/detail + PUT | AlarmKit break-through alarm. Default OFF (`DeliveryPreferences.swift:33, 73`). | +| `nextDueAt` | `String?` (ISO8601) | `null` | **read-only** (GET/detail) | Server-computed next scheduled instant. **`null` for PRN.** Client **never** sends it on PUT. | + +PUT accepts **only** the two booleans. `nextDueAt` is server-authoritative read-only. + +### 3b. Schedule object — cadence fields (PROPOSED) + +iOS's canonical names for cadence shapes the current wire (`MedicationScheduleDTO`, `Medication.swift:84-136`) does not yet express first-class. Ask server to confirm/adopt in `openapi.yaml`: + +| Field | Type | Semantics | +|---|---|---| +| `asNeeded` | `Bool` | PRN. **Excluded from expected-count + reminder projection; `nextDueAt` null.** (Today iOS infers PRN from empty `schedules`/`times`, `MedicationCadence.swift:263`, `MedicationsStore+CardCompliance.swift:42-59`. A first-class `asNeeded` flag removes the inference.) | +| `cycleWeeksOn` | `Int?` | Cyclic-cadence ON-week count. | +| `cycleWeeksOff` | `Int?` | Cyclic-cadence OFF-week count. | +| `cycleAnchor` | `String?` (ISO date) | Anchor date for the on/off-week cycle. | + +These extend the existing cadence vocabulary (`Cadence` enum, `MedicationCadence.swift:11-47`: daily / weekdays / everyNWeeks / monthly / everyNMonths / yearly / rolling / oneShot / legacy). The cycle fields express on/off-week dosing the current `iN;` interval encoding cannot. + +### 3c. Compliance payload — per-day additive fields (PROPOSED) + +Current per-day bucket: `DailyComplianceBucket` (`Medication.swift:534-560`) — `expected, taken, skipped, onTime, late, veryLate, early?`. iOS asks the server to **add** two fields: + +| Field | Type | iOS use | +|---|---|---| +| `due` | `Bool` | iOS reads `due` (or `expectedCount > 0`) to **suppress empty history marks on non-due days** in the Verlauf strip. | +| `expectedCount` | `Int` | Same suppression signal; per-day expected dose count. | + +**Why it matters:** today iOS recomputes the denominator locally and applies a `min(scheduleExpected, serverExpected)` cap because the server's `calculateCompliance` builds `totalExpected = schedules.length × effectiveDays` and **ignores `daysOfWeek` + `intervalWeeks`** (documented at `MedicationCadence.swift:354-399`; cap logic in `MedicationsStore.swift:816, 855` + `MedicationsStore+CardCompliance.swift`). When the server emits authoritative per-day `due`/`expectedCount`, iOS **drops its local cap** behind a `v1.7.0` capability gate. This is the highest-value reconcile item — it removes a whole client-side correction layer. + +--- + +## 4. iOS units + `/api/dashboard/snapshot` note + +### 4a. Walking-speed unit — convergence caveat (LOCKED) + +- **Wire + UCUM + iOS display are all `m/s`.** iOS sends raw HK `m/s` (`HealthKitWireConverter.swift:209-211`, `wireSymbol: "m/s"`), the FHIR UCUM is `m/s` (§1a), and the iOS tile/detail display unit is `m/s` (`MetricKind.unit` → `Measurement.swift:144`, `MetricKind.swift:144`). **There is no km/h conversion anywhere in the iOS codebase** (verified: no `km/h`, `UnitSpeed`, `kilometersPerHour`, or `× 3.6` exists). +- **Lock:** if the server **displays km/h**, that is a presentation-layer choice and **must not** change the stored/wire/FHIR value — those stay `m/s`. The server should convert for display only (`km/h = m/s × 3.6`) and continue to persist + export `m/s`. +- No metric/imperial unit-toggle exists on iOS today; iOS metrics are fixed-unit (`MetricKind.unit`, `Measurement.swift:128-156`). No toggle expectation to honour. + +### 4b. `GET /api/dashboard/snapshot` (if offered) + +If the server ships a per-metric seed snapshot, it should carry **all 27 widget IDs** (§2), including the 11 iOS-only ones, so a cold-launch first-paint can seed every tile without a second round-trip. Per-metric seed should use the same `MetricKind` raw values iOS decodes (`MetricKind.swift:30-122`) — note the non-obvious raw values: `spo2 = "oxygenSaturation"`, `bodyWater = "totalBodyWater"`, `hrv = "heartRateVariability"`, `bmi = "bodyMassIndex"`, `walkingAsymmetry = "walkingAsymmetryPercentage"`, `walkingDoubleSupport = "walkingDoubleSupportPercentage"`, `audioExposureEnvironment = "environmentalAudioExposure"`, `audioExposureHeadphone = "headphoneAudioExposure"`, `activeEnergy = "activeEnergyBurned"`. + +--- + +## 5. iOS final-pin commitment + +iOS pins the **final** wire field names from `docs/api/openapi.yaml` @ the `v1.7.0` tag. This document is iOS's **request** that the server adopt the PROPOSED names verbatim to minimise reconcile drift. LOCKED surfaces (FHIR LOINC/UCUM table, the 27-ID widget catalogue, walking-speed = m/s) are non-negotiable for byte-identical export — please match exactly. diff --git a/.planning/ios-coord/v1.7.0-ios-offline-sync-answers.md b/.planning/ios-coord/v1.7.0-ios-offline-sync-answers.md new file mode 100644 index 000000000..3612eb844 --- /dev/null +++ b/.planning/ios-coord/v1.7.0-ios-offline-sync-answers.md @@ -0,0 +1,376 @@ +# iOS answers — v1.7.0 offline / server-optional sync-reconcile contract + +Date: 2026-05-31. iOS team → server team. Reply to +`.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md` §7 +("iOS must specify / provide"). Companion grounding: +`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` (Pattern A) and +the iOS R4 research `.planning/v0100-marathon/R4-offline-architecture.md`. + +All claims below are grounded in the **iOS** repo at +`/Users/marc/Projects/healthlog-iOS/HealthLogIOS`, cited `file:line`. Where the +proposal's stated iOS position is wrong, it is corrected here, not echoed. + +**Read §0 first.** It bounds what iOS actually ships in v0.10.0 and reframes the +"BLOCKED on §7.1–§7.4" framing: the server is not blocked on iOS *implementing* +the consumer this cycle — only on iOS *deciding the contract shape*, which is +what this doc settles. The consumer lands in a later iOS release. + +--- + +## 0. iOS v0.10.0 scope vs this contract + +> **SCOPE UPDATE (operator directive 2026-05-31 — supersedes the "groundwork only" +> wording in this section and the "later release" mentions further down):** iOS +> v0.10.0 builds the FULL offline path THIS cycle — the groundwork primitives PLUS +> the `/api/sync/changes` consumer, push, tombstone-apply and cursor handling — +> **forward-compatible against this proposed contract, behind a standalone gate that +> is OFF by default**, unit-tested with a stub URLSession (no mock server). Live +> activation follows the server deploy. Exact field/endpoint shapes are pinned from +> `docs/api/openapi.yaml` @ v1.7.0; a final iOS→server handover +> (`v1.7.0-ios-to-server-FINAL-handover.md`) will lock every shape iOS built +> against. The §7 decisions below stand unchanged; only the "ships later" framing +> becomes "this cycle, behind a default-off gate." + +The groundwork primitives are still built first (R4 §3 Phase 0) — three additive, +paired-safe primitives: + +1. A **`BackendAvailability`** capability abstraction (R4 §3.1) — a + `@MainActor @Observable` surface views consult instead of hard-reading + `SyncModeStore.isStandalone`. **Does not exist yet** — confirmed: the only + occurrence of the name in-repo is a doc comment + (`HealthLog/Sync/SyncLifecycle.swift:75`), no type. v0.10.0 creates it. Ships + returning `true` for every capability in paired mode → zero behaviour change. +2. **401-cascade gating** (R4 §3.3) — route the doomed-in-standalone store + loads behind `BackendAvailability.hasServer`, generalising the + already-shipped Settings/Profile pattern (R4 §1.1). The 401→one-shot-refresh + bridge it must not fight already exists (`HealthLog/Services/APIClient.swift:179-201`). +3. **`HLCloudDerivedPlaceholder`** primitive (R4 §3.2) — neutral "pair to + enable" card for the ~9 Tier-B server-derived surfaces. **Does not exist + yet.** v0.10.0 builds it + a snapshot test, adopts it behind an always-true + capability flag. + +**Explicitly NOT in v0.10.0:** the `/api/sync/changes` delta consumer, the +opaque-cursor persistence, the local SwiftData mirror for manual entries +(R4 §3.4 — the one real data-layer task, deferred to a later wave), the +first-pair backfill orchestrator, and the Release standalone flip +(`OnboardingFlow.advanceFromWelcome` stays `#if DEBUG` per R4 §3.5). The full +`/api/sync/changes` consumer + standalone Release enable is a **subsequent iOS +release**, wired once the endpoints land in `docs/api/openapi.yaml` at the +v1.7.0 tag. + +**What this means for the server's "BLOCKED on §7.1–§7.4":** the answers below +are the authoritative contract decisions. The server can register +`/api/sync/state`, do the soft-delete conversion (§2.3 item 1), and ship +`/api/sync/changes` on this contract **independently of the iOS consumer +landing** — the consumer reads the contract this doc freezes. Nothing here +requires iOS to ship code first; the contract is forward-compatible with the +v0.10.0 groundwork and the later consumer alike. + +--- + +## §7.1 — First-sync domain slice + +**Recommendation: measurements-only for v1.7.0.** Mood + intakes follow once +their tombstone/version columns land (your §2.3 migration set 2/3). + +Rationale: +- Matches the server recommendation (§6) and your own feasibility table (§1.6): + `Measurement` is the only model with `updatedAt` **and** `deletedAt` **and** + `syncVersion` today. +- It is what the iOS wire layer is ready for: the measurement batch/dedup wire + is mature (`HealthLog/Models/MeasurementDTO.swift`, `MeasurementBatchUploader.swift`), + whereas mood/intakes have no `syncVersion`/tombstone concept on the wire at all + (mood DTO `HealthLog/Models/Mood.swift:32-80` has no version field; intake + bulk entry `HealthLog/Repositories/MedicationsRepository+ReminderIntake.swift:20-50` + has no version field). +- It aligns with R4's tier model: HealthKit-sourced measurements are the + Tier-A common case (R4 §2.1); mood + manual meds are the smaller manual-entry + surface that the local mirror (R4 §3.4) hasn't shipped for yet. + +**Therefore: ship migration set (2)/(3) deferred; only the soft-delete +conversion (§2.3 item 1) ships this cycle.** iOS will not consume mood/intake +deltas in the first consumer release regardless, so the columns are not on the +critical path. + +--- + +## §7.2 — Max offline window + +**Recommendation: align the incremental-delta window to the native +refresh-token lifetime, with a re-pair full backfill past it. Tombstone +retention = same window + margin.** + +Important correction to the proposal's framing: the proposal asserts native +refresh = 60 days as a server constant (§1.5, `native-client.ts:67-70`). **The +iOS client does not hardcode 60 days** — it trusts the server's issued expiry +and persists it verbatim: +`refreshTokenExpiresAt` is stored from the session response +(`HealthLog/Services/AuthService.swift:343-345`) and wiped on logout/revoke +(`AuthService.swift:126-127, 164`). So the window is **whatever the server +issues**, and 60 days is the server's number, not iOS's. iOS will adopt +"incremental-delta window == server-issued refresh-token lifetime" as a derived +value, not a second hardcoded constant. If the server changes +`refreshTokenDays`, the sync window should move with it — please keep them +coupled, or expose the window explicitly in `/api/sync/state` so iOS reads it +rather than inferring it. + +Concrete contract: +- **Incremental delta valid while** `now < refreshTokenExpiresAt` (i.e. the + device could still refresh and stay paired). Beyond that the device must + re-pair anyway (the refresh family is dead — see §7.x / your §4.3), so a + cursor-reset full resync is unavoidable and correct. +- **Tombstone retention ≥ refresh-token lifetime + margin** (server's PROPOSED + retention in §4.2 is right). With a 60-day refresh, retain tombstones ≥ ~75 + days. A device that was offline longer than the refresh lifetime has already + lost its token and re-pairs → backfill, not delta. +- **`cursorExpired: true`** (your §4.2) is the correct signal and iOS will honour + it: drop local cursor, clean initial sync. iOS treats it as authoritative — + no client-side staleness heuristic. + +Net: there is **one** window, not two. It is the refresh-token lifetime. Past +it, re-pair + backfill (idempotency-safe), not delta. + +--- + +## §7.3 — Local store schema + identity keys (tombstone identity field per domain) + +This is the most load-bearing answer; every claim is code-cited. **The identity +key is NOT uniform across domains — do not assume `externalId` everywhere.** + +### Measurements — identity = `externalId` (the cross-device key). CONFIRMED. + +- The wire row carries `externalId: String?` + (`HealthLog/Models/MeasurementDTO.swift:14`); on read-back the client maps it + into `Measurement.externalUUID` (`MeasurementDTO.swift:211, 464`; + `MeasurementsRepository.swift:430`). +- This is the same value used as the HK dedup key + (`HKSample.uuid.uuidString`, `HealthLog/Models/HealthKitBatchDTO.swift:12,22`) + and the bulk-delete reconciliation key + (`MeasurementsRepository.swift:453-461`, body `{ externalIds: [...] }`, + DTO `ExternalIDBatch`/`externalIds: [String]` `MeasurementDTO.swift:565-567`). +- **Tombstone identity field for measurements = `externalId`.** Your §2.2 draft + (`"tombstones":[{"externalId":"…","deletedAt":"…"}]`) is exactly right. + +**One caveat on the pairing-doc claim, corrected:** the pairing doc §4.1 says +the SwiftData `syncIdentifier: UUID` "is exactly the `externalId`." That holds +*as the intended design*, but note iOS today has **no SwiftData `@Model` for +measurements** — `Measurement` is a `Codable` server DTO +(`HealthLog/Models/Measurement.swift:262`, `public struct Measurement: Codable, Sendable`), +not a `@Model`, and there is **no `syncIdentifier` property anywhere in the iOS +tree today** (grep: zero hits). The `syncIdentifier`/`@Model` promotion is the +deferred A2 path (R4 §1.2, §4). For the v1.7.0 **wire** contract this changes +nothing: the cross-device identity on the wire is and stays `externalId` +(a UUID string for sample-class, `stats::` for cumulative — +`MeasurementDTO`/`AppContainer.swift:787`). So: **server keys tombstones on +`externalId`; iOS will key its eventual local mirror's `syncIdentifier` to the +same string.** Confirmed compatible, but the `syncIdentifier == externalId` +equality is a *future-local-model* statement, not a *today* one. + +### Mood entries — identity = server `id` (NOT a client-minted UUID). CONFIRMED, corrects the proposal. + +- iOS mints a **throwaway** local id `"local-"` only for the optimistic + row (`HealthLog/Repositories/MoodRepository.swift:26`; + `HealthLog/Stores/MoodStore.swift:227`), then POSTs to `/api/mood-entries` and + **replaces it with the server-canonical `MoodEntry`** returned by the server + (`MoodRepository.swift:120-127` — `postEntry` returns the decoded server row; + `req: APIRequest`). The `local-` id never reaches the server and is + not a stable cross-device key. +- All subsequent mutations key on the **server `id`**: + `update(id:patch:)` → `PATCH /api/mood-entries/[id]`, `delete(id:)` → + `DELETE …/[id]` (`MoodRepository.swift:52, 79, 103-116`). Server dedup is on + `(userId, date, moodLoggedAt)` (your §1.6) — but the **client mirrors by + server `id`**. +- **Tombstone identity field for mood = `id`** (server cuid), per your §2.2 + `mood` example `{"id":"…","deletedAt":"…"}`. Correct as drafted. iOS does + **not** have a stable client-minted mood UUID to offer; do not key mood + tombstones on anything but server `id`. + +### Medication intakes — identity = server `id`; dedup pre-insert = `idempotencyKey` + `(medicationId, scheduledFor, source)`. CONFIRMED. + +- The bulk-intake wire entry carries **no client UUID identity** — it is + `(medicationId, scheduledFor?, takenAt?, skipped, idempotencyKey?)` + (`HealthLog/Repositories/MedicationsRepository+ReminderIntake.swift:20-50`). + The `idempotencyKey` is an insert-dedup hint + (`…ReminderIntake.swift:31-35` — "server's `intake_events.idempotency_key` + UNIQUE index … duplicate POST returns the existing row"), **not** a stable + cross-device identity for tombstones. +- The natural key is `(medicationId, scheduledFor)` + (`MedicationsStore.swift:55`, `MedicationLiveActivityPlan.swift:150-164`; + server composite `(userId, medicationId, scheduledFor, source)`). The + read-back row is `MedicationIntakeWireDTO` with a server `id` + (`HealthLog/Models/Medication.swift:400-401`). +- **Tombstone identity field for intakes = server `id`.** An intake is an + immutable fact; a "correction" is tombstone + re-insert (matches your §3.2 + intake rule). iOS has no stable client UUID for an intake to offer — key + tombstones on server `id`, and let iOS reconcile via `(medicationId, + scheduledFor)` when it must match a locally-known dose. + +**Summary table (tombstone identity field iOS will dedup on):** + +| Domain | Tombstone identity | Source in iOS code | Note | +|---|---|---|---| +| Measurements | `externalId` (UUID string or `stats:…`) | `MeasurementDTO.swift:14`, `:565`; `HealthKitBatchDTO.swift:12` | cross-device stable; == future local `syncIdentifier` | +| Mood | server `id` (cuid) | `MoodRepository.swift:120-127`; `Mood.swift:33` | `local-…` is throwaway; never the key | +| Intakes | server `id` (cuid) | `Medication.swift:400-401`; `…ReminderIntake.swift:20-50` | `idempotencyKey` is insert-dedup only, not identity | + +--- + +## §7.4 — Conflict UX expectation + +**Recommendation: silent server-wins default. No merge prompt in v1.** + +This is doctrine, not a placeholder. The pairing doc §4.4 is explicit: "No +client-side conflict-prompt UI … Health data with a manual-resolve dialog every +reconnect would be unusable. Apple Health does not show one … we will not invent +one." R4 reinforces the calm-UX posture. So: + +- **Immutable domains (measurements sample-class, intakes):** there is **no + conflict** by construction — first-write-wins immutable (your §3.2; + `route.ts:276-294`). A re-post returns `duplicate`, iOS treats it as success + and advances its cursor. No UX, no prompt. +- **`stats:*` cumulative measurements:** server-overwrite, latest total wins + (your §3.2). iOS expects `updated`, no prompt — this is already how the + client thinks about cumulative rows + (`HealthLog/Models/Measurement.swift:176-187`, `stats:` prefix semantics). +- **Editable domains (mood, medication-definition rows):** **silent + server-wins** on a 409/`conflict` (the higher `syncVersion` wins; the loser's + edit is dropped). The pairing doc §4.3 rule 2 is verbatim this: "higher + `syncVersion` wins; lower is dropped … No merge UI." iOS will adopt the + server row and surface nothing modal — at most a non-blocking, dismissible + note if we later decide the dropped edit is worth a toast (not v1). +- **Deletion vs edit:** tombstone wins (pairing doc §4.3 rule 3). iOS applies + the tombstone, reverts the local edit, no prompt. + +**Payload iOS needs from the server (this is the part that sizes your +response):** +- **Immutable domains:** iOS needs **nothing extra on conflict** — a + `duplicate`/`updated` per-entry status (your `EntryStatus`) is sufficient. + Optionally echo `syncVersion` so iOS can keep its mirror's version monotonic, + but iOS does not branch UX on it. +- **Editable domains (mood / med-definition):** iOS needs the **full conflicting + server row** in the 409/`conflict` body (so it can replace its local row in + one step without a follow-up GET). Your §3.3 already returns "the current + server row in the body" — keep that; that is exactly what iOS consumes. Just + `syncVersion` alone is **insufficient** for editable domains because iOS would + then need a second round-trip to fetch the winning content. + +So: **`syncVersion` echo is enough for immutable domains; the full row is +required for editable domains.** That is the minimal payload that lets iOS stay +silent-server-wins with no extra fetch. + +--- + +## §7.5 — Page size + cursor concurrency + +**Recommendation: `limit=200` (your default), and a single combined cursor +across domains for v1. Per-domain cursors deferred.** + +- `limit=200` is fine. iOS pages until `hasMore:false` on a single cursor; + 200 keeps each page well under the 500 batch cap iOS already lives with on the + push side (measurement batch cap 500, `MeasurementBatchUploader.swift`), and + matches the server's own default. +- **Combined single cursor (your §2.2 as drafted), not per-domain, for v1.** + Reasons: (a) v1.7.0's iOS slice is measurements-only (§7.1) → there is only + one domain to page anyway, so per-domain cursors buy nothing now; (b) combined + is simpler server-side (your own note) and simpler for iOS to persist (one + opaque token, one drain-loop). Per-domain cursors only matter once iOS wants + to prioritise one domain's catch-up over another — defer to the multi-domain + release. +- iOS applies tombstones-before-upserts within each page (your §2.2 design + note) — confirmed, iOS will honour that ordering to avoid resurrecting rows. + +--- + +## §7.6 — Cursor opacity contract + +**CONFIRMED: iOS treats `cursor` as fully opaque. Echo, never parse.** + +iOS will persist the last fully-drained `cursor` verbatim and send it back +unmodified; it will never base64-decode it, inspect `(updatedAt, id)`, or depend +on any internal structure. The server is free to change the keyset/sequence +encoding (e.g. switch to a monotonic `syncVersion` sequence per your §2.1) with +no iOS release. iOS's only cursor invariants: (1) treat it as a token, (2) on +`cursorExpired:true` discard it and re-init, (3) persist it durably so it +survives app restarts (same durability the Outbox idempotency keys already have). + +--- + +## §7.7 — Backfill vs delta on first pair + +**CONFIRMED: first-pair backfill stays on the existing batch endpoints; the +delta feed is incremental catch-up ONLY, never a replacement for the initial +backfill.** + +- First-pair backfill uses `POST /api/measurements/batch`, + `POST /api/mood-entries/bulk`, `POST /api/medications/intake/bulk` exactly as + the pairing doc §3.3 specifies and as the iOS wire already implements + (`MeasurementBatchUploader.swift`; intake bulk + `…ReminderIntake.swift`; mood bulk path). Idempotency keys make re-runs safe + (`duplicate` on retry) — pairing doc §3.3 step 5. +- `/api/sync/changes` is consumed **after** the initial backfill, for + incremental deltas only (your §4.2). iOS will not attempt to bootstrap a + fresh-paired account through the delta feed. +- R4 §2.3 narrows this further for iOS's A1 path: most measurement history is + **already on the server** post-pair via the normal HK→server upload path the + moment HK background-delivery activates — so the explicit backfill iOS uploads + is bounded to manual-entry rows + GLP-1 local data, not the full HK history. + This does not change the server contract (batch endpoints, idempotent) — just + flagging that iOS's backfill volume is smaller than a naive "upload everything" + read of §3.3 implies. + +--- + +## Pushback / conflicts with R4 + pairing doc (the proposal invites it) + +1. **"BLOCKED on §7.1–§7.4" overstates the coupling.** The server is blocked on + the *contract decisions*, which this doc settles — not on iOS shipping the + consumer. iOS v0.10.0 ships groundwork only (§0); the consumer is a later + release. Please proceed with the server work (register `/api/sync/state`, + soft-delete conversion, `/api/sync/changes`) on this frozen contract without + waiting for an iOS consumer PR. + +2. **`syncIdentifier == externalId` is a future-local statement, not a today + one (corrects pairing-doc §4.1 as cited by your §7.3).** iOS has **no** + SwiftData `@Model` and **no** `syncIdentifier` property today + (`Measurement` is a `Codable` DTO, `Measurement.swift:262`; grep for + `syncIdentifier` = 0 hits). The wire identity is and stays `externalId`. Do + not design the tombstone feed around a client `syncIdentifier` field that + does not exist on the wire — key on `externalId` (measurements) / server `id` + (mood, intakes). See §7.3 table. + +3. **Refresh window is server-issued, not an iOS 60-day constant (corrects the + §7.2 framing).** iOS persists `refreshTokenExpiresAt` from the server + (`AuthService.swift:343-345`) — it does not assume 60 days. Couple the sync + window to `refreshTokenDays`, or surface the window in `/api/sync/state` so + iOS reads it. Don't let the two drift. + +4. **Mood/intake migrations are genuinely off the critical path for the first + consumer.** Since iOS's first consumer is measurements-only (§7.1) and iOS + has no mood/intake `syncVersion` on the wire today + (`Mood.swift:32-80`, `…ReminderIntake.swift:20-50`), shipping the §2.3 + set-(2) columns this cycle would be server work iOS cannot exercise yet. + Recommend deferring them with the consumer that uses them — avoids an + untested migration sitting idle. + +5. **No disagreement on the soft-delete conversion (§2.3 item 1).** It is the + right call and iOS depends on it for truthful tombstones on the long-offline + path (your §4.2; R4's data-correctness concern). Ship it this cycle. + +--- + +## One-line confirmations for the server's §8 worklist + +- §8.1 register `/api/sync/state` in OpenAPI — **yes, do it** (iOS already has a + `SyncStateDTO` consumer scaffold, R4 §1.1; registering it closes the drift). +- §8.2 soft-delete conversion — **yes, ship this cycle** (unblocks tombstones). +- §8.3 `/api/sync/changes` opaque-cursor + tombstones — **yes**, on this + contract; iOS consumer lands in a later release. +- §8.4 stable `errorCode` on `/api/auth/refresh` revoke/reuse — **yes, please** + — iOS today string-adjacent-detects refresh failure + (`AuthService.swift:282-306` returns a bare `false`); a stable + `auth.refresh.revoked` / `auth.refresh.reuse` code lets iOS distinguish + re-auth from transient network cleanly. This is a real ask, not optional. +- §8.5 mood/med/intake columns — **defer** with the multi-domain consumer (§7.1). +- §8.6 tombstone retention keyed to refresh lifetime — **yes** (§7.2). +- §8.7 regen + commit `docs/api/openapi.yaml` — **yes**; iOS treats the YAML at + the v1.7.0 tag as the single source of truth before wiring the consumer. diff --git a/.planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md b/.planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md new file mode 100644 index 000000000..1e947801e --- /dev/null +++ b/.planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md @@ -0,0 +1,35 @@ +# iOS → Server — MOOD_REMINDER double opt-in trap (non-blocking) + +Date: 2026-05-31. iOS branch `feat/v0100-marathon`. Non-blocking — iOS is shipping +its own local evening mood reminder regardless; this note is a server-side UX fix +that affects ALL users, not just iOS. + +## Symptom +A user enables the prominent "Stimmung erinnern / mood reminder" card +(`User.moodReminderEnabled = true`) and still receives NOTHING in the evening. + +## Root cause (server) +`MOOD_REMINDER` delivery is gated by a DOUBLE opt-in: +1. `User.moodReminderEnabled = true` — the obvious card. ✅ user flips this. +2. a per-event APNS preference that **defaults OFF**: + `EVENT_DEFAULT_ENABLED.MOOD_REMINDER = false` (`types.ts:51`). With no pref row + the dispatcher hits `else if (!defaultEnabled) continue` (`dispatcher.ts:142-143`) + and silently drops every push. +The cron itself is correct (runs ~every 15 min, fires in the 22:00-local window, +correctly skips users who already logged today). The drop is purely the buried +second toggle that users never find. + +## Ask (server, low-effort, your call) +Collapse the double opt-in for this event: derive the APNS event-enable from +`moodReminderEnabled` (i.e. enabling the card auto-enables the event), OR flip +`EVENT_DEFAULT_ENABLED.MOOD_REMINDER` to `true` so the card alone suffices. +Optional nice-to-have: a per-user reminder HOUR (today the window is hardcoded +22:00; a settable hour would let iOS + web align to one source of truth instead of +iOS scheduling its own). + +## What iOS is doing meanwhile (FYI, no server action needed) +Shipping an iOS-LOCAL evening mood reminder (`UNCalendarNotificationTrigger`, +user-settable time, default 20:00) that re-arms on foreground/day-rollover, cancels +itself when a mood entry exists for today, and deep-links to the mood quick-entry +sheet. Works offline, no server dependency. If you add the per-user hour above we'll +read it as the source of truth; until then iOS owns the local schedule. diff --git a/.planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md b/.planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md new file mode 100644 index 000000000..f246ea558 --- /dev/null +++ b/.planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md @@ -0,0 +1,52 @@ +# iOS → Server — v1.7.0 landed: confirmations + snapshot go-ahead + +Date: 2026-05-31. Reply to `v1.7.0-server-to-ios-confirmation.md`. iOS branch +`feat/v0100-marathon`. Thanks — all our locks adopted verbatim; we'll pin from +`docs/api/openapi.yaml` @ the `v1.7.0` tag before relying on anything live. + +## 1. Snapshot — YES, we want it as a cold-launch seed +Please add the two missing pieces so `GET /api/dashboard/snapshot` becomes +iOS-adoptable (your open ask #1): +- **`metricStates` block** keyed to our `MetricKind` raw values (the non-obvious + raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, + `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, + `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`; + full list in `v1.7.0-ios-convergence-locks.md`). Per metric we need the latest + value + timestamp + unit so first-paint needs no fan-out. +- **27-id layout block** (16 server-known + 11 iOS-only) so the layout round-trips. +- Keep it **one-key SWR-cacheable** + **additive** (per-store endpoints stay). +This directly serves our cold-launch ≤300 ms p95 budget. Treat iOS adoption as a +bonus on top of the web first-paint goal — ship the envelope additive and we wire +it opportunistically. + +## 2. AASA — acknowledged, not blocking +We have a passkey **timeout watchdog** + gate the passkey CTA to the default host, +so enrolment degrades gracefully if AASA isn't live yet. Please confirm the live +`/.well-known/apple-app-site-association` state here post-deploy; we'll flip full +trust on the default host once you confirm. + +## 3. iOS-side pin / reconcile plan (for your awareness) +When iOS pins to the v1.7.0 tag, we will: +- Decode **`scheduleType` (`SCHEDULED|PRN|CYCLIC`)** as the authoritative cadence + discriminator (we already decode `asNeeded` + `cycleWeeksOn/Off/cycleAnchor`; + `scheduleType` is the cleaner switch — thanks for adding it). Confirm it appears + on every schedule row in the YAML. +- **Remove** our widget workarounds `filteringForServer()` / + `byRestoringIosOnlyWidgets()` / `byMergingIosOnlyDefaults()` (now that unknown + ids round-trip). +- Our compliance path **auto-upgrades** to your canonical numbers on field + presence (`due`/`expectedCount`) — the `min(cap)` + `.noSchedule` short-circuit + drop is already gated on it. PRN → null adherence / zero missed, handled. +- **W-Sync consumer** (this iOS cycle, behind a default-off standalone gate) builds + against your now-frozen `/api/sync/changes` contract: opaque base64 cursor + (echo-only), measurements-only, single combined cursor, `limit` 200 / cap 500, + tombstones keyed on `externalId` before upserts, `cursorExpired`, `syncVersion` + echo, window read from `GET /api/sync/state`, refresh `errorCode` + (`auth.refresh.revoked|reuse|invalid`). Mood/intake deferred per our §7.1. + +## 4. Health-record export convergence — noted +`POST /api/export/health-record` (`pdf|fhir|package`) received. Our on-device FHIR +stays for offline/standalone; we'll converge the online path onto your endpoint in +a follow-up once it's tagged (your bundle is the superset). No blocker this cycle. + +All clear from our side — tag when ready; we pin from the YAML. diff --git a/.planning/ios-coord/v1.7.0-ios-to-server-reply.md b/.planning/ios-coord/v1.7.0-ios-to-server-reply.md new file mode 100644 index 000000000..0597385ba --- /dev/null +++ b/.planning/ios-coord/v1.7.0-ios-to-server-reply.md @@ -0,0 +1,238 @@ +# iOS → Server reply — v1.7.0 cycle + +Date: 2026-05-31. Reply to the three "Open asks back to iOS" in §10 of +`v1.7.0-server-to-ios-handover.md`. All claims are evidence-cited against the +iOS repo (`/Users/marc/Projects/healthlog-iOS/HealthLogIOS`, branch +`feat/v0100-marathon`). File:line references are authoritative as of this tag. + +--- + +## Ask 1 — What does iOS export today (doctor handover)? + +iOS ships **four** distinct export surfaces, reachable from +`Settings → Export` (`HealthLog/Screens/Settings/Sub/SettingsExportScreen.swift:9-130`). +Two are doctor-facing, two are portability/backup. Build the server superset +against all four. + +### 1a. Server-rendered Doctor Report (PDF) +- **Format:** `application/pdf`, server-generated. +- **Endpoint:** `POST /api/doctor-report/pdf`, body `{ days: Int, locale: String }`, `Accept: application/pdf` + (`HealthLog/Services/DoctorReportService.swift:11-28`). +- **Selection:** date-range only (`days`); no per-domain toggles on this path. +- This is the existing server surface — the server already owns its field set. + +### 1b. Local Doctor Report (on-device PDF) — **the richest iOS-only emitter** +- **Format:** PDF rendered fully on-device (PDFKit), zero server roundtrip + (`HealthLog/Screens/Settings/Sub/LocalDoctorReportScreen.swift`, + `HealthLog/Stores/LocalDoctorReportStore.swift`). +- **Date-range — user-selectable:** 30 / 60 / 90 / 180 / 365 days + (`LocalDoctorReportStore.swift:152-172`, `LocalDoctorReportPeriod`). +- **Domains — user-selectable per-section toggles** + (`DoctorReportSectionSelection`, `HealthLog/Services/PDF/DoctorReportSpec.swift:293-322`; + UI toggles `LocalDoctorReportScreen.swift:59-94`): + | Toggle | Domain | + |---|---| + | `vitals` | Vitals summary (per-metric mean/min/max rows) | + | `charts` | History time-series (per metric, per point) | + | `medications` | Medication list (active + archived) | + | `adherence` | Einnahmetreue / compliance (`ComplianceDay`) | + | `mood` | Mood entries (`MoodEntry`) | +- **Underlying data domains** fed into the spec + (`HealthLog/Services/PDF/DoctorReportSpecBuilder.swift:22-29`, `Snapshot`): + `measurements: [Measurement]`, `medications: [Medication]`, + `compliance: [ComplianceDay]`, `intakes: [MedicationIntake]`, + `moodEntries: [MoodEntry]`, plus `patientName` + `appVersion`. + +### 1c. FHIR R4 export (on-device, machine-readable) — **directly relevant to the server's interchange-format decision** +- **Format:** FHIR R4 **Document** `Bundle`, JSON, `.fhir.json`, on-device, zero + server roundtrip (`HealthLog/Screens/Settings/Sub/SettingsFHIRExportScreen.swift`). + Assembler: `HealthLog/FHIR/DoctorReportToFHIRBundle.swift:80-197`. +- **Date-range — user-selectable:** same 30/60/90/180/365 set + (`SettingsFHIRExportScreen.swift:29,82-93`); default 90 days. +- **Domains:** uses `selection: .all` (no per-domain toggle on the FHIR path — + emits everything the snapshot carries; `SettingsFHIRExportScreen.swift:182-190`). +- **Resource graph emitted** (`DoctorReportToFHIRBundle.swift`): + - `Composition` (first entry, mandatory for `.document`; type LOINC `11503-0` + "Medical records"; sections "Vital signs" + "Medications"; lines 134-152, 322-363). + - `Patient` (name only, fresh per-export id; lines 199-219). + - `Observation` per vitals row **and** per chart point. Vital-signs LOINC-coded + via `MetricFHIRMapper`; UCUM units; BP emitted as systolic/diastolic + components (lines 91-111, 221-283). + - `MedicationStatement` per active (status `active`) + archived (status + `completed`); dose+schedule as free-text `Dosage` (lines 113-132, 285-318). + - `DiagnosticReport` (last entry; LOINC `85353-1` vital-signs panel; `effective` + = report period; routes all Observation refs as `result[]`; lines 154-162, 372-404). +- **Note:** the server handover (§8) names FHIR R4 as the leading interchange + candidate. **iOS already emits FHIR R4 today** — so a server FHIR R4 export + would be a true superset/convergence, not a fork. Recommend the server reuse + the same LOINC/UCUM mapping conventions (`HealthLog/FHIR/MetricFHIRMapper.swift`, + `LOINCCode.swift`) so a Bundle from either side is interchangeable. + +### 1d. Full backup (JSON / CSV) — portability +- **Format:** JSON **or** CSV, user-selectable via segmented picker + (`HealthLog/Screens/Settings/ExportScreen.swift:10-20,49-56`). +- **Server-generated:** `POST /api/export`, body `{ format }`, + `Accept: application/json` | `text/csv` (`ExportScreen.swift:104-115`). +- **Domains (per in-app copy):** measurements, medications, mood, audit — + "same format as the weekly auto-backup", GDPR Art. 20 portability + (`ExportScreen.swift:34-37`). The server already owns this payload's field set. + +**Superset checklist for the server:** vitals (LOINC/UCUM-coded measurements), +time-series charts, medications (active + archived, dose + schedule), +adherence/compliance (per-day), mood, intakes, audit. Selectable date-range +(30/60/90/180/365) and selectable domains (5 section toggles) are the iOS UX +contract the server export should match or exceed. + +--- + +## Ask 2 — Which widget IDs are iOS-only? + +Canonical enum: `DashboardWidgetId` in +`HealthLog/Models/DashboardWidgetLayout.swift:73-235`. Wire shape of +`GET / PUT /api/dashboard/widgets` is `DashboardWidgetLayout` (lines 16-56). + +iOS already maintains an explicit split between **server-known** ids (accepted +by the server's current Zod enum) and **iOS-only** ids (which currently 422 — +issue #11). The server's accept-and-ignore decision should allowlist exactly the +iOS-only set below. + +### Server-known ids (16) — iOS sends these and expects them to round-trip +Source of truth: `DashboardWidgetId.serverKnownIds` +(`DashboardWidgetLayout.swift:137-142`): + +``` +weight, bp, pulse, bodyFat, mood, medications, +sleep, steps, glucose, totalBodyWater, boneMass, +bpInTarget, oxygenSaturation, achievements, vo2Max, +recentWorkouts +``` + +### iOS-only ids (11) — **no current server enum counterpart; allowlist these** +Every id below is in `DashboardWidgetLayout.default` (so iOS sends them on a +PUT) but is **absent** from `serverKnownIds`. These are the ids the server must +accept-and-ignore (`DashboardWidgetId` constants, lines 100-118; default layout +lines 295-355): + +| Widget id | Backing `MetricKind` | Origin | +|---|---|---| +| `restingHeartRate` | `.restingHeartRate` | v0.5.2 HK-completeness | +| `hrv` | `.hrv` | v0.5.2 HK-completeness | +| `walkingSpeed` | `.walkingSpeed` | v0.5.2 HK-completeness | +| `walkingAsymmetry` | `.walkingAsymmetry` | v0.5.2 HK-completeness | +| `walkingStepLength` | `.walkingStepLength` | v0.5.2 HK-completeness | +| `bmi` | `.bmi` | v0.5.2 HK-completeness | +| `bodyTemperature` | `.bodyTemperature` | v0.5.2 HK-completeness | +| `walkingDoubleSupport` | `.walkingDoubleSupport` | v0.7.0 HK adopt-and-stream | +| `respiratoryRate` | `.respiratoryRate` | v0.7.0 HK adopt-and-stream | +| `audioExposureEnvironment` | `.audioExposureEnvironment` | v0.7.0 HK adopt-and-stream | +| `audioExposureHeadphone` | `.audioExposureHeadphone` | v0.7.0 HK adopt-and-stream | + +**Today's iOS workaround (issue #11):** the PUT payload is filtered to +`serverKnownIds` via `DashboardWidgetLayout.filteringForServer()` +(`DashboardWidgetLayout.swift:423-428`), and the iOS-only rows are re-spliced +locally after the round-trip (`byRestoringIosOnlyWidgets(from:)` lines 443-449, +`byMergingIosOnlyDefaults()` lines 467-487). Once the server accept-and-ignores +these 11 ids (handover §1 / #11), iOS will **delete** the filter + both splice +helpers and send the full 27-id layout unmodified. The TODO markers +(`TODO(issue #11, 2026-05-24, marc)`) are the removal anchors. + +**Server allowlist recommendation:** accept the union of the 16 server-known ids ++ the 11 iOS-only ids = 27 ids, persisting visibility/order for all and ignoring +the unknown-to-web ones (web has no chart-row for them — `visible:false` already +in the iOS default). Do **not** 422 on any of the 11. + +--- + +## Ask 3 — Would iOS consume `GET /api/dashboard/snapshot`? + +**Recommendation: MAYBE — leaning yes for the cold-launch network path, but it +does not unblock the p95 first-paint budget (cache already does that).** + +### How the iOS dashboard loads today +First paint is **cache-first SWR**, not network. `DashboardStore.load()` observes +the `.dashboardSummary` SWR key and paints from the SwiftData cache in the +`.cached` arm "sub-100ms when SwiftData is warm" +(`HealthLog/Stores/DashboardStore.swift:74-115`, comment lines 92-95). So the +CLAUDE.md **cold-launch ≤300ms p95 / tile cache-paint ≤100ms p95** budgets are +met **from cache**, independent of any network round-trip count. + +### Round-trip count on the network (revalidate) path +The dashboard `.task` fans out across **8 independent stores**, each with its own +endpoint(s) (`HealthLog/Screens/Dashboard/DashboardScreen.swift:260-270`): + +| Store | Endpoint(s) | +|---|---| +| `DashboardStore` | `GET /api/dashboard/summary` | +| `DashboardLayoutStore` | `GET /api/dashboard/widgets` | +| `InsightsStore` (comprehensive) | `GET /api/insights/comprehensive` | +| `HealthScoreStore` | (health-score endpoint) | +| `MeasurementsStore` | `GET /api/measurements*` | +| `BriefingStore` | (briefing endpoint) | +| `MoodStore` | `GET /api/mood-entries` | +| `MedicationsStore` | `GET /api/medications` (+ compliance/intake) | + +Plus a **per-metric series fan-out** via `refreshMetricStates(...)` +(`DashboardScreen.swift:440`) and a detached prefetch of the top-3 vital-sign +series + a wide `recentAll(limit:400)` +(`HealthLog/Stores/AppContainer+Prefetch.swift:24-43`). That is well into +double-digit concurrent requests on a cold (cache-miss) network revalidate. + +### Verdict +- **Yes-ish:** a single `GET /api/dashboard/snapshot` that folds + summary + widgets + comprehensive-insights + health-score + briefing + + upcoming-meds/compliance + mood into one payload would meaningfully cut the + **cold cache-miss** revalidate fan-out (8 stores → 1 round-trip), reduce + radio wake-ups (battery — relevant to our energy budget), and remove + multi-request tail-latency on first-ever launch / post-logout. +- **But not load-bearing for the p95 budgets:** those are already satisfied by + the SWR cache-paint. The snapshot helps the *network* path and battery, not + the perceived first-paint on a warm device. +- **Conditions to actually adopt:** + 1. Snapshot must be **SWR-cacheable as one key** so iOS can keep the + cache-first paint model (one `SWRCoordinator` key instead of 8). + 2. Must carry the **per-metric latest-value/state** seed the tiles need + (`metricStates`), else iOS still fans out `refreshMetricStates`. + 3. Must include the **iOS-only widget ids** (Ask 2) in the layout block, or + iOS keeps a second widgets call. + 4. Keep it **additive** — iOS will only migrate stores onto it incrementally; + the per-store endpoints must remain. + +If those hold, iOS would adopt it as the cold-launch seed and keep per-store +endpoints for targeted refreshes. If it is web-shaped (no per-metric seed, no +iOS widget ids, not SWR-keyable), iOS will pass for now. + +--- + +## v1.7.0 dispositions iOS will consume + +Acknowledging the server's committed v1.7.0 items; iOS plans accordingly: + +- **SB-LA-1 / SB-AK-1 booleans** (`liveActivityEnabled`, `criticalAlarmEnabled`, + default `false`, GET/PUT on the medication contract): iOS will decode + wire to + the existing per-medication delivery-scope UI + (`HealthLog/Screens/Medications/EditMedicationSheet.swift` scope pickers). +- **`nextDueAt` (read-only):** iOS will decode it as nullable and treat it as + server-authoritative; will **not** send it. `null` ⇒ PRN. +- **SB-SCHED-2 canonical compliance:** once pinned to v1.7.0, iOS will **drop** + its `min(scheduleExpected, serverExpected)` cap + (`HealthLog/Stores/MedicationDetailStore.swift:384-391`) and the `.noSchedule` + short-circuit (`MedicationDetailStore.swift:347`, `:439-470`, `:493`, `:518`) + in favour of the server's authoritative `totalExpected`/per-day `expected`. + Will also consume the additive per-day `due`/`expectedCount` field to stop + rendering empty marks on non-due days. +- **PRN + cyclic (SB-SCHED-5):** iOS will decode + wire once the YAML at tag + locks the field names. **Proposed names iOS is anticipating:** + `asNeeded` / `prn` (PRN boolean); `cycleWeeksOn` / `cycleWeeksOff` + anchor + (cyclic on/off weeks). iOS will **not** hard-code these until pinned. +- **#11 widget accept-and-ignore:** on landing, iOS removes + `filteringForServer()` + `byRestoringIosOnlyWidgets(from:)` + + `byMergingIosOnlyDefaults()` and sends the full 27-id layout (see Ask 2). +- **#9 APNs suppression + multi-weekday projection:** iOS already sends + `clientManaged` via `PATCH /api/auth/me/notification-prefs`; no new client + field expected. iOS relies on the server-internal correctness fix so + device-owned local scheduling (AlarmKit / SpeziScheduler) doesn't double-fire. + +> One-line note: iOS is building the recurrence engine + UI **forward-compatible +> now** and will pin the exact field names from `docs/api/openapi.yaml` @ the +> `v1.7.0` tag (canonical) before enabling any decode that depends on PROPOSED +> names. diff --git a/.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md b/.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md new file mode 100644 index 000000000..8ce29279f --- /dev/null +++ b/.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md @@ -0,0 +1,471 @@ +# Offline / server-optional sync-reconcile contract — server-side proposal + +Date: 2026-05-31. Server team → iOS team. Companion to +`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` (Pattern A, +already decided) and `.planning/ios-coord/v1.7.0-server-to-ios-handover.md` §1 +("Offline / server-optional mode" — deferred, "specced separately when +pursued"). This is that spec. + +The intent is to flip the dependency direction: rather than the server waiting +for iOS to define a contract, the server team proposes the full contract here so +iOS can implement against a concrete spec. Everything marked **PROPOSED** is +design-in-progress and open for iOS to push back on. Items marked **EXISTS** +are already on the wire today with the cited file:line. The +**"iOS must specify"** section at the end lists the open questions only the +client can answer; fill them in and hand this back. + +**Single source of truth at ship time:** `docs/api/openapi.yaml` at the release +tag. Nothing in this doc is on the wire until the Zod registry under +`src/lib/openapi/` carries it and `pnpm openapi:generate` has emitted the YAML. +The `/api/sync/state` endpoint described below as EXISTS is, notably, **not yet +in the OpenAPI registry** (see §1) — that is a gap this cycle must close. + +--- + +## 1. Current state — what already exists server-side + +### 1.1 `GET /api/sync/state` — handshake endpoint EXISTS + +`src/app/api/sync/state/route.ts:38-93`. Confirmed present and wired. It is the +only `/api/sync/*` route in the tree (`src/app/api/sync/state/route.ts` is the +sole file under `src/app/api/sync/`). Today it: + +- Requires auth via `requireAuth()` (cookie OR Bearer; iOS uses Bearer) — + `route.ts:39`. +- Returns a measurements-only summary: `userId`, `timezone`, `lastSyncedAt` + (the previous checkpoint), `serverNow` (for clock-skew), and a `measurements` + block with `lastUpdatedAt` (MAX `updated_at` of live rows), `liveCount` + (`deletedAt IS NULL`), and `tombstonedCount` (`deletedAt IS NOT NULL`) — + `route.ts:82-92`. +- Has a **side effect**: every call bumps `User.lastSyncedAt` to "now" and + returns the *previous* value (`route.ts:66-71`). The call IS the checkpoint. + This is a fragile design for a real delta feed (see §2.4) — a GET with a write + side-effect means two concurrent foreground refreshes race the checkpoint and + one of them advances past rows the other never saw. +- Is **not registered in the OpenAPI registry** — no match in + `src/lib/openapi/` or `docs/api/openapi.yaml`. The route docblock claims a + "Locked contract" cross-reference but the YAML does not carry it. **This cycle + must add it to the registry** so CI's `openapi:check` covers it. + +### 1.2 Soft-delete `deletedAt` — column EXISTS on Measurement only; tombstone WRITE path does NOT + +This is the single most important current-state finding and it contradicts the +schema's own comments. + +- `Measurement.deletedAt` exists (`prisma/schema.prisma:590`) alongside + `syncVersion Int @default(1)` (`schema.prisma:585`), `updatedAt` + (`schema.prisma:594`), `createdAt`, and `externalId`. The schema comment at + `schema.prisma:585-594` describes `syncVersion` as the LWW reconciliation + counter and `deletedAt` as the soft-delete tombstone the "DELETE path on the + by-external-ids endpoint flips." +- **That claim is false in the current code.** Both deletion routes hard-delete: + - `DELETE /api/measurements/[id]` calls `prisma.measurement.delete(...)` — + `src/app/api/measurements/[id]/route.ts:186`. + - `DELETE /api/measurements/by-external-ids` calls + `prisma.measurement.deleteMany(...)` — + `src/app/api/measurements/by-external-ids/route.ts:102-107`. +- The **only** code path that ever writes a `deletedAt` timestamp is the legacy + step-consolidation worker: `src/lib/measurements/consolidate-legacy-steps.ts:310` + (`data: { deletedAt: new Date() }`). + +Consequence: `tombstonedCount` in `/api/sync/state` today reflects *only* +consolidation tombstones, never user deletions. A tombstone-based pull feed is +**not feasible without first converting the two DELETE routes to soft-delete** +(see §2.3, PROPOSED). `deletedAt` does not exist at all on `Medication`, +`MedicationIntakeEvent`, or `MoodEntry`. + +- Read paths already filter `deletedAt: null` broadly and correctly — e.g. + `src/app/api/measurements/route.ts:100`, `series/route.ts:147`, + `analytics/route.ts:319`, `src/lib/ai/coach/snapshot.ts`, + `src/lib/rollups/measurement-rollups.ts`. So flipping the DELETE routes to + soft-delete is read-side-safe: every list/analytics surface already hides + tombstoned rows. + +### 1.3 Measurement batch ingest — the closest existing sync primitive EXISTS + +`POST /api/measurements/batch` — `src/app/api/measurements/batch/route.ts`. + +- `apiHandler` + `withIdempotency` wrapped (`route.ts:130`), `requireAuth` + (`route.ts:133`), per-user rate limit `measurements:batch:${user.id}` at + 60 batches/min (`route.ts:141-152`, `BATCH_RATE_LIMIT_MAX = 60`). +- Cap 500 entries/batch (`MAX_BATCH_ENTRIES`, `route.ts:62`); over-cap → + 422 `measurement.batch.too_large` (`route.ts:161-171`). +- Per-entry status `"inserted" | "updated" | "duplicate" | "skipped"` + (`EntryStatus`, `route.ts:113`) so the client advances its cursor per row. +- Dedup keyed on the `(user_id, type, source, external_id)` composite unique + index (`schema.prisma` `@@unique([userId, type, source, externalId])`, + ~line 624). +- `stats:` externalId prefix → **overwrite** (`isStatsExternalId`, + `route.ts:120-123`; `toOverwrite` updateMany at `route.ts:296-321`). Every + other prefix keeps the immutable first-write-wins `duplicate` contract + (`route.ts:276-294`). +- `auditLog` + `annotate({ action: { name: "measurement.batch.ingest" } })`. + +This is the template every PROPOSED push endpoint below copies. + +### 1.4 Idempotency — reusable for push dedup EXISTS + +`src/lib/idempotency.ts`. User-scoped, length-bounded +(`KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/`, `idempotency.ts:21`), 24h TTL +(`TTL_MS`, `idempotency.ts:18`), composite unique +`(userId, key, method, path)` (`IdempotencyKey` model, +`schema.prisma:1899-1916`, `@@unique([userId, key, method, path])`). Replays +return the cached envelope with `X-Idempotent-Replay: true` +(`idempotency.ts:78-83`). A client cannot replay another user's key. The cache +helper refuses to persist secret-shaped bodies. **Reuse verbatim for every push +batch** — no new mechanism needed. + +### 1.5 Auth + per-device refresh rotation EXISTS + +- Token policy: `src/lib/auth/native-client.ts:67-71` — native clients get + `accessTokenDays: 1`, `refreshTokenDays: 60`. Web gets `accessTokenDays: 90`, + `refreshTokenDays: null` (no refresh) — `native-client.ts:60-64`. +- `issueAccessAndRefresh` mints an access `ApiToken` (`["*"]` scope) + a hashed + `RefreshToken` row bound to the optional `deviceId` (X-Device-Id) — + `src/lib/auth/refresh-token.ts:42-83`. +- `rotateRefreshToken` is one-time-use: validates, marks `usedAt`, sets + `replacedById`, revokes the paired access token (`refresh-token.ts:100-200`). + A second use of a consumed token is reuse-detection: it revokes the whole + family scoped to that `deviceId` when known, else user-wide + (`refresh-token.ts:117-150`). `RefreshToken` carries `deviceId`, + `accessTokenHash`, `expiresAt`, `revokedAt`, `usedAt`, `replacedById` + (`schema.prisma:1317-1345`). + +### 1.6 `updatedAt` / `createdAt` availability per model (change-cursor feasibility) + +| Model | `createdAt` | `updatedAt` | `deletedAt` | `syncVersion` | externalId/dedup key | +|---|---|---|---|---|---| +| `Measurement` | yes (`:593`) | **yes** (`:594`) | **yes** (`:590`) | **yes** (`:585`) | `external_id` + `(userId,type,source,externalId)` unique | +| `Medication` | yes (`:884`) | **yes** (`:885`) | **no** | no | none (cuid PK only) | +| `MedicationSchedule` | no | no | no | no | none | +| `MedicationIntakeEvent` | yes (`:1026`) | **no** | no | no | `idempotencyKey` unique (`:1025`) + `(userId,medicationId,scheduledFor,source)` unique | +| `MoodEntry` | yes (`:1442`) | **yes** (`:1443`) | **no** | no | `(userId,date,moodLoggedAt)` unique | + +Takeaway: only `Measurement` is ready for an `updatedAt`-watermark + tombstone +delta feed today. `Medication` and `MoodEntry` have `updatedAt` but no +tombstone column. `MedicationSchedule` and `MedicationIntakeEvent` lack +`updatedAt` entirely. Any cross-domain delta feed needs additive migrations +(§2.3). + +--- + +## 2. PROPOSED — Pull (server → client) delta feed + +### 2.1 Cursor strategy — recommendation: opaque cursor wrapping an `(updatedAt, id)` keyset + +**Recommended: an opaque base64 cursor that encodes `(updatedAt-high-water-mark, +last-id)`, NOT a bare `updatedAt` timestamp and NOT the current +`lastSyncedAt`-bump handshake.** + +Rationale: + +- A bare `updatedAt` watermark loses rows when many share the same millisecond + (a 500-row backfill writes hundreds of rows in the same tick). A keyset cursor + `(updatedAt, id)` with a stable tie-breaker (`id` ascending) never skips or + double-counts within a page boundary. +- An opaque cursor lets the server change the internal ordering later (e.g. + switch to a monotonic `syncVersion` sequence) without an iOS-visible contract + break — iOS treats it as a token to echo back, never parses it. +- The current `/api/sync/state` GET-with-write-side-effect (§1.1) must **not** be + the cursor of record for a delta feed. Keep `/api/sync/state` as a cheap + "should I sync?" summary; introduce a separate cursor for the actual feed so a + racing foreground refresh cannot advance the checkpoint past unseen rows. +- `serverNow` stays in every response for clock-skew (§4.1). + +The cursor is monotonic and resumable: the client persists the last cursor it +fully drained, and on reconnect sends it back. + +### 2.2 Endpoint — `GET /api/sync/changes` (PROPOSED) + +``` +GET /api/sync/changes?domains=measurements,mood,medications&cursor=&limit=200 +Authorization: Bearer +``` + +Request (query params, PROPOSED): +- `domains` — comma list; subset of `measurements | mood | medications | intakes | profile | settings`. Default: all paired domains. iOS picks its first-sync subset (see §6). +- `cursor` — opaque token from the previous page; omit for a full initial sync. +- `limit` — 1..500, default 200. Server may return fewer. + +Response envelope (PROPOSED, inside the standard `{ data, error, meta }`): + +```jsonc +{ + "data": { + "serverNow": "2026-06-01T08:00:00.000Z", + "cursor": "", // echo into the next request + "hasMore": true, // false when the client is caught up + "changes": { + "measurements": { + "upserts": [ { /* measurement wire row incl. syncVersion, externalId, updatedAt */ } ], + "tombstones": [ { "externalId": "uuid-…", "deletedAt": "…Z" } ] + }, + "mood": { "upserts": [ … ], "tombstones": [ { "id": "…", "deletedAt": "…Z" } ] }, + "medications": { "upserts": [ … ], "tombstones": [ … ] }, + "intakes": { "upserts": [ … ], "tombstones": [ … ] } + } + }, + "error": null, + "meta": { "requestId": "…" } +} +``` + +Design notes: +- **Tombstones are first-class** and carry the identity key the client dedups on + (`externalId` for measurements — the only stable cross-device key; `id` for + records the client mirrors by server id). The client must apply tombstones + before upserts within a page to avoid resurrecting a row. +- Pagination is per-call across all requested domains under one shared cursor. + When `hasMore` is false the client is fully caught up as of `serverNow`. +- Read-side filtering: `upserts` exclude `deletedAt IS NOT NULL`; `tombstones` + are exactly the rows whose `deletedAt` moved into the cursor window. Existing + read paths already filter `deletedAt: null` (§1.2) so the upsert query reuses + that predicate. +- `apiHandler` + `requireAuth`, rate-limited `sync:changes:${user.id}` (PROPOSED + 120/min — pull is cheap and idempotent), `annotate({ action: { name: "sync.changes.pull" } })`. + +### 2.3 Prerequisite migrations (PROPOSED, additive) + +For the feed to cover more than measurements: + +1. **Convert both measurement DELETE routes to soft-delete** — flip + `deletedAt = now()` + bump `syncVersion` instead of `delete`/`deleteMany` + (`measurements/[id]/route.ts:186`, `by-external-ids/route.ts:102`). Read-safe + (§1.2). This is the change that makes `tombstonedCount` and the tombstone + feed truthful. +2. **Add `deletedAt DateTime?` + `syncVersion Int @default(1)` + `updatedAt`** + to `MoodEntry` (has `updatedAt`, needs the other two) and `Medication` (has + `updatedAt`, needs the other two). Add `updatedAt` + `deletedAt` + + `syncVersion` to `MedicationIntakeEvent` (has neither `updatedAt` nor + tombstone). All additive; backfill `syncVersion = 1`, `deletedAt = NULL`. +3. Pre-allocate the migration numbers in the dispatch brief (the repo has a + known parallel-agent migration-number collision pattern; the next free prefix + is above `0088`). + +If iOS confirms (in §7) that v1.7.0 offline sync covers **measurements only** to +start, migration set (2)/(3) defers and only (1) ships this cycle. + +### 2.4 Why not keep the `lastSyncedAt`-bump handshake as the feed + +Restated for the record: a GET that mutates `User.lastSyncedAt` (§1.1, +`route.ts:66-71`) cannot be the delta cursor — two foreground refreshes race, +the loser's checkpoint advances past rows it never received, and they are lost +until a full resync. Keep `/api/sync/state` as the cheap summary; the durable +cursor lives in `/api/sync/changes` and is owned by the client, not bumped +server-side. + +--- + +## 3. PROPOSED — Push (client → server) batched upload + conflict resolution + +Push reuses the existing batch endpoints verbatim where they exist. No new +mechanism for measurements/mood/intakes — only conflict-rule clarification and, +for editable records, a versioned-write contract. + +### 3.1 Endpoints reused (EXIST) + +| Domain | Endpoint | Status | Conflict semantics today | +|---|---|---|---| +| Measurements | `POST /api/measurements/batch` | EXISTS | first-write-wins immutable (sample-class) / `stats:` overwrite — §1.3 | +| Mood | `POST /api/mood-entries/bulk` | EXISTS (per CLAUDE.md batch list) | dedup on `(userId,date,moodLoggedAt)` unique | +| Intakes | `POST /api/medications/intake/bulk` | EXISTS (per CLAUDE.md batch list) | dedup on `idempotencyKey` + `(userId,medicationId,scheduledFor,source)` | + +### 3.2 Conflict-resolution rule — one line per domain (PROPOSED) + +- **Measurements (sample-class, `uuid-*`/HK identifiers):** canonical-reading + immutability — first write wins, re-post returns `duplicate`, value never + changes (matches `route.ts:276-294`). +- **Measurements (`stats:*` per-day cumulative):** server overwrites on re-post + (latest total wins), returns `updated` (matches `route.ts:284`, + `296-321`). +- **Mood entries:** last-write-wins keyed by `(userId, date, moodLoggedAt)` — + the client's most recent edit of a given local-day entry replaces the row; + resolve via the PROPOSED versioned-write guard (§3.3) once `syncVersion` lands + on `MoodEntry`. +- **Medication intake events:** idempotent insert, never updated — an intake is + an immutable fact (taken/skipped at a time); dedup on `idempotencyKey` / + `(userId,medicationId,scheduledFor,source)`. A "correction" is a tombstone + + re-insert, not an in-place edit. +- **Medications (the definition row: name/dose/schedule/flags):** + last-write-wins keyed by `syncVersion` (§3.3) — editable record, the higher + `syncVersion` wins; equal `syncVersion` with differing content is a conflict + the server rejects with 409 and the current row (§3.4). +- **Profile (User: heightCm, dateOfBirth, gender, timezone):** + last-write-wins, server-authoritative timestamp; single-writer-per-account in + practice so a plain `PATCH /api/auth/me` is sufficient — no batch. +- **Settings (AppSettings / notification prefs / coach prefs):** + last-write-wins, server-authoritative; existing PATCH endpoints. Device-scoped + preferences (the v1.7.0 delivery-pref override, see the handover doc §3) are a + separate axis and do not conflict across devices by construction. + +### 3.3 Versioned-write guard for editable records (PROPOSED) + +For `Medication` and `MoodEntry` once `syncVersion` lands (§2.3): + +- Client sends the `syncVersion` it last saw on `PUT /api/medications/{id}` / + the mood upsert. +- Server compares: if `incoming.syncVersion === stored.syncVersion`, apply the + write and `syncVersion = stored.syncVersion + 1`; return the new row. +- If `incoming.syncVersion < stored.syncVersion`, the client is stale → **409** + with the current server row in the body so the client reconciles (last-writer + by wall-clock is then an iOS-UX decision, see §7). +- This mirrors `HKMetadataKeySyncVersion` semantics the iOS team already chose in + the pairing doc (`22-standalone-and-server-pairing.md` §4). + +### 3.4 Partial-failure semantics + +Every push batch returns per-entry status exactly like the measurement batch +(`EntryStatus`, `route.ts:113`): `inserted | updated | duplicate | skipped`, +plus PROPOSED `conflict` for the versioned-write 409-equivalent surfaced +per-entry inside a batch (so one stale row doesn't fail the whole batch). The +client advances its outbox cursor per row; `skipped`/`conflict` rows are +retried or surfaced to UX. + +--- + +## 4. PROPOSED — Reconcile + +### 4.1 Clock skew + server-authoritative timestamps + +- Every sync response carries `serverNow` (already in `/api/sync/state`, + `route.ts:86`; carried in `/api/sync/changes` §2.2). The client computes + `skew = serverNow − deviceNow` once per session and never trusts its own clock + for cursor comparisons. +- All persisted timestamps used for ordering (`updatedAt`, `deletedAt`) are + **server-set** (`@updatedAt`, `deletedAt = new Date()` on the server). The + client's `measuredAt` / `moodLoggedAt` are user-event times and are stored + verbatim but are **never** used as the delta cursor — only server `updatedAt` + drives the cursor. + +### 4.2 Long offline window + +- After a long offline period the client sends its last cursor to + `/api/sync/changes` and pages until `hasMore: false`. Because tombstones are + in the feed, deletions that happened while offline are applied — provided the + DELETE routes were converted to soft-delete (§2.3 item 1). Without that, a row + hard-deleted while the client was offline simply vanishes from `upserts` with + no tombstone, and the client can only detect it via the periodic + HealthKit-style full reconciliation (the existing + `DELETE /api/measurements/by-external-ids` window sweep, + `by-external-ids/route.ts:1-19`). **This is the strongest argument for the + soft-delete conversion.** +- Define a **max offline window** (iOS to specify, §7). Beyond it, the server + may have pruned tombstones (PROPOSED retention: keep tombstones ≥ the declared + max window + margin); the client must fall back to a full resync (cursor reset) + rather than trusting an incremental delta. The response signals this with a + PROPOSED `cursorExpired: true` flag → client drops local cursor and does a + clean initial sync. + +### 4.3 Refresh token revoked while offline + +- Native access token lives 1 day, refresh 60 days (§1.5, + `native-client.ts:67-70`). A client offline < 60 days refreshes normally on + reconnect. +- If the refresh family was revoked while offline (reuse-detection on another + device, or explicit logout), `POST /api/auth/refresh` returns a hard failure + (`rotateRefreshToken` → `reason: "revoked" | "reuse"`, + `refresh-token.ts:113-150`). The client must treat this as **re-auth required**: + keep the local SwiftData store intact (standalone semantics), drop the bearer + token, and re-pair (re-run the credential exchange, then a backfill that the + idempotency keys make safe — duplicates return `duplicate`). No local data is + lost; only the sync link is re-established. +- PROPOSED: `/api/auth/refresh` returns a stable `errorCode` + (`auth.refresh.revoked` / `auth.refresh.reuse`) so iOS can distinguish + "re-auth" from "transient network" without string-matching. + +--- + +## 5. PROPOSED — endpoints to add this cycle + +All `apiHandler`-wrapped, Zod-validated (`safeParse` + `returnAllZodIssues`), +rate-limited, `annotate()`d, registered in `src/lib/openapi/` so +`pnpm openapi:generate` covers them, `auditLog()` on the deletion path. + +| Method | Path | Purpose | Auth | Rate limit (PROPOSED) | Idempotent | +|---|---|---|---|---|---| +| GET | `/api/sync/changes` | delta feed §2.2 | Bearer/cookie | `sync:changes:${userId}` 120/min | n/a (read) | +| GET | `/api/sync/state` | **register in OpenAPI** (EXISTS, §1.1) | Bearer/cookie | PROPOSED add `sync:state:${userId}` 60/min | — | +| — | `POST /api/measurements/batch` | push (EXISTS, §1.3) | Bearer | 60/min EXISTS | yes (Idempotency-Key) | +| — | `POST /api/mood-entries/bulk` | push (EXISTS) | Bearer | confirm/add per-user limit | yes | +| — | `POST /api/medications/intake/bulk` | push (EXISTS) | Bearer | confirm/add per-user limit | yes | + +Migration-only (no new route): soft-delete conversion of the two measurement +DELETE routes (§2.3 item 1); additive `deletedAt`/`syncVersion`/`updatedAt` +columns (§2.3 item 2). + +Every new/changed request/response schema regenerates `docs/api/openapi.yaml` +and commits the YAML alongside — CI fails on drift. + +--- + +## 6. PROPOSED domains + first-sync ordering (server view) + +Server-derived surfaces (Health Score, Insights, Coach, Daily Briefing, Doctor +Report) stay **server-only** and out of the sync feed — they are computed, not +user-entered, and remain "pair to enable" placeholders in standalone mode +(consistent with `22-standalone-and-server-pairing.md` §3.6 and the MDR +boundary). The sync feed covers user-entered domains only: +`measurements | mood | medications | intakes | profile | settings`. + +Recommended first-sync slice for v1.7.0: **measurements only** (the one domain +ready today, §1.6), with mood + intakes following once their columns land. iOS +confirms the slice in §7. + +--- + +## 7. iOS must specify / provide + +Open questions only the client can answer. Fill these in and hand this back; the +server side cannot be finalised until §7.1–§7.4 are settled. + +1. **First-sync domain slice.** Measurements only for v1.7.0, or measurements + + mood + intakes from the start? This decides whether the §2.3 column + migrations ship this cycle or defer. (Server recommends measurements-only + first.) +2. **Max offline window.** The longest period the client may stay offline and + still expect an incremental delta (vs. a full resync). Drives tombstone + retention (§4.2) and the `cursorExpired` threshold. Native refresh tokens + live 60 days (§1.5) — is the sync window the same, shorter, or longer (with a + re-pair backfill past it)? +3. **Local store schema + identity keys.** Confirm the SwiftData + `syncIdentifier: UUID` is exactly the `externalId` the client already sends in + the batch contract (the pairing doc §4.1 says it is — confirm it holds for + mood + intakes too, which dedup on different keys). For mood/medications, does + the client mirror by server `id` or by a client-minted UUID? This decides the + tombstone identity field per domain in §2.2. +4. **Conflict UX expectation.** On a 409 / `conflict` per-entry status (§3.3, + §3.4), what does the client do — silently take server, silently take local, + or surface a merge prompt? The server returns the current row either way; the + resolution policy past that is a client decision and changes nothing + server-side, but the server team needs it documented to size the response + payload (does the client need the full conflicting row, or just its + `syncVersion`?). +5. **Page size + concurrency.** Preferred `limit` for `/api/sync/changes`, and + whether the client pulls domains in one combined cursor (§2.2 as drafted) or + prefers per-domain cursors. Combined is simpler server-side; per-domain lets + iOS prioritise. +6. **Cursor opacity contract.** Confirm iOS will treat `cursor` as fully opaque + (echo, never parse). The server wants the freedom to change its internal + keyset/sequence encoding without an iOS release. +7. **Backfill vs. delta on first pair.** Confirm first-pair stays on the + existing batch endpoints (`22-standalone-and-server-pairing.md` §3.3), and the + delta feed is only for *incremental* catch-up after the initial backfill — + not a replacement for it. (Server assumes yes.) + +--- + +## 8. Summary of server-side work implied + +1. Register existing `/api/sync/state` in the OpenAPI registry (§1.1). +2. Convert both measurement DELETE routes to soft-delete + `syncVersion` bump + (§2.3 item 1) — unblocks truthful tombstones. +3. Add `GET /api/sync/changes` delta feed with opaque keyset cursor + tombstones + (§2.2). +4. Stable `errorCode` on `/api/auth/refresh` revoke/reuse (§4.3). +5. (Conditional on §7.1) additive `deletedAt`/`syncVersion`/`updatedAt` columns + on `MoodEntry` / `Medication` / `MedicationIntakeEvent` (§2.3 item 2). +6. Tombstone retention policy keyed to the declared max offline window (§4.2). +7. Regenerate + commit `docs/api/openapi.yaml` for every schema touched. + +Nothing here ships until §7.1–§7.4 come back from iOS. The server-only surfaces +and the existing batch + idempotency + refresh infrastructure are unchanged. diff --git a/.planning/ios-coord/v1.7.0-server-final.md b/.planning/ios-coord/v1.7.0-server-final.md new file mode 100644 index 000000000..df58fd4f6 --- /dev/null +++ b/.planning/ios-coord/v1.7.0-server-final.md @@ -0,0 +1,46 @@ +# Server → iOS — v1.7.0 SHIPPED + LIVE + +Date: 2026-05-31. v1.7.0 is tagged, merged to `main`, deployed, and verified live +on the default host. `docs/api/openapi.yaml` at tag `v1.7.0` is canonical — pin from it. + +**Both sides read `.planning/ios-coord/` automatically on change** — this is the +standing async coordination channel. Drop a file here and it gets picked up; we do +the same. No need to ping out-of-band. + +## Live verification +- `GET https://healthlog.bombeck.io/api/version` → `{"version":"1.7.0", …}`. +- **AASA published** — `https://healthlog.bombeck.io/.well-known/apple-app-site-association` + returns HTTP 200 with `applinks` + `webcredentials` for `S8WDX4W5KX.dev.healthlog.app`. + You can flip full passkey trust on the default host. + +## Shipped (all your locks adopted) +- `scheduleType` (`SCHEDULED|PRN|CYCLIC`) on every schedule row, plus `asNeeded`, + `cycleWeeksOn`/`cycleWeeksOff`/`cycleAnchor`, read-only `nextDueAt` (null for PRN). +- Cadence-canonical compliance everywhere (dashboard + detail) + per-day `due` / + `expectedCount`. You can drop the `min(cap)` + `.noSchedule` short-circuit. +- **Widget layout now round-trips the full 27-id catalogue** (persisted + returned) — + you can delete `filteringForServer()` / `byRestoringIosOnlyWidgets()` / + `byMergingIosOnlyDefaults()`. +- `GET /api/dashboard/snapshot` carries the `metricStates` seed (keyed to your + `MetricKind` raw values) + a `layoutCatalogue` (27 ids) — adoptable as a one-key + cold-launch seed. +- FHIR R4 export byte-identical to your `MetricFHIRMapper` table. +- `liveActivityEnabled` / `criticalAlarmEnabled` + roaming delivery default + device override. + +## Offline sync — server is COMPLETE across all three domains (corrects the earlier "deferred") +The earlier confirmation said mood/intake sync was deferred. That is no longer true: +the server now serves **measurements + mood + intakes** from `GET /api/sync/changes` +over a single multi-domain opaque cursor, with tombstones (soft-delete), `cursorExpired`, +`syncVersion` echo, `/api/sync/state` reporting the window, and `auth.refresh.{revoked,reuse,invalid}` +error codes. Tombstone identity: measurements=`externalId`, mood/intakes=server `id`. +The server side is done and tested; only the iOS consumer for mood/intakes remains on +your roadmap (measurements-first is fine — the contract is frozen and forward-compatible). + +## Mood reminder +- The reminder card now delivers on its own (the buried default-off per-event push + preference no longer blocks it; an explicit per-event opt-out still suppresses). +- Per-user reminder hour added at `notificationPrefs.mood.reminderHour` (0–23, default 22). + Read it as the single source of truth instead of scheduling locally. + +Docs: the sync contract is documented at `docs.healthlog.dev` (API → Sync). Questions → +drop a file in this directory. diff --git a/.planning/ios-coord/v1.7.0-server-to-ios-confirmation.md b/.planning/ios-coord/v1.7.0-server-to-ios-confirmation.md new file mode 100644 index 000000000..f609b0ef2 --- /dev/null +++ b/.planning/ios-coord/v1.7.0-server-to-ios-confirmation.md @@ -0,0 +1,72 @@ +# Server → iOS confirmation — v1.7.0 landed + +Date: 2026-05-31. Reply to `v1.7.0-ios-to-server-reply.md`, +`v1.7.0-ios-convergence-locks.md`, and `v1.7.0-ios-offline-sync-answers.md`. +All v1.7.0 server work is on `release/v1.7.0` and awaits the maintainer's tag. +**`docs/api/openapi.yaml` at the `v1.7.0` tag is canonical** — pin to it before +wiring. Everything below is LANDED (no longer PROPOSED) unless marked otherwise. + +## Medication contract — your locks adopted verbatim +- `liveActivityEnabled`, `criticalAlarmEnabled` — Bool, default false, GET/detail + PUT. PUT accepts only these two. +- `nextDueAt` — read-only ISO8601, `null` for PRN. Do not send it. +- Schedule cadence fields shipped with your exact names: **`asNeeded`** (PRN), **`cycleWeeksOn` / `cycleWeeksOff` / `cycleAnchor`** (cyclic). `scheduleType` is an enum (`SCHEDULED|PRN|CYCLIC`). + +## Compliance — cadence-canonical, both surfaces +`calculateCompliance` AND the medication detail page now route through the +canonical recurrence engine (rrule / rolling / weekday subset / interval weeks / +one-shot / PRN / cyclic). The per-day bucket carries **`due`** + **`expectedCount`**. +You can drop the `min(scheduleExpected, serverExpected)` cap and the +`.noSchedule` short-circuit once pinned to v1.7.0. PRN reports null adherence / +zero missed. + +## Dashboard widgets — full 27-id catalogue accepted +The server widget allowlist now spans all 27 ids (your 16 server-known + the 11 +iOS-only). Unknown ids round-trip instead of 422-ing. You can remove +`filteringForServer()` + `byRestoringIosOnlyWidgets()` + `byMergingIosOnlyDefaults()`. + +## FHIR R4 — byte-identical to your MetricFHIRMapper +The server export matches your locked table: 23 LOINC codes (incl. BP panel +`85354-9` + components `8480-6`/`8462-4`, glucose-context `2339-0`/`1558-6`/`1521-4`), +the 6 HK-placeholder codes (HK identifier string in the LOINC slot), Composition +`11503-0`, DiagnosticReport `85353-1`, canonical UCUM (`mm[Hg]`, `/min`, `Cel`, +`kg/m2`, `{steps}`, `dB[A]`, `m/s` …). The server bundle is a SUPERSET (adds +opt-in medication-adherence + mood Observations) and reuses the PDF aggregator, so +numbers are identical across PDF and FHIR. Walking speed in the bundle stays +**m/s** per your lock — km/h is web display only, never in the wire/FHIR/stored value. + +## Health-record export +`POST /api/export/health-record` — `format: pdf | fhir | package` (package = one +zip). Selection = date range + per-domain section toggles, matching your +five-section model. Patient identity (full name / insurer / insurance number) is +on the profile; the insurance number is KVNR-validated + encrypted at rest. The +AI summary is an explicit opt-in section, clearly marked not clinically validated, +and never enters the FHIR bundle. +**Received your four export surfaces** — the server interface is built as the superset. + +## Offline / sync — your frozen contract implemented (measurements-only) +- `GET /api/sync/changes` — opaque base64 keyset cursor (echo, never parse), + measurements-only, single combined cursor, `limit` 200 / cap 500, tombstones + (keyed on `externalId`) returned before upserts, `cursorExpired:true` past + retention, `syncVersion` echoed per row. Indexed (`@@index([userId,updatedAt,id])`). +- Both measurement DELETE routes now soft-delete (tombstone), pruned at + `refreshTokenDays + 15`. +- `GET /api/sync/state` registered in the API and reports the sync window so you + read it rather than hardcoding 60 days. +- `POST /api/auth/refresh` returns a stable `errorCode`: `auth.refresh.revoked` / + `auth.refresh.reuse` / `auth.refresh.invalid`. +- Mood/intake tombstone+version columns DEFERRED per your §7.1 (measurements-only + first consumer). The full delta consumer lands on your side later, against this + frozen contract. + +## Open from us — needs your input / pending +1. **`GET /api/dashboard/snapshot` is web-shaped today.** It is additive and + one-key cacheable (your conditions 1 + 4) but does NOT yet carry the per-metric + `metricStates` seed (your condition 2) or the 27 widget ids in its layout block + (condition 3). So it is not iOS-adoptable as-is. If you want it as a cold-launch + seed, tell us and we'll add the `metricStates` block (keyed to your `MetricKind` + raw values) + the widget-id layout — the per-store endpoints stay regardless. +2. **AASA publication still to verify** on the default host post-deploy. We'll + confirm the live `/.well-known/apple-app-site-association` state here before you + rely on passkey enrolment on that host. + +Updated again at tag with the final contract pointers. diff --git a/.planning/ios-coord/v1.7.0-server-to-ios-handover.md b/.planning/ios-coord/v1.7.0-server-to-ios-handover.md new file mode 100644 index 000000000..5617ed2ee --- /dev/null +++ b/.planning/ios-coord/v1.7.0-server-to-ios-handover.md @@ -0,0 +1,204 @@ +# Server → iOS handover — v1.7.0 cycle + +Date: 2026-05-31. Counterpart to the iOS client handoff (v0.9.0 → v0.10). +This is the server team's reply: what's committed for this release, what's +deferred, the contract shape you can code against, and where to look for the +authoritative answer when something here goes stale. + +**Single source of truth for every contract:** `docs/api/openapi.yaml` at the +`v1.7.0` tag. CI fails on drift, so the YAML at the tag is canonical — pin to it. +Everything below marked **PROPOSED** is design-in-progress and may shift slightly +in field naming; the YAML at tag time wins. Items marked **LOCKED** are decided. + +--- + +## 1. Disposition of your handoff items + +| Your item | Disposition | Notes | +|---|---|---| +| **SB-LA-1** per-medication `liveActivityEnabled` | **v1.7.0** | Additive boolean on the medication contract, GET/PUT. Default `false`. | +| **SB-AK-1** per-medication `criticalAlarmEnabled` | **v1.7.0** | Same shape as SB-LA-1. Default `false`. | +| **Delivery-pref default (device-scoped)** | **v1.7.0** | User-level roaming default + device-local override. Generalises SB-LA-1/AK-1. See §3. | +| **#11 / Dashboard widgets PUT 422** | **v1.7.0** | Server will accept-and-ignore unknown (iOS-only) widget IDs rather than 422. Layout writes stop failing. | +| **#9 / Spezi Phase E — APNs suppression + multi-weekday projection** | **v1.7.0** | Extends the existing `clientManaged` suppression. See §4. | +| **SB-SCHED-2** cadence-canonical compliance | **v1.7.0** | `calculateCompliance` moves off `daysOfWeek`-only to the canonical recurrence engine. You can drop the `min(scheduleExpected, serverExpected)` cap + `.noSchedule` short-circuit once you've pinned to v1.7.0. See §5. | +| **SB-SCHED-3** server-computed `nextDueAt` | **v1.7.0** | Per medication/schedule, RFC-5545 + rolling math, exposed on the medication contract. Removes two-engine parity risk. | +| **SB-SCHED-4** APNs suppression (device owns reminders) | **v1.7.0** | Same track as #9. | +| **SB-SCHED-5** PRN/as-needed + cyclic (on/off weeks) | **v1.7.0** | Decided **yes** — both added server-side. See §6. You can delete the iOS-side compensation. | +| **Schedule types model** (rrule/rolling/timesOfDay/one-shot/start-end) | **already on the wire** | Confirmed present on GET /api/medications since v1.5.0; was an iOS decode gap, not a server gap. No server change needed beyond §5/§6 additions. | +| **Schedule-aware compliance payload** (which days were due) | **v1.7.0** | Additive per-day `due`/`expectedCount` field so history doesn't render empty marks on non-due days. See §5. | +| **AASA publication** (`/.well-known/apple-app-site-association`) | **verifying** | Being confirmed on the default host this cycle; if missing it gets published. See §7. | +| **Offline / server-optional mode** (R4) | **deferred** | Needs a sync-reconcile contract. `/api/sync/state` DTOs noted. Will be specced separately when pursued — not in v1.7.0. | + +Everything in the v1.7.0 column ships together. If any item slips, it moves to +v1.7.1 and this table is updated. + +--- + +## 2. Medication contract additions (LOCKED intent, PROPOSED field names) + +Additive only — no breaking changes. On `GET /api/medications` and the detail +endpoint, each medication gains: + +```jsonc +{ + // ... existing fields ... + "liveActivityEnabled": false, // SB-LA-1 + "criticalAlarmEnabled": false, // SB-AK-1 + "nextDueAt": "2026-06-01T08:00:00Z" // SB-SCHED-3, server-computed, nullable (null for PRN) +} +``` + +`PUT /api/medications/{id}` accepts `liveActivityEnabled` and +`criticalAlarmEnabled`. `nextDueAt` is **read-only** (server-computed; do not +send it). Confirm exact names against `openapi.yaml` at tag. + +--- + +## 3. Delivery-preference default + device override (PROPOSED) + +Goal: make the iOS "Dieses Gerät / Alle Geräte" scope real. A user-level default +that roams across devices, plus a device-local override. + +- A **user-level** delivery-pref default lives alongside the existing + notification-prefs surface (today reachable via `PATCH /api/auth/me/notification-prefs`, + which already carries `clientManaged`). The roaming default extends that object. +- The **device-local override** stays client-owned; the server stores the + default, the device decides whether to honour it or override locally. + +Exact field layout is being finalised in design and will land in `openapi.yaml`. +Code against the YAML at tag; treat this section as intent, not signature. + +--- + +## 4. APNs suppression (SB-SCHED-4 / #9) + +Server already suppresses `MEDICATION_REMINDER` APNs when `clientManaged` is true +and annotates each skip (`medication_reminder.suppressed_client_managed`). v1.7.0: + +- Extends suppression so that when the device owns local scheduling (AlarmKit / + SpeziScheduler), the server reliably does **not** double-fire. +- Multi-weekday schedule projection is corrected server-side so suppression + decisions match the canonical recurrence (weekday subsets honoured). + +No new client field is required to opt in beyond the existing `clientManaged` +contract; the fix is server-internal correctness. If design surfaces a new +per-medication suppression flag, it appears in the YAML. + +--- + +## 5. Compliance becomes cadence-canonical (SB-SCHED-2 + payload) + +Today `calculateCompliance` is `daysOfWeek`-only: `totalExpected = +schedules.length × days` and `dailyCompliance` emits `expected: 1` every day, +which is wrong for non-daily meds. + +v1.7.0 routes expected-dose computation through the canonical recurrence engine +so `rrule`, `rollingIntervalDays`, weekday subsets and interval-weeks all count +correctly. The compliance payload gains an **additive per-day field** indicating +whether a dose was actually due (shape PROPOSED, e.g. `due: boolean` / +`expectedCount: number`) so your history view can skip empty marks on non-due +days. + +**Migration note for iOS:** once you pin to v1.7.0 you can remove the +`min(scheduleExpected, serverExpected)` cap and the `.noSchedule` short-circuit — +the server number is now authoritative. Keep them until then; the change is +additive but the *values* of `totalExpected`/`expected` change for non-daily meds. + +--- + +## 6. PRN + cyclic schedule types (SB-SCHED-5, PROPOSED) + +Both added server-side this cycle. + +- **PRN / as-needed:** a schedule marked as-needed is **excluded** from the + compliance expected-count and from reminder projection, but intakes are still + loggable. `nextDueAt` is `null` for PRN. (Proposed: an `asNeeded`/`prn` boolean + on the schedule; confirm in YAML.) +- **Cyclic (on/off weeks):** N weeks on / M weeks off, repeating from an anchor + date (e.g. 3 weeks on, 1 week off). The recurrence engine projects due days + accordingly; compliance and `nextDueAt` honour the off-weeks. (Proposed: + `cycleWeeksOn` / `cycleWeeksOff` + anchor; confirm in YAML.) + +Decode these from the medication/schedule object once present. Until the YAML at +tag lands, do not hard-code the field names. + +--- + +## 7. Onboarding / infra + +- **AASA:** the passkey `webcredentials:` flow needs + `https:///.well-known/apple-app-site-association` on the default host. + Being verified this cycle; if absent, it gets published. We'll confirm the live + state in this doc before tag. If your passkey enrolment hangs on the default + host, this is the most likely cause. +- **Offline (R4):** deferred. When pursued, the sync-reconcile contract will be + specced separately; the client-side `/api/sync/state` DTOs are noted. + +--- + +## 8. New in v1.7.0 that you may want to consume later (not blocking iOS) + +These are server/web features this cycle; flagged so you can plan client parity. + +- **Health-record / doctor export.** A polished PDF **and** a structured, + machine-readable interchange export (format under research — FHIR R4 is the + leading candidate) with **user-selectable** data domains. The iOS app already + has a partial export; we want to converge on one server-offered interface. + **Ask:** please send what the iOS app exports today (format + fields + which + domains are selectable) so the server interface is a superset, not a fork. +- **Coach data clustering.** The AI Coach will accept a user-selected set of data + clusters (cardio, body, activity, sleep, mood, glucose, adherence, + environmental) instead of the fixed 5 domains. No client contract change + required, but the Coach settings surface gains cluster toggles. +- **HealthKit metric coverage.** New chart support + daily aggregation for + high-frequency / previously-orphan metric types (flights climbed, environmental + audio exposure, step length, walking speed). Walking speed will display in km/h. + This consumes the data you're already sending via `POST /api/measurements/batch`; + no new ingest contract — keep sending what you send. See + `.planning/ios-coord/v155-step-length-speed-followup.md` for the prior thread. +- **Unified dashboard snapshot.** Web-side first-paint optimisation + (`GET /api/dashboard/snapshot`, web-facing). Not required by iOS, but if you + ever want a single-round-trip dashboard payload, this endpoint will exist — + tell us if you'd use it and we'll keep it client-friendly. + +--- + +## 9. Standing contracts that do not change (reaffirmed) + +- `requireAdmin()` is **cookie-only**. A Bearer token, even `["*"]` scope, cannot + reach `/api/admin/*`. iOS is not an admin client. +- `POST /api/measurements/batch`: `stats:` externalId prefix = overwrite; + every other prefix is first-write-wins immutable. Per-entry status + `"updated"` vs `"inserted"`. +- Refresh-token rotation is per-device; reuse-detection revokes the device's + family. +- `clientManaged` on `PATCH /api/auth/me/notification-prefs` suppresses + server-side `MEDICATION_REMINDER` APNs (see §4). +- `push_attempts` holds 90 days of delivery attempts; admin diagnostic at + `/api/admin/notifications/diagnostic`. +- Any request/response schema change regenerates `docs/api/openapi.yaml` and CI + fails on drift. + +--- + +## 10. Where to look / who owns what + +| Question about… | Look here | +|---|---| +| Exact request/response shape of any endpoint | `docs/api/openapi.yaml` @ tag `v1.7.0` (canonical) | +| Standing iOS contracts + gotchas | `CLAUDE.md` → "iOS handoff" section | +| This cycle's dispositions | this file | +| Schedule/compliance server design detail | (server-internal design notes; ask the server team) | +| Prior step-length / walking-speed thread | `.planning/ios-coord/v155-step-length-speed-followup.md` | +| Batch ingest / overwrite semantics | `CLAUDE.md` → iOS handoff; `openapi.yaml` | + +**Open asks back to iOS:** +1. What does the iOS app export today (doctor handover) — format, fields, + selectable domains? Needed so the server export is a superset. +2. Confirm which widget IDs are iOS-only so the server allowlist is precise + (interim: server accept-and-ignores unknown IDs regardless). +3. Would you consume `GET /api/dashboard/snapshot` if offered? + +This file is updated as v1.7.0 design firms up and again at tag with the final +contract pointers. diff --git a/.planning/medication-detail-page-2026-05-28/D-2-direction.md b/.planning/medication-detail-page-2026-05-28/D-2-direction.md new file mode 100644 index 000000000..b0e2e380a --- /dev/null +++ b/.planning/medication-detail-page-2026-05-28/D-2-direction.md @@ -0,0 +1,615 @@ +# D-2 — v1.5.5 design direction: medication wizard polish + detail page + +Authoritative spec the implementation will follow. Grounded in `I-1-deleted-features-inventory.md` (the sixteen actions the v1.5.4 cut buried) and `R-2-detail-patterns.md` (Apple Health / MyTherapy / Medisafe / Round Health / Dosecast / Shotsy + Glapp evidence, plus the verdict on architecture, history, settings, destructive flow, visual rhythm, and status-bar morph). Every layout token references what already ships under `src/components/ui/*`, `src/components/medications/*`, and `src/lib/query-keys.ts`; no new primitives, no new dependencies. + +## 1. Direction in one paragraph + +The wizard becomes a dignified plan editor, and a brand-new `/medications/[id]` detail page becomes everything else — Today's dose, cadence summary, dose ladder, intake history, notifications, settings (API tokens / CSV import / phase config / reminder grace), and a destructive zone. The dialog gets a wider 560 px shell on desktop, a sticky-footer + scrolling body on mobile, a top-right close X, a 40×40 step icon plate with a left-aligned step header below the progress bar, and a single 6/8 spacing rhythm so the asymmetry Marc reported can never recur. A horizontal progress band morphs across three discrete shapes (circle → rounded square → capsule) over the eight-step path, falling back to an instant snap under `prefers-reduced-motion`. The detail page reads top-down as a clinical document: drug + status pill, today's tap-to-log card, plain-language cadence line with a Bearbeiten affordance that opens the wizard, the existing dose-ladder for GLP-1, a preview of the last 7–14 intakes grouped Heute / Diese Woche / Älter with row-level Bearbeiten + Löschen, notifications, settings rows, and a three-tier destructive footer (Pausieren reversible, Beenden archival, Löschen purges). Patient feel: every action Marc lost in v1.5.4 is reachable within one scroll on the device that prompted the rebuild. + +## 2. Wizard polish (`MedicationWizardDialog`) + +`src/components/medications/wizard/MedicationWizardDialog.tsx` stays the entry point. The shell still nests `` for the dialog/sheet split; the polish is geometry, header structure, spacing tokens, the status-bar morph, and the step transition. + +### 2.1 Geometry + +Desktop (`md+`) — switch from the inherited `sm:max-w-md` (≈448 px) to `sm:max-w-[560px]`. R-2's evidence: the asymmetry Marc reported reads cramped at 448 px because the cadence-summary line, the schedule list on Step 8, and the icon-plate + step subline all fight for the same horizontal band. 560 px lets the summary breathe one full line and matches the iOS 26 sheet preference for left-aligned readability. + +Mobile (`< md`) — keep `` capped at `max-h-[90dvh]`, sticky-pinned footer (already wired in `ResponsiveSheet`'s Sheet branch), body scroll on overflow. Add `min-h-[60dvh]` so short steps (Step 1 — single text input) don't collapse the sheet to a thin band that looks broken. The 60/90 floor + ceiling is the Marc-reported "too short" fix. + +Concrete `` change: + +```tsx + +``` + +The Dialog branch of `ResponsiveSheet` already applies `max-h-[calc(100dvh-2rem)]` and `overflow-y-auto` through the shared `` primitive (`src/components/ui/dialog.tsx:72`). No new primitive needed; the className override drives both widths simultaneously. + +### 2.2 Close affordance + +Already exists in `` and `` at top-right. The wizard currently passes `showCloseButton` and `` forwards it. The audit failure was that Marc couldn't see it because the icon is `text-muted-foreground` at low contrast against the header progress band. Two fixes: + +1. The shell's first child is the progress + counter band on a `border-b` strip; the X sits absolutely positioned at `top-3 right-4` (per `dialog.tsx:84-86`). The progress strip is `p-4`, so the X overlaps it visually. Move the progress band's right-padding to `pr-12` so the X has its own gutter, mirroring how `responsive-sheet.tsx` already pads the Sheet header (`pr-12`). +2. The `` close button already carries `min-h-11 min-w-11` on mobile (44 px tap target) and shrinks to 36 px on `sm+` per `dialog.tsx:86`. Leave the floor; just raise contrast by adding `text-foreground/70 hover:text-foreground` to the existing class chain in the wizard-specific override. (No edit to the shared primitive — wrap the override at the call site if contrast tests fail.) + +Aria — the primitive already injects `{t("common.close")}`. Verify the DE key resolves; it ships as "Schließen" in `messages/de.json`. No new i18n keys. + +### 2.3 Header structure + +Vertical rhythm inside the dialog body, top to bottom: + +``` +┌───────────────────────────────────────────────────────────┐ +│ ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ [X] │ ← progress strip + close gutter +│ Schritt 3 von 8 [✨ Aus Text] │ +├───────────────────────────────────────────────────────────┤ +│ │ +│ ┌────┐ │ +│ │ 🧪 │ Dosis & Einheit │ +│ └────┘ Gib die Dosis pro Einnahme an. │ +│ │ +│ [ field row ] │ +│ [ field row ] │ +│ │ +└───────────────────────────────────────────────────────────┘ +[ Zurück Weiter ] ← sticky footer +``` + +Concrete numbers: + +- Progress strip: `border-b border-border/70 p-4 pr-12 space-y-1.5` (existing) — the `pr-12` is the close-X gutter fix. +- Step body: `space-y-6 p-6` on desktop (was `space-y-4 p-4`), `space-y-5 p-5` on mobile. Use `space-y-6 p-5 sm:p-6` so one class chain drives both. +- Icon plate: `grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-border/60 bg-card` (already in `MedicationWizardDialog.tsx:417`) — keep. +- Header row: `gap-3` between plate and title block; title block uses `space-y-1`. +- Step caption (multi-schedule context): `text-muted-foreground text-xs` — keep. +- Step title: bump from `text-base font-medium leading-tight` to `text-lg font-semibold leading-tight tracking-tight` so the title outweighs the subline by the type cascade R-2 endorsed. +- Step subline: `text-muted-foreground text-sm` — keep. +- Field surface: `space-y-4`. + +### 2.4 Spacing tokens — the symmetry contract + +Every internal gap maps to one of three Tailwind classes; reviewers reject any PR that introduces a fourth. + +| Concern | Token | +|---|---| +| Progress strip padding | `p-4 pr-12` (X-gutter on the right) | +| Step body padding | `p-5 sm:p-6` | +| Step body vertical rhythm | `space-y-5 sm:space-y-6` | +| Icon plate → title block gap | `gap-3` | +| Within title block | `space-y-1` | +| Top-level wizard sections (Step 8 only) | `gap-6 sm:gap-8` | +| Footer button group gap | `gap-2` | +| Field rows internally | `space-y-3` | + +R-2's concentric 24/20 + 32 px maps to `p-6 / p-5` and `gap-8 / gap-6` cleanly. 20 px mobile floor, 32 px desktop ceiling. Nothing in between. + +### 2.5 Symmetry invariants + +Hard-code these into the code-review checklist for the v1.5.5 PR: + +1. The step icon plate AND the primary footer CTA share `rounded-xl` (not `rounded-md`, not `rounded-lg`). The plate is `rounded-xl` today; the Button primitive ships `rounded-md`. Override the wizard-Next button to `rounded-xl` so the curvature reads as one family. +2. Every progress strip element (bar, counter text, NL button) lives at `text-xs`. The counter is already `text-xs`; do not promote. +3. The step subline (`text-sm`) and the footer secondary "Back" button text (`text-sm` via Button defaults) share weight. No bolding the subline. +4. Every section gap on Step 8 (the only multi-section step) is `gap-6 sm:gap-8`. The schedule cards inside ride `space-y-3`. +5. The 40×40 plate is the only square chrome. No nested square iconography in the body — Lucide icons render at `h-4 w-4` or `h-5 w-5` inline with text. +6. Loader animations everywhere use `animate-spin motion-reduce:animate-none`. No exceptions; this is the project precedent (e.g. `intake-history-list-v2.tsx:184`). + +### 2.6 Status-bar morph animation + +The dialog's progress is currently ``. R-2's verdict: morph the progress *shape* across the eight-step path so the patient feels the rhythm of the form rather than watching a fill bar inch. Three discrete shapes — circle at the head (step 1), rounded square at midpoint (step 4–5), elongated capsule at the tail (step 8). Implemented as a CSS-only `clip-path` interpolation over the existing `` indicator; no Framer Motion, no `motion` library, no new dependency. + +The fill bar itself stays linear; the *leading edge* morphs. The 280 ms total uses `cubic-bezier(0.32, 0.72, 0, 1)` — the same curve Apple ships for spring-like attack on system UI, available in plain CSS. R-2's "Material 3 spring damping 25–30" maps to this curve once translated from spring physics to a Bézier approximation. + +Add to `src/app/globals.css` under a `@layer components` block (the project already keeps custom utilities there): + +```css +@layer components { + .wizard-progress-bar { + /* Underlying primitive paints the fill; we override the + indicator's clip-path + transition so the leading edge + morphs across the three shapes as `--wizard-step` advances. */ + --wizard-step: 0; + transition: + clip-path 280ms cubic-bezier(0.32, 0.72, 0, 1), + width 280ms cubic-bezier(0.32, 0.72, 0, 1); + } + + /* Step 1 — leading edge is a half-circle (full radius). */ + .wizard-progress-bar[data-step="1"] [data-slot="progress-indicator"] { + clip-path: inset(0 0 0 0 round 0 999px 999px 0); + } + + /* Steps 2–4 — leading edge tapers to a rounded square. */ + .wizard-progress-bar[data-step="2"] [data-slot="progress-indicator"], + .wizard-progress-bar[data-step="3"] [data-slot="progress-indicator"], + .wizard-progress-bar[data-step="4"] [data-slot="progress-indicator"] { + clip-path: inset(0 0 0 0 round 0 6px 6px 0); + } + + /* Steps 5–7 — leading edge stretches into a capsule. */ + .wizard-progress-bar[data-step="5"] [data-slot="progress-indicator"], + .wizard-progress-bar[data-step="6"] [data-slot="progress-indicator"], + .wizard-progress-bar[data-step="7"] [data-slot="progress-indicator"] { + clip-path: inset(0 0 0 0 round 0 999px 999px 999px); + } + + /* Step 8 — fully filled capsule (the entire bar reads as one pill). */ + .wizard-progress-bar[data-step="8"] [data-slot="progress-indicator"] { + clip-path: inset(0 0 0 0 round 999px); + } + + @media (prefers-reduced-motion: reduce) { + .wizard-progress-bar, + .wizard-progress-bar [data-slot="progress-indicator"] { + transition: none; + } + } +} +``` + +Wire it in `MedicationWizardDialog.tsx`: + +```tsx + +``` + +Three shapes, not seven (R-2's upper bound) — HealthLog's discrete state space is small. The h-1.5 (was h-1) is the minimum stroke that lets the clip-path morph register at 1× pixel density without aliasing. + +Reduced-motion fallback: the transitions collapse to `none`; the clip-path still applies but snaps instantly. Patients on `prefers-reduced-motion: reduce` still see the shape rhythm — just without the easing — which preserves the navigational hint while honouring the system preference. + +### 2.7 Step transition animation + +R-2 hinted "fade + 8 px slide-from-right on goNext". Confirmed, but the fade carries the load; the 8 px slide should only ride on goNext, NOT goBack (sliding from the right on backward navigation reads as a forward gesture and confuses the spatial model). + +```css +@layer components { + .wizard-step-body { + animation: wizard-step-in 220ms cubic-bezier(0.32, 0.72, 0, 1); + } + .wizard-step-body[data-direction="back"] { + animation-name: wizard-step-back-in; + } + + @keyframes wizard-step-in { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + @keyframes wizard-step-back-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .wizard-step-body, + .wizard-step-body[data-direction="back"] { + animation: none; + } + } +} +``` + +Wire the direction on the body wrapper: + +```tsx +const [direction, setDirection] = useState<"next" | "back">("next"); +// goNext / goBack already exist; set the direction before setStep(...). + +
+``` + +The `key={step}` already in place re-mounts the body on every step change, which triggers the CSS animation deterministically without needing any state machine. + +## 3. Detail page — `src/app/medications/[id]/page.tsx` (NEW) + +`/medications/[id]/history/page.tsx` exists today; `/medications/[id]/page.tsx` does not. v1.5.5 creates the route and the existing history page stays for the dedicated bulk-history surface (§6). The route is a client component (`"use client"`) because every section subscribes through TanStack Query. + +Vertical rhythm at the route level: outer wrapper `space-y-6 sm:space-y-8` so sections breathe at 24/32 px — R-2's concentric token. Inner sections use the existing `` wrapper at `src/components/medications/medication-detail-section.tsx` for chrome symmetry with the GLP-1 sub-pages. + +Mobile-first: every wireframe below describes the 360 px baseline. On `md+` the page widens to `max-w-3xl mx-auto` so the cadence-summary line + intake-history table stop reading edge-to-edge on tablet portrait. + +### Section order (locked) + +``` +1. Header band — drug name + dose + status pill + edit affordance +2. Today's dose card — Genommen / Übersprungen / Verschoben one-tap +3. Cadence summary — plain-language line + Bearbeiten → wizard +4. Dose ladder / Phasen — only for GLP-1 + course window +5. Intake history preview — last 7–14 grouped Heute / Diese Woche / Älter +6. Notifications — switch + helper line +7. Settings — API tokens, CSV import, Phasen, grace +8. Destructive zone — Pausieren / Beenden / Löschen +``` + +Daily actions at the top (log today's dose, see what's coming), rare ones at the bottom — Apple Health's pattern with the labelled destructive zone Apple's surface lacks. + +### 3.1 Header band + +``` +┌───────────────────────────────────────────────────┐ +│ Mounjaro [✏ Bearbeiten] │ +│ 7,5 mg │ +│ ● Aktiv · Wöchentlich · seit 12.03. │ +└───────────────────────────────────────────────────┘ +``` + +- Component: inline JSX (no new wrapper). The header is light enough not to warrant a custom component. +- Type cascade: `text-2xl font-bold tracking-tight` for the drug name (matches `/medications/[id]/history/page.tsx:82`); `text-muted-foreground text-sm` for the dose; `text-xs` for the status row. +- Status pill: `` with one of three shapes (Aktiv / Pausiert / Beendet). The accent dot is a `bg-emerald-500` / `bg-amber-500` / `bg-zinc-500` square `h-2 w-2 rounded-full`. +- Edit affordance: outline `
`. Cadence row: section `

` first, `

` next, edit last. Acceptance: screen-reader walk announces name → dose → status → "Bearbeiten button". + +**C-E4-4 — Modal stack ≤ 2; row-edit Sheet hosts no destructive action** (E-4 C-4; D-2 §6.3). Sheet → peer `` mid-close returns focus to `document.body`. Fix: row-edit Sheet hosts only the edit form. Single-row Löschen lives on the row kebab (kebab → `` directly). Sheet → AlertDialog stack forbidden. Acceptance: Sheet tree contains no ``; focus returns to kebab. + +**C-E4-5 — Status-pill text never hides; dot uses Dracula tokens** (E-4 C-5 + H-9; D-2 §3.1). Raw Tailwind palette (`bg-emerald-500` / `bg-amber-500` / `bg-zinc-500`) bypasses theme; D-2 did not pin that text never hides. Fix: `` with `Aktiv` / `Pausiert` / `Beendet` text always rendered. Dot `aria-hidden="true"`, Dracula tokens via `bg-[hsl(var(--success))]` / `bg-[hsl(var(--warning))]` / `bg-muted-foreground`. If `--success` / `--warning` not yet exposed, pre-work adds them next to `--destructive` with Dracula `#50fa7b` / `#ffb86c`. Acceptance: axe contrast passes; snapshot at 320 px shows label. + +### UX wire correctness + lifecycle (E-2 Criticals) + +**C-E2-1 — Two-button Today's-dose card + locked wire shape + `compliance-chart-inline` in bundle** (E-2 C-1; co-resolves E-3 H-1/H-2/H-3; D-2 §3.2; `medicationDependentKeys`). D-2 invented a `DEFERRED`/`Verschoben` status absent from route + schema + i18n; the wire shape mismatched the actual route (`{ scheduledFor, takenAt, skipped, idempotencyKey }`); `compliance-chart-inline` prefix was not in the bundle. Fix: two buttons `[Genommen]` + `[Übersprungen]` (Verschoben dropped, §12); POST `{ scheduledFor: , takenAt: , skipped: boolean }`; card derives "due today" from `medication.cadence` + `summariseCadence`; empty `Heute keine Einnahme geplant.`. Pre-work adds `["compliance-chart-inline"]` to `medicationDependentKeys`; every detail mutation routes through `invalidateKeys(queryClient, medicationDependentKeys)` only. Acceptance: integration test asserts wire shape; cache test asserts inline-compliance evicts in one tick. + +**C-E2-2 — `landingStepForEdit` gains `intent`; step title receives focus on landing ≠ 1** (E-2 C-2; co-resolves E-4 H-1/H-2 + E-2 L-4; `wizard-payload.ts:870`; `MedicationWizardDialog.tsx:213-224`). D-2's cadence-row edit promised "the cadence step" but the helper only returns 1 or 8. `key={step}` re-mount drops one frame of focus; `onEditSchedule` 8→5 used `direction="next"`. Fix: `landingStepForEdit(payload, intent?: "cadence" | "summary" | "name"): number` — `"cadence"`→5, `"name"`→1, omitted→current 1/8. Header edit passes `"name"`; cadence row passes `"cadence"`. Title `

` carries `tabIndex={-1}`; focus effect focuses the title first when landing ≠ 1. Slide stripped (C-E1-1) → `direction` state removed. Acceptance: unit test asserts `(payload, "cadence") === 5`; component test asserts title focus on edit-mount. + +**C-E2-3 — One-shot variant of the detail page; suppress sections 4/6/7 once logged** (E-2 C-3; D-2 §3 section order). v1.5.4 auto-deactivate (commit `39430e16`) kills notifications post-log; sections 5 / 6 / 7 read as bureaucratic theatre for a single dose. Fix: page branches on `kind = medication.oneShot ? "oneShot" : "recurring"`. One-shot renders 5 sections — Header (Einmalig genommen pill when logged) / Today's-dose-or-logged-state / Cadence static line / Intake row (no grouping) / Verwaltung & Gefahrenzone. Notifications + Settings not rendered. Cadence row hides edit affordance (wizard handles via header edit). Acceptance: snapshot asserts 5 sections; wizard-edit from header lands correctly for one-shot. + +## 4. High-priority items that ship in v1.5.5 + +Bundled by area. `[absorbed]` = a Critical already covers it. + +**H-cluster-D — Type cascade + spacing tokens + padding alignment** (E-1 H-1/H-2/H-3/H-4). Wizard title at `text-lg` (sole outlier); "three tokens" framing with eight; page rhythm `space-y-6 sm:space-y-8` while siblings ship one class; body `p-5 sm:p-6` vs strip `p-4`. Fix: wizard title stays `text-base font-semibold leading-tight tracking-tight`. Five buckets — outer `p-4 pr-12 sm:p-6 sm:pr-14`, section `gap-6 sm:gap-8`, row `gap-3`, tight `space-y-1`, footer `gap-2`. Page rhythm `space-y-6`. Strip + body share padding. Tests: component asserts both titles at `text-base`; snapshot asserts shared left edge. + +**H-cluster-E — Loading/error/empty contract per section** `[absorbed by C-E4-2]`. Pinned in §9. + +**H-cluster-F — Pausieren copy rewrite + two-card destructive split** (E-2 H-1/H-2). Old helper reads as "skip today"; mixing scope+severity in one card hides escalation. Fix: helper → `Erinnerungen anhalten, bis du sie wieder aktivierst. Verlauf bleibt erhalten.`. Split destructive into two cards under heading `Verwaltung & Gefahrenzone`. Card A hosts Pausieren + Beenden (reversible); Card B (`border-destructive/40`) hosts Verlauf + Medikament löschen (irreversible). Tests: i18n key `medications.pause.helper` ships; snapshot shows split. + +**H-cluster-G — Bundle includes `compliance-chart-inline`** `[absorbed by C-E2-1]`. Tier 3b orders `await invalidateKeys(...); router.push("/medications");`. Per-row edit/delete also routes through the bundle. Inline comment on bundle explains prefix-match coverage. Tests: cache test asserts inline-compliance evicts on every mutation; Tier 3b lands on fresh cache. + +**H-cluster-H — Switch in `