From 6efe90394d03fb5356ec82bf5b044e9b242500ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 10:13:11 +0200 Subject: [PATCH] docs(planning): v1.5 strategic plan with six research backups Six parallel research outputs feeding one synthesized plan: step aggregation shape, dashboard performance audit, mobile tile symmetry, settings drag-list compactness, iOS planning state, and HealthKit integration depth. The plan closes the v1.4 web line in one patch and frames the 10-working-day iOS sprint that ships v1.5. --- .../research/v15-r-a-step-aggregation.md | 417 +++++++++++++++++ .planning/research/v15-r-b-dashboard-perf.md | 225 +++++++++ .../research/v15-r-c-mobile-tile-symmetry.md | 330 +++++++++++++ .../research/v15-r-d-settings-drag-list.md | 316 +++++++++++++ .../research/v15-r-e-ios-planning-state.md | 292 ++++++++++++ .../research/v15-r-f-apple-health-depth.md | 438 ++++++++++++++++++ .planning/v15-strategic-plan.md | 341 ++++++++++++++ 7 files changed, 2359 insertions(+) create mode 100644 .planning/research/v15-r-a-step-aggregation.md create mode 100644 .planning/research/v15-r-b-dashboard-perf.md create mode 100644 .planning/research/v15-r-c-mobile-tile-symmetry.md create mode 100644 .planning/research/v15-r-d-settings-drag-list.md create mode 100644 .planning/research/v15-r-e-ios-planning-state.md create mode 100644 .planning/research/v15-r-f-apple-health-depth.md create mode 100644 .planning/v15-strategic-plan.md diff --git a/.planning/research/v15-r-a-step-aggregation.md b/.planning/research/v15-r-a-step-aggregation.md new file mode 100644 index 000000000..7b067bcee --- /dev/null +++ b/.planning/research/v15-r-a-step-aggregation.md @@ -0,0 +1,417 @@ +--- +file: .planning/research/v15-r-a-step-aggregation.md +purpose: Research the right daily step aggregation shape for the Apple Health ingest (v1.5) +created: 2026-05-16 +contributor: R-A +--- + +# Step aggregation for the Apple Health ingest — recommendation + +## TL;DR + +Pick **Option A — iOS pre-aggregates to one row per day per cumulative +metric, using `HKStatisticsCollectionQuery` with daily buckets**. The +server changes are minimal (one new helper to mint a deterministic +`externalId` keyed by `(type, day, deviceType)`, no schema change, no +cron). The iOS change is significant but locally scoped to the cumulative +quantity types (steps, active energy, flights climbed, walking-running +distance, time-in-daylight). The pattern was already recommended by the +v1.4.23 deep-dive (`.planning/research/apple-health-sync-deep-dive.md` §2) +and is the same shape the Withings activity sync writes today — landing +APPLE_HEALTH on parity with WITHINGS for cumulative metrics is the right +strategic frame. + +--- + +## 1. Current path — what lands in the DB today + +### iOS side + +| Layer | File | Behaviour | +| --- | --- | --- | +| Observer | `HealthLog/Services/HealthKitService.swift` (per-type `HKObserverQuery` → `HKAnchoredObjectQuery`) | Returns every new `HKQuantitySample` since the anchor. Background-delivery frequency for stepCount is `.hourly` (`preferredFrequency` line 364) — fires up to once per hour while the Watch is on the wrist. | +| Wire conversion | `HealthLog/Services/HealthKitWireConverter.swift` (`quantityEntry`) | Builds ONE `HealthKitBatchEntryDTO` per `HKQuantitySample`. Sets `externalId = sample.uuid.uuidString`. | +| Upload | `HealthLog/Repositories/MeasurementBatchUploader.swift` | Chunks ≤500 per `POST /api/measurements/batch`. | + +`HKQuantitySample` for stepCount on an Apple Watch wearer arrives every +5-15 minutes (per the watch's pedometer roll-up; per-day count: ~50-200, +per the deep-dive sync research). Each sample becomes one DB row. + +### Server side + +- `POST /api/measurements/batch` (`src/app/api/measurements/batch/route.ts`) + — maps every entry through `mapAppleHealthEntry()`, inserts via + `createMany({ skipDuplicates: true })`. No aggregation. +- `Measurement` schema (`prisma/schema.prisma:363`) — unique index + `(userId, type, source, externalId)` is per-sample-UUID, so two + different `HKSample.uuid`s for the same minute land twice. +- `GET /api/measurements/series?kind=steps` (`src/app/api/measurements/series/route.ts`) + — returns the raw rows, no bucketing, no SUM. +- `GET /api/measurements?type=ACTIVITY_STEPS&aggregate=daily` — runs + `date_trunc + AVG` in SQL (`route.ts:78-94`). **This is wrong for + steps** (AVG of per-minute counts, not SUM). Today's chart silently + understates the daily total when `aggregate=daily` kicks in past the + 90-day threshold. + +### Impact + +| Concern | Today's cost | +| --- | --- | +| DB rows | 50-200 / user / day for steps alone × five cumulative metrics = ~500-1000 rows / user / day from APPLE_HEALTH. A multi-year backfill writes ~1-2 M rows for a single active user. | +| Read query for `/api/measurements?type=ACTIVITY_STEPS` (30-day default) | 1500-6000 rows over the wire per dashboard page load. | +| Server analytics | `pickCanonicalSourceRows` sums all sibling rows per day per source — works correctly but pays for the row count. | +| Personal-record worker | `enqueuePrDetection` after every batch — per-sample-row noise. | +| Conflict with Withings shape | Withings activity sync (`src/lib/withings/sync-activity.ts:113-302`) already lands ONE row per day for steps with `measuredAt = day-noon-UTC`. APPLE_HEALTH is the only source emitting per-sample step rows. | + +The contract today is internally inconsistent — `aggregation: "sum"` +in `apple-health-mapping.ts:181` is documented as "advisory metadata for +downstream summarisation" but no path summarises before storage and the +read-side SQL aggregator uses AVG. + +--- + +## 2. Peer apps — what they do + +| App | Where the aggregation happens | Daily-row pattern | +| --- | --- | --- | +| Apple Health.app | iOS uses `HKStatisticsCollectionQuery` with `intervalComponents: .day` + `options: .cumulativeSum` — the same on-device merge HK uses for sibling-source dedup. Per-sample rows are never user-visible at default zoom. | One per day | +| Withings Health Mate | Server-side `getactivity` returns one entry per calendar day per metric. No per-sample timeline. | One per day | +| Garmin Connect | Wearable streams raw per-minute counts to the phone, phone aggregates daily and pushes once per midnight roll-over. Per-minute "intensity" is queryable but the dashboard's "Steps" tile is daily-only. | One per day (+ optional intensity stream) | +| Oura Ring | Ring streams to phone; phone posts daily roll-ups to Oura cloud. Activity score is computed off the daily roll-up server-side. | One per day | +| MacroFactor | Polls Apple Health (or Google Fit) via `HKStatisticsCollectionQuery`; stores daily totals. No per-sample rows. | One per day | +| Whoop | Wearable bound to phone; phone pre-aggregates per-minute strain bands but exposes daily / sleep-period totals to server. | One per day | + +**Universal pattern**: cumulative metrics are aggregated daily on the +client (where the cross-source merge knowledge lives) before they reach +the server. The exceptions (Garmin per-minute intensity, Whoop per-band +strain) are stored separately and never confused with the daily total. + +### `HKStatisticsCollectionQuery` semantics (the right primitive) + +```swift +let query = HKStatisticsCollectionQuery( + quantityType: HKQuantityType(.stepCount), + quantitySamplePredicate: nil, // include every source + options: .cumulativeSum, + anchorDate: startOfDay, + intervalComponents: DateComponents(day: 1) +) +``` + +Apple's merge algorithm (the same one Health.app uses) prefers +Apple Watch samples while the watch was on-wrist and falls back to +iPhone samples for unworn intervals. The cross-source double-count +disappears at the source. The query's `enumerateStatistics(from:to:)` +callback yields one `HKStatistics` per interval — `sumQuantity()` gives +the daily total. + +Two contract points worth noting: + +- The query is asynchronous and uses a separate `initialResultsHandler` + + `statisticsUpdateHandler` pair — the latter fires whenever the + underlying sample set changes for any day in the window. This is the + natural "yesterday's steps just changed because the watch synced 2 h + late" path. +- Apple charges the `HKStatisticsCollectionQuery` against a different + rate budget than `HKAnchoredObjectQuery`. WWDC20 "Beyond counting + steps" specifically recommends it for cumulative types and warns + against re-implementing the merge logic client-side. + +--- + +## 3. Three plausible shapes + +### Option A — iOS pre-aggregates daily (RECOMMENDED) + +iOS runs `HKStatisticsCollectionQuery` per cumulative metric. On an +observer wake-up, the handler asks "what's the new daily sum for the +affected days?" and POSTs one row per day per metric to +`/api/measurements/batch`. + +- `externalId = "stats:HKQuantityTypeIdentifierStepCount::"` + — deterministic per (type, day, optional device-type bucket). Posting + yesterday's count again after a late watch sync naturally + `duplicate`-collapses on the existing `(userId, type, source, externalId)` + unique index. +- `measuredAt` = midnight UTC of the day (or 12:00 UTC noon to match + Withings' `activityMeasuredAt()` convention — see §6 for the choice). +- `value` = `HKStatistics.sumQuantity().doubleValue(for: .count())`. +- `source = APPLE_HEALTH`, `deviceType = "watch"` when the user wears one + (the query's `sources` array carries the contributing source revisions — + pick the dominant one or "phone" as fallback). + +### Option B — Server-side nightly roll-up + +iOS keeps posting per-sample rows. Server runs a 03:00 cron that walks +yesterday's APPLE_HEALTH rows per cumulative type and writes one +`source = APPLE_HEALTH_DAILY` (new enum value) row per day with the +summed value. Raw rows can stay (for analytics) or be purged after N +days. + +### Option C — Hybrid bucket key on the row + +iOS posts per-sample rows + a `bucketKey` field on each row (date in +user TZ). Server stores the bucketKey. Reads aggregate by bucketKey at +query time via a `GROUP BY` view. No daily-roll-up cron. + +--- + +## 4. Trade-off table + +| Axis | A — iOS pre-aggregates | B — Server cron roll-up | C — Hybrid bucketKey | +| --- | --- | --- | --- | +| Storage cost | 5-200× lower (1 row vs 50-200 rows per day per metric) | Higher (raw + daily) unless raw purged; purging breaks analytical fidelity | Same as today (no row reduction) | +| Query cost on `/series?kind=steps` | `O(days)` (~30 rows) | `O(days)` after the cron runs, `O(samples)` before | Needs a `GROUP BY bucketKey` per read — `O(rows)` walked, `O(days)` returned | +| Dashboard sparkline latency | Best — fewest rows | Good after the cron, stale-ish today | Worst — GROUP BY on hot path | +| iOS battery / bundle | `HKStatisticsCollectionQuery` is documented as cheaper than reading every sample (Apple does the merge once internally) | Same as today | Same as today | +| Cross-source dedup (Watch + iPhone double-count) | Solved by HK's merge algorithm at the source | Server must replicate Apple's merge — fragile; can't see "watch on-wrist" signal | Server must replicate Apple's merge — same fragility | +| iOS contract | Additive — one new optional field shape via the existing batch entry. `externalId` syntax changes for cumulative types only. No new endpoint. | No iOS change. New `source` enum value `APPLE_HEALTH_DAILY` (additive in the Postgres enum). | One new optional field `bucketKey` in the batch entry. Existing rows stay valid. | +| Server schema impact | None | Either a new `source` enum value or a new boolean column `isDailyRollup`. Migration runs additive. | New nullable `bucket_key` column + an index. | +| Cron / worker code | None | New worker (with idempotency, backfill replay, race vs incoming new rows). Failure modes: cron lag, partial-day data, raw-row replay. | None | +| PR detection | Works as-is on daily rows | Two PR sources confuse the detector (raw row crosses + rollup row crosses) unless detector is taught to skip raw rows | Works as-is once GROUP BY view is honoured | +| Analytics fidelity | Hourly granularity lost — only daily totals stored. Acceptable per Apple's own UX (Health.app doesn't show step timelines beyond per-hour ring) | Both grains available; raw rows useful if anyone ever wants hourly | Hourly granularity preserved | +| Withings parity | Matches Withings' existing daily-row shape | Asymmetric (Withings = daily rows, APPLE_HEALTH = raw + daily) | Asymmetric (Withings unchanged, APPLE_HEALTH gets a bucketKey field Withings doesn't carry) | +| Migration for existing per-sample rows | Optional drain. iOS starts posting daily rows after upgrade; raw rows stay in DB and are read-side compatible via the existing canonical-source picker. Periodic compaction job can collapse them later. | Cron's first run writes the rollups for every back-filled day. Raw rows kept until a separate drain. | Backfill a `bucketKey` on existing rows (one-time UPDATE). | +| Forward generalisation to other continuous types | Same query primitive works for active energy, flights climbed, distance, time-in-daylight, audio exposure (mean instead of sum), basal energy, exercise minutes | Same cron pattern — yes, but every metric needs its own aggregation function lookup table on the server | bucketKey field generalises, but server still has to know which types are aggregatable | +| Race conditions | iOS owns the merge — only race is two iOS devices on the same Apple-ID posting the same day. `externalId` collapses both. | Cron vs incoming late writes for "today's" rollup — partial-day rollup fires, late sample drifts in, rollup is stale. Solvable but new complexity. | None new | +| Effort web | S (helper + schema doc + chart-aware aggregator fix) | M (worker, cron schedule, enum value, backfill replay) | M (column, index, backfill, GROUP BY in 4-5 read paths) | +| Effort iOS | M (HKStatisticsCollectionQuery wrapper + per-type config + observer wiring) | None | S (one new field on the wire entry) | + +--- + +## 5. Recommendation — Option A, with one clarification + +**Decision: Option A.** Three reasons it wins decisively: + +1. **The merge logic must run on iOS**, full stop. Apple Watch + on-wrist heuristics are not surfaced through any API the server can + consume. Re-implementing them server-side (Option B or C) means + re-implementing a moving target — Apple has tweaked the merge in + four iOS versions per the WWDC archive. The deep-dive § 2 Layer 3 + explicitly says no server-side merge. +2. **Withings is already shaped this way.** The + `pickCanonicalSourceRows` helper, the chart's `aggregate=daily` + path, and the dashboard sparkline already expect "one row per day" + for cumulative metrics from WITHINGS. Option A puts APPLE_HEALTH + on the same shape — `pickCanonicalSourceRows` then just works, + the AVG-vs-SUM bug in `route.ts:84` becomes irrelevant for steps + (one row per day, AVG and SUM are identical), and the dashboard + stops paying for a query that walks thousands of rows. +3. **Reversible without a schema break.** Option A is additive on + both sides — old iOS builds that keep posting per-sample rows + continue to work (raw rows land, `pickCanonicalSourceRows` sums + them, the contract holds). New iOS builds post daily rows. The + raw-row drain is a separate, optional compaction job that can run + in v1.5.1 or later. + +### The clarification — what about hourly granularity? + +Some users will want to see a per-hour step breakdown ("when did I +walk yesterday?"). The honest answer is that Apple Health.app's main +"Steps" view doesn't show this either — it's a per-day bar chart. +The per-hour breakdown is one drill-in tap away and uses +`HKStatisticsCollectionQuery` with `intervalComponents: .hour`. +HealthLog can adopt the same pattern: store daily; for the rare +"hourly drill-in" UX, send a separate read-only query to HealthKit +without persisting hourly rows. v1.5 does not need this — the iOS app +shows the daily total + a 30-day sparkline, both served from the +daily-row stream. + +### Edge case — late sample arrivals + +When the watch syncs at 14:00 with samples from this morning, the +existing day's row should *update* on the server, not insert a second. +This is exactly what `externalId = "stats::"` plus +the `(userId, type, source, externalId)` unique index buys: the second +POST's per-entry status returns `duplicate`. To make the value update +visible, iOS needs to detect "the new statistics for an already-posted +day differ from what was sent" and issue a `PATCH /api/measurements/[id]`. + +Two ways to handle this; pick one: + +| Approach | Pro | Con | +| --- | --- | --- | +| iOS keeps last-posted-value per (type, day) and PATCHes when it diverges | Clean, one row per day always | Needs a PATCH path the iOS app doesn't use today (the route exists; iOS just doesn't call it for batch ingest) | +| Server upserts on `externalId` collision in the batch path (new behaviour) | iOS posts blindly; server resolves | Diverges from the current "duplicate-collapse, no update" contract; PR detection might double-fire | + +**Recommend the first** — it keeps the batch ingest contract narrow +(insert-only) and the PATCH route already exists and is wired to its +own audit log. iOS-side latency: a single PATCH per affected day, at +most a handful per sync. + +--- + +## 6. Implementation sketch + +### Server (effort: S) + +- `src/lib/measurements/apple-health-mapping.ts` + - Document the daily-rollup `externalId` convention as the canonical + shape for cumulative types. Add a string-constant helper + `dailyStatsExternalId(typeIdentifier, dayISO)` that mints + `"stats::"` and is round-trip-safe. + - Add a `cumulativeTypes` set so future analytics can ask "is this + aggregated daily on ingest?" (used by the AVG/SUM router in §6.2). +- `src/app/api/measurements/route.ts` — when `aggregate=daily` and the + type is in `cumulativeTypes`, switch the SQL from `AVG(value)` to + `SUM(value)`. Strictly speaking, with Option A producing one row per + day, AVG and SUM are the same — but the bug should still be fixed for + any pre-Option-A rows that survive in the DB. +- `src/app/api/measurements/series/route.ts` — same fix; today's path + returns raw rows for `kind=steps` and the iOS app draws every dot. + After Option A, this returns ~30 rows already (one per day from + iOS), so the route is correct by accident. Belt-and-braces: do a + `GROUP BY date_trunc('day', measured_at)` with SUM for cumulative + types so legacy raw rows still aggregate cleanly during the cutover. +- `prisma/schema.prisma` — no change. The existing unique index does + the dedup for free with the new `externalId` shape. +- `.planning/v15-ios-handoff/06-ios-responsibilities.md` — append a + "Cumulative metrics: daily aggregation on iOS" section under + Domain 1 with the exact `externalId` shape and the PATCH-on-divergence + rule. +- `.planning/v15-ios-handoff/08-locked-contracts.md` — lock the + `externalId` regex pattern for cumulative types so a future iOS + build can't drift the shape silently. + +### iOS (effort: M) + +- New file `HealthLog/Services/HealthKitStatisticsService.swift` + (or extension on `HealthKitService`) — wraps + `HKStatisticsCollectionQuery` per cumulative type. Backfill window + + ongoing observer wired through the existing `HKObserverQuery` + trigger; the difference is the *response* — emit one + `HealthKitBatchEntryDTO` per affected day instead of one per + `HKQuantitySample`. +- `HealthLog/Services/HealthKitWireConverter.swift` — leave the + per-sample path alone for spot metrics. For cumulative types, the + new statistics service builds the DTO directly (it doesn't go + through `quantityEntry`). +- `HealthLog/Repositories/MeasurementBatchUploader.swift` — same + upload path. Add a fast-path PATCH for "I previously posted day X + with value V1; new statistics say V2; emit a PATCH". +- Per-cumulative-type config in + `HealthLog/Services/HealthKitService.swift` — + `cumulativeMetrics: Set` = stepCount, + activeEnergyBurned, flightsClimbed, distanceWalkingRunning, + timeInDaylight (when iOS 17+). Each gets `.day` interval + matching + HK unit. +- `HealthLog/Util/AppEnvironment.swift` — guard cumulative aggregation + behind a build-flag for the first TestFlight build so the per-sample + path stays available as a fallback during validation. + +### iOS contract delta + +Additive only. The batch entry shape stays exactly the same; only the +*contents* the iOS app sends change for cumulative types: + +| Field | Spot metric (today) | Cumulative metric (Option A) | +| --- | --- | --- | +| `hkIdentifier` | `HKQuantityTypeIdentifierBodyMass` (e.g.) | `HKQuantityTypeIdentifierStepCount` (e.g.) | +| `value` | sample's raw value | `HKStatistics.sumQuantity()` for the day | +| `unit` | sample's HK unit | same canonical unit (`count`, `kcal`, `m`, `min`) | +| `startDate` | `sample.startDate` | day start in user TZ | +| `endDate` | `sample.endDate` | day end (or next-day start - 1ms) in user TZ | +| `externalId` | `sample.uuid.uuidString` | `"stats::"` | +| `deviceType` | `HKDevice.model` mapping | dominant source revision's device (watch \| phone), or `null` | + +### Rollout path + +1. **Server first** (no schema change): document the new `externalId` + shape; fix the AVG → SUM router for cumulative types; ship. +2. **iOS opt-in build** behind a `ENABLE_DAILY_STATS` flag for a + TestFlight beta. Run side-by-side against the per-sample path so + the maintainer can sanity-check daily totals against Apple + Health.app. +3. **Cut over** the iOS build to daily-stats-by-default. Old iOS + builds continue to post per-sample; the read-side aggregator + keeps both shapes correct. +4. **Compaction** (v1.5.1 or later, optional): one-time script + collapses per-sample APPLE_HEALTH cumulative rows into daily rows + keyed by the new `externalId` shape. Idempotent — re-running is a + no-op. Tracked as a back-burner item; not required for the v1.5 + release. + +### Generalisation to other continuous types + +| HK type | Aggregation | Server enum today | Notes | +| --- | --- | --- | --- | +| `stepCount` | sum | `ACTIVITY_STEPS` | The canonical case. | +| `activeEnergyBurned` | sum | `ACTIVE_ENERGY_BURNED` | Same pattern; one row per day. | +| `flightsClimbed` | sum | `FLIGHTS_CLIMBED` | Same. | +| `distanceWalkingRunning` | sum | `WALKING_RUNNING_DISTANCE` | Same. | +| `timeInDaylight` (iOS 17+) | sum | `TIME_IN_DAYLIGHT` | Same; one sample-per-minute roll-up. | +| `environmentalAudioExposure` | mean | `AUDIO_EXPOSURE_ENV` | `HKStatisticsCollectionQuery` with `options: .discreteAverage` instead of `.cumulativeSum`. | +| `headphoneAudioExposure` | mean | `AUDIO_EXPOSURE_HEADPHONE` | Same as env. | +| `appleExerciseTime` (deferred) | sum | — | Future. | + +Spot metrics — weight, body fat, BP, pulse, blood glucose, body +temperature, oxygen saturation, HRV, resting HR, VO2 max, skin +temperature — all stay per-sample. They are point-in-time +observations; aggregating to "the day's average BP" loses clinical +fidelity. The deep-dive § 2 Layer 2 already argues this. + +Sleep stays a special case — per-stage rows for one night, anchored +by the v1.4.25 W17b 5-axis unique index. Not in scope for this +research. + +### Why not Option B in one sentence + +It re-implements Apple's cross-source merge on the server with strictly +worse inputs and adds a new failure mode (cron lag) that the customer +will see as "today's steps are wrong until 03:00 tomorrow". + +### Why not Option C in one sentence + +It keeps the per-sample row volume (no storage win), pays for a +GROUP BY on every chart read, and still doesn't solve the +Watch + iPhone double-count problem — the bucketKey collapses sibling +rows by *day*, not by *moment*, so the watch and iPhone both post +non-deduped samples for the same minute. + +--- + +## 7. Risks + open questions + +| Risk | Mitigation | +| --- | --- | +| Late watch sync writes a divergent value for an already-posted day | iOS keeps a small SQLite cache of `(type, day, last-posted-value)`; on next observer wakeup, diff against the new `HKStatistics.sumQuantity()` and PATCH if changed. | +| User changes timezone — day boundaries shift mid-stream | Anchor the day key in **user-server-timezone** (from `User.timezone`) — same convention `MoodEntry.tz` introduced in v1.4.25 W7b. iOS reads the user's timezone from `/api/auth/me` and pins it for the duration of the session. | +| PR detection over-fires on the daily roll-up replacing yesterday's value | PR detector compares against the historical best; a late-sync update from 9500 to 10100 steps still triggers a legitimate PR. Acceptable. | +| Pre-Option-A rows + post-Option-A rows for the same day land in the DB | `pickCanonicalSourceRows` already sums same-day same-source rows. Per-sample rows + a daily-rollup row sum together and double-count — the cutover window needs an upper bound. Either drain raw rows before flipping the iOS build's `ENABLE_DAILY_STATS` to ON by default, or have the iOS migration write a one-time "delete my raw cumulative rows for this account" call against `DELETE /api/measurements/by-external-ids`. Recommend the latter — it's already in the iOS toolkit. | +| `HKStatisticsCollectionQuery` doesn't surface deleted samples the same way `HKAnchoredObjectQuery` does | The deep-dive § 3 already addresses HK deletion via the 30-day window reconciliation on `DELETE /api/measurements/by-external-ids`. For cumulative rows the reconciliation must compare *day-keyed* externalIds against HK's current 30-day daily sums — one row per day, easier than the spot-sample case. | + +--- + +## 8. Files touched (server, server-side change set) + +| Path | Change | +| --- | --- | +| `src/lib/measurements/apple-health-mapping.ts` | Add `dailyStatsExternalId()` helper + `CUMULATIVE_HK_TYPES` set. | +| `src/app/api/measurements/route.ts` | Switch `aggregate=daily` SQL from AVG to SUM for cumulative types (covered by the new set). | +| `src/app/api/measurements/series/route.ts` | Group-by-day SUM for cumulative `kind`s (steps + future cumulative additions). | +| `.planning/v15-ios-handoff/06-ios-responsibilities.md` | Document daily-aggregation contract for cumulative types under Domain 1. | +| `.planning/v15-ios-handoff/08-locked-contracts.md` | Lock the `externalId` shape `"stats::"`. | +| `src/lib/analytics/source-priority.ts` (no change) | Already sums per-day per-source — works correctly with Option A by accident. | +| `prisma/schema.prisma` (no change) | The existing 5-axis + 4-axis unique indexes already enforce the right dedup behaviour for daily rows. | + +## 9. Files touched (iOS, sketch) + +| Path | Change | +| --- | --- | +| `HealthLog/Services/HealthKitStatisticsService.swift` (new) | `HKStatisticsCollectionQuery` wrapper per cumulative type. | +| `HealthLog/Services/HealthKitWireConverter.swift` | Skip cumulative types in `quantityEntry`; statistics service builds those DTOs. | +| `HealthLog/Services/HealthKitService.swift` | Wire the statistics service into the per-type observer wakeup. Track per-day last-posted-value cache. | +| `HealthLog/Repositories/MeasurementBatchUploader.swift` | Add PATCH-on-divergence path for daily-rollup rows. | +| `HealthLog/Models/HealthKitBatchDTO.swift` (no change) | DTO shape unchanged. | +| `HealthLog/Util/AppEnvironment.swift` | Add `ENABLE_DAILY_STATS` build flag (default OFF for the first build, ON for the cut-over build). | + +--- + +## 10. Six-line summary + +- **Chosen option**: A — iOS pre-aggregates daily via `HKStatisticsCollectionQuery`. +- **Rationale headline**: Apple's cross-source merge must run on-device (server can't see watch-on-wrist state); Withings already lands daily rows, so Option A puts APPLE_HEALTH on parity instead of staying the odd one out. +- **iOS contract impact**: Additive — same `HealthKitBatchEntryDTO` shape; only the `externalId` *convention* changes for cumulative types (`"stats::"`), and a new `PATCH` path is exercised when late watch syncs revise yesterday's total. +- **Generalisation to other continuous types**: Yes — same primitive covers active energy, flights climbed, walking-running distance, time-in-daylight (sum), and audio-exposure metrics (mean). Spot metrics (weight, BP, BG, HRV…) stay per-sample. +- **Top risk**: Cutover window when pre-Option-A per-sample rows coexist with post-Option-A daily rows for the same day — `pickCanonicalSourceRows` will sum both shapes and double-count. Mitigate by draining raw cumulative rows via `DELETE /api/measurements/by-external-ids` on the iOS migration's first run. +- **Effort**: web S (helper + AVG-to-SUM router + handoff doc updates), iOS M (statistics service + per-day cache + PATCH-on-divergence wiring + per-cumulative-type config). diff --git a/.planning/research/v15-r-b-dashboard-perf.md b/.planning/research/v15-r-b-dashboard-perf.md new file mode 100644 index 000000000..8fdc60bc2 --- /dev/null +++ b/.planning/research/v15-r-b-dashboard-perf.md @@ -0,0 +1,225 @@ +--- +file: .planning/research/v15-r-b-dashboard-perf.md +purpose: Incremental dashboard perf audit after v1.4.28 — why the dashboard still feels slow, why pulse is the slowest tile +created: 2026-05-16 +contributor: R-B +--- + +# v1.5 R-B — Incremental dashboard perf audit + +## What the dashboard is actually waiting on + +The dashboard's perceived slowness after v1.4.28 is not driven by the +chart-data fetch any more — H0 closed that vector. The remaining lag +sits in two places. The first is `/api/analytics`, an aggregator that +on every dashboard mount walks every persisted row of every measurement +type via a chunked Prisma pager, then runs the in-memory `summarize()` +helper across each of those row sets. For a small seeded demo the route +returns in ~290 ms (median over five samples); for a power user with +multi-year Apple Health backfill the per-type read costs scale linearly +with row count, and `summarize()` re-sorts the full series three times +(once per `trendSlope` window) plus spreads it twice through +`Math.min/Math.max(...values)`. The second is the dashboard's React +mount pipeline: a single `useAuth()` gate serialises the first paint +behind `/api/auth/me`, and only then do three parallel React-Query +fetches fan out — `/api/analytics`, `/api/dashboard/widgets`, and +`/api/mood/analytics` — joined a beat later by +`/api/gamification/achievements` from the `AuthShell`. None of those +gate-after-auth queries have a `staleTime` longer than 60 s except +`useAuth` itself, so a hard reload pays the full cost every visit. + +The pulse chart specifically feels slowest because pulse data density +is structurally higher than every other metric the dashboard renders. +A continuous-monitoring source (Apple Watch, Withings ScanWatch) writes +a pulse sample on the order of once per minute; weight, BP, body fat, +mood, and sleep all write at most once per day. When the chart pulls +its bounded 30/90/365-day window the server still returns up to 5 000 +raw rows for pulse (the post-H0 ceiling) versus a few dozen for weight. +That's a Recharts re-render on a series an order of magnitude larger +than its siblings, every time the user switches range tabs. The +server-side aggregation path that would collapse those 5 000 rows into +~365 daily buckets — `aggregate=daily` on `/api/measurements` — is +deployed but never invoked by the client, AND when I probed it +end-to-end on the live demo it returns HTTP 500. The closure report +already flagged the "client never sets `aggregate`" deferral as +SD-H1; what it did not catch is that the server side is also broken, +so even when v1.4.29 wires the client, today's server code will hand +back errors instead of buckets. + +## Live TTFB baseline + +All medians over five samples against the public demo (`demo / demo123demo123`). +Production was sampled in dry-run shape (login pages, version endpoint) +and matched the demo numbers within ~30 ms; authed prod was not +exercised to avoid noise on the maintainer's account. + +| Endpoint | Median TTFB | Note | +|---|---|---| +| `/api/auth/me` | 110 ms | Gates every dashboard query | +| `/api/dashboard/widgets` | 111 ms | Cheap, JSON-blob read | +| `/api/mood/analytics` | 105 ms | Mood entries scoped to 30 days | +| `/api/gamification/achievements` | 147 ms | Fired by `AuthShell`, refetches every 120 s | +| `/api/insights/pulse-status` | 124 ms | Web only, retry: 0, 20 s upstream timeout | +| `/api/analytics` | **287 ms** | Dominant cost on the dashboard, seed data only | +| `/api/measurements?type=PULSE` 30/90/365d | 109-111 ms | Bounded window, demo seed of ~60 days of data | +| `/api/measurements?type=PULSE` all-time | 125 ms | Same row count as 365d on demo | +| `/api/measurements?type=WEIGHT` 90d | 110 ms | Reference: low-density type | +| `/api/measurements?...&aggregate=daily` | **500** | Server-side aggregation path is broken in production | +| `/` (HTML, authed) | 127 ms | Server roundtrip, no waterfall yet | +| `/insights/puls` (HTML, authed) | 113 ms | Same | + +The demo seed is too sparse to expose the volume-scaling tail: pulse +and weight return in roughly the same time because both are bounded +windows that fit comfortably under the demo's row count. For an +account with a year of Apple-Watch pulse samples and a few thousand +manual or ingested rows across the other metric types, `/api/analytics` +should be the slow leader by a wide margin — every `summarize()` call +is super-linear in series length. + +## Hotspots — severity-grouped + +### Critical + +| ID | Location | Impact | Fix shape | Effort | +|---|---|---|---|---| +| C1 | `src/app/api/analytics/route.ts:60-116` | The route iterates `measurementTypeEnum.options` (~30 types), pages every persisted row of every type through `fetchMeasurementSeriesChunked`, and feeds the full series into `summarize()`. For high-density types the read alone allocates a few MB of `MeasurementType[]` rows per request, and the sort/spread/anomaly passes are O(N log N) each. The maintainer's perceived dashboard lag almost certainly lives here. | Run aggregation in SQL with `prisma.groupBy` or a `date_trunc` SQL view that returns one row per `(type, day)` with `avg`, `min`, `max`, `count`. Drop the in-memory full-series walk for everything except trend slope, which only needs daily buckets anyway. Cap the request to "last 365 days" — anything older is already painted into the all-time mini chart, not the dashboard tile. | M | +| C2 | `src/app/api/measurements/route.ts:74-94` (the `aggregate` branch) | Server-side range aggregation returns HTTP 500 in production for all three grains (`daily`, `weekly`, `monthly`). Confirmed against `demo.healthlog.dev` for every grain. The Prisma `$queryRaw` call passes `${truncUnit}` as a bound parameter to `date_trunc`, which Postgres rejects because the function's first argument must be a literal in the prepared statement. Tests pass only because they mock `prisma.$queryRaw` — the real path was never executed end-to-end. This is the entire foundation that v1.4.29's SD-H1 client wire-up plans to consume. | Switch from `${truncUnit}` (parameter) to `Prisma.sql` interpolation for the grain literal, or build the SQL string with whitelisted grain tokens inline. Add an integration test that hits a real Postgres (the existing test-container fixture in `src/lib/db/__tests__`) rather than a mocked `$queryRaw`. | S | +| C3 | `src/components/charts/health-chart.tsx:572-613` (the chart-data fetch loop) | Even after H0 bounded the window, the chart pulls raw rows: `limit=5000` for pulse means up to ~5 000 points per type per request, then the client groups by day in JavaScript. For a continuous-monitoring user the response is ~150-500 KB per chart per range-tab change; Recharts then paints a sub-1-pixel-spaced line that takes 200-500 ms to draw on a mid-range phone. | Have the chart always pass `aggregate=daily` for windows over 7 days. Server returns one row per day. Drop the client-side `Map` aggregation loop entirely. Bound payload at ~365 rows per type instead of 5 000. (Depends on C2 landing first.) | S | +| C4 | `src/hooks/use-chart-overlay-prefs.ts:38-53` | The hook reads `/api/dashboard/widgets` under cache key `["dashboard-layout"]`. The rest of the codebase uses `queryKeys.dashboardWidgets()` → `["user", "dashboardWidgets"]`. Same endpoint, two cache slots. The dashboard fires both keys on mount; the overlay-prefs cache never warms from the dashboard's primary fetch and vice-versa. Comment on the hook (line 19) claims "Reads from the existing dashboard-widgets cache" — that claim is false, the keys diverge. | Replace `["dashboard-layout"]` with `queryKeys.dashboardWidgets()` so both consumers share one cache slot. One file, four lines. | XS | + +### High + +| ID | Location | Impact | Fix shape | Effort | +|---|---|---|---|---| +| H1 | `src/lib/analytics/trends.ts:171-247` (`summarize()`) | Sorts the series by date, then calls `trendSlope(data, …)` three times — each of which **re-sorts** the same series. Plus `Math.min(...values)` and `Math.max(...values)` allocate spread arrays that can blow the V8 argument-count limit (~50 k entries) on pulse-rich accounts. `detectAnomalies(data)` is another O(N) walk. Five O(N log N) passes per type, ~30 types, every dashboard mount. | Pass the already-sorted series down into `trendSlope`; replace `Math.min/max(...values)` with a single reducer loop; share the mean/std-dev pre-compute between `summarize` and `detectAnomalies`. Pure-function rewrite, no contract change. | S | +| H2 | `src/app/api/analytics/route.ts:213-228` (`glucoseRows.findMany`) | Reads every `BLOOD_GLUCOSE` row the user has ever written, with no time bound. For a user logging 4-6 readings per day for a year that's a few-thousand-row fetch on every dashboard mount, none of which is needed for the headline tiles (`avg7`, `avg30`, `latest` only need the trailing window). | Bound `where.measuredAt: { gte: thirtyDaysAgo }` for the dashboard tile path; keep the full-history path on the dedicated `/insights/glucose` sub-page if it ever lands. | XS | +| H3 | `src/hooks/use-auth.ts:34-49` + dashboard `useQuery` gates | Every dashboard query gates on `isAuthenticated`. Because `useAuth` has a 5-minute `staleTime` but no `placeholderData`, the first paint after a hard reload is `auth/me` resolves → THEN three queries fan out → THEN charts mount and fan out their own fetches. The waterfall is serial across two round-trips before any chart sees data. | Hydrate the user from a server-rendered initial payload (the root layout already reads cookies + headers; lift the user record into a server-prop and seed `queryClient.setQueryData(["auth","me"], user)`). The dashboard's three queries then fire in the same micro-task as the page mount, removing one network round-trip from the critical path. | M | +| H4 | `src/app/api/analytics/route.ts:236` + 242-256 (correlations + health-score) | Runs three Pearson correlations and the full Personal Health Score compute on every dashboard load, even though the dashboard tile strip doesn't display the correlation cards or the health-score hero panel at first paint. Both branches fire additional Prisma reads (intake events, medications, prior-week shifted windows). | Split `/api/analytics` into two surfaces: a lightweight `/api/analytics/dashboard` that returns only the `summaries` + glucose blocks (driving the visible tiles), and `/api/analytics/insights` that carries the correlations + health-score for the `/insights` page. Or fold the correlations+health-score block under an explicit `?include=correlations` query param so the dashboard mount can skip it. | M | +| H5 | `src/components/charts/health-chart.tsx:613` (`Promise.all(types.map(...))`) | For BP, the chart fires two concurrent `/api/measurements` requests (`BLOOD_PRESSURE_SYS` + `BLOOD_PRESSURE_DIA`). Two HTTP round-trips for one logical chart. Same shape on any multi-series chart in the future. | Accept an array of types on the `/api/measurements` endpoint (`?type=BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA`), return rows for both types in one response. One HTTP round-trip per chart instead of N. Additive contract change, iOS unaffected. | S | + +### Medium + +| ID | Location | Impact | Fix shape | Effort | +|---|---|---|---|---| +| M1 | `src/app/api/analytics/route.ts:151-209` (BP-in-target chunked walk) | Runs two `fetchMeasurementSeriesChunked` calls against `BLOOD_PRESSURE_SYS` + `BLOOD_PRESSURE_DIA` with NO `since` bound — pulls the entire BP history every dashboard mount. The `computeBpInTargetWindows` helper only needs the trailing 365 days (the longest window it computes is `priorYear`). | Pass `since: oneYearAgo` to both chunked reads; `computeBpInTargetWindows` already works against the bounded set. | XS | +| M2 | `src/components/gamification/achievement-unlock-notifier.tsx:69-86` | Mounted in `AuthShell` for every authenticated user on every route. Fires `/api/gamification/achievements` every 120 s plus on every dashboard mount. The achievements endpoint returns ~20 KB on the demo. The notifier's purpose is to surface unlocks; the polling is the implementation but its cost is paid even when the user is not looking. | Replace 2-minute polling with an SSE/WebSocket or a localStorage check-on-focus pattern. Or push the refetch interval to 10 minutes — the average user does not unlock an achievement every 2 minutes. | S | +| M3 | `prisma/schema.prisma:419` (`@@index([userId, type, measuredAt])`) — present, but… | The index exists and is good for `(userId, type, measuredAt)` range queries. What is missing is a partial index on `(userId, measuredAt) WHERE type IN (BLOOD_PRESSURE_SYS, BLOOD_PRESSURE_DIA)` for the BP-in-target chunked walk, and a covering index that includes `value` so Postgres can serve the analytics aggregations as an index-only scan. Today every analytics aggregate forces a heap fetch. | Add `(userId, measuredAt) INCLUDE (type, value, source)` as a covering index, or two purpose-built indexes for the analytics aggregations. Bench against `EXPLAIN ANALYZE` before merging. | S | +| M4 | `src/components/layout/auth-shell.tsx:131` (`AchievementUnlockNotifier`) | The notifier mounts before any route-specific data lands. Its first render fires a network call that competes with the dashboard's `/api/analytics` for the same browser HTTP/2 connection's bandwidth share. | Lazy-mount via `next/dynamic` with no SSR + a 1 s mount delay. Achievements are not part of the first paint. | XS | +| M5 | `src/app/page.tsx:194-228` (three `useQuery` blocks inline) | Each block declares its own `queryFn`, no `staleTime`, no `gcTime`. Default 0 ms `staleTime` means a tab-focus-and-return triggers a refetch storm. | Lift each into a hook with `staleTime: 60_000` matching the chart query, plus `refetchOnWindowFocus: false` for analytics/widgets (they're not real-time data). | XS | + +### Low + +| ID | Location | Impact | Fix shape | Effort | +|---|---|---|---|---| +| L1 | `src/lib/analytics/trends.ts:200-220` | The window-mean helper allocates two filtered arrays per type for `avg30LastMonth` + `avg30LastYear`. Saved per type per request — small but adds up at ~30 types. | Single pass over the sorted series, computing all four window means with one loop. | XS | +| L2 | Per-chart `Vo2MaxChartRow`, `MoodChart`, `MedicationComplianceChart` each fire their own internal fetches even when off-screen below the fold | Initial paint pays for charts the user has to scroll to see. | Wrap below-the-fold charts in an `IntersectionObserver` so the dynamic chunk + its data fetch only fires when the chart enters viewport. The `next/dynamic` import does NOT defer the React-Query subscription. | S | +| L3 | `src/components/charts/health-chart.tsx:635-720` (`useMemo` chain) | Several `useMemo` blocks chain off the same `chartData` slice; on Recharts re-renders these all re-evaluate. The Recharts paint itself dominates, but the memo chain adds 5-10 ms per re-render on a mid-range phone. | Consolidate the memo chain into a single `useMemo` returning all derived inputs, keyed on the same dependency tuple. | XS | + +## v1.4.28 gap analysis + +The bounded-window work in `b00be286` is the load-bearing fix; the +pulse chart used to paginate the full measurement history on every +visit and now it does not. The 60 s `staleTime` + 5 min `gcTime` plus +the shared cache key across sub-pages collapses tab-navigation cost +to a cache hit. `ChartSkeleton` + `HealthChartDynamic` reduce the +blank-then-jolt mounting feel. The `dispatchLocalisedNotification` +LRU defends the audit-log write path. + +What v1.4.28 left behind: + +1. **`/api/analytics` itself.** Every C1/H1/H2/H4 finding above + pre-dates v1.4.28 and is still true. The chart fix saved the chart, + not the page. +2. **The `aggregate=*` server path.** Built, mock-tested, deployed, + never invoked, currently 500s when invoked. SD-H1's client wire-up + cannot land until C2 closes the regression. +3. **Cache-key collision in `use-chart-overlay-prefs`.** v1.4.18 vintage, + compounds the mount waterfall. + +## Pulse-specific path — volume IS the architecture + +| Stage | Pulse behaviour | +|---|---| +| Chart fetch | Bounded `from`-`to` window + `limit=5000`. A 90-day window on an Apple-Watch user returns ~5 000 rows; the same window on a weight-scale user returns ~30. The bounded-window fix saved us from the unbounded historical pull; it did not save us from "today's window is still 100× larger for pulse than for weight". | +| Server `findMany` | Uses the `(userId, type, measuredAt)` index correctly. The cost is the returned row count, not the lookup. | +| Daily-bucket aggregation | Runs on the **client** in JavaScript. ~5-10 ms for 5 000 rows — wasted CPU, not the bottleneck. | +| Recharts paint | `ComposedChart` with `` series. 5 000 dots = 5 000 SVG nodes on hover. Browser layout/paint cost dominates. | + +There is no in-place rewrite that makes painting 5 000 points feel +fast; the answer is to never send 5 000 points. C2 + C3 collapse the +visible series to ~365 daily-bucket rows worst case. + +## Mount waterfall on `/` + +``` +T+0 HTML returned (~127 ms TTFB) +T+~50 useAuth fires /api/auth/me (~110 ms) +T+~160 Fan-out, in parallel: + /api/analytics (~287 ms — dominant) + /api/dashboard/widgets (~111 ms) + /api/mood/analytics (~105 ms) + /api/gamification/achievements (AuthShell) (~147 ms) + /api/dashboard/widgets (C4 duplicate) (~111 ms) +T+~450 Analytics resolves, tile strip paints, charts fan out their own + /api/measurements requests +``` + +Critical-path latency to "tile strip painted" is `auth/me` + +`analytics` ≈ 400 ms on the demo, before any chart paints. For a +power-user the analytics leg likely doubles or triples. H3 collapses +that to one round-trip; C1 + C2 + C3 collapse the analytics leg itself. + +## Index + SSR audit + +Schema indexes in `prisma/schema.prisma`: `@@index([userId, type, measuredAt])` +(line 419) plus the two `@@unique` constraints support every analytics +filter correctly. Missing: a covering index that includes `value` + +`source` so analytics aggregates can serve as index-only scans (M3). + +`/` is `"use client"` — no server-side data fetch on the dashboard +route. Every fetch happens after hydration, which is why H3 + C1 + C4 +target the post-hydration waterfall. + +## Recommendation — single-day patch + v1.5 architectural lift + +**Single-day patch (ships in v1.4.29 or a v1.4.28.x point release):** + +- **C2** (3 LOC + a real-Postgres integration test) — restore aggregate path. +- **C4** (4 LOC) — fix the cache-key collision. +- **C3** (10 LOC) — wire chart to pass `aggregate=daily` for windows > 7 days. +- **M5** (3 × 5 LOC) — `staleTime: 60_000` on the dashboard queries. +- **H2** (1 LOC) — bound the glucose-rows read. +- **M1** (1 LOC) — bound the BP-in-target reads. + +Combined effect: pulse chart paint cost drops from 5 000 points to +~90 points per range tab, response payload drops ~50× for high-density +types, cache-key fix removes one duplicate round-trip per dashboard +mount. **Pulse chart TTFB on a power-user account: estimated 1 200 ms +→ 200 ms** (the 1 200 ms is extrapolated from C1 against a power-user +dataset; demo seed cannot expose that scale). + +**v1.5 architectural lift (alongside the iOS sprint):** + +- **C1 + H1 + H4** — split `/api/analytics`, move aggregations to SQL, + rewrite `summarize()` to single-pass. ~2-3 days plus integration tests. +- **H3** — server-rendered auth seed. Removes one round-trip from + the first-paint waterfall; touches root layout + auth flow; flag-gate. +- **M3** — covering index. Needs `EXPLAIN ANALYZE` against + production-scale data first. +- **L2** — viewport-gated below-the-fold charts. Needs a UX call on + the fold boundary per breakpoint. + +The patch restores perceived speed; the lift removes the scaling cliff. + +## Coach drawer + side-panel mounts + +The dashboard does not mount the Coach drawer; `` +on the insights surfaces opens it on demand. `AchievementUnlockNotifier` +is the one `AuthShell`-mounted side fetch — M2 + M4 cover its cost. + +## Constraints check + +Read-only audit, every finding cites file:line or measured TTFB. +Forbidden vocabulary purged. No PII — scale-words ("tens of thousands") +where the actual count matters. Aggregate path 500 confirmed against +the public demo, not the maintainer's production account. diff --git a/.planning/research/v15-r-c-mobile-tile-symmetry.md b/.planning/research/v15-r-c-mobile-tile-symmetry.md new file mode 100644 index 000000000..8ce7b7cf7 --- /dev/null +++ b/.planning/research/v15-r-c-mobile-tile-symmetry.md @@ -0,0 +1,330 @@ +--- +file: .planning/research/v15-r-c-mobile-tile-symmetry.md +purpose: Dashboard tile mobile height-symmetry research and contract proposal +created: 2026-05-16 +contributor: R-C +--- + +# Dashboard tile mobile height-symmetry research + +## TL;DR + +The maintainer is right. The dashboard tile strip on `/` does NOT enforce the equal-height contract on mobile viewports (`` instance can render between two and four content blocks (label row, value row, optional comparison-delta callout, sub-row pair) the rendered heights drift by 16-24 px between siblings on a 375 px viewport. + +The fix is one wrapper-level edit plus one tile-internal slot-policy edit. The single `` primitive is already shared across every dashboard tile (the v1.4.28 FB-C2 rewrite closed the BD-Zielbereich outlier), so the contract lands in one component file plus the page-level grid switch. Estimated effort: **S**. Files touched: **3** (`src/app/page.tsx`, `src/components/charts/trend-card.tsx`, plus one snapshot test). + +iOS impact: none. The native client renders its own SwiftUI tiles; web-only chrome. + +--- + +## 1. Sources read + +| Source | Relevance | +|---|---| +| `src/app/page.tsx` (lines 549-1239) | Dashboard mount, tile-strip wrapper, every tile call-site | +| `src/components/charts/trend-card.tsx` (374 lines) | The shared tile primitive (only one variant exists post-FB-C2) | +| `src/components/insights/trends-row.tsx` | v1.4.28 insights row equal-height contract (`auto-rows-fr` + fixed chart slot) | +| `src/components/insights/trend-annotation.tsx` | `line-clamp-3` caption clamp policy (FB-K2) | +| `src/lib/dashboard-layout.ts` | `DASHBOARD_WIDGET_IDS` — full tile inventory | +| `.planning/v1428-feedback-2026-05-15.md` Theme K + Theme M | Maintainer's "ruthless symmetry" directive | +| `.planning/research/v1427-r3c-mobile-dashboard.md` F10 + F11 + F14 | Prior mobile tile-strip findings (wrap-vs-scroll, mobile-secondary asymmetry, vestigial flex wrapper) | +| `.planning/research/v1427-r1-dashboard.md` finding 4 | Chart-row symmetry baseline (`auto-rows-fr` introduced v1.4.25 W3) | + +The two files explicitly named in the brief (`v1428-r1-ui-inventory.md`, `v1428-r1-competitive.md`) were not authored under those filenames in this repository. The closest existing artifact for ui-inventory is the v1.4.27 mobile-dashboard audit (`v1427-r3c-mobile-dashboard.md`), which already enumerates the trend-card variants and flags F10 (the wrap-vs-scroll decision at 320 px) and F11 (mobile-secondary row asymmetry). The maintainer's Theme K narrative in `v1428-feedback-2026-05-15.md` carries the competitive-app reasoning inline (`grid-auto-rows: 1fr` is the prevailing convention). + +--- + +## 2. Mobile tile-strip current state + +### 2.1 The wrapper (page.tsx:1194-1229) + +```tsx +
+ {trendCards.map((entry) => ( +
+ {entry.node} +
+ ))} +
+``` + +Two layout regimes: + +| Breakpoint | Wrapper | Tile cell | Height contract | +|---|---|---|---| +| `= 640 px) | CSS grid, auto-fit columns, `auto-rows-fr` | `min-w-0 shrink` | **`auto-rows-fr` forces equal row height** | + +The `` itself carries `flex h-full w-full min-w-0 flex-col` (line 199), which fills whatever height its parent gives it. On the grid branch the parent gives every tile the same height (`auto-rows-fr` makes the implicit row tall enough for the tallest content, then every cell stretches to that height). On the flex branch the parent gives each tile only the height of its own content — there is no parent constraint that ties siblings together. + +That is the symmetry break the maintainer sees. + +### 2.2 The cell wrapper is also vestigial + +Each tile is wrapped in `
`. The wrapper is a `flex` container with no `flex-direction` and exactly one child. On the mobile flex branch it inherits the strip's flex behaviour (cross-axis stretch by default), which **would** stretch the tile height to match the tallest sibling IF the strip itself had a determined height — but the strip's height is unset, so cross-axis stretch resolves to the tile's content height, and the strip's height resolves to the tallest tile. + +Net result: the tallest tile dictates the strip's height. Every other tile *could* stretch to that height (cross-axis stretch on flex children is the default), but the wrapper's own height is `auto`, and the ``'s `h-full` resolves against the wrapper, which resolves against the strip's cross-axis. Because flex `align-items` defaults to `stretch`, the cross-axis SHOULD propagate — but the ``'s internal layout (`mt-auto` on the sub-row pair) collapses any extra height into the sub-row pair's `pt-1` baseline, leaving the tile's visible chrome at content height regardless of available stretch. + +In practice on a live Pixel 5 viewport (verified through prior `.planning/research/v1427-r3c-mobile-dashboard.md` F11): the BD-Zielbereich tile (when the comparison overlay was active and the avgAllTime row painted) was 16-24 px taller than the Weight / BP / Pulse tiles. The v1.4.28 FB-C2 rewrite closed that one (BD-Zielbereich now ships through the same ``), but the underlying flex-strip lack-of-contract remains. The next divergent payload (e.g. a comparison overlay that paints on one tile and not another, or a 7d delta that wraps to two lines) will re-introduce the drift. + +--- + +## 3. Tile-by-tile inventory + +Every dashboard tile in `DASHBOARD_WIDGET_IDS` and its rendered shape at 375 px. All tiles route through `` after the v1.4.28 FB-C2 rewrite. The "divergent block" column lists the per-tile feature that can break the symmetry. + +| Tile | Page.tsx call-site | Latest unit | Sub-rows | Comparison-delta callout? | Divergent block? | +|---|---|---|---|---|---| +| Weight | 553-591 | `kg` | `7d:` + `30d:` (avg7Hint + avg30Hint Tooltip) | yes when `compareBaseline != none` | none | +| BP-sys | 599-636 | `mmHg` | `7d:` + `30d:` (avg7Hint + avg30Hint Tooltip) | yes | none | +| BP-dia | 637-676 | `mmHg` | `7d:` + `30d:` (avg7Hint + avg30Hint Tooltip) | yes | none | +| Pulse | 678-715 | `bpm` | `7d:` + `30d:` (avg7Hint + avg30Hint Tooltip) | yes | none | +| BodyFat | 717-737 | `%` | `7d:` + `30d:` (no hint) | yes | none | +| Mood | 739-759 | `/ 5` | `7d:` + `30d:` (no hint) | yes | none | +| Sleep | 761-781 | `h` | `7d:` + `30d:` (no hint) | yes | none | +| Steps | 783-803 | `` (empty) | `7d:` + `30d:` (no hint) | yes | **empty unit slot — value row narrower** | +| VO2 max | 812-832 | `mL/(kg·min)` | `7d:` + `30d:` (no hint) | yes | **long unit string — value row taller via wrap if `text-3xl` digit count + `mL/(kg·min)` exceeds tile width** | +| BpInTarget | 879-898 | `%` | `7d:` + `30d:` (no hint) | yes | **trend7Delta is computed locally (bp7 - bp30) — same primitive, same shape** | +| Glucose (per context, up to 4 tiles) | 901-933 | `mmol/L` or `mg/dL` | `7d:` + `30d:` (no hint, no trend7Delta, no compareDelta) | **no** | **no `trend7Delta` → 7d label says "7d" not "7d trend"; no compareDelta → callout block fully absent** | + +Glucose tiles are the most-divergent payload today: they ship with neither `trend7Delta` nor `compareDelta`, so the callout `
` does NOT paint. Every other tile may or may not paint it depending on the global comparison-baseline toggle. With comparison set to `lastMonth` the strip will paint 9-10 tiles WITH the callout and 4 glucose tiles WITHOUT — heights diverge by exactly one row (16 px line-snug at `text-xs`). + +### 3.1 The `` content-height arithmetic + +Reading `src/components/charts/trend-card.tsx` line-by-line at the 375 px viewport (mobile `` | fixed-height `trends-row-chart-slot` wrapper | +| `/` dashboard strip | `flex` on `` content: + +```diff +- "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2", ++ "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2", ++ "[--tile-h:140px] sm:[--tile-h:auto]", + "sm:grid sm:snap-none sm:auto-rows-fr sm:overflow-visible", + "sm:[grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]", +``` + +Then on each tile cell wrapper: + +```diff +- className="flex min-w-[10rem] shrink-0 snap-start sm:min-w-0 sm:shrink" ++ className="flex min-w-[10rem] shrink-0 snap-start sm:min-w-0 sm:shrink h-[var(--tile-h)] sm:h-auto" +``` + +The wrapper now carries a deterministic 140 px height on ``'s `h-full`. On `sm:+` the `--tile-h: auto` puts the grid's `auto-rows-fr` back in charge. + +**Edit 2 (trend-card.tsx)** — bound the comparison-delta callout to a single line at ` ++
+ ` so the longer values clip at the right edge instead of wrapping down. + +Concretely: + +```diff +-
++
+``` + +The `7d trend: 122.3 (+1.2)` will then truncate at `7d trend: 122…` on a 160 px tile rather than wrap to `7d trend: 122.3` / `(+1.2)`. Trade-off: the trend-delta chip clips on very narrow tiles. Acceptable because the headline value above already carries the same signal via the trend arrow (`` / `` / ``). On `sm:+` the wrap returns. + +### 6.4 Chart band policy + +Not applicable. The dashboard tile strip carries no chart band — each `` is a value-and-text tile only. The lower row on `/` does carry `` instances, which are full-width single-column cards on ``) can stay — converting it to `block` would be cleaner but is orthogonal to the height contract. Defer. + +The mobile sub-row clamp may need an accessibility note: when `7d trend: 122.3 (+1.2)` clips to `7d trend: 122…`, the screen reader still announces the full value via the existing `aria-label={\`7-day trend ${formatDelta(trend7Delta)}\`}` on the `` (line 327-328). Verified via Read. + +--- + +## 8. Tests + snapshots + +The current test inventory in `src/app/__tests__/` and `src/components/charts/__tests__/` covers `` rendering shape but does not assert pixel-uniform tile heights across a strip. The contract delivery should add: + +1. A jsdom snapshot test that renders the dashboard with the full `DASHBOARD_WIDGET_IDS` list + comparison ON + a 375 px-wide root and asserts every `data-slot="trend-card-…"` parent measures the same `clientHeight` (jsdom does not layout, but the test can read computed style and assert `h-[var(--tile-h)]` resolves to the same value on every cell). +2. A Playwright mobile screenshot test (`@playwright/test` already in the repo per `playwright.config.ts`) at the 375 px viewport that diffs the strip against a baseline image. Fails if any tile's bounding box differs by > 1 px from its siblings. + +The insights row already has a `trends-row.test.tsx` precedent — mirror the structure for dashboard strip. + +--- + +## 9. Effort estimate + +**S** (small). + +Rationale: + +- One CSS-only edit at the page level (two diff hunks) +- One CSS-only edit inside the shared tile primitive (two diff hunks) +- No DOM restructure; no per-tile branch; no widget-API change +- Test additions: one snapshot, one Playwright spec +- No iOS contract surface affected — every endpoint, every DTO, every Prisma field stays identical +- No copy change — i18n keys untouched + +Sub-S risk: the `flex-nowrap overflow-hidden truncate` switch on the sub-row pair may clip values in locales the dashboard does not currently test (e.g. fa-IR with longer numeric strings). Mitigation: keep the `` wrapper as vestigial. The proposed contract requires the wrapper to carry the `h-[var(--tile-h)]` class, so it stays. Drop the F14 deferral; the wrapper has a real job now. + +5. **Comparison-overlay callout copy**: a one-line clamp may truncate the German `Δ +1,2 mmHg vs. letzter Monat` at the right edge on the narrowest tiles (160 px). The aria-label still carries the full string. Acceptable trade-off, but Marc may prefer to drop the ` vs. letzter Monat` suffix on `` after the v1.4.28 FB-C2 BD-Zielbereich rewrite — no surviving divergent shapes) | +| Target tile heights | **140 px on ` +
+ + +
+ {label} +
+
+
+``` + +Measured against the Tailwind base: + +| Layer | Class | Height | +|------------------|------------------|--------| +| Outer padding | `p-3` | 12 + 12 = 24 px | +| Arrow stack | two `size-11` + `gap-1` | 44 + 4 + 44 = **92 px** | +| Row total | | **≈ 116 px** | + +Across 15 default widgets the list is **15 × 116 ≈ 1 740 px** tall — +just the widget table, not counting the section header, the comparison +picker, or the Save bar. On a 390 × 844 viewport the list alone needs +more than two full scroll-heights. + +For reference, peer surfaces on the same Settings page: + +- Account inputs (`account-section.tsx`): `h-9` = 36 px field + 4 px + gap → **40 px** row rhythm. +- Sources tiles (`sources-section.tsx`): `px-2 py-1.5` row, single + `h-11 w-11` button → **44 px** row. +- AI provider chain (`ai-section.tsx`): `p-2` row, single `size="sm"` + (h-8) arrow button → **48 px** row. + +The Dashboard drag-list is the only Settings surface that doubles its +arrow buttons vertically. That is the entire source of the bloat — the +switches, the label, and the padding are otherwise in family. + +## 2 — Why two stacked arrows in the first place + +The current shape comes from v1.4.15. There was no DnD library in the +tree then (still none — `package.json` has no `@dnd-kit/*` / +`react-beautiful-dnd` / `sortablejs`), and the maintainer asked for an +explicit "move up / move down" surface so reorder works without a +press-and-hold gesture and stays driveable from a desktop keyboard. + +Stacking the two buttons vertically and inflating each to 44 px was +the path-of-least-resistance answer: each button hit the 44-px tap +target on its own without any horizontal layout fuss. The cost is the +row height — paid once per widget, on every visit. + +## 3 — Peer comparison (mobile patterns surveyed) + +- **Apple Health → Summary → Edit** uses a single drag-handle on the + trailing edge (the three-bar grip glyph). Rows are ~44 px. Reorder + is a press-and-drag, with star toggles separated. No paired arrows. +- **Withings → Home → reorder** offers per-widget drag handles, ~52 px + rows; settings are buried in a separate sheet. +- **Whoop → "Customize Overview"** uses ~48 px rows with a leading + grip + trailing toggle, never both visibility states on the same + row (toggle drills into a sub-sheet for the surface picker). +- **MacroFactor → Stat order** is again the single-grip pattern, ~44 + px rows. + +Every peer collapses to **one** action on the row plus a single drag +affordance. None of them stack two icon-buttons. + +The pattern the peers converge on is *long-press-then-drag*. That's +out of scope here (a true drag would need DnD-kit + an iOS-impact +parity story we explicitly don't want for v1.5). The compaction +proposed below keeps the up/down-arrow contract but shrinks it to a +single horizontal pair, which closes the height gap without taking on +new dependencies. + +## 4 — Proposed shape + +Replace the vertical arrow stack with a **horizontal pair of +`size="icon-sm"` (32-px) buttons** sitting to the right of the label, +on the same axis as the two switches. Drop the `p-3` to `px-3 py-2` to +match the Sources pattern. Bump the row to a deliberate `min-h-12` +(48 px) so the rhythm is consistent regardless of label wrap. + +The shape: + +```tsx +
+ {label} +
+
+ + +
+``` + +Per-row math: + +| Layer | Class | Height | +|------------------|------------------|--------| +| Outer padding | `py-2` | 8 + 8 = 16 px | +| Tallest cell | `size-8` arrows | 32 px | +| `min-h-12` floor | | 48 px | +| Row total | | **48 px** | + +That's a **2.4 × reduction** (116 px → 48 px). Across the 15 default +widgets the list collapses from ~1 740 px to ~720 px — fits inside a +single mobile viewport's worth of scroll. + +The column headers (Tile / Chart) already align with the two-switch +slot via `w-12 text-center`; that contract carries forward unchanged. +The arrows live in their own trailing slot, *not* under a column +header, because reorder is a verb, not a column. + +## 5 — Accessibility ledger + +- **Tap target.** `size="icon-sm"` is 32 × 32 px, **below** the + Apple HIG / WCAG 2.5.5 44-px minimum. The compensation: bump only + these two buttons back to `size="icon"` (`size-10`, 40 px) on + `sm:` and *up*, and keep `size-11` (44 px) on mobile via an + override (`max-sm:size-11`). The arrows then read as 44-px tap + targets on phone (where it matters most) and visually shrink on + desktop where pointer accuracy is high. **The 48-px row min-height + stays the same** because both buttons sit on the row axis, not + stacked. + + Net effect on mobile: row height = 44 px (tallest cell) + 4 px + padding floor = 48 px. The 44 px tap target is preserved exactly; + the height penalty is paid once horizontally rather than twice + vertically. + +- **Keyboard order.** Label → tile-switch → chart-switch → up → + down. Tab order stays linear. Each control keeps its existing + `aria-label` (`dashboard.moveUp`, `dashboard.moveDown`, + `${label} — ${tile|chart column}`). + +- **Screen reader.** No `` semantics are introduced; the + visual column headers are presentational already + (`aria-hidden="true"` on the spacer span; `text-[10px] uppercase` + on the two visible header labels). The proposed shape doesn't + change that contract. + +- **Reduced motion.** No animation added; existing + `motion-reduce:animate-none` on the Loader2 path is unaffected. + +- **Focus ring.** `Button` already ships + `focus-visible:ring-[3px]` from `buttonVariants`; the smaller + buttons inherit the same ring at the same offset, so the focus + affordance scales down without going invisible. + +## 6 — What collapses on mobile (and what doesn't) + +The audit looked at hiding the second switch on mobile (a popover or +`
` "secondary controls" pattern). **Recommendation: don't +do it.** Two reasons: + +1. The whole point of v1.4.15 Fix 5 was to give the maintainer + independent control of the tile surface and the chart surface + from one place. Burying one of them behind a disclosure + undermines the feature he explicitly asked for. +2. Two `Switch` components at `data-size=default` are 32 × 18.4 px + each. They cost ~64 px of horizontal real estate including + centering slots. There's room for them on a 390-px viewport + alongside two 44-px arrow buttons and a `flex-1` label that can + `truncate`. Verified against the iPhone-SE-class width budget + (`375 - 24 (card p-6 inner) - 48 (label min) - 48 (arrow pair) - + 64 (switch pair) - 24 (gaps) = 167 px label slack`). + +What does change on mobile: + +- The label gets `truncate`. Today the label can wrap, which makes + the row taller than the arrow stack and disrupts vertical rhythm. + With a tight `min-h-12` and 32-px buttons, wrapping would push the + row off-grid; truncating with the full label still reachable via + `title` / `aria-label` keeps the rhythm clean. + +## 7 — Classes to change (one-file diff sketch) + +Inside the `.map` block of `dashboard-layout-section.tsx`: + +```diff +
+-
+- +- +-
+- {t(labelKey)} ++ ++ {t(labelKey)} ++ +
+
++ ++ +``` + +Also bump the column-header alignment spacer so the new arrow slot +doesn't break the Tile / Chart column meters: + +```diff +-
` semantics introduced; existing `data-slot`-based tests + stay green. diff --git a/.planning/research/v15-r-e-ios-planning-state.md b/.planning/research/v15-r-e-ios-planning-state.md new file mode 100644 index 000000000..fc6e275ba --- /dev/null +++ b/.planning/research/v15-r-e-ios-planning-state.md @@ -0,0 +1,292 @@ +--- +file: .planning/research/v15-r-e-ios-planning-state.md +purpose: iOS planning state audit — what's open in the v15 handoff pack and the iOS repo before v1.5 can ship +created: 2026-05-16 +contributor: R-E +--- + +## Scope and method + +This audit reads the 24-file `v15-ios-handoff/` pack at `/Users/marc/Projects/HealthLog/.planning/v15-ios-handoff/` end to end, walks the iOS repo at `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/` Swift-file-by-Swift-file (184 files, 13 directories), cross-references the URL paths the iOS app actually calls against the live server route inventory at `/Users/marc/Projects/HealthLog/src/app/api/`, and reads the iOS-side roadmap + changelog through v0.4.1.1. The result is grouped by severity, each find carries a file-line citation on both sides where applicable, and the close-out path is concrete (one PR scope per find). + +The iOS repo is far further along than the handoff pack assumes. Phases 0–7 in `.planning/ROADMAP.md` are all "in progress" or "partial", iOS CHANGELOG marks 0.4.1.1 as shipped, and the test suite reads 667 unit-and-UI tests under Swift-6 strict concurrency. The four big load-bearing pieces — login + bearer + refresh, the HealthKit → batch ingest pipeline, the SwiftData outbox + replay, and the AppContainer + APIClient + 401-bridge composition — all exist and have been audited in v0.2.x → v0.4.1.1. The gap to v1.5 is narrower than the handoff pack hinted at, and the gaps that remain cluster around three things: the Coach drawer (no native code at all), the standalone/paired pivot the v1.4.27 R1 research mandated (iOS half-built, server not started), and a handful of additive endpoint shapes the iOS code references but the server has either not yet exposed or has exposed under a different path. + +## Severity counts + +| Severity | Count | +|---|---| +| Critical (release-blocker) | 5 | +| High | 8 | +| Medium | 9 | +| Low | 6 | +| Total | 28 | + +## Critical — release blockers + +### C-1. Coach SSE drawer has zero native code + +What's missing — the iOS app does not call `POST /api/insights/chat` anywhere. `grep -rn "Coach\|coach"` returns nine matches across the iOS source tree, every one of them either a deep-link route stub (`AppRouter.swift:55-58` parks `coach` on the Insights tab with a `// Coach hat noch kein eigenes Tab` note), a settings entry comment, or unrelated context (the GLP-1 PK files mention the word coach inside doc-strings only). The iOS changelog explicitly defers Coach to v0.5.0 ("Coach SSE conversational interface — XL scope + MDR-Class-IIa boundary risk requires deliberate review; v0.5.0", `HealthLogIOS/CHANGELOG.md` 0.4.0 "Deferred" section). The handoff pack treats the Coach as in-scope for v1.5 across `06-ios-responsibilities.md`, `14-coach-mental-model.md`, and `17-error-handling.md`; the marketing surface for v1.5 ("AI insights are the differentiator", `00-philosophy.md` Rule 3) is exactly the Coach. + +iOS side — no `URLSession.bytes(for:)` AsyncThrowingStream client, no SSE-frame parser, no `CoachConversation` SwiftData model, no `CoachStreamEvent` decoder, no `provenance` → "What I'm looking at" disclosure, no GROUND-RULE-9-and-15 refusal-acceptance UI, no `coach.budget.exceeded` 429 surface. The Glp1 PK package has the MDR dialog primitive (`DesignSystem/MDRAcknowledgmentDialog.swift`) but no Coach screen uses it. + +Server side — fully implemented at `/Users/marc/Projects/HealthLog/src/app/api/insights/chat/route.ts` plus the message-feedback endpoints. Locked contract documented in `08-locked-contracts.md` §1 (GROUND RULES) and `03-api-contracts.md` §Insights. + +Close-out — one iOS slot, 3–4 days. Build `CoachService` actor over `URLSession.bytes`, the `CoachStreamEvent` AsyncThrowingStream, a SwiftData-backed `CoachConversation` cache for offline render of past turns, the streaming bubble view (Apple-Messages-style typing dots between frames), and the provenance disclosure. Reuse the existing `MDRAcknowledgmentDialog` for GROUND RULE 15 surface refusals. Add the `coach.*` locale keys to `Localizable.xcstrings`. Wire AppRouter to a real `.coach` TabIdentifier, drop the `// Coach hat noch kein eigenes Tab` short-circuit. The 5.1.2(i) Third-Party-AI consent gate (`AIConsentStore.swift` exists) already protects every LLM call site; reuse the same gate for Coach. + +### C-2. iOS SyncMode + standalone-first pivot is half-built; server side is unstarted + +What's missing — the v1.4.27 R1 research (handoff pack files `22-offline-first-architecture.md` and `22-standalone-and-server-pairing.md`) directs an architectural inversion: SwiftData becomes canonical, server becomes optional mirror, pairing is a user toggle. iOS has the foundation (`Stores/SyncModeStore.swift`, `Models/SyncMode.swift`, `Screens/Onboarding/ModeSelectionStep.swift`, "Standalone-mode foundation" line in 0.4.1 changelog). Server has none of the contract additions the handoff pack scopes for v1.4.28: `syncVersion Int @default(1)` on `Measurement`, `deletedAt DateTime?` soft-delete column, `/api/sync/state` endpoint, `/api/mood-entries/bulk` and `/api/medications/intake/bulk` bulk-backfill endpoints. `grep "syncVersion\|deletedAt"` returns zero hits across Prisma + server source. + +iOS side — the `SyncMode` gate exists but is not honoured everywhere; the network repositories still hit the API in `.standalone` mode in many paths (the pairing flow stub uploads SwiftData rows but the everyday read paths have not been re-wired). The conflict-resolution columns the contract calls for (`syncIdentifier`, `syncVersion`, `deletedAt`) are not on the iOS `@Model` types. + +Server side — the entire v1.4.28 server prep menu from `22-standalone-and-server-pairing.md` §6.2 is unstarted: column adds, the bulk endpoints, the sync-state endpoint. The locked-batch contract (`/api/measurements/batch`) will accept the iOS first-pair backfill today — but the row-level last-writer-wins semantics need server enforcement before v1.5 ships with multi-device sync claims. + +Close-out — one server slot to land the Prisma migration + the two bulk endpoints + the sync-state endpoint (M effort, ~1.5 days), one iOS slot to extend `@Model` types with `syncVersion + deletedAt`, gate every repository call behind `SyncModeStore.isPaired`, build the actual pair / unpair sheet (M effort, ~2 days). Without C-2 we ship a v1.5 that markets standalone but breaks the moment the user pairs a second device. + +### C-3. `/api/devices` endpoint exists server-side but only as a partial APNs surface; iOS expects a richer shape + +What's missing — `Models/HealthKitSync.swift:83-113` defines `DeviceRegistration` with `token, bundleId, locale, appVersion, model, apnsToken?, apnsEnvironment?`, the contract documented in `05-auth-flows.md` §7 (Apple Health auth). The handoff pack scopes `POST /api/devices` plus `PATCH /api/devices/{id}` for APNs token rotation in `06-ios-responsibilities.md` §Domain 3. Server has `/api/devices/route.ts` and `/api/devices/[id]/route.ts` — but the v0.4.0 changelog notes "APNs-Push-Delivery gated auf Server-side `.p8` env-vars (KeyID `M9WAFLNC2U` + Team `S8WDX4W5KX` + Topic `dev.healthlog.app`)" and the v1.4.27 release notes do not confirm those secrets ever landed in Coolify. The iOS-side iOS PROJECT.md "Open Decisions / Blockers" lists APNs `.p8` as Marc-needs-to-supply. + +iOS side — `Services/NotificationService.swift` + the `/api/devices` round-trip exists per 0.3.0 changelog "Wave 2b-A6 APNs + Deep Links" entry. Token hex-encoding done, env tag set, idempotency + 409-recovery wired. + +Server side — code exists, but Coolify env vars (`APNS_KEY_PEM` or equivalent, plus `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC`) are unconfirmed. + +Close-out — Marc-action, not a code change: generate the `.p8` key in Apple Developer Portal, paste into Coolify, smoke-test from the production app. S effort, 1 hour total once the developer-portal capability is provisioned. Drops to High if there is evidence the secrets already landed. + +### C-4. Refresh-token contract documents `tokenExpiresAt` as ISO-8601 with offset; iOS persists it but tests do not cover the wall-clock drift on midnight refreshes + +What's missing — handoff pack `05-auth-flows.md` §2.1 commits the iOS app to "Refresh ~5 min before" the `accessTokenExpiresAt` value. iOS implementation at `Services/AuthService.swift:280-307` (`refresh()`) is single-flight via `RefreshCoordinator` and persists the new pair correctly, but the actual scheduling — the part that fires the refresh 5 min before — does not appear in the search. No `Task.detached` for the proactive refresh, no `Timer.publish(every:)`, no scene-phase watcher that schedules the next refresh on background-to-foreground. The 401-bridge in `APIClient.swift:135-158` is in place as a reactive safety net, but Marc's MEMORY notes flag silent-logout-after-24h as a v0.2.x audit find that landed `RefreshCoordinator`; the proactive window is implied but not verified. + +iOS side — `RefreshCoordinator` is described in CHANGELOG 0.3.0 as the "silent-logout 24h nach Login behoben" fix, but the implementation does not appear to schedule a proactive refresh — it answers in-flight 401s only. + +Server side — no server change required; the contract is intact. + +Close-out — one iOS slot, ~half a day. Add a `RefreshScheduler` that schedules a one-shot `Task` for `(accessTokenExpiresAt - 5min - now())`, cancels on logout, re-schedules on every successful refresh. Unit-test the timer with a `Clock` injection. Without this find we are likely shipping the same midnight-401 bug the 0.3.0 fix promised to close. + +### C-5. iOS does not call `/api/auth/me/source-priority` anywhere + +What's missing — `grep -rn "source-priority\|sourcePriority\|deviceTypePriority"` returns zero matches across the iOS Swift tree. `08-locked-contracts.md` §4 is unambiguous: the iOS Settings → Sources screen renders BOTH axes (per-metric ladder + per-device-type tiebreak), writes through `PUT /api/auth/me/source-priority` with the full new ladder. The two-axis resolver shipped server-side at v1.4.25 W8c and is documented across `02-server-architecture.md`, `06-ios-responsibilities.md`, and `08-locked-contracts.md`. + +iOS side — `Screens/Settings/Sub/` has a `SourcesChipStrip` (mentioned in changelog 0.4.1 "B5"), but that surface is read-only — it labels which sources contributed to a chart. The full editor that POSTs the user's preferred ladder back to the server does not exist. + +Server side — `/api/auth/me/source-priority/route.ts` exists; `04-data-model.md` §4.7 documents the JSON shape. + +Close-out — one iOS slot, ~1.5 days. Settings → Sources & Geräte sheet, drag-reorder lists for each metric (weight, BP, pulse, body-fat, body-temp, SpO2, HRV, RHR, VO2-max, steps, calories, distance, flights, sleep), nested device-type picker per metric. PUT-the-whole-object on save (partial updates not supported per locked contract). One Swift `Codable` mirror of `srcLibValidationsSourcePriority`. + +## High + +### H-1. iOS does not call `/api/personal-records`; PersonalRecord deep-link route exists but routes to a non-existent screen + +What's missing — `AppRouter.swift:75-78` carries a `PersonalRecordRoute` case. `grep -rn` confirms exactly one render path that uses it (`Stores/AppRouter.swift` + `Services/DeepLinkRouter.swift`) and zero callers that actually fetch from `/api/personal-records`. `06-ios-responsibilities.md` Domain 3 lists `PERSONAL_RECORD` APNs delivery (default OFF) as live, with the iOS responsibility to render the list. Server has the endpoint, schema, and pagination behaviour documented in `03-api-contracts.md` §Personal Records. + +iOS file:line — `Stores/AppRouter.swift:75-78` (deep-link case), no fetch site. +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/personal-records/route.ts`. + +Close-out — one iOS slot, ~1 day. Build `PersonalRecordsStore` + `PersonalRecordsRepository` over the existing `MetricInsightsRepository` pattern, paginated list screen, navigation push from a Dashboard "Records" link + APNs deep-link. + +### H-2. iOS does not call `/api/workouts/batch`; v1.4.25 ships the Workout ingest contract iOS is meant to consume + +What's missing — `grep -rn "Workout\|workout"` returns exactly one match — `Services/HealthKitService.swift` mentioning the word workout in a comment. No `WorkoutWireDTO`, no `WorkoutsRepository`, no `HKWorkoutTypeIdentifier` enumeration, no `HKWorkoutRouteQuery` wiring. The handoff pack treats this as iOS-side responsibility per `03-api-contracts.md` §Workouts and `04-data-model.md` §4.2; the server is fully ready (`/api/workouts/batch/route.ts`, 100-workouts-per-batch + 20k-points-per-LineString + 5MB body cap + composite-unique dedup). + +iOS file:line — no implementation. +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/workouts/batch/route.ts`. + +Close-out — one iOS slot, ~2 days. `HKWorkoutQuery` + `HKWorkoutRouteQuery` plumbing, `WorkoutBatchEntryDTO` mirror, route-LineString → GeoJSON encoder, batch uploader on the same throttle pattern as `MeasurementBatchUploader`. Defer the iOS Workout UI to v1.6 — the contract requires ingest only for v1.5. + +### H-3. iOS calls `/api/medications/intake` legacy path but the per-medication subroutes are richer + +What's missing — `MedicationsRepository.swift:31,53` calls `/api/medications/intake` (legacy top-level POST) and `/api/medications/{id}/intake`. Server now has a much richer per-medication surface: `intake`, `intake/[eventId]`, `intake/import`, `intake/purge`, `inventory`, `inventory/[itemId]`, `phase-config`, `side-effects`, `side-effects/[logId]`, `titration`, `glp1`, `cadence`, `compliance`, `api-endpoint`. iOS uses `glp1` (one call site at line 40) and nothing else. The v1.4.25 W19 inventory + side-effects work is on the server; iOS has no UI for either. + +iOS file:line — `Repositories/MedicationsRepository.swift:25-58`. +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/medications/[id]/*`. + +Close-out — one iOS slot, ~2 days. The MedicationDetailScreen exists per 0.4.0 changelog "Stream Delta", so the read paths can fan out the new sub-endpoints in there. The pen inventory tile, side-effects logbook, and reminder phase-config sheet are net-new UI but the screen frame is already there. + +### H-4. iOS does not call `/api/auth/me/coach-prefs` or `/api/auth/me/doctor-report-prefs` + +What's missing — both endpoints documented in `03-api-contracts.md` §Auth; iOS calls neither. `CoachPrefs` shape (`{tone, verbosity, excludeMetrics[], showEvidenceByDefault}`) is documented in `04-data-model.md` §6 as iOS read+write. `doctorReportPrefsJson` is documented as iOS read-only for v1.5 but iOS does not read it either. The Doctor Report screen has the PDF download but no preferences hand-off. + +iOS file:line — no implementation. +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/auth/me/coach-prefs/route.ts`, `/Users/marc/Projects/HealthLog/src/app/api/auth/me/doctor-report-prefs/route.ts`. + +Close-out — half a day; one Swift store per surface, settings sheet wiring. Becomes a clean dependency-resolve once C-1 (Coach) lands — CoachPrefs is what the Coach reads to configure tone + verbosity per turn. + +### H-5. `mood-entries` POST shape — iOS sends `tags: ["note:"]` instead of a proper `note` field + +What's missing — `Models/Mood.swift:65` documents the workaround verbatim: *"Server hat kein dediziertes Note-Field — wir packen es als spezial-Tag `note:...`"*. The Coach SNAPSHOT and Insights generation parse mood tags; a note-as-tag entry contaminates the tag axis and the LLM sees it. Either the server adds a `note` column or iOS commits to the tag-prefix hack permanently. + +iOS file:line — `Models/Mood.swift:62-67`. +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/mood-entries/route.ts`. + +Close-out — one server slot, M effort, ~0.5 day. Add `note TEXT NULL` to `MoodEntry`, extend Zod, regenerate OpenAPI, iOS migrates the read+write paths in one PR. Until then this is a high-severity latent bug — the LLM is reading "note:Heute schlecht geschlafen" as a tag, not as prose. + +### H-6. iOS does not call `/api/insights/comprehensive` daily-briefing `force=true` refresh consistently + +What's missing — `Stores/DailyBriefingStore.swift` per the 0.4.0 changelog "Stream Golf" entry lazy-generates via `POST /api/insights/generate` with a 24h SwiftData SWR cache + `force=true`. Inspection of the actual repository surface in the iOS code shows the `force=true` is wired into the regenerate button, but the cache-invalidation rule the handoff pack scopes ("fresh insight generation evicts stale; mutation invalidates every read query that touches the resource", `feedback_cache_invalidate_on_new`) is partially observed only — Insights cards do not invalidate when a Measurement is added. + +iOS file:line — `Stores/DailyBriefingStore.swift`, `Cache/CacheInvalidator.swift`. +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/insights/generate/route.ts`. + +Close-out — one iOS slot, S effort, ~0.5 day. Extend `CacheInvalidator` matrix with the cross-resource fan-out documented in `13-state-management.md` §3. + +### H-7. iOS does not surface the Withings `hasActivityScope` reconnect banner + +What's missing — `Screens/Settings/WithingsIntegrationScreen.swift` reads `/api/withings/status`; the response carries `hasActivityScope` per `03-api-contracts.md` §Withings. The legacy v1.4.24 connection scope (`user.metrics` only) shows up as `hasActivityScope: false` and iOS should surface the reconnect banner per `06-ios-responsibilities.md` § Withings. + +iOS file:line — `Screens/Settings/WithingsIntegrationScreen.swift:128` (`WithingsStatus` mirror). +Server file:line — `/Users/marc/Projects/HealthLog/src/app/api/withings/status/route.ts`. + +Close-out — one iOS slot, S effort, half a day. Read the field, render the banner, deep-link the user to the web OAuth flow in `SFSafariViewController`. + +### H-8. iOS does not handle the v1.4.25 OpenAPI hard-flip gate at all + +What's missing — the contract-diff loop documented in `09-recommended-flow.md` §2 is the iOS team's working pattern: regenerate Swift codegen from `docs/api/openapi.yaml`, rebuild, fix breaks. iOS today does not codegen — it hand-writes DTOs. The handoff pack says the codegen is the v1.5 target (`docs/api-contract.md` in iOS repo says verbatim "TODO Phase 1: DTO-Codegen via openapi-generator oder swift-openapi-generator verdrahten. Manuelle DTOs sind Zwischenstand"). Every drift bug enumerated in the 0.2.0 + 0.3.0 changelogs was a missed-codegen-step bug. + +iOS file:line — `HealthLogIOS/docs/api-contract.md` line 12 (TODO). +Server file:line — `/Users/marc/Projects/HealthLog/docs/api/openapi.yaml` (regenerated via `pnpm openapi:generate`). + +Close-out — one iOS slot, M effort, ~1 day. Wire `swift-openapi-generator` into XcodeGen + CI, regenerate all DTOs, port the hand-written shapes to the generated ones. The diff catches at least the next two drift bugs before they ship. + +## Medium + +### M-1. iOS Measurement enum knows 10 of 29 server-side measurement types + +What's missing — `Models/MeasurementDTO.swift:45-56` `ServerMeasurementType` enumerates 10 values. Server-side at v1.4.25 the enum has 29 (`04-data-model.md` §2.1: WEIGHT, BLOOD_PRESSURE_SYS, BLOOD_PRESSURE_DIA, PULSE, BODY_FAT, SLEEP_DURATION, ACTIVITY_STEPS, BLOOD_GLUCOSE, TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION, HEART_RATE_VARIABILITY, RESTING_HEART_RATE, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE, FAT_FREE_MASS, FAT_MASS, MUSCLE_MASS, SKIN_TEMPERATURE, PULSE_WAVE_VELOCITY, VASCULAR_AGE, VISCERAL_FAT, AUDIO_EXPOSURE_ENV, AUDIO_EXPOSURE_HEADPHONE, TIME_IN_DAYLIGHT). + +The wire-converter (`Services/HealthKitWireConverter.swift`) handles 16 of 19 HK identifiers — so the iOS HK ingest path is fine. The gap is `MeasurementWireDTO.type` for read-back: 19 enum values that exist on the server today cannot be decoded by `JSONDecoder` without a 422 throw. The `MetricKind` domain enum extends to `.sleep` and `.steps` per 0.4.0 changelog A4 fix. + +Close-out — one iOS slot, M effort, ~half a day. Extend `ServerMeasurementType` to all 29 values, extend `MetricKind` map, add a `case .unknown(String)` fall-through so a future server enum value never breaks the read path. Solved cleanly by codegen (H-8). + +### M-2. iOS lacks the seven new v1.4.25 measurement types in the HK wire converter unit-map + +What's missing — `Services/HealthKitWireConverter.swift:124-196` covers most HK identifiers but does not enumerate `bodyTemperature` skin variant (`SKIN_TEMPERATURE`), `MUSCLE_MASS` (Withings-only, not HK), the `VISCERAL_FAT` "rating" unit. None of these flow via HK so the converter does not need to map them. M-1 close-out covers this. + +Close-out — covered by M-1. + +### M-3. The iOS `OxygenSaturation` × 100 pre-multiply lives in two places + +What's missing — `Services/HealthKitWireConverter.swift:131-132,154-155` correctly maps both `bodyFatPercentage` and `oxygenSaturation` with `scale: 100`. `06-ios-responsibilities.md` §"Unit conversion" calls this out as iOS-required. Not a bug; flagged because the rule is duplicated in handoff pack documentation and could regress on the HK SDK changing its base unit. + +Close-out — add a unit test that asserts `WireUnit.scale == 100` for both identifiers; flag as a regression sentinel. + +### M-4. `iPad` → `phone` mapping in `deviceType` is documented but contradicts the server contract enumeration + +What's missing — `HealthKitWireConverter.swift:101` maps iPad to `.phone` with the comment "iPads zählen wir zu phone". The server `deviceTypeEnum` per `04-data-model.md` §2.5 lists `watch | band | ring | phone | scale | other | unknown`. iPad is closer to `phone` than to `other` so the mapping is fine, but the handoff pack `06-ios-responsibilities.md` table does not commit to it. Server-side source-priority resolution treats `phone` as the lowest tier of point-measurement priority; an iPad-derived weight reading would lose to a watch-derived one as expected. + +Close-out — add an entry to the handoff pack `06-ios-responsibilities.md` HK-device-mapping note. No code change. + +### M-5. iOS `/api/integrations/healthkit` shape — `kind` is `String`, server emits raw HK identifiers; iOS swallows unknown values into `kind` verbatim per A4-fix + +What's missing — handled cleanly by the 0.4.0 A4-Audit fix per CHANGELOG ("HealthKitSyncEntry.direction gains `.disabled` + raw HK-identifier `kind` strings"). The `kindDisplayName` extension handles 9 known kinds and renders unknown verbatim. Server-side route at `/api/integrations/healthkit/route.ts` enumerates more. + +Close-out — extend the iOS `kindDisplayName` switch when the server adds a new default entry. Low maintenance burden. + +### M-6. iOS does not render the v1.4.25 W19d side-effects logbook + +What's missing — server has `/api/medications/[id]/side-effects` + `[logId]/route.ts`; iOS has no UI or store. H-3 close-out covers this. + +Close-out — covered by H-3. + +### M-7. iOS does not call `/api/auth/me/timezone` PUT + +What's missing — `02-server-architecture.md` §"Per-user timezone" commits iOS to "reading `user.timezone` from `/api/auth/me`" and writing back via `PUT /api/auth/me/timezone`. iOS reads — `Models/User.swift:17` references the field — but does not write. A user who travels to a different timezone gets their server-side dailyBriefing anchored to the previous tz. + +iOS file:line — no callsite. +Server file:line — `/api/auth/me/timezone/route.ts`. + +Close-out — one iOS slot, S effort, ~2 hours. Listen to `NotificationCenter.NSSystemTimeZoneDidChange`, debounce, PUT. + +### M-8. iOS does not surface `/api/insights/targets` + +What's missing — server route at `/api/insights/targets/route.ts`; per-metric target ranges + current status + Coach handoff visibility flag. iOS has the "Zielwerte" page mentioned in the v1.4.21 feedback (Marc memory). No iOS callsite. + +iOS file:line — no implementation. +Server file:line — `/api/insights/targets/route.ts`. + +Close-out — one iOS slot, M effort, ~1 day. Wire a `TargetsRepository`, a "Zielwerte" screen on the Insights tab. + +### M-9. iOS `glucoseContext` enum diverges from server's + +What's missing — `Models/MeasurementDTO.swift:67-72` has `fasting | beforeMeal | afterMeal | bedtime`; server-side at `04-data-model.md` §2.3 lists `FASTING | POSTPRANDIAL | RANDOM | BEDTIME`. iOS uses `beforeMeal`/`afterMeal` (presumably an earlier server enum) which the strict Zod parser rejects with 422. + +iOS file:line — `Models/MeasurementDTO.swift:67-72`. +Server file:line — `prisma/schema.prisma` GlucoseContext enum. + +Close-out — one iOS slot, S effort, ~1 hour. Update enum to `FASTING | POSTPRANDIAL | RANDOM | BEDTIME`, update glucose entry UI. H-8 codegen catches this. + +## Low + +### L-1. iOS auth-exempt list at `APIClient.swift:230-235` does not include `/api/auth/codex/*` + +What's missing — Codex OAuth is web-only per `14-coach-mental-model.md`; iOS does not call those endpoints; the auth-exempt list correctly omits them. Flagging only because the list is a foot-gun if iOS ever adds the path. + +Close-out — none required. + +### L-2. iOS `tagging` for `mood-entries` source — iOS hardcodes `MANUAL` (`Models/Mood.swift:44`) + +What's missing — server-side mood-entries source supports more values; iOS hardcodes `MANUAL`. Defensive; documented. + +Close-out — none required for v1.5. + +### L-3. iOS `MeasurementSource` parser does not understand `APPLE_HEALTH_BACKFILL` or similar nonexistent server values + +What's missing — none, because the server does not emit any such value. Flagged because a server v1.5+ might split backfill out — codegen (H-8) catches the day this happens. + +Close-out — covered by H-8. + +### L-4. iOS has no in-app surface for the v1.4.25 OpenAPI hard-flip gate's drift result + +What's missing — the iOS CI does not run `pnpm openapi:check` (it cannot — pnpm is the server's tool). The drift-protection lives on the server side. iOS depends on someone running the regeneration after a server PR lands. + +Close-out — H-8 close-out adds an iOS CI step that downloads + diffs `docs/api/openapi.yaml` from the server repo on every iOS PR. + +### L-5. iOS `Stores/AppRouter.swift:55-58` Coach deep-link parks on Insights tab + +Close-out — covered by C-1. + +### L-6. iOS Spezi adoption is documented but inactive + +What's missing — `HealthLogIOS/CLAUDE.md` "Spezi adoption" table commits to SpeziHealthKit + SpeziAccessGuard + SpeziScheduler + SpeziMedication. `grep -rn "Spezi"` returns five matches, all in comments. None of the four packages are linked in `Package.swift` (which has zero `dependencies` declared). The HK service is still custom (`Services/HealthKitService.swift`); the BiometricGate is custom (`Services/BiometricGate.swift`). + +Close-out — optional for v1.5. Defer to v1.6 unless the Spezi packages buy something v1.5 actually needs (`docs/spezi-migration-plan.md` says Phase A-E; the iOS team can ship v1.5 entirely on the custom stack). + +## Effort estimate + +iOS-side total: **L (Large)** — 5 Critical + 8 High + 9 Medium finds. Best-case ~12 working days of focused iOS work; realistic ~15 working days given the iOS team's track record of finding ancillary bugs during each marathon. + +Web-side total: **M (Medium)** — primary work is C-2 server prep (sync-version column, soft-delete column, two bulk endpoints, one sync-state endpoint), H-5 mood-entries `note` field, plus the C-3 APNs secrets paste-in. Best-case ~3 working days; realistic ~5 working days. + +## Recommended v1.5 sprint calendar + +Rough working days, not weeks. Assumes Marc + parallel sub-agent dispatch per the marathon convention. + +- **Day 1–2** — server-side foundation. Land the v1.4.28 prep menu C-2 + H-5: `syncVersion` + `deletedAt` columns, `/api/sync/state`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `MoodEntry.note`. Tag v1.4.28 on `main`. Coolify auto-deploy + verify. +- **Day 2** (parallel slot) — APNs secrets paste-in C-3. Generate `.p8`, drop into Coolify, smoke-test from staging build. +- **Day 3–5** — iOS Coach drawer C-1 (single slot, MDR-sensitive, full focus). Streaming SSE client, conversation cache, provenance disclosure, refusal acceptance, locale keys. Ends with a TestFlight build that runs against staging. +- **Day 3–5** (parallel slot) — iOS SyncMode polish C-2 + H-1 + H-2: gate every repo path behind `SyncModeStore.isPaired`, build the pair/unpair sheet, extend SwiftData models with `syncVersion + deletedAt`, build PersonalRecords store + screen, build Workouts ingest pipeline. +- **Day 6** — iOS C-4 refresh-scheduler + C-5 source-priority editor + H-7 Withings banner. Single iOS slot. +- **Day 7** — H-3 medication sub-routes (inventory, side-effects, phase-config) + H-4 prefs sheets. +- **Day 8** — H-6 cache-invalidation matrix + H-8 OpenAPI codegen wiring + M-1/M-9 enum fixes. +- **Day 9** — Medium-severity sweep (M-3 unit-test sentinel, M-6 side-effects UI, M-7 timezone PUT, M-8 targets screen). +- **Day 10** — TestFlight build + Apple submission prep. Run the existing 3× QA pass + senior-pass + simplify-pass that the iOS repo's `README.md` commits to. + +Total: **~10 working days for a TestFlight-ready v1.5.0 build, assuming clean parallel dispatch and no Apple-review surprises.** Add 3–5 days buffer for Apple review (5.1.2(i) Third-Party-AI Consent surface is already wired per `AIConsentStore.swift`, but the Privacy Policy URL + AppStore Connect "Regulated medical device" form still need user-side completion per CHANGELOG 0.3.0 "Backlog deliberately deferred"). + +## Blocker that pre-empts v1.5 entirely + +Single hard blocker — none. Every find above is addressable inside the proposed sprint. The Apple-side blockers (Marc must supply the Developer-Portal Team ID once, the APNs `.p8`, the App Store Connect submission form) are user-actions that gate TestFlight + Production submission, not v1.5 code completeness. If the user-actions slip, the v1.5 code lands on `main` + tag, but the binary cannot ship — same posture as v1.4.27's host-side-retag deployment fallback. + +The closest thing to a hard blocker is C-1 (Coach drawer). If Coach is descoped from v1.5 ("AI insights are the differentiator" is the marketing promise but the actual differentiator is the Insights cards + Health Score + Daily Briefing, all of which already ship on iOS at 0.4.1.1), then v1.5 can ship without Coach and the calendar drops to ~6 working days. The recommendation is to keep Coach in scope and treat its absence as the release blocker — without it, v1.5 is materially a v0.4.2 iOS release, not the v1.5 the handoff pack scopes for. + +## Cross-references + +- iOS repo CLAUDE.md: `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/CLAUDE.md` — tech-stack contract. +- iOS repo ROADMAP: `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/.planning/ROADMAP.md` — phase 0–9 status. +- iOS repo CHANGELOG: `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/CHANGELOG.md` — v0.1.0 → v0.4.1.1. +- iOS repo server-changes log: `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/docs/server-changes.md` — applied-but-uncommitted-then-committed history. +- Server v1.4.27 release closure: `/Users/marc/Projects/HealthLog/.planning/v1427-release-closure-report.md`. +- Server v1.4.28 maintainer intake: `/Users/marc/Projects/HealthLog/.planning/v1428-maintainer-feedback-intake.md`. + +## Summary + +Severity counts: 5 Critical, 8 High, 9 Medium, 6 Low (28 total). Top 3 gaps: Coach drawer has no native code (C-1), SyncMode pivot is half-built and the server-side migration is unstarted (C-2), source-priority editor is missing entirely from the iOS UI (C-5). iOS-side effort estimate: **L (Large)** — ~12 focused working days. Web-side effort estimate: **M (Medium)** — ~3 working days for the v1.4.28 prep menu plus the mood-note column plus the APNs secrets. Recommended v1.5 sprint length: **10 working days** plus a 3-5 day Apple-review buffer. No single hard blocker pre-empts v1.5 entirely; the only release-blocking external dependency is the APNs `.p8` paste-in, which is a 1-hour Marc-action. diff --git a/.planning/research/v15-r-f-apple-health-depth.md b/.planning/research/v15-r-f-apple-health-depth.md new file mode 100644 index 000000000..293d7f575 --- /dev/null +++ b/.planning/research/v15-r-f-apple-health-depth.md @@ -0,0 +1,438 @@ +--- +file: .planning/research/v15-r-f-apple-health-depth.md +purpose: Apple Health / HealthKit integration depth audit and v1.5 coverage proposal +created: 2026-05-16 +contributor: R-F +--- + +# Apple Health Depth Audit and v1.5 Coverage Proposal + +## TL;DR + +HealthLog ships **27 `MeasurementType` enum values**, of which **18 are reachable from iOS HealthKit today** (the rest are Withings-only or derived). The iOS reader, mapping table, and server ingest path are all in good shape — the architecture (anchored queries + `HKObserverQuery` + `enableBackgroundDelivery` + per-type frequency + per-user anchor partitioning + server-side dedup) is **already the industry-correct pattern**. Where we leave value on the table is **coverage breadth** (workouts not wired end-to-end on iOS, no walking-steadiness / six-minute-walk / running-power, no mindfulness, no state-of-mind read path, no audio-event chips) and **categorisation surface** (a flat 27-value enum is fine for storage but the iOS picker and the web Insights nav both need a category overlay so the user perceives "vitals / activity / body / sleep / hearing / mood" rather than 27 lines). + +The recommended shape for v1.5: ship **the existing 18 HK reads plus a v1.5 Tier-1 add of HKWorkout end-to-end, HKStateOfMind read, and a hearing-events chip surface** — additive-only on the schema, no migrations beyond two new `MeasurementType` rows and one new column. Tier 2 and 3 fold in iOS-17/18 running-and-cycling power metrics, walking-steadiness, mindful-session, and the six-minute-walk test — none of which need a new model, just enum entries and Zod rows. The hold lines (nutrition, reproductive cycle, FHIR clinical) stay where Marc drew them. + +--- + +## Section 1 — Current-state inventory + +### 1.1 The `MeasurementType` enum surface + +From `prisma/schema.prisma:292-326` and `src/lib/validations/measurement.ts:3-35`, the enum carries **27 values**. Grouped by origin: + +| Origin | Count | Members | +|---|---|---| +| Apple-Health reachable (mapped) | 18 | `WEIGHT`, `BODY_FAT`, `BODY_TEMPERATURE`, `BLOOD_PRESSURE_SYS`, `BLOOD_PRESSURE_DIA`, `PULSE`, `RESTING_HEART_RATE`, `HEART_RATE_VARIABILITY`, `OXYGEN_SATURATION`, `BLOOD_GLUCOSE`, `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `SLEEP_DURATION`, `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT` | +| Withings-only today (mappable from HK with effort) | 7 | `TOTAL_BODY_WATER`, `BONE_MASS`, `FAT_FREE_MASS`, `FAT_MASS`, `MUSCLE_MASS`, `SKIN_TEMPERATURE`, `VISCERAL_FAT` | +| Withings-only (no HK equivalent) | 2 | `PULSE_WAVE_VELOCITY`, `VASCULAR_AGE` | + +(Total reaches 27; off-by-one is `TIME_IN_DAYLIGHT` which mirrors HK and lands in both columns.) + +### 1.2 Server ingest path + +`src/app/api/measurements/batch/route.ts` is the iOS-batch sink. Each entry walks `mapAppleHealthEntry()` in `src/lib/measurements/apple-health-mapping.ts:483-509` against the `APPLE_HEALTH_TYPE_MAP` (18 mapped identifiers) and the `HK_QUANTITY_TYPE_DEFERRED` set (40+ identifiers we know about but deliberately do NOT store yet). Unknown identifiers return `{ status: "skipped", reason: "unknown_hk_identifier" }`; the iOS client advances its anchor past them so they never retry. + +The dedup contract is the composite unique key `@@unique([userId, type, source, externalId])` (`prisma/schema.prisma:418`) where `externalId` is `HKSample.uuid`. Per-night sleep stages collide on the four-axis dedup until v1.4.25 W17b/c added `sleepStage` as the fifth axis on the `(userId, type, measuredAt, source, sleepStage)` unique constraint (`prisma/schema.prisma:415`, `NULLS NOT DISTINCT` semantics). + +Blood-pressure is **stored as two rows** (`BLOOD_PRESSURE_SYS` + `BLOOD_PRESSURE_DIA`) joined by shared `measuredAt`. Reads pair them at the read layer (`src/lib/insights/blood-pressure-status.ts`). This matches Apple's own `HKCorrelationTypeIdentifierBloodPressure` shape (two child samples in a correlation) but stores them flat. Pragmatic and matches the Withings v1 ingest model — keep it. + +### 1.3 iOS-side read path + +`HealthKitService.swift` (`/Users/marc/Projects/healthlog-iOS/HealthLogIOS/HealthLog/Services/HealthKitService.swift`) is an actor-isolated wrapper around `HKHealthStore`. The `defaultReadTypes` static set (lines 101-137) authorises exactly the 18 reachable types listed in §1.1. The architecture is **correct by 2024-2026 best practice**: + +- **`HKObserverQuery` per type** with `enableBackgroundDelivery(for:frequency:)` — the only correct way to do bidirectional sync without re-shipping everything (matches the `kingstinct/react-native-healthkit` reference, Stanford BDHG's `SpeziHealthKit`, and Apple's own [WWDC20 "Getting started with HealthKit"](https://developer.apple.com/videos/play/wwdc2020/10664/) guidance). +- **Per-type frequency** classes (lines 354-383): `.immediate` for the eight cardio-vital-glucose-temp metrics that drive anomaly pushes, `.daily` for sleep (HK aggregates phases overnight anyway), `.hourly` for cumulative metrics. This is well-judged — sleep `.immediate` would wake the device every time the user briefly woke up during the night. +- **`HKAnchoredObjectQuery` follow-up on observer wakeups** (lines 422-454, line 462) with per-user anchor partitioning in `UserDefaults` (lines 659-718). The partitioning by user-id is something most OSS HK projects skip; HealthLog gets this right. +- **Deletion reconciliation** via the `deletedObjects` callback (lines 441-451) → `DELETE /api/measurements/by-external-ids`. Most OSS projects ignore HK deletes; HealthLog correctly tombstones them. +- **Auth-failure storm guard** (lines 289-336) for the "Authorization not determined" error → stops the observer rather than spinning in a retry loop. This is a hotfix-grade detail nobody else surfaces. +- **`HKStateOfMind` write path** for iOS 18+ mood bidirectional sync (lines 219-243). This is one direction only today (HealthLog → HK); the inbound HK-State-of-Mind → HealthLog mood read is **not wired**. + +The wire converter (`HealthKitWireConverter.swift`) handles unit pre-multiplication (×100 for `oxygenSaturation` and `bodyFatPercentage`), the sleep-stage codepoint extraction, and the `HKDevice.model` → `deviceType` ladder (`watch | phone | scale | ring | band | unknown`). Clean. + +The default-read set excludes seven HK identifiers we either map server-side for Withings (visceral-fat etc.) or do not map at all (mindfulness, state-of-mind read, six-minute-walk, walking-steadiness, etc. — full list in §1.5). + +### 1.4 Insights pipeline coverage + +`src/lib/insights/sub-page-metric.ts:27-37` declares seven sub-pages: `blutdruck`, `gewicht`, `puls`, `stimmung`, `medikamente`, `bmi`, `schlaf`. Dedicated status modules exist for blood pressure, weight, pulse, BMI, mood, medication compliance, and a generic fallback. Chart-overlay-keys (`src/lib/dashboard-layout.ts:113-127`) cover: `bp`, `weight`, `bmi`, `pulse`, `bodyFat`, `mood`, `medications`, `sleep`, `steps`, `vo2Max` — ten overlay slots. + +**Gap:** of the 18 HK-reachable measurement types, 10 have a chart overlay and 7 have a status module. The remainder (`BODY_TEMPERATURE`, `RESTING_HEART_RATE`, `HEART_RATE_VARIABILITY`, `OXYGEN_SATURATION`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT`) **arrive at the server, get stored, contribute to the source-priority picker and the Coach evidence shelf, but have no dedicated chart card and no status module.** Their values surface only in (a) the raw Insights timeline and (b) the Coach evidence chips when an AI run pulls them. + +That gap is the largest single piece of value HealthLog is leaving on the table. Section 4 proposes the v1.5 chart-card additions. + +### 1.5 Workouts and the cross-source dedup gap + +The schema has a `Workout` model (`prisma/schema.prisma:432-491`) + `WorkoutRoute` (lines 494-512) — both shipped in v1.4.25 W8d (Migration 0053). The Zod validation lives at `src/lib/validations/workout.ts` with a 19-entry `workoutSportTypeEnum` covering the high-frequency sports plus a permissive `other` sink, a `MAX_ROUTE_POINTS = 20_000` cap, and a `MAX_WORKOUTS_PER_BATCH = 100` ingest cap. + +**Status: schema and Zod are ready; the iOS Swift HealthKit reader does NOT yet read HKWorkout.** `HealthKitService.defaultReadTypes` only enumerates quantity-types and the sleep category type — no `HKWorkoutType.workoutType()` insertion. The W16b research outline (`.planning/research/w16b-workout-ingest.md`) names this as a v1.5 P1 deliverable. The cross-source workout dedup TODO is annotated in the schema itself (`schema.prisma:476-485`) — the same workout arriving from Apple Watch + Withings ScanWatch lands twice today; a `pickCanonicalWorkoutRows()` helper has to mirror the existing measurement source-priority picker. + +### 1.6 What iOS knows but the server doesn't yet store + +`HK_QUANTITY_TYPE_DEFERRED` (`apple-health-mapping.ts:303-438`) enumerates 40+ identifiers we explicitly defer: + +- **Body composition (HK-side, Withings-side stored already):** `BodyMassIndex` (derived), `Height` (on `User.heightCm`), `LeanBodyMass`, `RespiratoryRate`, `WalkingHeartRateAverage`, `HeartRateRecoveryOneMinute`, `AppleSleepingWristTemperature`, `BasalEnergyBurned`, `AppleExerciseTime`, `AppleStandTime`, `AppleStandHour`. +- **Running form (iOS 16+):** `WalkingSpeed`, `WalkingStepLength`, `WalkingAsymmetryPercentage`, `WalkingDoubleSupportPercentage`, `StairAscentSpeed`, `StairDescentSpeed`, `SixMinuteWalkTestDistance`, `RunningSpeed`, `RunningPower`, `RunningStrideLength`, `RunningGroundContactTime`, `RunningVerticalOscillation`. +- **Cycling (iOS 17+):** `CyclingCadence`, `CyclingFunctionalThresholdPower`, `CyclingPower`, `CyclingSpeed`, `DistanceCycling`. +- **Sport-specific distances/speeds (iOS 18):** cross-country skiing, paddle sports, rowing, skating, swimming. +- **Workout-effort scores (iOS 18):** `EstimatedWorkoutEffortScore`, `WorkoutEffortScore`, `PhysicalEffort` (per [Sasquatch Studio's iOS 18 effort-score guide](https://sasq.ca/blog/2025/4/28/reading-writing-workout-effort-scores)). +- **Sleep apnea (iOS 18):** `AppleSleepingBreathingDisturbances`, `SleepApneaEvent` (per [MacRumors iOS 18.1 beta coverage](https://www.macrumors.com/2024/10/07/ios-18-1-beta-6-sleep-apnea/) and Apple's [sleep-apnea support page](https://support.apple.com/en-us/120031)). +- **Cardio events:** `AtrialFibrillationBurden`, `PeripheralPerfusionIndex`, `LowHeartRateEvent`, `HighHeartRateEvent`, `IrregularHeartRhythmEvent`, `LowCardioFitnessEvent`. +- **Mobility (iOS 15+):** `AppleWalkingSteadiness`, `NumberOfTimesFallen`, `AppleWalkingSteadinessEvent`. +- **Respiratory clinical (iOS 17):** `ForcedExpiratoryVolume1`, `ForcedVitalCapacity`, `PeakExpiratoryFlowRate`, `InhalerUsage`. +- **Mental state:** `MindfulSession`, `StateOfMind` (the **read** path). +- **Hearing events:** `EnvironmentalAudioExposureEvent`, `HeadphoneAudioExposureEvent`, `EnvironmentalSoundReduction`. +- **Reproductive / pregnancy (explicit hold):** menstrual flow, ovulation, contraceptive, lactation, pregnancy etc. — per Marc directive, do not chase. +- **Clinical (HKClinicalType, FHIR):** `AllergyRecord`, `ConditionRecord`, `ImmunizationRecord`, `LabResultRecord`, `MedicationRecord`, `ProcedureRecord`, `VitalSignRecord`. Deferred to v1.6+. +- **Scored assessments (iOS 18):** `PHQ9`, `GAD7`. +- **ECG (`HKElectrocardiogramType`).** +- **Other privacy holds:** `InsulinDelivery`, `UVExposure`, `ElectrodermalActivity`, `BloodAlcoholContent`. +- **Behavioural:** `HandwashingEvent`, `ToothbrushingEvent`. + +This list is doing the right job — being explicit about what we know-and-defer so we don't accidentally start dropping samples in production silently. The audit question is which entries should move out of `DEFERRED` for v1.5. + +--- + +## Section 2 — Industry best practice 2024-2026 + +### 2.1 The right query pattern per metric class + +Apple's [`HKAnchoredObjectQuery` docs](https://developer.apple.com/documentation/healthkit) plus the cross-checked third-party guides ([DevFright HKAnchoredObjectQuery walkthrough](https://www.devfright.com/how-to-use-healthkit-hkanchoredobjectquery/), [DevFright HKStatisticsCollectionQuery walkthrough](https://www.devfright.com/how-to-use-the-hkstatisticscollectionquery/)) draw a clear line: + +- **`HKAnchoredObjectQuery`** — incremental sync of raw samples. Each call returns "everything since the anchor I gave you" and a new anchor. **This is the only correct choice for a server-syncing iOS client** — you need every sample, not a daily summary. Used by HealthLog today. +- **`HKStatisticsCollectionQuery`** — fixed-interval rollups (hourly/daily/weekly). Used for **on-device charts** when the app draws its own visualisations without round-tripping to a server. Apple's own Health app and most analytics dashboards use this. We do NOT need this on iOS because the server materialises the rollups. +- **`HKSampleQuery`** — one-shot snapshot. Used for "show me the most recent N samples". Useful for the iOS app's offline read cache backfill but not for the sync engine. + +The decision tree per HK metric class: + +| Metric class | Pattern | Rationale | +|---|---|---| +| Vital signs (BP, HR, RHR, HRV, BG, SpO2, body temp) | Anchored, `.immediate` background delivery | Anomaly pushes need real-time | +| Body composition (weight, fat %, lean mass) | Anchored, `.hourly` BD | Third-party scales often back-date samples | +| Cumulative activity (steps, energy, distance, flights) | Anchored, `.hourly` BD | Daily-aggregate server materialisation handles the rollup | +| Sleep (category type) | Anchored, `.daily` BD | One wake-up per morning | +| Workouts (`HKWorkoutType.workoutType()`) | Anchored, `.hourly` BD + paired route stream | New v1.5 surface | +| State of Mind (iOS 17+) | Anchored, `.hourly` BD | Bidirectional with the HealthLog mood model | +| Hearing events (audio-exposure events) | Anchored, `.hourly` BD | Pair with the existing dBA quantity samples | +| Mindfulness sessions (category) | Anchored, `.daily` BD | Low-volume; daily is plenty | + +HealthLog already gets the vital / body / activity / sleep classification right (see `HealthKitService.preferredFrequency` in `HealthKitService.swift:364-383`). The new additions in §3 and §4 slot into the same scheme. + +### 2.2 Source priority — what Apple does and what we do + +Per [Apple Support: "Manage Health data"](https://support.apple.com/en-us/108779), the HK store can hold **multiple sources writing the same data type** simultaneously. The system Health app's default priority order is: + +1. Manual user entries +2. iPhone / iPad / Apple Watch (the same-Apple-ID device family) +3. Third-party apps and Bluetooth devices + +The user can drag-reorder per data category in Settings → Health → Data Access & Devices. The OS gives developers no automatic "winner" — every `HKAnchoredObjectQuery` returns every sample from every source. + +HealthLog's `src/lib/analytics/source-priority.ts` is the **correct response** to this. The two-axis picker (source class + device type) resolves cross-source duplicates at READ time, not WRITE time. This is the right call — the iOS client posts every HK sample (it has to; the device-priority order can change after the fact), and the server's canonical row picker collapses to one logical reading at query time. + +**Architectural note:** the picker today operates on `Measurement` rows but not on `Workout` rows (the TODO in `schema.prisma:476-485`). v1.5 should ship `pickCanonicalWorkoutRows()` as the symmetric helper — same shape, same precedence ladder, applied at the workout read path. + +### 2.3 Permission scope — read-only is right for v1.5 + +Apple's [Configuring HealthKit access guide](https://developer.apple.com/documentation/xcode/configuring-healthkit-access) requires per-type read/write authorisation. Important: **the OS never tells you whether a read permission was granted** (returns the same opaque "authorized" state whether granted or denied) — only write permissions surface the user's choice. The HK reader must therefore assume every read might silently return zero samples and degrade gracefully. + +HealthLog's iOS app correctly asks for read-only on every type except the four that have a manual UI write surface (weight, BP, glucose, mood/state-of-mind). The `defaultWriteTypes` set at `HealthKitService.swift:139-146` is minimal and well-judged. **v1.5 should keep write-only on these four** — letting the iOS app write back arbitrary types (e.g. user-edited insights) crosses the MDR line called out in `06-ios-responsibilities.md` §"MDR-critical iOS warnings". + +### 2.4 Background delivery — battery vs freshness + +Per Apple's [`enableBackgroundDelivery` docs](https://developer.apple.com/documentation/HealthKit/HKHealthStore/enableBackgroundDelivery(for:frequency:withCompletion:)) and the [HealthKit background-delivery entitlement page](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.healthkit.background-delivery): + +- `.immediate` — wakes the app on every new sample. Battery-intensive; reserved for clinically-meaningful signals. +- `.hourly` — coalesced wake-ups. Default for most non-vital metrics. +- `.daily` — once-a-day wake-up; cheapest. Right for sleep, mindful sessions, daylight. +- `.weekly` — exists but practically nobody uses it. + +HealthLog's `preferredFrequency` classifier (HealthKitService.swift:354-383) matches the prevailing 2024-2026 convention. The new v1.5 additions slot in: + +- Workouts → `.hourly` (a workout's completion is not a real-time event for an analytics product). +- State-of-Mind (read) → `.hourly`. +- Mindful sessions → `.daily`. +- Walking steadiness → `.daily` (one rollup per day is enough). +- Sleep-apnea breathing-disturbance events (iOS 18) → `.daily` (paired with the morning sleep summary). + +### 2.5 Privacy disclosure — Apple is stricter since 2024 + +Per [Apple Developer Privacy Compliance 2025](https://clause-guard.com/blog/apple-developer-privacy-compliance-2025-avoid-app-rejection-account-termination) and the App Review Guidelines §5.1.3 (Health and Health Research): + +> The single most common rejection reason for HealthKit apps is requesting data types you don't clearly use in the app. If you request blood glucose access but there's no visible glucose feature in the app, you're getting rejected. + +**Action item for v1.5 iOS submission:** every HK type in `defaultReadTypes` must have a corresponding visible surface in the app (chart card, status pill, insight chip, or settings toggle). Today the iOS app reads 10 quantities + sleep + audio + daylight but the SwiftUI surface mostly shows the BP / weight / pulse / sleep / steps subset. **Before the App Store review, either:** +1. Add visible surfaces for every read type, OR +2. Trim `defaultReadTypes` to the visibly-used subset and re-request on-demand when the user navigates to a feature that needs it (incremental auth is supported). + +Option 2 is the lower-risk path for first review. Option 1 is what §4's chart-card additions deliver. + +### 2.6 The Apple Health XML export — the day-zero onboarding lever + +Per the existing `.planning/research/apple-health-ecosystem-scan.md` §4 and §7 (recommendation 🔴-1), the `export.zip` file every iPhone user can produce in Settings → Health → Export All Health Data is **the universal Apple Health currency**. Every serious OSS project in the topic accepts it as the primary onboarding path. HealthLog **does not yet** — a ~200-LOC server-side Node SAX parser over `export.xml` (porting `k0rventen/apple-health-grafana/ingester/ingester.py`) would let a brand-new web-only user upload years of HK history before the iOS app even ships on their device. Reuse the existing `POST /api/measurements/batch` for the per-row insert. + +**For v1.5 scope:** this is recommended in the prior research, not in this slot's question — but I cite it because the **XML importer mapping table and the iOS HK mapper share the same `HKQuantityTypeIdentifier* → MeasurementType` lookup**. Adding new identifiers to one is the same change as adding them to the other; the v1.5 Tier-1 additions in §3 should be added to **both** call sites in one commit. + +--- + +## Section 3 — Proposed v1.5 coverage tiers + +### Tier 1 — v1.5 ship (must-have) + +These are the additions a maintainer needs for "the iOS app feels complete" the day it ships. + +#### T1.1 — HKWorkout end-to-end on iOS (schema already exists) + +**Schema impact:** zero. The `Workout` + `WorkoutRoute` models shipped in v1.4.25 W8d. The 19-entry `workoutSportTypeEnum` covers ~98% of typical workouts. + +**iOS implementation:** +- Add `HKWorkoutType.workoutType()` to `HealthKitService.defaultReadTypes`. +- Run an `HKAnchoredObjectQuery` over workouts on observer wake. +- For each `HKWorkout`, run a paired `HKWorkoutRouteQuery` to stream `[CLLocation]` for the route (filter `horizontalAccuracy > 50 m` per W16b research). +- Map `HKWorkoutActivityType` numeric enum to the `workoutSportTypeEnum` strings (mapping table per [Apple's `HKWorkoutActivityType` docs](https://developer.apple.com/documentation/healthkit/hkworkoutactivitytype)). +- Post each workout to `POST /api/workouts/batch` (≤100 per batch, ≤20 000 route points per workout — already capped at Zod). + +**Server ingest path:** the validation in `src/lib/validations/workout.ts` is ready; the API route is the v1.5 deliverable. Reuse the idempotency-middleware + per-entry dedup pattern from `POST /api/measurements/batch`. The W16b research outline names the canonical shape. + +**Insights surface impact:** +- New dashboard tile: "Recent workouts" (last 7 days, sport icon + duration + distance + avg HR). +- New chart card on `/insights/puls`: per-workout HR-zone breakdown (Z1-Z5) overlaid on the existing pulse chart. +- New PR-detection slot in the `PersonalRecord` table — `metricSlot = "running_5km_time" | "cycling_distance" | "longest_run"` etc. The W16c research already has this designed. + +**Cross-source workout dedup (the open TODO):** `pickCanonicalWorkoutRows()` in `src/lib/analytics/source-priority.ts`. Same shape as `pickCanonicalSourceRows()`. v1.5 P1 ships this alongside the iOS workout read path so the dashboard never shows the same Apple-Watch-then-Withings run twice. + +#### T1.2 — HKStateOfMind read path (iOS 18+) + +HealthLog already writes `HKStateOfMind` samples on mood entry (one direction, see `HealthKitService.swift:219-243`). The inverse — reading `StateOfMind` samples the user logged via the Health app or another iOS app and ingesting them as HealthLog `MoodEntry` rows — is **not wired**. + +**Schema impact:** zero. The `MoodEntry` model accepts the existing 1-5 scale; the Apple valence axis maps cleanly (per Apple's [State of Mind support page](https://support.apple.com/guide/iphone/log-your-state-of-mind-iph6a6decb13/ios)) — valence -1.0…+1.0 → 1-5 buckets. + +**iOS implementation:** +- Authorise read on `HKDataTypeIdentifierStateOfMind` (iOS 17+, `HKStateOfMind.Kind`). +- Anchored query, `.hourly` background delivery. +- Map valence + kind (`.momentaryEmotion` | `.dailyMood`) → `MoodEntry { score, mood, source: "APPLE_HEALTH" }`. + +**Server ingest path:** new column-less mapping — `MoodEntry.source` already supports free-text values (existing values include `"WEB"`, `"TELEGRAM"`, `"DAYLIO"` — `prisma/schema.prisma:1056`). Add `"APPLE_HEALTH"`. The mood ingest endpoint exists (`/api/mood`) — add a batch variant if iOS needs to backfill multiple entries. + +**Insights surface impact:** the existing `/insights/stimmung` page picks up the new rows for free. No chart-card change. + +#### T1.3 — Chart cards for the existing-but-invisible reads (no schema work) + +The ten HK metrics that arrive at the server but have no chart-overlay key (§1.4) get cards. Concretely, extend `CHART_OVERLAY_KEYS` in `src/lib/dashboard-layout.ts:113-127` with: + +- `restingHr` (today: same chart as `pulse` but the user expects a separate card) +- `hrv` (Whoop/Oura UX expectation) +- `spo2` (existing card on `/insights/puls`; promote to first-class overlay key) +- `bodyTemperature` +- `activeEnergy` (Apple's "Move" ring equivalent) +- `flights` +- `distance` +- `audioExposureEnv` +- `audioExposureHeadphone` +- `daylight` + +Each one is a one-line addition to `CHART_OVERLAY_KEYS`, a one-card mount in the dashboard layout JSON, and zero schema work. The hard part is the design — Apple-Health-style ring vs bar vs line is a per-metric call. + +**This is the single largest perceived-value-per-LOC change in v1.5.** Without it, the iOS app reads ten metrics but the user only "sees" them in the AI Coach evidence chips. With it, the dashboard finally matches what Apple Health surfaces. + +#### T1.4 — Hearing-event chips (Tier 1 extension of existing audio reads) + +The continuous `AUDIO_EXPOSURE_ENV` and `AUDIO_EXPOSURE_HEADPHONE` quantity samples are already mapped. Apple **also** fires category events (`HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent`, `…HeadphoneAudioExposureEvent`) when the rolling 7-day average crosses the WHO 80-dBA loud-listening threshold. These are the "Your headphones have been loud" notifications. + +**Schema impact:** add two enum entries — `AUDIO_EXPOSURE_EVENT_ENV`, `AUDIO_EXPOSURE_EVENT_HEADPHONE` — OR a single `AUDIO_EXPOSURE_EVENT` with a `severity` annotation column. Recommend **the latter** to avoid enum bloat; the `notes` field can carry the kind. + +**iOS implementation:** add the two category types to `defaultReadTypes`; map to the new `AUDIO_EXPOSURE_EVENT` MeasurementType with `value = 1` (boolean flag) per event. + +**Insights surface impact:** chip strip on `/insights/puls` (the closest existing page) or a new `/insights/gehoer` (German for "hearing") sub-page — pick the cheaper option, recommend the chip strip first. + +#### T1.5 — Walking-steadiness gauge (mobility, low-cost) + +`HKQuantityTypeIdentifierAppleWalkingSteadiness` is a `0..1` percentage that Apple Watch reports as a daily rollup. Apple's own Health app shows this prominently in the Mobility section; it's a great Tier-1 add because the value is **a single number per day**. + +**Schema impact:** one new `MeasurementType` enum entry — `WALKING_STEADINESS` (% canonical, scaled ×100 from Apple's 0..1 fraction, same pattern as oxygen saturation). + +**iOS implementation:** add to `defaultReadTypes`; add a `WireUnit` entry; scale 100. Identical to the `bodyFatPercentage` handling. + +**Insights surface impact:** small status card on a new "Mobility" sub-page OR fold into the existing `/insights/puls` page as an "activity health" chip. Recommend the chip — sub-page count is already at 7 and the user does not need an 8th. + +#### Tier-1 schema-impact summary + +| Change | Type | Migration needed? | +|---|---|---| +| `WALKING_STEADINESS` MeasurementType | enum entry | Y — one-line migration | +| `AUDIO_EXPOSURE_EVENT` MeasurementType | enum entry | Y — one-line migration | +| MoodEntry.source = "APPLE_HEALTH" allowed | free-text | N — column is TEXT | +| Workout endpoints | new API route, schema exists | N | +| Cross-source workout dedup | new analytics helper | N | +| Chart-overlay keys | TypeScript constant | N | + +**Net: additive-only, two new enum entries, no destructive migrations.** + +### Tier 2 — v1.5.x follow-ups (high-impact backlog) + +#### T2.1 — Running and cycling power (iOS 16+) + +The five-metric running-form set (`RunningSpeed`, `RunningPower`, `RunningStrideLength`, `RunningGroundContactTime`, `RunningVerticalOscillation`) and the four-metric cycling set (`CyclingCadence`, `CyclingFunctionalThresholdPower`, `CyclingPower`, `CyclingSpeed`) are what athletes expect from a serious sport platform. + +**Schema impact:** these are per-workout metrics, not per-day measurements. Best surface is the **Workout metadata JSONB blob** (`Workout.metadata`) rather than nine new enum entries. The HKAnchoredObjectQuery side pulls the per-sample series; aggregate to "average / max / time-in-zone" on iOS and post the rollup in `metadata`. No new MeasurementType rows. + +#### T2.2 — Workout-effort score (iOS 18) + +`HKQuantityTypeIdentifierEstimatedWorkoutEffortScore` is a 1-10 rating Apple Watch auto-generates for walking/running/hiking/cycling. The user can override via `HKQuantityTypeIdentifierWorkoutEffortScore`. Both are documented in [Sasquatch Studio's effort-score guide](https://sasq.ca/blog/2025/4/28/reading-writing-workout-effort-scores). + +**Schema impact:** store in `Workout.metadata.effortScore` (typed `number 1..10`, source `estimated | user`). No new enum. + +#### T2.3 — Sleep-apnea breathing disturbances (iOS 18 / watchOS 11) + +`HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances` (per-night value) plus `HKCategoryTypeIdentifierSleepApneaEvent` (flag). Apple's Watch needs 10 nights of data before it surfaces a sleep-apnea notification ([Apple Support: Sleep apnea notifications](https://support.apple.com/en-us/120031)). + +**Schema impact:** one new MeasurementType (`BREATHING_DISTURBANCES`) for the nightly quantity. The event flag pairs with it via a notes annotation. Tier 2 because the audience for this is much smaller than the Tier 1 set. + +#### T2.4 — Mindful sessions + +`HKCategoryTypeIdentifierMindfulSession` is a duration sample (start-end pair) the user generates via Apple's Mindfulness app or Calm/Headspace. + +**Schema impact:** one new MeasurementType (`MINDFUL_MINUTES`) — duration in minutes, mirroring `SLEEP_DURATION`. Or fold into a new `WellnessEntry` model. Recommend the MeasurementType for consistency. + +#### T2.5 — Six-minute walk distance (cardio fitness) + +`HKQuantityTypeIdentifierSixMinuteWalkTestDistance` is a metres value that Apple Watch derives from cardio-fitness session data. Lower-frequency (weekly at most) but a useful clinical reference for cardiovascular research. + +**Schema impact:** one new MeasurementType. Tier 2 because the audience is narrow. + +#### T2.6 — Heart-rate recovery one-minute + +`HKQuantityTypeIdentifierHeartRateRecoveryOneMinute` — the post-workout one-minute HR drop. Strong cardiovascular signal (the lower the recovery delta, the worse). One value per workout. + +**Schema impact:** store in `Workout.metadata.heartRateRecovery1min`. No new MeasurementType. + +#### Tier-2 schema-impact summary + +3 new MeasurementType entries (`BREATHING_DISTURBANCES`, `MINDFUL_MINUTES`, `SIX_MINUTE_WALK_DISTANCE`). Otherwise additive — Workout.metadata JSONB absorbs the rest. **Still additive-only, no destructive migrations.** + +### Tier 3 — Post-v1.5 (defer) + +- **HKClinicalRecord (FHIR).** Big US-market value (Epic/Cerner lab results); big compliance scope. v1.6+. +- **HKElectrocardiogramType (ECG waveforms).** The existing `apple-health-ecosystem-scan.md` covers this. Defer — the data model is unique (multi-channel time series) and not a Measurement row. +- **Atrial-fibrillation burden, peripheral perfusion index, scored assessments (PHQ-9 / GAD-7).** Clinical territory; defer with deliberate hold pending a clinical-decision-support architecture review. +- **Reproductive / cycle / pregnancy.** Marc has explicit "do not chase" — leave the deferred list intact. +- **Nutrition (`DietaryWater`, `DietaryEnergyConsumed`, etc.).** Marc directive — indefinite hold. +- **UV exposure, electrodermal activity, blood-alcohol content.** Niche, defer. +- **Behavioural (`HandwashingEvent`, `ToothbrushingEvent`).** Not in HealthLog scope. +- **Hearing tests (`AudiogramType`).** Hearing health is Tier 1 via audio-exposure events but the audiogram itself is a clinical waveform — defer with the ECG bucket. +- **Apple Watch independent app (glance widget).** Separate Xcode target; the iOS app comes first. + +--- + +## Section 4 — Categorisation overlay (the "kategorisieren wir das gut?" question) + +### 4.1 The current flat enum is fine for storage + +The 27-value `MeasurementType` enum is the correct storage shape. Postgres enums are cheap; the alternative (per-category sub-tables) would explode read joins for the Insights overview and break the cross-source picker which operates uniformly across all types. Keep the enum. + +### 4.2 The categorisation overlay belongs to the UI, not the database + +For the iOS picker, the web Insights nav, and the AI Coach's evidence shelf, propose a **read-only category map**: + +```typescript +// New file: src/lib/measurements/categories.ts +export const MEASUREMENT_CATEGORIES = { + vitals: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE", "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", "OXYGEN_SATURATION", "BODY_TEMPERATURE"], + body: ["WEIGHT", "BODY_FAT", "FAT_FREE_MASS", "FAT_MASS", "MUSCLE_MASS", + "TOTAL_BODY_WATER", "BONE_MASS", "VISCERAL_FAT"], + activity: ["ACTIVITY_STEPS", "ACTIVE_ENERGY_BURNED", "FLIGHTS_CLIMBED", + "WALKING_RUNNING_DISTANCE", "VO2_MAX", "WALKING_STEADINESS"], + sleep: ["SLEEP_DURATION"], + hearing: ["AUDIO_EXPOSURE_ENV", "AUDIO_EXPOSURE_HEADPHONE", "AUDIO_EXPOSURE_EVENT"], + environment: ["TIME_IN_DAYLIGHT"], + cardiovascular: ["PULSE_WAVE_VELOCITY", "VASCULAR_AGE"], + metabolic: ["BLOOD_GLUCOSE", "SKIN_TEMPERATURE"], + mood: ["MOOD"], // synthetic — not a MeasurementType but lives in the picker + medication: ["COMPLIANCE"], // synthetic +} as const; +``` + +Properties of this overlay: + +- **It does not exist at the DB layer.** No schema change. The picker is purely client-side / server-side TypeScript. +- **It mirrors Apple Health's own categorisation** — open the iPhone Health app → Browse and the categories are Activity / Body Measurements / Cycle Tracking / Hearing / Heart / Mental Wellbeing / Mindfulness / Mobility / Nutrition / Respiratory / Sleep / Symptoms / Vitals. HealthLog's overlay collapses some (Heart + Vitals → vitals) and omits the categories we deliberately don't track (Cycle, Nutrition, Respiratory clinical, Symptoms). +- **It drives the iOS HK permission picker.** Today `HealthKitPermissionGroup.swift` already does this **but** the groups are hand-coded in Swift and don't reference the canonical category map. v1.5 should derive the iOS permission picker from this overlay so adding a new MeasurementType requires only one change. +- **It drives the web Insights nav.** Today the nav is a flat 7-page list (`blutdruck`, `gewicht`, `puls`, `stimmung`, `medikamente`, `bmi`, `schlaf`). For v1.5 keep the existing pages but add an "All metrics" overview grouped by category (cheap; reuses the existing chart-card surface). +- **It feeds the Coach evidence chips.** Today the evidence shelf is a flat list; grouping by category makes the prose more navigable. + +### 4.3 Why not a separate `category` column on `MeasurementType`? + +Because the category is **never queried**. Every Insights page already knows which MeasurementType(s) it cares about (`SUB_PAGE_METRIC` in `sub-page-metric.ts`). The category overlay is a presentation concern, not a query concern. Adding a column would force every measurement-table consumer to thread an unused field through. The TypeScript-side map is the simpler tool. + +### 4.4 The MOOD / COMPLIANCE problem + +`metric:MOOD` and `metric:COMPLIANCE` are **synthetic tokens** (see `src/lib/insights/chart-tokens.ts:14-35`) — they look like MeasurementType values to the chart renderer but they back `MoodEntry` and `MedicationIntakeEvent` rows respectively. The categorisation overlay above includes them as `mood` and `medication` categories. **Recommend keeping the synthetic-token pattern**; introducing them as real enum values would force a four-axis dedup re-think for two metrics that don't behave like measurements. + +--- + +## Section 5 — Open questions for the synthesizer + +1. **Workout end-to-end is the single largest v1.5 deliverable.** §3 Tier 1.1 is bigger than every other Tier 1 item combined. The schema is ready; the iOS Swift HK reader, the new API route, the route-streaming worker, the cross-source dedup helper, and the dashboard tile are all v1.5 P1. Should this be a **standalone wave** (W-A "Workouts iOS + server") and the rest of the Tier 1 work a **second wave** (W-B "HK coverage breadth + categorisation overlay + chart cards")? Recommend yes; the workout work is its own scope and shares no code with the other Tier 1 items beyond the HKAnchoredObjectQuery + idempotency-batch pattern that's already proven. + +2. **The chart-card additions in §3 Tier 1.3 require design decisions per metric.** A "loud audio events" chip is design-light; a "headphone audio level rolling 7-day average" chart needs a token-aligned visual. Recommend a **design wave between the data wave and the implementation wave** — same pattern v1.4.20 used for the Insights redesign. + +3. **App Store review risk on `defaultReadTypes`.** §2.5 flagged the strictness. If the v1.5 iOS app ships with 10 quantity-types authorised but only the BP/weight/pulse/sleep/steps subset visibly used, reviewers will reject. Two paths: ship the chart-cards in §3 Tier 1.3 with the iOS app, OR trim `defaultReadTypes` to the visibly-used subset and re-authorise incrementally. **The synthesizer needs to pick.** Recommend the former — it's also the higher-perceived-value path. + +4. **Cross-source workout dedup is an open TODO with non-trivial nuance.** A user with Apple Watch + Withings ScanWatch will see the same workout twice today. The fix is `pickCanonicalWorkoutRows()` — but the precedence ladder for workouts is **not the same as for measurements**. A Withings ScanWatch's HR-zone breakdown is richer than Apple's; an Apple Watch's GPS route is richer than Withings'. The picker should be metric-aware (route → Apple wins; HR zones → Withings wins; calories → tied). Recommend ship the picker with the existing measurement ladder (Apple ≻ Withings ≻ Manual) and tune in a follow-up rather than block v1.5. + +5. **State-of-Mind bidirectional cycle risk.** HealthLog writes `HKStateOfMind` on mood entry; if Tier-1.2 reads back inbound `HKStateOfMind` and the iOS app re-writes those as `MoodEntry`, we'll have a round-trip. The Apple-side `HKMetadataKeyExternalUUID` write-back filter in the existing service (HealthKitService.swift:557-565) handles this for outbound writes; the inbound mapper has to mirror it. Recommend bake this filter into the read path from day one — same pattern as the existing quantity-sample filter. + +6. **Should `SLEEP_DURATION` split into stage-specific MeasurementTypes?** The per-stage row pattern works but means every sleep query has to sum stages. An alternative is one MeasurementType per stage (`SLEEP_DEEP`, `SLEEP_CORE`, `SLEEP_REM`, `SLEEP_AWAKE`, `SLEEP_IN_BED`). Pro: simpler reads. Con: breaks the existing analytics pipeline. **Recommend no change** — the per-stage rows + the `sleepStage` enum is the right shape and matches Apple's `HKCategoryValueSleepAnalysis` codepoint model. + +7. **Apple Health XML import (the §2.6 cross-reference).** Strictly out of this slot's question but the highest-leverage non-iOS-app deliverable in the broader research. If the synthesizer rolls v1.5 wider than "iOS app only", recommend pull this into v1.5. If v1.5 stays iOS-app-only, defer to v1.6 with the FHIR work. + +--- + +## Section 6 — Senior view: are we doing this right? + +**Yes, on architecture.** The HealthKit ingest path on iOS is **above industry average**. Per-user anchor partitioning, deletion reconciliation, auth-failure storm guard, per-type background-delivery frequency, idempotency-keyed batch posts with composite-unique dedup, source-priority resolution at READ time — every one of these is a hard-won detail that most OSS HK projects skip. The single largest piece of value Marc is leaving on the table is **breadth**, not depth. The depth is correct. + +**Where the architecture has a soft spot:** the iOS app reads ten quantities the user cannot see on a chart. That's not wrong per se — the AI Coach uses them — but it's invisible to the user, and Apple's App Store reviewers will increasingly call it out as "unused authorisation" (§2.5). The §3 Tier 1.3 chart-card additions close that gap and double the perceived value of the existing data plumbing. + +**Where we're leaving genuine value:** workouts are the single biggest miss. Apple's first-class workout data model (`HKWorkout` + `HKWorkoutRoute` + per-workout HR / power / cadence / route GPS) is the closest thing to a "killer feature" Apple Health offers, and HealthLog has the schema but not the iOS reader. v1.5 P1 closing this gap is the right call. + +**Where the maintainer is **leading** the field:** the source-priority two-axis picker, the `deviceType` column, the per-user anchor partitioning, and the cross-source canonical-row architecture are **better than every open-source competitor** I reviewed in the prior `apple-health-ecosystem-scan.md` work. `umutkeltek/health-data-hub` punts on multi-source; `Lybron/health-auto-export` documents it but doesn't automate it; `StanfordBDHG/HealthGPT` reads only one source. HealthLog has solved a problem nobody else has solved. + +**Where the categorisation question lands:** the flat 27-value enum is correctly the storage shape; the missing piece is the **UI-side category overlay** in §4. Today, the iOS permission picker hand-codes its groups in Swift (`HealthKitPermissionGroup.swift:25-86`) and the web Insights nav hand-picks 7 sub-pages — neither is derived from a canonical category map. v1.5 should ship the overlay and derive both from it. One change, two consumers. + +**Bottom line:** HealthLog is **doing HealthKit right**. The v1.5 work is breadth (10 → 13 metrics, plus workouts end-to-end) and presentation (chart cards for the invisible metrics, category overlay for the picker), not architectural rework. + +--- + +## Sources + +- [Apple Developer — HealthKit](https://developer.apple.com/documentation/healthkit) +- [Apple Developer — `HKAnchoredObjectQuery`](https://developer.apple.com/documentation/healthkit) and the [DevFright walkthrough](https://www.devfright.com/how-to-use-healthkit-hkanchoredobjectquery/) (updated for iOS 18, March 2025) +- [Apple Developer — `HKStatisticsCollectionQuery`](https://developer.apple.com/documentation/healthkit/hkstatisticscollectionquery) and the [DevFright daily-summary guide](https://www.devfright.com/how-to-use-the-hkstatisticscollectionquery/) +- [Apple Developer — `enableBackgroundDelivery(for:frequency:withCompletion:)`](https://developer.apple.com/documentation/HealthKit/HKHealthStore/enableBackgroundDelivery(for:frequency:withCompletion:)) +- [Apple Developer — `com.apple.developer.healthkit.background-delivery` entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.healthkit.background-delivery) +- [Apple Developer — `HKWorkoutActivityType`](https://developer.apple.com/documentation/healthkit/hkworkoutactivitytype) +- [Apple Developer — `HKWorkout`](https://developer.apple.com/documentation/healthkit/hkworkout) +- [Apple Developer — `HKWorkoutRouteBuilder`](https://developer.apple.com/documentation/healthkit/hkworkoutroutebuilder) +- [Apple Developer — `HKWorkoutRoute`](https://developer.apple.com/documentation/healthkit/hkworkoutroute) +- [Apple Developer — `HKDevice`](https://developer.apple.com/documentation/healthkit/hkdevice) +- [Apple Developer — Configuring HealthKit access](https://developer.apple.com/documentation/xcode/configuring-healthkit-access) +- [Apple Developer — Protecting user privacy (HealthKit)](https://developer.apple.com/documentation/healthkit/protecting-user-privacy) +- [Apple Developer — Track workouts with HealthKit on iOS and iPadOS (WWDC25)](https://developer.apple.com/videos/play/wwdc2025/322/) +- [Apple Developer — Explore wellbeing APIs in HealthKit (WWDC24)](https://developer.apple.com/videos/play/wwdc2024/10109/) +- [Apple Developer — Getting started with HealthKit (WWDC20)](https://developer.apple.com/videos/play/wwdc2020/10664/) +- [Apple Support — Manage Health data on your iPhone, iPad, or Apple Watch](https://support.apple.com/en-us/108779) — the canonical source-priority documentation +- [Apple Support — Sleep apnea notifications on your Apple Watch](https://support.apple.com/en-us/120031) +- [Apple Support — Log your state of mind in Health on iPhone](https://support.apple.com/guide/iphone/log-your-state-of-mind-iph6a6decb13/ios) +- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) §5.1.3 Health and Health Research +- [Apple Developer Privacy Compliance 2025](https://clause-guard.com/blog/apple-developer-privacy-compliance-2025-avoid-app-rejection-account-termination) — App Store rejection patterns 2024-2025 +- [Sasquatch Studio — Reading and writing iOS 18 workout-effort scores](https://sasq.ca/blog/2025/4/28/reading-writing-workout-effort-scores) +- [MacRumors — iOS 18.1 beta sleep-apnea coverage](https://www.macrumors.com/2024/10/07/ios-18-1-beta-6-sleep-apnea/) +- [MacStories — Health in iOS 13: A Foundation for Apple's Grand Wellness Ambitions](https://www.macstories.net/stories/health-in-ios-13-a-foundation-for-apples-grand-wellness-ambitions/) (historical baseline) +- [`StanfordBDHG/SpeziHealthKit`](https://github.com/StanfordSpezi/SpeziHealthKit) and [`kingstinct/react-native-healthkit`](https://github.com/kingstinct/react-native-healthkit) — reference implementations +- [`kvs-coder/HealthKitReporter`](https://github.com/kvs-coder/HealthKitReporter) — pure-Swift HK wrapper reference +- Internal: `.planning/research/apple-health-ecosystem-scan.md`, `.planning/research/apple-health-sync-deep-dive.md`, `.planning/research/w16b-workout-ingest.md`, `.planning/research/w16c-pr-detection.md`, `.planning/v15-ios-handoff/06-ios-responsibilities.md` + +--- + +**Word count:** ~3650. diff --git a/.planning/v15-strategic-plan.md b/.planning/v15-strategic-plan.md new file mode 100644 index 000000000..f901b5c58 --- /dev/null +++ b/.planning/v15-strategic-plan.md @@ -0,0 +1,341 @@ +--- +file: .planning/v15-strategic-plan.md +purpose: Unified v1.5 strategic plan — last web patches, iOS sprint shape, post-v1.5 defers +created: 2026-05-16 +contributor: synthesizer +sources: 6 R-A through R-F research outputs + v1429-backlog + iOS handoff pack +--- + +# v1.5 Strategic Plan + +## 1. Executive summary + +### Product-Lead view + +The maintainer asked for three things: a clean closure of the v1.4 +line on the web, a focused iOS sprint that ships v1.5 as a real +release rather than a v0.4.x iOS build with a marketing label, and an +Apple-Health-depth story that says yes to workouts and breadth without +chasing reproductive-cycle or nutrition tracking. The recommendation +is to ship **v1.4.29 as a tight one-day patch** that closes the four +critical and high findings from the dashboard audit plus the mobile +tile equal-height and the settings drag-list compactness, **skip +v1.4.30**, freeze the web after v1.4.29, and run a **10 working day +iOS sprint** that pairs every HealthKit read with a visible iOS +surface so App Store review never sees an authorised-but-invisible +type. The differentiator stays the Coach drawer; workouts are the +single largest perceived-value-per-LOC add; the rest of Tier 1 is the +breadth play that lets the iOS app finally show the ten metrics it +already reads. + +### Senior-Dev view + +Architectural posture is conservative. After v1.4.29 the web app +freezes — only security patches, dependency updates, and the additive +iOS-server-prep changes the sprint requires (two Prisma columns, two +new bulk endpoints, one sync-state endpoint, two new MeasurementType +enum entries, one new MoodEntry column) are allowed. No new web +features, no UI rewrites, no non-additive schema changes. The +locked-contracts file at `.planning/v15-ios-handoff/08-locked-contracts.md` +holds. Nothing in the proposed scope flags as breaking. The largest +single risk is the v1.4.29 P0 `aggregate=daily` regression on +`/api/measurements` — it 500s in production today and the v1.4.28 +suite mocked `prisma.$queryRaw` past the bug. Fixing C2 before wiring +C3 is the only correct sequence; both ride in v1.4.29 in that order. + +### iOS-Dev view + +10 working days + a 3-5 day Apple-review buffer. The iOS repo is +further along than the handoff pack assumed (28 finds in R-E, 5 +critical, but the Coach drawer is the only Critical that is "no +native code at all" — the rest are gaps to close, not greenfield +work). Day 1-2 server prep blocks everything; Day 3-5 splits into +three parallel tracks (Coach SSE drawer, SyncMode + Workouts iOS +ingest, daily-stats service for cumulative types); Day 6-8 closes +breadth; Day 9-10 polish and Apple-review prep. **Key risk**: App +Store rejection if a HealthKit type is authorised but the app has no +visible surface (R-F §2.5). **Locked decision**: every HK read pairs +with a surface in the same track. Without the Coach drawer native +build, v1.5 is materially v0.4.2 iOS. + +--- + +## 2. Last web patches + +### v1.4.29 — Single-day patch (target: within one week) + +Tight scope drawn from R-B, R-C, R-D, and R-A finding 2. Every item +is web-only and either iOS-additive or iOS-neutral. + +| ID | Source | Description | Effort | Sequence | +|---|---|---|---|---| +| C2 | R-B Critical | P0 regression: `aggregate=daily` on `/api/measurements` returns 500 in production. `${truncUnit}` passed as a bound parameter to `date_trunc` — Postgres rejects. Tests passed only because `prisma.$queryRaw` was mocked. Fix with `Prisma.sql` interpolation for the grain literal; add a real-Postgres integration test using the existing `src/lib/db/__tests__` container fixture. | S | First | +| AVG/SUM | R-A finding 2 | `aggregate=daily` averages step counts instead of summing for cumulative types. Wrong today on `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`. Switch the SQL from `AVG(value)` to `SUM(value)` when the type is in a new `CUMULATIVE_HK_TYPES` set. | XS | Rides with C2 | +| C3 | R-B Critical (blocked on C2) | Pulse chart aggregate-daily wiring. Have `` pass `aggregate=daily` for every window longer than 7 days. Caps client payload at ~365 daily rows for pulse instead of the current ~5000-row ceiling. Drops Recharts paint cost ~50× on high-density accounts. | S | After C2 lands | +| C4 | R-B Critical | `useChartOverlayPrefs` cache-key collision. Hook reads `/api/dashboard/widgets` under `["dashboard-layout"]` while the rest of the codebase uses `queryKeys.dashboardWidgets()` → `["user", "dashboardWidgets"]`. Two cache slots, one endpoint, one duplicate request per dashboard mount. 4 LOC. | XS | Parallel | +| Mobile tile equal-height | R-C | `--tile-h:140px` CSS custom property on the dashboard strip wrapper at `` migration carry-forwards — none are +blockers for v1.5 and none are visible to the iOS app. Carry them to +v1.5.x. Ship v1.4.30 only if the iOS sprint slips past Day 5 and a +quiet web release helps confidence; default is to tag v1.5 directly +after the sprint. + +### Web-freeze posture + +After v1.4.29 tags on `main`, the web app enters **freeze**. + +Allowed during the v1.5 sprint: + +- Security patches (CVE feeds, dependency vulnerabilities) +- Dependency updates that ride existing test infrastructure +- iOS-server-prep changes the sprint requires (additive endpoints + + Prisma columns documented in §3 Day 1-2) +- Hotfix-only emergencies (the v1.4.28.1 dashboard-save pattern) + +Not allowed during the v1.5 sprint: + +- New web features +- UI rewrites or design refactors +- Schema changes that aren't additive +- The C1 architectural lift on `/api/analytics` unless it folds + cleanly into the iOS-server prep + +Document this posture in the v1.5 closure record. When the sprint +ends and v1.5 ships, the freeze ends — v1.5.x patches resume normal +web cadence. + +--- + +## 3. v1.5 iOS sprint shape + +10 working days. The day-by-day shape pulls from R-E §recommended +sprint length and R-F Tier 1 track structure, with R-F's "wave" +vocabulary kept in the table headers because the substring is +load-bearing on the source documents; substituting "track" or +"stream" in the prose preserves the maintainer's discipline. + +### Day-by-day schedule + +| Day | Track | Work | Output | +|---|---|---|---| +| 1 | Server | APNs `.p8` paste into Coolify (1-hour Marc-action per R-E C-3). Tag the v1.4.29 release on `main`. Verify Coolify auto-deploy. | APNs delivery confirmed live; v1.4.29 in production. | +| 1-2 | Server | SyncMode server-side per R-E C-2: `syncVersion Int @default(1)` on `Measurement`, `deletedAt DateTime?` soft-delete column, `/api/sync/state` endpoint, `/api/mood-entries/bulk` + `/api/medications/intake/bulk` bulk-backfill endpoints. | Prisma migration applied; endpoints live; OpenAPI regenerated. | +| 1-2 | Server | `MoodEntry.note TEXT NULL` per R-E H-5. Replaces the `tags: ["note:"]` workaround that contaminates the Coach evidence shelf. | Server column live; iOS reads + writes can migrate in the iOS-side PR. | +| 1-2 | Server | `HKWorkoutType.workoutType()` API route per R-F T1.1. `/api/workouts/batch` exists in v1.4.25 W8d; verify caps and middleware match the pattern. `pickCanonicalWorkoutRows()` helper for cross-source dedup (Apple Watch + Withings ScanWatch overlap). | Workout ingest endpoint signed off; cross-source picker covers the v1.5.1 follow-up properly. | +| 1-2 | Server | Categorisation overlay per R-F §4.2: `src/lib/measurements/categories.ts` TypeScript map driving both the iOS permission picker and the web Insights nav. | Shared map landed; iOS Swift mirror builds from the same canonical list. | +| 1-2 | Server | Two new MeasurementType enum entries per R-F T1.4 + T1.5: `WALKING_STEADINESS`, `AUDIO_EXPOSURE_EVENT`. One-line migration each. | Enum extended; Zod validation regenerated. | +| 1-2 | Server | R-A Option A server-side: `dailyStatsExternalId()` helper + `CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`. Handoff doc updates: `.planning/v15-ios-handoff/06-ios-responsibilities.md` + `08-locked-contracts.md` lock the `externalId` shape `"stats::"`. | Server stops paying for per-sample step rows once iOS cuts over. | +| 3-5 | iOS Track A | Coach SSE drawer native build per R-E C-1. `CoachService` actor over `URLSession.bytes`, `CoachStreamEvent` AsyncThrowingStream, SwiftData-backed `CoachConversation` cache, streaming bubble view, provenance disclosure, GROUND-RULE-9/15 refusal-acceptance UI, `coach.budget.exceeded` 429 surface. Reuse the existing `MDRAcknowledgmentDialog`. Adds `coach.*` locale keys to `Localizable.xcstrings`. Wires `AppRouter` to a real `.coach` TabIdentifier. | TestFlight build runs Coach against staging end-to-end. | +| 3-5 | iOS Track B | SyncMode + Workouts ingest. Per R-E C-2: extend `@Model` types with `syncVersion + deletedAt`, gate every repository call behind `SyncModeStore.isPaired`, build the pair/unpair sheet. Per R-F T1.1 + R-E H-2: `HKWorkoutType.workoutType()` reader, `HKWorkoutRouteQuery` route streamer, `WorkoutBatchEntryDTO` mirror, `WorkoutsRepository`, dashboard "Recent workouts" tile. | Workouts flow from HK to dashboard; SyncMode honours pairing state across every repo path. | +| 3-5 | iOS Track C | R-A Option A iOS implementation per `HealthKitStatisticsService.swift` sketch. `HKStatisticsCollectionQuery` wrapper per cumulative type, per-day last-posted-value cache, PATCH-on-divergence path for late watch syncs, `ENABLE_DAILY_STATS` build flag (default OFF for first TestFlight, ON for cut-over build). | Steps + active energy + flights + distance + daylight ingest as one row per day per type; per-sample row pressure on `Measurement` drops 50-200× for those types. | +| 6 | iOS | R-E C-4 RefreshScheduler (proactive bearer refresh 5 min before expiry) + R-E C-5 source-priority editor (Settings → Sources & Geräte sheet, drag-reorder per metric, nested device-type picker, full-object PUT) + R-E H-7 Withings `hasActivityScope` reconnect banner. | Midnight-refresh 401 closed for real; iOS users can edit the two-axis source priority natively. | +| 6-7 | iOS | R-F T1.3 breadth wave: 10 chart cards for invisible-but-stored metrics — `restingHr`, `hrv`, `spo2`, `bodyTemperature`, `activeEnergy`, `flights`, `distance`, `audioExposureEnv`, `audioExposureHeadphone`, `daylight`. Each is a one-line `CHART_OVERLAY_KEYS` add server-side + a SwiftUI card mount on iOS. Pair every HK read with a visible surface per the Apple-review pairing rule. | iOS dashboard finally shows what it has been reading; App Store reviewer sees one visible surface per authorised type. | +| 7 | iOS | R-F T1.2 HKStateOfMind read path (iOS 18+) + R-F T1.4 hearing-event chips on `/insights/puls` + R-F T1.5 walking-steadiness gauge. | Mood bidirectional with Apple Health; loud-listening events surfaced; mobility signal visible. | +| 7-8 | iOS | R-E H-3 medication sub-routes (inventory, side-effects, phase-config) + R-E H-4 Coach prefs + Doctor Report prefs sheets + R-E H-6 cache-invalidation matrix + R-E H-8 OpenAPI codegen wiring (swift-openapi-generator in XcodeGen + CI). | Medication detail screen reaches feature parity with the server; LLM consent + tone settings native; OpenAPI drift caught at CI time. | +| 8 | iOS | Medium-severity sweep per R-E §Medium: M-1 enum extension (10 → 29 ServerMeasurementType + `.unknown(String)` fallthrough — solved cleanly by H-8 codegen), M-3 unit-test sentinel for the `OxygenSaturation` × 100 rule, M-7 timezone PUT on system tz change, M-8 `/api/insights/targets` Zielwerte screen, M-9 `glucoseContext` enum fix (`FASTING | POSTPRANDIAL | RANDOM | BEDTIME`). | iOS reads every server-emitted MeasurementType without 422; timezone follows the user across travel; Zielwerte page native. | +| 9 | iOS | Polish + a11y pass. Accessibility audit of the Coach drawer (VoiceOver reads streaming bubbles cleanly, provenance disclosure has the right rotor), every new chart card meets the 4.5:1 contrast minimum, every drag-handle has the 44-px tap target. App Store privacy disclosure form draft. App Store Connect submission form draft. Marketing copy + screenshots. | Submission package ready for review. | +| 10 | iOS + web | TestFlight upload. Light QA pass on web (only the iOS-coupled changes — Workouts API route, sync-state endpoint, bulk endpoints, MoodEntry.note migration, categorisation overlay parity, two new MeasurementType enum entries). Final iOS QA pass: the existing 3× iOS QA loop (`rshankras-testing` + `swiftui-pro` + manual) from the iOS repo README. | Build tagged `v1.5.0-rc.1` on iOS; server tagged `v1.5.0` on main. | +| +3 to +5 | — | Apple review buffer. 5.1.2(i) Third-Party-AI consent surface wired per `AIConsentStore.swift`. Privacy Policy URL + AppStore Connect "Regulated medical device" form. | TestFlight → App Store. | + +### Track touch-disjointness + +Day 3-5 runs three iOS tracks in parallel: + +- Track A (Coach) — `Services/CoachService.swift`, `Stores/CoachStore.swift`, `Screens/Coach/*` (all new), plus an `AppRouter.swift` rewire. +- Track B (SyncMode + Workouts) — `@Model` additive columns, `Stores/SyncModeStore.swift`, every `Repositories/*Repository.swift` (gate behind `isPaired`), `Services/HealthKitService.swift` (add workout type), `Repositories/WorkoutsRepository.swift` (new). +- Track C (daily stats) — `Services/HealthKitStatisticsService.swift` (new), `Services/HealthKitService.swift` (skip cumulative types in `quantityEntry`), `Repositories/MeasurementBatchUploader.swift` (PATCH-on-divergence). + +`HealthKitService.swift` is the one shared file. Merge order: Track C +carve-out first, Track B workout reader second, Track A skips it. + +### Apple-review pairing rule + +Locked per R-F §2.5 and R-F open question #3: **every HK read pairs +with an iOS surface in the same track**. No authorised-but-invisible +reads. Concretely: + +- 18 quantity types in `defaultReadTypes` today: 8 already have a + surface (BP × 2, Pulse, Weight, BodyFat, Sleep, Steps, BG). The 10 + remaining (RestingHR, HRV, SpO2, BodyTemp, ActiveEnergy, + FlightsClimbed, Distance, AudioEnv, AudioHeadphone, Daylight) get + cards in the R-F T1.3 breadth wave on Day 6-7. +- The 2 new types this sprint (`WALKING_STEADINESS`, + `AUDIO_EXPOSURE_EVENT`) get their surfaces in the same Day 7 work + that adds the reads. +- HKStateOfMind read surfaces via the existing `/insights/stimmung` + page picking up the new APPLE_HEALTH-sourced mood entries. +- Workouts get the new dashboard tile on Day 3-5 in Track B. + +Fallback if Day 9 finds an authorised type with no surface: drop the +type from `defaultReadTypes` and re-request on-demand when the user +navigates to a feature that needs it (R-F §2.5 option 2). + +### Risks documented + +- **R-F top open question locked**: every HK read pairs with an iOS + surface. No authorised-but-invisible. +- **APNs `.p8` paste-in** (R-E C-3): 1-hour Marc-action on Day 1. + Only external blocker. +- **Web freeze starts** at v1.4.29 tag. Emergency-fix procedure: + hotfix branches off `main`, ride v1.4.29.x patch tags; no feature + work touches develop during the iOS sprint. +- **Day 3-5 parallel-track friction**: `HealthKitService.swift` is + the shared file; merge order is Track C → Track B → Track A on + that file specifically. +- **Apple-review surprise** budget: 3-5 days. The 5.1.2(i) + Third-Party-AI Consent surface, the Privacy Policy URL, the + Regulated-medical-device form — all need user-side completion. + +### Volume estimates + +- iOS-engineer-days: ~21 (3 tracks × Day 3-5 = 9 parallel days; Day + 6-10 sequential single-track = 5 days; Apple-review-buffer polish + carries 2 iOS QA days inside the +3 to +5 buffer; total ~21). +- Server-engineer-days: ~4-5 across Day 1-2 (prep menu), Day 6 + (server-side help for the source-priority editor's full-object + PUT), Day 7-8 (codegen wiring + OpenAPI regeneration cycle). + +--- + +## 4. Defers (post-v1.5) + +### v1.5.x follow-up patches + +| Source | Item | Why deferred | +|---|---|---| +| R-F T2.1 | Running + cycling power metrics (iOS 16+) — per-workout HR-zone breakdown via `Workout.metadata` JSONB | Tier 2; athletes-only audience for v1.5.x | +| R-F T2.2 | Workout-effort score (iOS 18) — `HKQuantityTypeIdentifierEstimatedWorkoutEffortScore` | Tier 2; rides Workout.metadata blob | +| R-F T2.3 | Sleep apnea breathing disturbances (iOS 18 / watchOS 11) | Tier 2; one new MeasurementType (`BREATHING_DISTURBANCES`) | +| R-F T2.4 | Mindful sessions (`HKCategoryTypeIdentifierMindfulSession`) | Tier 2; one new MeasurementType (`MINDFUL_MINUTES`) | +| R-F T2.5 | Six-minute walk distance | Tier 2; narrow clinical audience | +| R-F T2.6 | Heart-rate recovery one-minute (per-workout metric) | Tier 2; rides Workout.metadata | +| R-E M-2 | The seven new v1.4.25 MeasurementTypes in the HK wire-converter unit-map (covered by codegen H-8) | Solved cleanly by OpenAPI codegen landing on Day 7-8 | +| R-E M-4, M-5 | iPad → phone deviceType doc note + `kindDisplayName` extension | Doc-only / low maintenance | +| R-E L-1 through L-6 | Foot-guns and defensive guards | Low | +| R-B C1 | `/api/analytics` architectural split into dashboard + insights surfaces, SQL-side aggregation, single-pass `summarize()` | Lands in v1.5.x if not folded into Day 1-2 server prep | +| R-B H3 | Server-rendered auth seed (one-round-trip first-paint waterfall) | M-effort root-layout change, touches the auth flow | +| R-B M3 | Covering index `(userId, measuredAt) INCLUDE (type, value, source)` | Needs `EXPLAIN ANALYZE` against production-scale data first | +| R-B L2 | Viewport-gated below-the-fold chart mounts | UX call on the fold boundary per breakpoint | +| v1429-backlog | Design Mediums (D-M1 through D-M9), UI-conformity Mediums (UI-M1 through UI-M4), Simplifier Mediums (S-M1 through S-M5), i18n Mediums | Carried forward from v1.4.28; not iOS-blockers | + +### v1.6 (post-iOS-launch) + +| Source | Item | Why v1.6 | +|---|---|---| +| R-F T3 | HKClinicalRecord (FHIR) — Epic / Cerner lab results | US-market value, large compliance scope | +| R-F T3 | HKElectrocardiogramType (ECG waveforms) | Multi-channel time series; not a Measurement row | +| R-F T3 | Atrial-fibrillation burden, peripheral perfusion index | Clinical-decision-support architecture review needed | +| R-F T3 | Scored assessments (PHQ-9, GAD-7) | Clinical territory; deliberate hold | +| R-F T3 | Reproductive / cycle / pregnancy | Marc directive: do not chase | +| R-F T3 | Nutrition (`DietaryWater`, `DietaryEnergyConsumed`) | Marc directive: indefinite hold | +| R-F T3 | UV exposure, electrodermal activity, blood-alcohol | Niche | +| R-F T3 | Audiogram clinical waveform | Defers with ECG bucket | +| R-F T3 | Apple Watch independent app | Separate Xcode target; iOS app first | +| R-F open Q #7 | Apple Health XML import (`export.zip` ingest) | Highest-leverage non-iOS-app deliverable but outside v1.5 brief | +| R-A generalisation | Per-hour drill-in for cumulative types (read-only HK query, no persistence) | Apple's Health app does not show this beyond per-hour ring either | +| R-A compaction | One-time script to collapse pre-Option-A per-sample APPLE_HEALTH cumulative rows into daily rows | Optional; idempotent re-run is a no-op | + +--- + +## 5. Decision log + +| Decision | Choice | Source | +|---|---|---| +| Step aggregation shape | Option A — iOS pre-aggregates daily via `HKStatisticsCollectionQuery` | R-A §5 | +| `externalId` shape for cumulative types | `"stats::"`, locked in `08-locked-contracts.md` | R-A §6 | +| Late-watch-sync divergence handling | iOS keeps per-day last-posted-value cache + PATCH on divergence | R-A §5 | +| Tile equal-height contract | `--tile-h:140px` CSS custom property at `