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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 10:13:11 +02:00
1 parent 7be20281d2
commit 6efe90394d
7 files changed
+2359

No files matched your search

@@ -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:<YYYY-MM-DD>:<deviceType?>"`
— 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:<type>:<YYYY-MM-DD>"` 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:<typeIdentifier>:<YYYY-MM-DD>"` 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<HKQuantityTypeIdentifier>` = 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:<typeIdentifier>:<YYYY-MM-DD>"` |
| `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:<typeIdentifier>:<YYYY-MM-DD>"`. |
| `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:<typeIdentifier>:<YYYY-MM-DD>"`), 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).
@@ -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 `<Line>` 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; `<CoachLaunchButton />`
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.
@@ -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 (`<sm`, < 640 px). On `sm:+` it does. The mismatch is one CSS branch: at `<sm` the strip is a `flex overflow-x-auto` row where each tile takes its content height; at `sm:+` it is a `grid auto-rows-fr` row that equalises by definition. Because each `<TrendCard>` 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 `<TrendCard>` 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
<div className={cn(
"flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2",
"sm:grid sm:snap-none sm:auto-rows-fr sm:overflow-visible",
"sm:[grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]",
)}>
{trendCards.map((entry) => (
<div className="flex min-w-[10rem] shrink-0 snap-start sm:min-w-0 sm:shrink">
{entry.node}
</div>
))}
</div>
```
Two layout regimes:
| Breakpoint | Wrapper | Tile cell | Height contract |
|---|---|---|---|
| `<sm` (< 640 px) | flex row, horizontal scroll, snap-mandatory | `min-w-[10rem] shrink-0 snap-start` | **None — each tile takes content height** |
| `sm:+` (>= 640 px) | CSS grid, auto-fit columns, `auto-rows-fr` | `min-w-0 shrink` | **`auto-rows-fr` forces equal row height** |
The `<TrendCard>` 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 `<div className="flex min-w-[10rem] shrink-0 snap-start sm:min-w-0 sm:shrink">`. 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 `<TrendCard>`'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 `<TrendCard>`'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 `<TrendCard>`), 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 `<TrendCard>` 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 `<div className="mt-1">` 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 `<TrendCard>` content-height arithmetic
Reading `src/components/charts/trend-card.tsx` line-by-line at the 375 px viewport (mobile `<sm` branch, padding `p-4` = 16 px each side):
| Block | DOM | Vertical contribution |
|---|---|---|
| Card padding (top) | `p-4` | 16 px |
| Label row | `h-5` (line 206) | 20 px |
| Spacer | `mt-2` (line 226) | 8 px |
| Value row | `text-3xl leading-none` (line 229) | 30 px |
| Comparison callout (conditional) | `mt-1 text-xs leading-snug` | 4 + 18 = 22 px when painted, 0 when not |
| Sub-row pair (avg7 + avg30) | `mt-auto pt-1 flex-wrap text-xs leading-snug` | 4 + 18 = 22 px when on one line, 4 + 36 = 40 px when wrapped to two lines |
| Card padding (bottom) | `p-4` | 16 px |
| **Total (callout off, sub-rows one line)** | | **112 px** |
| **Total (callout on, sub-rows one line)** | | **134 px** |
| **Total (callout on, sub-rows wrapped)** | | **152 px** |
The sub-row pair wraps to two lines when the value-row digits force the card to be narrow enough that `7d: 122.3 (+1.2)` and `30d: 120.5` no longer fit on the same flex line. On a 375 px viewport with 9 tiles enabled and a strip width of `100vw - 32 px` (page gutter), each tile is `(343 - 8 * 12) / 9 = ~27 px` wide on the grid branch — clearly not enough, which is why the grid branch falls back to wrap (1-2 tiles per row) at `<sm`. On the actual mobile flex branch each tile is forced to `min-w-[10rem] = 160 px`. At 160 px the sub-rows DO wrap for tiles with `trend7Delta` painted (sys / dia / weight / pulse / bodyFat / mood / sleep / steps / vo2 / bpInTarget) but not for tiles without (glucose).
So on mobile, at 160 px tile width, with comparison on:
- Weight / BP / Pulse / BodyFat / Mood / Sleep / Steps / VO2 / BpInTarget tiles: **152 px** (callout on, sub-rows wrap)
- Glucose tiles (no `trend7Delta`, no compare delta): **112 px** (callout off, sub-rows one line)
That is a **40 px height delta between sibling tiles on the same scroll-snap row**. Visible to the eye, exactly what the maintainer flagged.
When comparison is off (the default for a fresh user):
- Tiles with `trend7Delta`: 130 px (callout off, sub-rows wrap because the `(+1.2)` chip still occupies width)
- Glucose tiles: 112 px
Still an 18 px delta — smaller, still asymmetric.
---
## 4. Insights vs dashboard contract diff
| Surface | Wrapper | Equal-height? | Caption clamp? | Chart band? |
|---|---|---|---|---|
| `/insights` trends row | `grid auto-rows-fr grid-cols-1 md:grid-cols-3 md:items-stretch` | yes on every viewport (`auto-rows-fr` covers `<md` too) | `line-clamp-3` on `<TrendAnnotation>` | fixed-height `trends-row-chart-slot` wrapper |
| `/` dashboard strip | `flex` on `<sm`, `grid auto-rows-fr` on `sm:+` | **NO on `<sm`**, yes on `sm:+` | none — tile has no caption block | none — tile renders no chart |
The insights row had the same problem in v1.4.27 (a mood tile inflated the row) and got fixed in v1.4.28 R3c-Insights via two edits: (a) move `auto-rows-fr` out of the `md:` prefix into the unconditional class, and (b) clamp the captioning slot with `line-clamp-3`. Both edits apply directly to the dashboard strip with a minor adjustment for the horizontal-scroll branch.
---
## 5. Peer-app convention
Field notes from public app reviews + the prior `apple-health-ecosystem-scan.md` + `open-wearables-comparison.md`:
| App | Mobile dashboard tile strategy | Equal-height? |
|---|---|---|
| Apple Health (Summary tab "Favorites") | Single-column list, each metric a full-width row of fixed height (~96 px). One sub-line ("Heute, 14:32 Uhr") that does not wrap. | yes — single row height, content truncates |
| Withings Health Mate (Today screen) | Two-column grid of equal-aspect-ratio tiles (~1:1) + one full-width hero card on top | yes — fixed aspect ratio enforces height |
| Whoop | Vertical full-width tiles, each a self-contained card with a fixed-height chart band (160 px) + caption | yes — chart band is a design constant |
| MacroFactor | Single-column daily-summary list; each row is one bar chart + label, fixed at ~64 px | yes — fixed row height |
| Oura | Card-stack vertical, each card sized 1:1.4, single number + 7d sparkline at fixed height | yes — aspect-ratio drives height |
The pattern is uniform: every reviewed peer app enforces a **single tile height** at the design level, NOT at the content level. Caption variance is absorbed via truncation or icon-only states; chart variance is absorbed via fixed-band heights. Apple Health is the most aggressive — every favourite metric collapses to one number + one sub-line + chevron, no per-metric divergence allowed.
The current HealthLog dashboard tile strip mostly follows this convention on `sm:+` (where `auto-rows-fr` pins the row) but breaks on `<sm`. Fixing the break re-aligns the strip with the prevailing convention.
---
## 6. Proposed contract
### 6.1 One pixel height per tile across the strip
**Target height: 140 px on `<sm`, 156 px on `sm:+`** (the `sm:+` number stays the resolved `auto-rows-fr` height under the current content set — verified against the live `sm:` grid layout).
The 140 px target is chosen to fit the worst-case content (callout on + sub-rows wrapped + tiny visual buffer at the bottom). Glucose tiles at 112 px will paint with 28 px of extra space below the sub-row pair (`mt-auto` absorbs the gap into the bottom padding by extending the `flex-col`'s flex space). The header / value / callout / sub-row stack stays top-anchored; the empty space below reads as deliberate breathing room, not unfinished content.
### 6.2 Implementation — two edits
**Edit 1 (page.tsx:1212-1216)** — replace the flex-only mobile branch with flex + an explicit minimum height that matches the worst-case `<TrendCard>` 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 `<sm` that propagates into the `<TrendCard>`'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 `<sm`:
```diff
- <div className="mt-1">
+ <div className="mt-1 min-h-[18px]">
<span className={cn(
- "inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
+ "line-clamp-1 inline-block max-w-full text-xs leading-snug font-medium tabular-nums sm:line-clamp-none",
comparisonDeltaColor,
)}
```
`line-clamp-1` at `<sm` ensures the callout cannot inflate to a second line; `min-h-[18px]` reserves the slot even when the callout is suppressed (compareBaseline === "none") so the bottom sub-row pair pins to the same y-position across every tile. On `sm:+` the clamp releases and the existing overflow-wrap behaviour returns.
### 6.3 Caption clamp policy
Apply `line-clamp-1` to:
- The comparison-delta callout (`data-slot="tile-compare-delta"`)
- The sub-row pair contents — current `flex-wrap` allows wrap to a second line, which is the wrap-trigger the inventory section identified. Switch to `flex-nowrap overflow-hidden` and `truncate` each `<span>` so the longer values clip at the right edge instead of wrapping down.
Concretely:
```diff
- <div className="text-muted-foreground mt-auto flex min-w-0 flex-wrap gap-x-3 gap-y-1 pt-1 text-xs leading-snug">
+ <div className="text-muted-foreground mt-auto flex min-w-0 flex-nowrap items-baseline gap-x-3 overflow-hidden pt-1 text-xs leading-snug sm:flex-wrap sm:gap-y-1">
```
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 (`<ArrowUp>` / `<ArrowDown>` / `<ArrowRight>`). On `sm:+` the wrap returns.
### 6.4 Chart band policy
Not applicable. The dashboard tile strip carries no chart band — each `<TrendCard>` is a value-and-text tile only. The lower row on `/` does carry `<HealthChart>` instances, which are full-width single-column cards on `<sm` (`grid auto-rows-fr` already applies to that row, from page.tsx:1232-1236). No edit needed there.
### 6.5 Per-tile overrides
The glucose-per-context tiles do not pass `trend7Delta` or `compareDelta`. The proposed contract leaves their internal content unchanged; the 140 px height target gives them 28 px of bottom slack that reads as deliberate breathing room. If the slack reads as wasted space on Marc's live walk-through, follow-up adjustment: pass a synthetic `trend7Delta` (latest - avg7) into the glucose tiles so they paint the same delta chip as their siblings. Defer to v1.5.2 or beyond unless explicitly raised.
The BpInTarget tile (post-FB-C2) already routes through the same primitive — no per-tile override needed.
### 6.6 Breakpoint-specific differences
The contract differs at the breakpoint boundary, but symmetrically:
| Property | `<sm` (mobile) | `sm:+` (tablet, desktop) |
|---|---|---|
| Wrapper | `flex overflow-x-auto` | `grid auto-rows-fr auto-fit` |
| Tile height | `140 px` fixed | `auto` (driven by `auto-rows-fr`) |
| Sub-row layout | `flex-nowrap overflow-hidden` (clip) | `flex-wrap gap-y-1` (wrap) |
| Comparison callout | `line-clamp-1 min-h-[18px]` (single line, reserved) | `inline-block` (unbounded) |
| Scroll behaviour | horizontal snap-mandatory | none (grid wraps to multiple rows) |
The `sm:` boundary is the natural cutover: above 640 px every tile gets at least 160 px of dedicated width (the `minmax(min(100%, 9rem), 1fr)` floor), at which point sub-rows fit without wrap. Below 640 px tiles are 160-180 px wide via `min-w-[10rem]`, which is the width at which sub-rows must clip rather than wrap.
---
## 7. File-touch list
| File | Lines | Edit |
|---|---|---|
| `src/app/page.tsx` | 1212-1224 | Add `[--tile-h:140px] sm:[--tile-h:auto]` to strip wrapper; add `h-[var(--tile-h)] sm:h-auto` to per-tile cell wrapper |
| `src/components/charts/trend-card.tsx` | 268-290 (compare callout), 291-369 (sub-row pair) | Clamp callout to `line-clamp-1` with `min-h-[18px]` reserve; switch sub-row pair to `flex-nowrap overflow-hidden truncate` at `<sm`, `flex-wrap` from `sm:+` |
| `src/app/__tests__/page-tile-strip.test.tsx` (new) OR an existing dashboard test | n/a | Snapshot test asserting every tile renders with the data-slot `trend-card` carrying the same computed bounding box at a 375 px viewport |
The vestigial F14 flex-without-direction wrapper on each tile cell (`<div className="flex min-w-[10rem]…">`) 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 `<span data-slot="trend7-delta">` (line 327-328). Verified via Read.
---
## 8. Tests + snapshots
The current test inventory in `src/app/__tests__/` and `src/components/charts/__tests__/` covers `<TrendCard>` 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 `<sm` clip + restore wrap on `sm:+`, and add a single Playwright spec at 375 px in en + de locales.
The contract delivery fits inside the v1.4.28 R3c-Insights-K1/K2 mental model: it is the same contract translated from `/insights` to `/`, with the small wrinkle that the dashboard strip needs to handle the horizontal-scroll branch the insights row does not. The wrinkle costs one extra CSS custom-property (`--tile-h`) but no additional architectural surface.
---
## 10. iOS impact
None. The native iOS client (`/Users/marc/projects/healthlog-iOS` per `.planning/v15-ios-handoff/`) renders its own SwiftUI dashboard tiles via locked DTOs from `/api/analytics`. The native tile heights are managed by SwiftUI's `GridItem(.flexible())` / `aspectRatio` modifiers, not by CSS. The proposed contract is web-only chrome.
The shared design language — "every tile reads at the same y-rhythm on a phone-sized viewport" — should ideally land in both the web and iOS clients, but the iOS implementation is out of scope for this research slot and is presumably already handled by the native rendering. Cross-check via the iOS handoff docs is worth doing as a separate slot deliverable.
---
## 11. Open questions for the consolidator
1. **Does the maintainer want the `sm:` boundary to stay 640 px, or shift to 480 px / `min-width: 30rem` so iPad-portrait and large-phone landscape get the grid behaviour?** Current `sm:+` corresponds to the Tailwind default 640 px. Most phones in landscape (Pixel 5 at 851 px landscape, iPhone 14 Pro at 852 px) land above the boundary already, so no edit needed unless the maintainer specifically calls out a phone-landscape viewport. Default decision: keep 640 px.
2. **Should the 140 px target shift to 132 or 148 px after the live measure?** The 140 px number is back-of-envelope from the content-height arithmetic in §3.1. A 5-minute Playwright snapshot at 375 px will settle the exact pixel. The contract's structure is breakpoint-stable regardless.
3. **Per-tile overrides for glucose**: should the glucose tiles synthesise a `trend7Delta` from `latest - avg7` so they paint the same delta chip as their siblings, or stay slack-padded? The former adds one prop and visually homogenises the strip; the latter respects the current contract that glucose has no headline trend signal. Default decision: stay slack-padded, escalate if Marc's next walk-through still calls out the empty space.
4. **Vestigial F14 cell-wrapper drop**: the v1.4.27 R3c-Mobile-Dashboard audit flagged the `<div className="flex min-w-[10rem]…">` 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 `<sm` and rely on the global comparison-toggle chip in the header for context. Default decision: keep full string + clip; revisit after live measure.
---
## 12. Forbidden-vocab compliance
This doc is free of: AI, Claude, agent, marathon, wave, phase (except inside file-path identifiers like `phase-config-dialog.tsx` which is not used here), session, subagent, Anthropic.
Marc-Voice English. Terse. No PII. No personal health figures.
---
## 13. Summary table for the v1.5 strategic plan
| Field | Value |
|---|---|
| Tile-variant count | **1** (`<TrendCard>` after the v1.4.28 FB-C2 BD-Zielbereich rewrite — no surviving divergent shapes) |
| Target tile heights | **140 px on `<sm`, `auto` on `sm:+` (driven by `grid auto-rows-fr`)** |
| Contract one-liner | **One CSS custom property `--tile-h` on the dashboard strip wrapper + `line-clamp-1` + `flex-nowrap overflow-hidden` on the sub-row pair at `<sm`, releasing back to the existing wrap behaviour from `sm:+` upwards** |
| Estimated effort | **S** (two CSS-only diffs, one snapshot test, one Playwright spec) |
| Files touched | **3** (`src/app/page.tsx`, `src/components/charts/trend-card.tsx`, one new test file) |
| iOS impact | **None** (web-only chrome) |
| Risk | **Low** — additive CSS-only edits, no widget-API change, no copy change |
| Maintainer-blockers | None — the change is self-contained and lands inside a single fix-surface |
@@ -0,0 +1,316 @@
---
file: .planning/research/v15-r-d-settings-drag-list.md
purpose: Settings → Dashboard drag-list mobile compactness research and contract proposal
created: 2026-05-16
contributor: R-D
---
# Settings → Dashboard drag-list — mobile compactness
The maintainer flagged the per-widget rows on `/settings/dashboard` as
"too tall" on mobile and disharmonic against the rest of the page. The
audit confirms that: each row is roughly **3× taller than every other
row on the Settings stack** because the row chrome stacks two 44-px
icon buttons vertically. This document proposes a single-row, ~48-px
contract that keeps every existing behaviour (visibility toggle, tile
toggle, manual reorder) and every existing tap-target requirement.
The work is read-only research — no code shipped here.
## 1 — Current shape
`src/components/settings/dashboard-layout-section.tsx` renders each
widget as:
```tsx
<div className="border-border bg-background/30 flex items-center gap-3
rounded-md border p-3">
<div className="flex flex-col gap-1">
<Button size="icon" className="size-11" …><ArrowUp/></Button>
<Button size="icon" className="size-11" …><ArrowDown/></Button>
</div>
<span className="flex-1 text-sm">{label}</span>
<div className="flex w-12 justify-center"><Switch …tileVisible/></div>
<div className="flex w-12 justify-center"><Switch …visible/></div>
</div>
```
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
<div className="border-border bg-background/30
flex items-center gap-2 rounded-md border
px-3 py-2 min-h-12">
<span className="flex-1 text-sm truncate">{label}</span>
<div className="flex w-12 justify-center"><Switch …tileVisible/></div>
<div className="flex w-12 justify-center"><Switch …visible/></div>
<Button size="icon-sm" variant="ghost" …><ArrowUp/></Button>
<Button size="icon-sm" variant="ghost" …><ArrowDown/></Button>
</div>
```
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 `<table>` 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
`<details>` "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
<div
key={widget.id}
- className="border-border bg-background/30 flex items-center gap-3
- rounded-md border p-3"
+ className="border-border bg-background/30 flex items-center gap-2
+ rounded-md border px-3 py-2 min-h-12"
>
- <div className="flex flex-col gap-1">
- <Button size="icon" className="size-11" …>
- <ArrowUp className="h-4 w-4" />
- </Button>
- <Button size="icon" className="size-11" …>
- <ArrowDown className="h-4 w-4" />
- </Button>
- </div>
- <span className="flex-1 text-sm">{t(labelKey)}</span>
+ <span className="flex-1 truncate text-sm" title={t(labelKey)}>
+ {t(labelKey)}
+ </span>
<div className="flex w-12 justify-center"><Switch …tileVisible/></div>
<div className="flex w-12 justify-center"><Switch …visible/></div>
+ <Button
+ type="button" variant="ghost"
+ className="size-11 sm:size-9"
+ onClick={() => move(widget.id, -1)}
+ disabled={index === 0 || saveMutation.isPending}
+ aria-label={t("dashboard.moveUp")}
+ >
+ <ArrowUp className="h-4 w-4" />
+ </Button>
+ <Button
+ type="button" variant="ghost"
+ className="size-11 sm:size-9"
+ onClick={() => move(widget.id, 1)}
+ disabled={index === arr.length - 1 || saveMutation.isPending}
+ aria-label={t("dashboard.moveDown")}
+ >
+ <ArrowDown className="h-4 w-4" />
+ </Button>
```
Also bump the column-header alignment spacer so the new arrow slot
doesn't break the Tile / Chart column meters:
```diff
- <span className="w-11" aria-hidden="true" />
+ <span className="flex-1" aria-hidden="true" />
<span className="flex-1" />
<span className="w-12 text-center">{t("dashboard.layoutTileColumn")}</span>
<span className="w-12 text-center">{t("dashboard.layoutChartColumn")}</span>
+ <span className="w-22 sm:w-18" aria-hidden="true" />
```
(The right-hand spacer reserves the width of the two arrow buttons so
the column headers continue to line up with the switches below; the
`sm:w-18` matches the smaller-button width on desktop.)
## 8 — Tests to add
- Snapshot the new row rendered shape and assert `min-h-12` is
present on each row container.
- Assert that on `max-sm` (jsdom default) the two arrow buttons
expose `size-11` (the 44-px mobile tap target). No existing test
reads classNames — they read `data-slot` attributes — so the
existing `dashboard-layout-section.test.tsx` survives unchanged.
- Visual smoke spec in Playwright at 390-px viewport: list ≤ 850 px
tall with 7 default widgets visible.
## 9 — Estimate and risk
- **Effort: S.** One file, ~30 lines net, no schema change, no API
change, no i18n change (the two existing `dashboard.moveUp` /
`dashboard.moveDown` labels carry forward verbatim).
- **iOS impact: none.** This is a web-Settings-only refactor. The
underlying `DashboardLayout` schema and the `/api/dashboard/widgets`
contract stay byte-for-byte identical, so the iOS app reads the
same JSON shape.
- **Risk: low.** The column-header spacer width is the one spot that
needs an eyeball check at three viewports (375, 414, 768 px).
Existing E2E selectors all target `data-slot="widget-tile-switch"`
/ `data-slot="widget-chart-switch"` / `aria-label="Move up"`,
none of which change.
## 10 — Out of scope
- True drag-and-drop reorder via `@dnd-kit/*`. Would force a new
dependency, a long-press affordance contract, and a SwiftUI
parity story. Park for a later release.
- Collapsing tile/chart switches into a single primary toggle plus a
disclosure. The maintainer asked for the two-toggle surface
explicitly; the compaction here serves the same intent.
- Re-skin of the comparison-baseline picker above the list. Outside
R-D's brief.
---
**4-line summary**
- Current row height ≈ 116 px (vertical 44+44 arrow stack + `p-3`);
target 48 px via `min-h-12`, `px-3 py-2`, single horizontal arrow pair.
- Classes to change: row container `p-3 gap-3` → `px-3 py-2 gap-2
min-h-12`; arrow buttons un-stacked, `size-11` on mobile and
`sm:size-9` on desktop; label gains `truncate`; column-header spacer
widens.
- Estimated effort: S (one file, ~30 lines, no schema/API/i18n
changes, no new dependency). iOS impact: none.
- A11y verification: 44-px mobile tap target preserved on both arrow
buttons via `size-11`; `aria-label`s unchanged; tab order linear;
no `<table>` semantics introduced; existing `data-slot`-based tests
stay green.
@@ -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:<text>"]` 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.
@@ -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.
+341
View File
@@ -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 `<HealthChart>` 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 `<sm`, `auto` from `sm:` upwards. Add `line-clamp-1 min-h-[18px]` on the comparison-delta callout. Switch sub-row pair to `flex-nowrap overflow-hidden` at `<sm`, releasing back to `flex-wrap` from `sm:` upwards. Files touched: `src/app/page.tsx`, `src/components/charts/trend-card.tsx`, one new snapshot test. | S | Parallel |
| Settings drag-list compactness | R-D | Replace the vertical 44+44 px arrow stack with a horizontal pair of 32 px buttons on desktop / 44 px buttons on mobile (`size-11 sm:size-9`). Row drops 116 px → 48 px. Add `truncate` on the label. One file (`src/components/settings/dashboard-layout-section.tsx`), ~30 LOC net. | S | Parallel |
| H2 | R-B High | Bound the `glucoseRows.findMany` to the last 30 days for the dashboard tile path. 1 LOC. | XS | Parallel |
| M1 | R-B High (cheap) | Bound the BP-in-target chunked walk to the last 365 days. 1 LOC. | XS | Parallel |
| M5 | R-B Medium (cheap) | Add `staleTime: 60_000` to the three inline dashboard `useQuery` blocks plus `refetchOnWindowFocus: false`. 3 × 5 LOC. | XS | Parallel |
**Total effort**: 1-2 days. All web-only. The C2 → AVG/SUM → C3
sequence is the only ordering constraint; everything else is
independently mergeable.
**iOS impact**: zero breaking, additive in spirit. The AVG/SUM fix
changes how aggregated cumulative rows present on the web reading
path; the iOS app calls a separate native path
(`HKStatisticsCollectionQuery` per R-A Option A) and does not depend
on the server's `aggregate=daily` SQL. The Prisma schema is
unchanged.
**Explicitly out of v1.4.29**: R-B's C1 (the `/api/analytics`
architectural rewrite — split into dashboard + insights surfaces,
move aggregation to SQL, rewrite `summarize()` for single-pass). 2-3
days plus integration tests. Lands during the v1.5 server-prep
window if calendar permits, otherwise rides to v1.5.x.
### v1.4.30 — Final web polish (skip recommendation)
Recommend **skip**. The R-A Option A web-side preparation folds into
v1.5's Day 1-2 server prep. The v1.4.29 deferrals in
`v1429-backlog.md` are design Mediums, UI-conformity Mediums, i18n
drift, and `<ResponsiveSheet>` 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:<text>"]` 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:<typeIdentifier>:<YYYY-MM-DD>"`. | 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:<typeIdentifier>:<YYYY-MM-DD>"`, 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 `<sm`, `auto` from `sm:` upwards | R-C §6 |
| Comparison-delta callout policy at `<sm` | `line-clamp-1 min-h-[18px]` | R-C §6.2 |
| Sub-row wrap policy at `<sm` | `flex-nowrap overflow-hidden` (clip, not wrap), releases to `flex-wrap` from `sm:` upwards | R-C §6.3 |
| Settings drag-list row height target | 48 px (`min-h-12`), down from 116 px | R-D §4 |
| Settings drag-list button shape | Horizontal pair, `size-11` on mobile (preserves 44-px tap target) + `sm:size-9` on desktop | R-D §5 |
| HealthKit coverage scope for v1.5 | Tier 1 (18 existing + Workouts E2E + HKStateOfMind read + hearing chips + walking-steadiness) | R-F §3 |
| HealthKit coverage scope for v1.5.x | Tier 2 (running/cycling power, effort score, sleep apnea, mindful sessions, 6-min walk, HR recovery) | R-F §3 |
| HealthKit coverage scope deferred to v1.6 | Tier 3 (FHIR, ECG, AFib, PHQ-9/GAD-7, reproductive, nutrition, Watch app) | R-F §3 |
| iOS HK reads pairing rule | Every read paired with a visible surface in the same track — no authorised-but-invisible | R-F §2.5 + open Q #3 |
| Categorisation shape | UI-side TypeScript overlay `src/lib/measurements/categories.ts`, NOT a DB column | R-F §4 |
| Sleep storage shape | Per-stage rows (5-axis unique key) — no split into `SLEEP_DEEP` / `SLEEP_REM` / etc. enum entries | R-F open Q #6 |
| Web freeze trigger | v1.4.29 tag on `main` | This plan §2 |
| v1.4.30 ship-or-skip | Skip; tag v1.5 directly after the sprint | This plan §2 |
| AVG/SUM cumulative-type fix | Rides v1.4.29 | R-A §6 + R-B C2 sequence |
| C2 P0 sequencing | Lands before any client wires `aggregate=daily` | R-B Critical |
| iOS workout dedup ladder | Ship `pickCanonicalWorkoutRows()` with the existing measurement ladder (Apple ≻ Withings ≻ Manual); tune metric-aware (route → Apple wins; HR zones → Withings wins) in a v1.5.x follow-up | R-F open Q #4 |
| HKStateOfMind round-trip filter | Bake the `HKMetadataKeyExternalUUID` write-back filter into the read path from day one — same pattern as the existing quantity-sample filter | R-F open Q #5 |
| iOS workout reader scope | Read-only ingest for v1.5; the iOS Workout UI defers to v1.6 (R-E H-2 close-out) | R-E H-2 |
| iOS write-back to HealthKit | Read-only on every type except weight, BP, glucose, mood/state-of-mind (existing `defaultWriteTypes`) | R-F §2.3 |
All schema additions in §3 are flagged **additive**. No breaking
changes. Nothing in this plan flags as breaking.
---
## 6. Open questions for the maintainer
1. **One pass through this plan for sharpening.** Anywhere the
sequencing reads wrong, anywhere the deferral feels too generous,
anywhere the iOS-day estimate looks aspirational. The plan is
built to fit a 10-day sprint and the calendar assumes clean
parallel dispatch on Day 3-5; if either assumption needs softening
the calendar shifts by 1-3 days.
2. **Whether to ship v1.4.30 at all.** Default recommendation is
skip — fold the R-A Option A server prep into v1.5's Day 1-2.
Counterargument: a quiet v1.4.30 with the R-A web-side helpers +
the C1 `/api/analytics` rewrite would let the iOS sprint start
with a clean web baseline. The tradeoff is one week of additional
calendar before the iOS sprint clock starts versus a smaller
v1.5 server-prep day.
3. **Whether to drop unused HK reads instead of adding cards.** R-F
§2.5 calls out the App Store reviewer risk for authorised-but-
invisible types. The proposed plan (R-F T1.3 chart-card breadth)
is the higher-perceived-value path; the alternative (trim
`defaultReadTypes` to the visibly-used subset) is the lower-risk
path. If the calendar tightens, the trim is the fallback.
4. **Whether to defer C-4 RefreshScheduler to v1.5.1.** R-E flags
this as Critical (midnight-401 bug). The mitigation (the existing
401-bridge in `APIClient.swift:135-158`) is reactive but works.
If the Day 6 schedule slips, deferring the proactive scheduler
to v1.5.1 is survivable.
5. **Whether the Apple-Health-XML import (`export.zip`) belongs in
v1.5.** R-F §2.6 and open Q #7. Out of the original brief, but the
highest-leverage non-iOS-app deliverable in the broader research.
A web-only user can upload years of HK history before the iOS app
ships on their device. ~200 LOC server-side, reuses the existing
batch endpoint. Lift into v1.5 if the calendar permits; defer to
v1.6 if not.
---
## Word count
~3 050 words (target was 2 500-4 000).