From 1a2447295d1a8682f127272eeac9489fae3957da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 11:48:08 +0200 Subject: [PATCH] =?UTF-8?q?docs(planning):=20iOS=20contributor=20brief=20?= =?UTF-8?q?=E2=80=94=20current=20state=20+=20full=20v1.4.x=20=E2=86=92=20v?= =?UTF-8?q?1.5.0=20forward=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single onboarding doc the iOS contributor (or their automation) reads once and then references continuously. Covers: the release model (v1.5.0 is version-bump-only marker), every convention every commit must honour, what's live in production today (v1.4.29.1), what incoming patches deliver through to web freeze, and the locked forward state at v1.5.0 — every endpoint + DTO + Prisma column + new MeasurementType the iOS app can rely on consuming by ship date. --- .planning/ios-contributor-current-brief.md | 605 +++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 .planning/ios-contributor-current-brief.md diff --git a/.planning/ios-contributor-current-brief.md b/.planning/ios-contributor-current-brief.md new file mode 100644 index 000000000..c6b75a916 --- /dev/null +++ b/.planning/ios-contributor-current-brief.md @@ -0,0 +1,605 @@ +--- +file: .planning/ios-contributor-current-brief.md +purpose: Single onboarding brief for the iOS contributor — current server state, conventions, locked contracts, what is live, what is coming in v1.4.30, what is still pending. Pass this verbatim to the iOS development assistant. +created: 2026-05-16 +audience: iOS engineer + their automation +--- + +# HealthLog iOS contributor brief — current state + +This document is the single-page orientation for anyone (engineer or +automation) working on the iOS native client at +`/Users/marc/Projects/healthlog-iOS/`. Read it once; it points at the +locked-contract pack for everything else. + +## 1. The release model in one paragraph + +The web app ships every functional change the iOS client depends on +incrementally in v1.4.x patches. **v1.5.0 is a version-bump-only +marker** the web tags on the day the iOS app clears Apple review. +There is no coordinated web+iOS sprint that ships together. Web and +iOS run in parallel: the web side lands a patch, deploys, and the iOS +client consumes the new surface as soon as its next TestFlight build +is ready. The web freezes after v1.4.33 (last planned patch); from +that point only hotfixes + dependency updates touch the web until iOS +launches. + +## 2. Conventions every commit must honour + +These apply to web AND iOS contributors, every artefact, every PR: + +### Marc-Voice English + +Every commit message, code comment, CHANGELOG line, in-app string, +release note, planning doc reads as the maintainer's authorship. +Terse, professional, no emojis, no marketing fluff, no personal +pronouns ("I" / "we" / "our"). When in doubt, drop one adjective and +re-read. + +### Forbidden vocabulary + +Banned anywhere except backticked file paths and identifiers: + +- `AI`, `Claude`, `agent`, `marathon`, `wave`, `phase`, `session`, + `subagent`, `Anthropic` + +Substitute: `assistant`, `coach`, `automation`, `contributor`, +`round`, `pass`, `track`, `stream`. + +Two documented exemptions live in the locale bundles only — the +provider-chooser dropdown values `settings.ai.providerOptions.anthropic` +and `settings.ai.activeProviderOptions.anthropic` render +`Anthropic (Claude)` across all six locales because the operator is +selecting the literal vendor product. + +### No PII in user-facing artefacts + +Maintainer name, real health figures, real target ranges, real +measurement counts do not appear in commit messages, CHANGELOG, +release notes, in-app copy, locale bundles, public docs, GitHub +releases. Use placeholders ("the maintainer", "a moderate delta", +"tens of thousands of rows") if you need to reference shape. + +### iOS contract is additive only + +Every server-side change must be additive to the iOS client. No +renames, no drops, no type changes on endpoints + DTO fields + Prisma +columns the iOS client reads. New columns + new endpoints are +welcome. The locked-contract reference is at +`.planning/v15-ios-handoff/08-locked-contracts.md` — read it before +touching any `/api/*` route or any Prisma model the iOS client +consumes. + +### Branch + release discipline + +- Commit to `develop`. Never `main` directly. +- Release via PR `develop → main`, squash on `main`, tag on `main`. +- GHCR multi-arch builds publish from `main`. The `develop`-to-`main` + squash + tag pipeline is automated end-to-end except for the APNs + `.p8` paste (operator action — see §6). +- No `Co-Authored-By: Claude` trailer. +- No `--no-verify`. No `--no-gpg-sign`. + +## 3. What is live in production right now + +Both production hosts (`healthlog.bombeck.io` and +`demo.healthlog.dev`) currently serve **v1.4.29.1**. + +### Recent releases + +| Tag | Headline | Notes | +|---|---|---| +| v1.4.27 | Mobile capability sweep — ``, ``, `` | The mobile baseline | +| v1.4.28 | Bug-fix + scope-reduction follow-through | Retired the GLP-1 dashboard tile, the InsightAdvisorCard, the weekly-report code path. Locked-contract endpoints kept | +| v1.4.28.1 | Dashboard-save hotfix | Resolver filters retired widget ids from the saved layout on read | +| v1.4.29 | Dashboard performance + chart polish | aggregate=daily server path, AVG/SUM for cumulative HK types, pulse chart bounds, x-axis tick positions, mobile tile equal-height, drag-list compactness | +| v1.4.29.1 | Daily-step aggregation hotfix | Client-side daily aggregator branches on `CUMULATIVE_HK_TYPES` for sum vs average | + +### Endpoints the iOS client may consume today (additive contracts) + +Stable since their respective release tags. Every one is locked per +`.planning/v15-ios-handoff/03-api-contracts.md`. + +- `POST /api/auth/login` + `POST /api/auth/login/native-token` — bearer auth flow +- `POST /api/auth/passkey/*` — passkey enrollment + login + native-token verify +- `POST /api/auth/refresh` — bearer refresh +- `GET /api/auth/me` + `PATCH /api/auth/me/*` (timezone, doctor-report-prefs, devices, source-priority, research-mode) +- `GET /api/measurements` + `POST /api/measurements` + `PATCH /api/measurements/{id}` (additive 409 on duplicate-timestamp since v1.4.28; new `from`/`to`/`aggregate=daily|weekly|monthly` query params since v1.4.28, server-side bug closed in v1.4.29) +- `GET /api/medications/[id]/intake` — read-only (FB-E1 web mount retired in v1.4.28, endpoint preserved) +- `GET /api/medications/[id]/glp1` — full DTO including `Glp1InventoryDTO` slot (FB-E2 web mount retired in v1.4.28, DTO preserved) +- `POST /api/insights/generate` — assistant regen path (FB-J2 advisor card retired in v1.4.28, endpoint preserved for iOS) +- `GET /api/insights/chat` (SSE) — Coach stream +- `GET /api/insights/cards`, `GET /api/insights/correlations`, `GET /api/insights/comprehensive`, `GET /api/insights/targets` +- `POST /api/workouts/batch` — batch ingest from HKWorkout (since v1.4.25 W8d; canonical-row picker added in v1.4.30 — see §4) +- `GET /api/version` — live build + `offlineGeoEnabled` flag +- 47 distinct paths total per the R-E iOS audit (`/Users/marc/Projects/HealthLog/.planning/research/v1428-r1-ios-contracts.md` enumerates them; mostly admin + monitoring routes not iOS-relevant) + +### Server-side cumulative-type aggregation already in place + +Since v1.4.29 the server's `GET /api/measurements?aggregate=daily|weekly|monthly` +path reduces with `SUM` for cumulative HealthKit types — see the +`CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`: + +- `ACTIVITY_STEPS` +- `ACTIVE_ENERGY_BURNED` +- `FLIGHTS_CLIMBED` +- `WALKING_RUNNING_DISTANCE` +- `TIME_IN_DAYLIGHT` + +Every other measurement type reduces with `AVG`. The client-side +daily aggregator in `health-chart.tsx` follows the same branch since +v1.4.29.1. + +## 4. What is incoming in v1.4.30 (currently being implemented) + +v1.4.30 is the iOS-coordinated foundation patch. Ships in 2-3 days. +Every change additive. Track the canonical scope at +`.planning/v15-strategic-plan.md` §2 "v1.4.30 — iOS-coordinated +foundation". + +### Daily-Stats externalId — the cutover for cumulative HK types + +The biggest single iOS-side coordination point. v1.4.30 ships: + +- A new helper `dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` + returning `"stats::"` — locked + in `.planning/v15-ios-handoff/08-locked-contracts.md`. The shape is + the iOS-side contract for one-row-per-day-per-cumulative-type ingest. + +- The server tolerates BOTH ingest shapes during the cutover: existing + per-sample rows AND new daily-aggregated rows. The existing + `@@unique([userId, type, measuredAt, source, sleepStage])` constraint + keeps both shapes deduplicated. + +- A drain script `scripts/drain-per-sample-cumulative.ts` (CLI + admin + endpoint `POST /api/admin/drain-per-sample-cumulative`) idempotently + collapses existing per-sample APPLE_HEALTH cumulative rows into one + daily row per type per user-calendar-day. Operator runs it once + after the iOS TestFlight build cuts over. + +### iOS-side responsibility (please implement in the next TestFlight build) + +Build a `HealthKitStatisticsService.swift` per R-A §5: + +- For every `CUMULATIVE_HK_TYPES` member, run + `HKStatisticsCollectionQuery` with + `intervalComponents: DateComponents(day: 1)` and + `options: .cumulativeSum` (for steps / energy / flights / distance / + daylight). Period: from each user's `firstSampleAt` or sensible + bound (last 365 days for first install, anchored for incremental + syncs). +- For each daily bucket, POST one row via the existing + `POST /api/measurements` (or the batch endpoint) with: + - `type` = mapped MeasurementType + - `source` = `APPLE_HEALTH` + - `value` = day total + - `measuredAt` = midday UTC of the user's calendar day (matches the + Withings activity-sync convention) + - `externalId` = `"stats::"` +- Keep a per-day last-posted-value cache. On a late-watch-sync where + the day's total changes after the row was first posted, send a + `PATCH /api/measurements/{id}` (or re-POST with the same + `externalId` to trigger an UPSERT) so the server row tracks the + true total. +- Gate behind a build-flag `ENABLE_DAILY_STATS` — default ON for the + cut-over TestFlight build. This stops sending per-sample rows for + cumulative types simultaneously. + +Spot metrics (BP, weight, pulse, mood, glucose, body fat, sleep) +stay per-sample as today. + +### SyncMode foundation + +For the standalone-first / paired-with-server / cloud-sync trio (see +`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md`). +v1.4.30 ships: + +- New columns: `Measurement.syncVersion Int @default(1)` + + `Measurement.deletedAt DateTime?` (soft-delete) +- New columns: `User.lastSyncedAt DateTime?` +- New endpoint `GET /api/sync/state` — returns per-user current + `syncVersion` and `lastSyncedAt` so iOS can decide whether to pull + or push. +- New endpoints `POST /api/mood-entries/bulk` and + `POST /api/medications/intake/bulk` — bulk-backfill paths for the + pair-fresh-server flow. + +### MoodEntry.note as a first-class column + +Replaces the `tags: ["note:"]` workaround. The iOS app currently +encodes mood notes as a tag prefix; in v1.4.30+ the iOS app should +send `note: string` directly on the mood-entry payload. Server-side +backfill collapses the prior `note:`-tag rows into the new column. + +### Workout canonical-row picker + +`pickCanonicalWorkoutRows()` lands in `src/lib/measurements/` to +dedup Apple Watch + Withings ScanWatch workouts that report the same +session. iOS-side workout ingest stays unchanged — the server picks +the canonical row. + +### Categorisation overlay + +`src/lib/measurements/categories.ts` is a TypeScript map of +MeasurementType → category (vitals / body / activity / sleep / +hearing / environment / cardiovascular / metabolic / mood / +medication). iOS HealthKit permission picker should derive its groups +from this map; the web Insights nav will too. iOS-side: read the map +on app launch (or build-time-codegen it from the server's OpenAPI +schema). + +### Two new MeasurementType enums + +- `WALKING_STEADINESS` (mapped from `HKQuantityTypeIdentifierAppleWalkingSteadiness`) +- `AUDIO_EXPOSURE_EVENT` (mapped from `HKCategoryTypeIdentifierAudioExposureEvent`) + +iOS-side: these will land in the iOS app's `MeasurementType` mirror +enum after the OpenAPI codegen wiring closes (R-E H-8). Until then, +iOS can ignore them or the manual enum-extension matches the server +addition. + +## 5. What is incoming in v1.4.31 - v1.5.0 + +Plan reference: `.planning/v15-strategic-plan.md` §2. Every change +listed below is additive on the iOS contract unless explicitly +flagged otherwise — and nothing is flagged otherwise. By v1.5.0 every +endpoint + DTO + Prisma column listed here is live in production. The +iOS-side implementation can be written against the planned surfaces +in parallel, ready to consume each one as soon as the corresponding +web patch deploys. + +### v1.4.31 — Operator toggles + insights tab-strip blocking + Coolify auto-deploy fix + +**New API surface (iOS can consume from v1.4.31 onwards):** + +- `GET /api/feature-flags` — public endpoint (auth-required but no + admin gate). Response shape: + ```json + { + "data": { + "assistant": { + "enabled": true, + "coach": true, + "briefing": true, + "insightStatus": true, + "correlations": true, + "healthScoreExplainer": true + } + } + } + ``` + iOS calls this on app launch and caches per-session. When any flag + is `false`, the relevant iOS surface should hide (Coach tab, daily + briefing card, per-metric insight status, correlation card, + delta-explainer `?`). + +**Endpoints that may now return 403 with `errorCode: "assistant.disabled."`:** + +| Endpoint | Flag | +|---|---| +| `GET /api/insights/chat` (SSE) | `coach` | +| `POST /api/insights/generate` | `coach` (regenerate is part of the Coach feature set) | +| `GET /api/insights/comprehensive` | `coach` | +| `GET /api/insights/briefing` (if exists; per v1.5 research) | `briefing` | +| `GET /api/insights/cards` per-metric status | `insightStatus` | +| `GET /api/insights/correlations` | `correlations` | +| (No endpoint for healthScoreExplainer — it's a pure client-side overlay) | `healthScoreExplainer` | + +**iOS handling**: catch 403 + `errorCode: "assistant.disabled."`, +render a neutral "Coach is disabled by your operator" / "Briefing is +disabled by your operator" view in the affected slot. No retry, no +backoff — wait for the user to navigate elsewhere. + +**Other v1.4.31 items (not iOS-facing):** +- Insights tab-strip blocking fixes (web client only) +- Coolify auto-deploy investigation (ops only) + +### v1.4.32 — HealthKit Tier 1 web surfaces wave A + +The web surfaces for Workouts + 5 of the 10 invisible-but-stored +metrics land here. **No new endpoints**; iOS continues consuming the +existing endpoints. The contracts iOS plans against: + +- `POST /api/workouts/batch` (live since v1.4.25 W8d) gains the + server-side `pickCanonicalWorkoutRows()` dedup (landed in v1.4.30). + iOS keeps its existing batch shape. +- `GET /api/workouts` (if not already live — check the route file in + v1.4.30 verification) — list workouts. iOS can mirror the new web + workout-list page. +- `GET /api/workouts/{id}` — workout detail with cross-source merged + payload, route GPS optional, HR-zone samples optional. iOS-side + workout detail screen consumes this shape. +- `GET /api/measurements?type=HRV` (and same for RestingHR, SpO2, + BodyTemperature, ActiveEnergyBurned) — all already live since the + type was added to the enum. iOS-side chart cards consume the same + `/api/measurements` path it uses for every other type. + +### v1.4.33 — HealthKit Tier 1 web surfaces wave B + web freeze marker + +The breadth wave + the editor parity surfaces. iOS can consume: + +**Existing endpoints with new MeasurementType values flowing through them:** + +- `GET /api/measurements?type=FLIGHTS_CLIMBED` — daily-aggregated + cumulative-type shape per R-A. +- Same for `WALKING_RUNNING_DISTANCE`, `AUDIO_EXPOSURE_ENV`, + `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT`. +- `GET /api/measurements?type=WALKING_STEADINESS` — new MeasurementType + added in v1.4.30; iOS picks this up via the v1.4.30 codegen extension. +- `GET /api/measurements?type=AUDIO_EXPOSURE_EVENT` — same. + +**Source-priority editor parity:** + +- `GET /api/auth/me/source-priority` and `PUT /api/auth/me/source-priority` + — both live and locked since v1.4.25 W8c. iOS implementation of the + two-axis editor under Settings → Sources & Devices can be built + against the existing API; the web-side editor that lands in v1.4.33 + is a reference shape iOS can mirror. + +**HKStateOfMind round-trip:** + +- `POST /api/measurements` with `type=MOOD` and `source=APPLE_HEALTH` + — already live since the MOOD type exists. v1.4.33 wires + `/insights/stimmung` to surface APPLE_HEALTH-sourced mood entries + cleanly. iOS-side: write HKStateOfMind samples via the existing + POST path; read back via `GET /api/measurements?type=MOOD`. The + `HKMetadataKeyExternalUUID` round-trip filter (per R-F open Q #5) + prevents iOS from re-uploading its own samples. + +### v1.5.0 — version-bump-only marker + +Tags when the iOS app clears Apple review. CHANGELOG entry: "iOS +native client now live on the App Store; web functionality unchanged +since v1.4.33." Triggers a GHCR rebuild so the running image's +`/api/version` sentinel matches the public tag. **No source diff, +no API change, no DTO change.** From this point every web change +coordinates with iOS. + +## 5b. Forward state at v1.5.0 — what the iOS app can rely on + +By the time v1.5.0 ships, every iOS-facing surface listed below is +live and locked. The iOS engineer can plan the full v1.5 feature +set against this state — there are no further additive contracts +planned for v1.4.x. + +### Endpoints (locked + additive only) + +| Path | Method | Status by v1.5.0 | +|---|---|---| +| `/api/auth/login` | POST | Stable since v1.4.x | +| `/api/auth/login/native-token` | POST | Stable since v1.4.23 | +| `/api/auth/passkey/enrolment/start` | POST | Stable | +| `/api/auth/passkey/enrolment/finish` | POST | Stable | +| `/api/auth/passkey/login-start` | POST | Stable | +| `/api/auth/passkey/login-verify` | POST | Stable | +| `/api/auth/refresh` | POST | Stable | +| `/api/auth/me` | GET, PATCH | Stable | +| `/api/auth/me/timezone` | PATCH | Stable | +| `/api/auth/me/source-priority` | GET, PUT | Locked v1.4.25 W8c | +| `/api/auth/me/research-mode` | GET, PATCH | Stable | +| `/api/auth/me/doctor-report-prefs` | GET, PATCH | Stable | +| `/api/auth/me/devices` | GET | Stable | +| `/api/auth/me/devices/{id}` | PATCH, DELETE | Stable | +| `/api/measurements` | GET, POST | Stable; `from`/`to`/`aggregate` query params live since v1.4.29 | +| `/api/measurements/{id}` | PATCH | Stable; 409 on duplicate-timestamp since v1.4.28 | +| `/api/measurements/by-external-ids` | DELETE | Stable; used by drain script + iOS sync | +| `/api/mood-entries` | GET, POST | Stable; `note` column lands in v1.4.30 | +| `/api/mood-entries/bulk` | POST | NEW in v1.4.30 | +| `/api/medications` | GET, POST | Stable | +| `/api/medications/{id}` | GET, PATCH, DELETE | Stable | +| `/api/medications/{id}/intake` | GET, POST | Stable; preserved per FB-E1 | +| `/api/medications/{id}/intake/{logId}` | PATCH, DELETE | Stable | +| `/api/medications/intake/bulk` | POST | NEW in v1.4.30 | +| `/api/medications/{id}/glp1` | GET | Stable; Glp1InventoryDTO preserved per FB-E2 | +| `/api/medications/{id}/inventory` | GET, POST | Stable | +| `/api/medications/{id}/side-effects` | GET, POST | Stable | +| `/api/medications/{id}/side-effects/{logId}` | PATCH, DELETE | Stable | +| `/api/medications/{id}/titration` | GET, PATCH | Stable | +| `/api/medications/{id}/cadence` | GET, PATCH | Stable | +| `/api/workouts` | GET | Verified in v1.4.32 | +| `/api/workouts/{id}` | GET | Verified in v1.4.32 | +| `/api/workouts/batch` | POST | Stable since v1.4.25 W8d; canonical-row picker server-side in v1.4.30 | +| `/api/personal-records` | GET | Stable | +| `/api/insights/chat` | GET (SSE) | Stable; 403 + assistant.disabled.coach possible from v1.4.31 | +| `/api/insights/generate` | POST | Stable; preserved per FB-J2; 403 from v1.4.31 | +| `/api/insights/comprehensive` | GET | Stable; 403 from v1.4.31 | +| `/api/insights/cards` | GET | Stable; 403 from v1.4.31 | +| `/api/insights/correlations` | GET | Stable; 403 from v1.4.31 | +| `/api/insights/targets` | GET | Stable | +| `/api/insights/briefing` | GET | Verified in v1.4.31; 403 from v1.4.31 | +| `/api/sync/state` | GET | NEW in v1.4.30 | +| `/api/feature-flags` | GET | NEW in v1.4.31 | +| `/api/dashboard/widgets` | GET, PUT, DELETE | Stable | +| `/api/dashboard/summary` | GET | Stable | +| `/api/dashboard/chart-overlay-prefs` | GET, PATCH | Stable | +| `/api/admin/drain-per-sample-cumulative` | POST | NEW in v1.4.30 (admin-gated) | +| `/api/version` | GET | Stable; `offlineGeoEnabled` flag + version sentinel | + +### Prisma schema additions through v1.5.0 + +All additive. No iOS-breaking drops + no type changes. + +| Column / enum | Patch | Notes | +|---|---|---| +| `Measurement.syncVersion Int @default(1)` | v1.4.30 | SyncMode foundation | +| `Measurement.deletedAt DateTime?` | v1.4.30 | Soft-delete column | +| `Measurement.externalId String?` (verify if already exists) | pre-existing | UPSERT key for daily-stats rows | +| `User.lastSyncedAt DateTime?` | v1.4.30 | SyncMode foundation | +| `MoodEntry.note TEXT NULL` | v1.4.30 | Replaces `tags: ["note:..."]` workaround | +| `AppSettings.assistantEnabled Boolean @default(true)` | v1.4.31 | Master feature flag | +| `AppSettings.coachEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.briefingEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.insightStatusEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.correlationsEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.healthScoreExplainerEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `enum MeasurementType { … WALKING_STEADINESS }` | v1.4.30 | New value, iOS picks up via codegen | +| `enum MeasurementType { … AUDIO_EXPOSURE_EVENT }` | v1.4.30 | New value | + +### MeasurementType + categorisation overlay + +By v1.5.0, the categorisation overlay at +`src/lib/measurements/categories.ts` is locked. iOS reads the map at +build-time via OpenAPI codegen (or mirrors it manually): + +| Category | MeasurementType values | +|---|---| +| `vitals` | `BP_SYSTOLIC`, `BP_DIASTOLIC`, `PULSE`, `OXYGEN_SATURATION`, `BODY_TEMPERATURE` | +| `body` | `WEIGHT`, `BODY_FAT`, `TOTAL_BODY_WATER`, `BONE_MASS` | +| `activity` | `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `WALKING_STEADINESS` | +| `cardiovascular` | `HRV`, `RESTING_HR`, `VO2_MAX` | +| `sleep` | `SLEEP_*` (per-stage rows) | +| `hearing` | `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `AUDIO_EXPOSURE_EVENT` | +| `environment` | `TIME_IN_DAYLIGHT` | +| `metabolic` | `BLOOD_GLUCOSE`, `BMI` | +| `mood` | `MOOD` | +| `medication` | (medications use a separate model — not a MeasurementType) | + +The iOS HealthKit permission picker derives its groups from this +map. + +### Daily-stats cumulative-type ingest (R-A Option A) + +By v1.5.0 the iOS TestFlight build is posting one row per day per +cumulative type. The `CUMULATIVE_HK_TYPES` set is: + +- `ACTIVITY_STEPS` ↔ `HKQuantityTypeIdentifierStepCount` +- `ACTIVE_ENERGY_BURNED` ↔ `HKQuantityTypeIdentifierActiveEnergyBurned` +- `FLIGHTS_CLIMBED` ↔ `HKQuantityTypeIdentifierFlightsClimbed` +- `WALKING_RUNNING_DISTANCE` ↔ `HKQuantityTypeIdentifierDistanceWalkingRunning` +- `TIME_IN_DAYLIGHT` ↔ `HKQuantityTypeIdentifierTimeInDaylight` + +`externalId` shape: `"stats::"` +locked in `.planning/v15-ios-handoff/08-locked-contracts.md`. + +### SyncMode trio (standalone-first / paired-with-server / cloud-sync) + +By v1.5.0 the iOS app supports all three modes. The web-side +endpoints are stable: + +- `GET /api/sync/state` returns current server `syncVersion` + + `lastSyncedAt` +- `POST /api/mood-entries/bulk` + `POST /api/medications/intake/bulk` + let iOS push a backlog after pairing +- `Measurement.deletedAt` makes tombstoned-row sync straightforward + +Mode-switching is iOS-local; the server has no concept of "this user +is in standalone mode". The server just answers whatever the iOS app +asks. + +### Coach SSE — the v1.5 differentiator + +By v1.5.0 the iOS Coach drawer is the native implementation of +`GET /api/insights/chat`. Reference web implementation in +`src/components/insights/coach-panel/`. Contract specifics: + +- Server-Sent Events stream +- Token-by-token streaming text +- Provenance events embedded with each token chunk +- Refusal events per GROUND-RULE-9 / 15 +- 429 `coach.budget.exceeded` on rate-limited paths +- 403 `assistant.disabled.coach` if the operator-side flag is off + +### Push notifications (post-APNs `.p8` paste) + +The APNs scaffolding exists server-side. After the operator pastes +the `.p8` key, server-issued push notifications start. The iOS-side +handler should already be wired per the v1.4.23 work — verify in the +iOS repo. + +### What is explicitly NOT in v1.5 + +These defer to v1.5.x or v1.6: + +- HealthKit Tier 2 categories (R-F T2): workout power metrics, + workout-effort score, sleep apnea breathing disturbances, mindful + sessions, six-minute walk, HR recovery +- HealthKit Tier 3 (R-F T3): FHIR clinical, ECG waveforms, atrial-fib + burden, PHQ-9/GAD-7, reproductive, nutrition, Apple Watch + independent app +- Apple Health XML import (`export.zip` ingest) — open question per + v1.5 plan §6 #3 +- Two-axis source-priority editor on iOS — endpoint exists; UI lands + in v1.5.x +- C1 architectural lift on `/api/analytics` (split into dashboard + + insights surfaces, SQL-side aggregation) — v1.5.x + +## 6. iOS-side blockers and pending coordination + +### APNs `.p8` paste (operator action, 1 hour) + +The Apple Push Notification Service key has not been pasted into the +Coolify environment yet. Per the R-E C-3 finding, this is a 1-hour +operator action that gates server-issued push notifications. The +APNs scaffolding (server-side route + iOS-side handler) is partially +in place from the v1.4.23 work; the missing piece is the operator +pasting the `.p8` into the deployment environment. + +**Status: pending — operator (Marc) has not yet pasted the key.** +This blocks push notifications only; everything else functions +without it. + +### Coach SSE drawer iOS implementation + +Per R-E C-1, the iOS Coach drawer has zero native code today. The +server-side SSE endpoint at `/api/insights/chat` is live and tested. +The v1.5 differentiator is the native Coach drawer. The iOS side is +the implementation: + +- `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 + +Pattern reference: `src/components/insights/coach-panel/` (web +implementation) is the model. The iOS implementation should mirror +the user experience while consuming the same SSE stream. + +### Source-priority editor + +The endpoint `/api/auth/me/source-priority` has been locked since +v1.4.25 W8c. The iOS app does not call it anywhere today. v1.4.33 +adds a web-side editor as a reference shape; iOS should mirror the +two-axis editor under Settings → Sources & Devices. + +## 7. Reference paths + +If the iOS contributor needs deep context: + +| Doc | Purpose | +|---|---| +| `.planning/v15-ios-handoff/03-api-contracts.md` | Every endpoint + DTO shape | +| `.planning/v15-ios-handoff/04-data-model.md` | Prisma schema + invariants | +| `.planning/v15-ios-handoff/06-ios-responsibilities.md` | What iOS owns vs server | +| `.planning/v15-ios-handoff/07-server-responsibilities.md` | The mirror | +| `.planning/v15-ios-handoff/08-locked-contracts.md` | The do-not-touch list | +| `.planning/v15-ios-handoff/14-coach-mental-model.md` | Coach drawer architecture | +| `.planning/v15-ios-handoff/15-insights-architecture.md` | Insights pipeline | +| `.planning/v15-ios-handoff/16-health-score-logic.md` | HealthScore algorithm | +| `.planning/v15-ios-handoff/17-error-handling.md` | Server error contracts | +| `.planning/v15-ios-handoff/18-pattern-cookbook.md` | Common patterns | +| `.planning/v15-ios-handoff/22-offline-first-architecture.md` | Offline-first model | +| `.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` | Pair / unpair flow | +| `.planning/v15-strategic-plan.md` | The full strategic plan | +| `.planning/research/v15-r-a-step-aggregation.md` | R-A daily-stats deep dive | +| `.planning/research/v15-r-e-ios-planning-state.md` | iOS gap audit (28 finds) | +| `.planning/research/v15-r-f-apple-health-depth.md` | HealthKit coverage roadmap | + +## 8. Communication + +The web side ships continuously. The iOS contributor should not block +on web releases — read this brief weekly (the maintainer can refresh +it after every patch), and pick up new server surfaces as soon as +they're documented in the CHANGELOG + `.planning/round-v14NN-closure-report.md`. + +When the iOS-side needs a server change, the request goes through +the maintainer; if the request is additive on the iOS contract the +web ships the change in the next patch. If the request is breaking, +the maintainer either reshapes the request additively or coordinates +a paired release (which is currently out of scope until v1.5.x). + +The next web release is v1.4.30 — wait for the closure report at +`.planning/round-v1430-closure-report.md` before adopting the +daily-stats path.