diff --git a/.planning/ios-contributor-current-brief.md b/.planning/ios-contributor-current-brief.md index c6b75a916..e9d293af5 100644 --- a/.planning/ios-contributor-current-brief.md +++ b/.planning/ios-contributor-current-brief.md @@ -83,7 +83,7 @@ consumes. ## 3. What is live in production right now Both production hosts (`healthlog.bombeck.io` and -`demo.healthlog.dev`) currently serve **v1.4.29.1**. +`demo.healthlog.dev`) currently serve **v1.4.30**. ### Recent releases @@ -94,6 +94,8 @@ Both production hosts (`healthlog.bombeck.io` and | 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 | +| v1.4.30 | iOS-coordinated foundation | Daily-stats externalId lock + SyncMode columns + bulk-backfill endpoints + `MoodEntry.note` first-class column + workouts canonical-row picker + categorisation overlay + two new MeasurementType enums | +| v1.4.30.1 | Categories endpoint + conflict-resolution spec lock | `GET /api/measurement-categories` exposes the overlay over HTTP; `08-locked-contracts.md` §13 locks the SyncMode conflict-resolution policy (LWW by `updatedAt`, server-wins on tie) | ### Endpoints the iOS client may consume today (additive contracts) @@ -111,6 +113,11 @@ Stable since their respective release tags. Every one is locked per - `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/sync/state` — SyncMode handshake (since v1.4.30). Returns `lastSyncedAt`, server clock for skew, live + tombstoned counters; the call also bumps the user-side checkpoint. +- `POST /api/mood-entries/bulk` — bulk mood-entry backfill (since v1.4.30). Up to 500 entries per call; probe-then-upsert distinguishes inserted vs duplicate; rate-limited at 60/min/user. +- `POST /api/medications/intake/bulk` — bulk medication-intake backfill (since v1.4.30). Same envelope shape, idempotency-key collision yields duplicate; rate-limited at 60/min/user. +- `POST /api/admin/drain-per-sample-cumulative` — operator-only drain endpoint (since v1.4.30, `requireAdmin()`). Collapses pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row per day per type. Idempotent; default `dryRun: true`. +- `GET /api/measurement-categories` — HTTP projection of the categorisation overlay (since v1.4.30.1). Returns `{ version, categories[], assignments }` per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. `Cache-Control: public, max-age=600`. iOS reads on cold start to drive the HealthKit permission picker grouping; hard-coded mirror in the iOS app stays as the offline fallback. - `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) @@ -486,11 +493,18 @@ 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 +### Coach SSE — server endpoint stays live; iOS native drawer deferred -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: +Coach SSE remains live as a server endpoint. iOS native server-Coach +drawer is deferred pending MDR Class-IIa pre-review. v1.5.0 ships +iOS with Apple Foundation Models on-device Daily Briefing + Trend +Observations as the primary assistant surface. The server's +`GET /api/insights/chat` SSE endpoint stays live for: PWA users on +non-AFM-capable devices, future iOS reevaluation post-MDR, any other +client that adopts the SSE protocol. + +Contract for any future iOS adoption (or for the PWA + non-iOS +consumers today): - Server-Sent Events stream - Token-by-token streaming text @@ -499,6 +513,8 @@ By v1.5.0 the iOS Coach drawer is the native implementation of - 429 `coach.budget.exceeded` on rate-limited paths - 403 `assistant.disabled.coach` if the operator-side flag is off +Reference web implementation: `src/components/insights/coach-panel/`. + ### Push notifications (post-APNs `.p8` paste) The APNs scaffolding exists server-side. After the operator pastes @@ -538,12 +554,18 @@ pasting the `.p8` into the deployment environment. This blocks push notifications only; everything else functions without it. -### Coach SSE drawer iOS implementation +### Coach SSE drawer iOS implementation — deferred past v1.5 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: +server-side SSE endpoint at `/api/insights/chat` stays live and +tested. v1.5.0 ships iOS with Apple Foundation Models on-device +Daily Briefing + Trend Observations as the primary assistant +surface; a native server-Coach drawer is deferred pending the MDR +Class-IIa pre-review. No server-side coordination needed for the +deferral — the SSE endpoint is already shaped for the PWA + any +future client that re-enters this work. + +If a future iOS revisit lands post-MDR, the contract is: - `CoachService` actor over `URLSession.bytes` - `CoachStreamEvent` AsyncThrowingStream @@ -554,8 +576,7 @@ the implementation: - `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. +implementation) stays the model whenever iOS reopens the work. ### Source-priority editor diff --git a/.planning/round-v1430-closure-report.md b/.planning/round-v1430-closure-report.md new file mode 100644 index 000000000..07e0a82f9 --- /dev/null +++ b/.planning/round-v1430-closure-report.md @@ -0,0 +1,159 @@ +--- +file: .planning/round-v1430-closure-report.md +purpose: v1.4.30 release closure — iOS-coordinated foundation (Daily-Stats + SyncMode) +created: 2026-05-16 +tag: v1.4.30 +--- + +# v1.4.30 — release closure + +Shipped 2026-05-16 mid-day. Server-side prep for the next iOS +TestFlight build. Five surfaces land together so the iOS engineer +can pick them up in one cut-over: a locked `externalId` shape for +daily-aggregated cumulative HealthKit rows, the SyncMode foundation +columns + handshake + bulk-backfill endpoints, a first-class +`MoodEntry.note` column that replaces the legacy tag-based +workaround, a cross-source workout dedup helper, and two new +`MeasurementType` enums (`WALKING_STEADINESS`, `AUDIO_EXPOSURE_EVENT`) +plus the shared `MEASUREMENT_CATEGORIES` overlay that drives the +iOS permission picker and the future Insights nav. + +## Outcome + +- `healthlog.bombeck.io/api/version` → `1.4.30`, `/privacy` → 200. +- `demo.healthlog.dev/api/version` → `1.4.30`, `/privacy` → 200. +- GitHub Release: . +- Sister-repos: `healthlog-docs@dcbc49b`, `healthlog-landing@de620bf`. +- PR #174 squashed on `main` at `3ece95f1`; tag `v1.4.30` points + to annotated tag commit `e2062300`. + +## Commits on develop since v1.4.29.1 + +| Commit | Subject | +|---|---| +| `6e6bc618` | feat(api): lock the daily-stats externalId shape for cumulative HealthKit types | +| `0e92f96e` | feat(scripts): drain per-sample cumulative HealthKit rows into daily-aggregated rows | +| `0ccc00b7` | feat(api): SyncMode foundation — syncVersion column, sync-state endpoint, bulk backfill routes | +| `11325e31` | feat(mood): add a first-class note column replacing the tag-based workaround | +| `57abfbfb` | feat(workouts): pick canonical rows across Apple Watch and Withings sources | +| `479e0aed` | feat(measurements): shared category map for the iOS picker and the web Insights nav | +| `dd487cfd` | feat(measurements): add walking-steadiness and audio-exposure-event types | +| `a3b00f49` | test(db): expand the real-Postgres integration coverage | +| `0522652a` | feat(measurements): wire walking-steadiness + audio-exposure-event through every registry | +| `e1e628da` | chore(release): v1.4.30 | +| `5206ae60` | chore(merge): reconcile main into develop for v1.4.30 release | +| `8d153146` | chore(openapi): regenerate the public spec after the v1.4.30 enum additions | + +Squashed on `main` at `3ece95f1`; tag `v1.4.30` points there. + +## Findings closed + +- **R-A Option A server-side**. `dailyStatsExternalId()` helper + + handoff-doc lock for the canonical + `"stats::"` shape. The + v1.4.29 `CUMULATIVE_HK_TYPES` set already documents the five + cumulative types; the new helper closes the iOS side so the next + TestFlight build can adopt `HealthKitStatisticsService.swift` + without a server release. +- **R-A drain script**. `scripts/drain-per-sample-cumulative.ts` + + `POST /api/admin/drain-per-sample-cumulative` collapse pre-Option-A + per-sample APPLE_HEALTH cumulative rows into one row per day per + type. Idempotent; dry-run by default. Operator runs once after + the iOS cut-over. +- **R-E C-2 SyncMode foundation**. Migration 0062 adds + `Measurement.sync_version` (Int, default 1), + `Measurement.deleted_at` (Timestamp, nullable), + `User.last_synced_at` (Timestamp, nullable). New endpoints: + `GET /api/sync/state`, `POST /api/mood-entries/bulk`, + `POST /api/medications/intake/bulk` — all rate-limited at + 60/min/user, all carry a 500-entry cap. +- **R-E H-5 mood-entry note column**. Migration 0063 + + `scripts/backfill-mood-note-column.ts` lifts the legacy + `tags: ["note:"]` workaround into a first-class + `MoodEntry.note` (TEXT, nullable, max 500 chars in Zod). +- **R-F T1.1 workout API verification**. The existing + `POST /api/workouts/batch` route (v1.4.25 W8d) passes verification — + rate-limit + idempotency + 5 MB body cap + 100-workout cap all + intact. `pickCanonicalWorkoutRows()` helper buckets workouts by + 5-minute startedAt slot + sportType and walks the existing + measurement source ladder. +- **R-F §4 categorisation overlay**. `src/lib/measurements/categories.ts` + exposes `MEASUREMENT_CATEGORIES: ReadonlyMap` over eight categories (vitals / body / + activity / sleep / hearing / environment / cardiovascular / + metabolic). Completeness wall in the test suite catches a new + MeasurementType lacking a category assignment. +- **R-F T1.4 + T1.5 enum additions**. `WALKING_STEADINESS` + + `AUDIO_EXPOSURE_EVENT` land via migration 0064. The wiring + registries (`apple-health-mapping`, `categories`, `pr-direction`, + `chart-tokens`, six locale files) pick them up in the same release. + +## Deploy mechanics note + +Coolify auto-deploy fired against apps01 ("deployment finished" at +10:46:09) but did NOT pull the new `:latest` digest — fourth release +in a row matching the v1.4.27 / v1.4.28 / v1.4.29 / v1.4.29.1 gap. +Host-side SSH fallback finished the rollout (`docker pull +ghcr.io/mbombeck/healthlog:latest` + `docker compose up -d +--force-recreate --no-deps app`). The runbook for the toggle lives +at `.planning/coolify-auto-deploy-howto.md`; the v1.4.31 backlog +already carries the "investigate auto-deploy gap" item. + +edge-01 deploy: the docker-compose pins the explicit `1.4.29.1` tag +rather than `:latest`. Precise sed +(`s|healthlog:1.4.29.1|healthlog:1.4.30|g`) plus a +`.pre-v1430.bak` snapshot bumped the file in place; clean execution +the first pass. + +## iOS-contract notes + +Every change in v1.4.30 is **additive** on the wire: + +- The `dailyStatsExternalId` helper is server-side only; iOS + generates its own externalIds. The locked shape in handoff doc §12 + is the contract iOS reads before implementing + `HealthKitStatisticsService.swift`. +- The SyncMode columns carry defaults (`sync_version = 1`, + `deleted_at = null`, `last_synced_at = null`) so existing iOS + POSTs round-trip unchanged. +- The new endpoints (`/api/sync/state`, + `/api/mood-entries/bulk`, + `/api/medications/intake/bulk`, + `/api/admin/drain-per-sample-cumulative`) are net-new — no iOS + consumer yet. +- `MoodEntry.note` is optional Zod-side; old iOS payloads keep + round-tripping via `tags`. The bulk endpoint and the + POST/PUT routes accept the new field when supplied. +- The two new `MeasurementType` enums are net-new values; iOS + clients that predate the codegen pass (R-E H-8) will not + encounter them in read paths because no source writes them yet. + +**Cutover sequence**: v1.4.30 ships with the helper + drain script ++ server tolerance for both shapes (per-sample uuid AND daily +`stats:...` externalIds). The next iOS TestFlight build adopts +`HealthKitStatisticsService.swift` and starts posting daily- +aggregated rows for the five cumulative types. Operator runs the +drain script once after the new TestFlight cuts over; per-sample +row pressure on `Measurement` drops 50-200× for cumulative types. + +## v1.4.31 scope seed + +Carried into v1.4.31 from the strategic plan §2 plus the v1.4.30 +window: + +- **Assistant-optional operator toggles** (R-E §"assistant-optional", + research at `.planning/research/v15-assistant-optional.md`). +- **Insights tab-strip blocking on mobile** + (`.planning/research/v15-insights-blocking-bug.md`). +- **Coolify auto-deploy investigation** — fourth release in a row + the webhook reported "finished" but didn't pull the new `:latest` + digest. Document the root cause in + `.planning/round-coolify-auto-deploy-fix.md`. + +## Closure complete + +v1.4.30 lives on both production hosts (apps01 + edge-01) and the +GitHub Release reads it as the latest. The full integration suite +runs 47 files / 190 specs against the real-Postgres container; the +unit suite passes 4035 specs. The next patch is v1.4.31 (operator +toggles + insights blocking + Coolify auto-deploy fix). diff --git a/.planning/v15-ios-handoff/08-locked-contracts.md b/.planning/v15-ios-handoff/08-locked-contracts.md index 92fc3e6e8..77d23b343 100644 --- a/.planning/v15-ios-handoff/08-locked-contracts.md +++ b/.planning/v15-ios-handoff/08-locked-contracts.md @@ -520,7 +520,144 @@ cache (`type, day, last-posted-value`) drives the divergence check. **Test fence**: `dailyStatsExternalId` round-trip is covered by `src/lib/measurements/__tests__/apple-health-mapping.test.ts`. -## 13. What is NOT in this file +## §13 — SyncMode conflict-resolution policy (locked v1.4.30.1) + +Source: `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R9. + +Hard-spec for the bidirectional sync path between iOS-side SwiftData +and the server-side `Measurement` / `MoodEntry` / `MedicationIntakeLog` +rows once `SyncMode = paired` (or `SyncMode = cloud-sync`). The +underlying SyncMode columns (`syncVersion Int @default(1)`, +`deletedAt DateTime?` soft-delete, `User.lastSyncedAt DateTime?`) +ship in v1.4.30 migration 0062. + +The four sub-policies plus the sync-state envelope below are locked. +Richer merge semantics (per-field LWW, three-way merge) defer to v1.6 +if iOS-side evidence warrants — they are explicitly out of scope for +v1.5. + +1. **Bulk-backfill (first-pair).** iOS pushes a backlog via + `POST /api/mood-entries/bulk` / `POST /api/medications/intake/bulk` + the first time a standalone user pairs with the server. The + server UPSERTs every entry keyed on `externalId` (or `clientId` + when no `externalId` exists). The existing + `@@unique([userId, type, measuredAt, source, sleepStage])` index + on `Measurement` and the sibling uniqueness constraints on + `MoodEntry` + `MedicationIntakeLog` handle dedup. LWW is not + invoked on the bulk path — duplicates collapse to + `status: "duplicate"` in the per-entry response. + +2. **Steady-state bidirectional sync.** Every synced row carries: + + - `updatedAt DateTime` — server-set on every write + - `syncVersion Int @default(1)` — server-incremented on every + write + - `deletedAt DateTime?` — soft-delete only; hard-delete blocked + server-side under `SyncMode = paired` + + iOS treats `(updatedAt, syncVersion)` as the version pair for + optimistic concurrency control. + +3. **Write conflict.** iOS sends: + + ```http + PATCH /api//{id} + If-Match: + ``` + + Server resolution: + + - `If-Match` matches the row's `syncVersion`: accept the write, + increment `syncVersion`, return `200 OK` with the new + `syncVersion` in the response body. + - `If-Match` is older than the row's `syncVersion`: reject with + `409 Conflict` and return the canonical row in the standard + envelope: + + ```json + { + "data": { "id": "…", "syncVersion": 7, "updatedAt": "…", "…": "…" }, + "error": "sync.conflict", + "meta": { "errorCode": "sync.conflict" } + } + ``` + + iOS-side resolution on 409: + + - **Default policy: LWW by `updatedAt`.** Whoever has the newer + `updatedAt` wins. + - If iOS-local `updatedAt` is newer than the server's: iOS + re-sends the PATCH with the server's new `syncVersion` in + `If-Match` (effectively a rebase). + - If the server's `updatedAt` is newer: iOS adopts the server + payload and discards the local edit. + - **Edge case (tie on `updatedAt` to the millisecond): + server-wins.** iOS adopts the server payload. The server's + `updatedAt` resolution is millisecond; sub-millisecond ties are + a theoretical edge case in practice but the rule keeps the + algorithm total. + - User-visible UX is iOS's call — a small toast "synced with + cloud version — your changes were discarded" is the suggested + pattern when iOS adopts the server payload over a pending local + edit. + +4. **Delete conflict.** Hard-delete is blocked server-side under + `SyncMode = paired`. Deletes flow as soft-delete via: + + ```http + PATCH /api//{id} + Content-Type: application/json + { "deletedAt": "" } + ``` + + Server resolution: + + - iOS PATCHes a soft-delete on a row the server has subsequently + edited: server returns `409 Conflict` + the canonical row. + iOS-side: treat as "server says this row was edited after your + delete intent — abort the delete and prompt the user to + confirm again". + - iOS PATCHes an edit on a row the server has already + soft-deleted (`deletedAt IS NOT NULL`): server returns + `410 Gone`. iOS-side: adopt the server's soft-delete state, or + surface "this row was deleted on another device — discard + your edit?" depending on the iOS UX call. + +5. **Sync-state envelope.** `GET /api/sync/state` returns: + + ```json + { + "data": { + "syncVersion": 42, + "lastSyncedAt": "2026-05-16T10:30:00.000Z", + "perEntity": { + "Measurement": 42, + "MoodEntry": 17, + "MedicationIntakeLog": 9 + } + }, + "error": null + } + ``` + + - `syncVersion` is the user-level high-water mark — the max of + every `perEntity` value. + - `perEntity` lets iOS decide which `?since=syncVersion=N` reads + to fire after a reconnect (no point pulling MoodEntry rows if + the per-entity high-water hasn't moved). + - The handshake also bumps `User.lastSyncedAt` — iOS reads the + OLD value in the response and trusts that subsequent server + writes after the new checkpoint round-trip via the standard + read paths. + +**Test fence**: the bulk + sync-state paths are covered by the +v1.4.30 integration suite (`src/app/api/sync/state/__tests__/`, +`src/app/api/mood-entries/bulk/__tests__/`, +`src/app/api/medications/intake/bulk/__tests__/`). The 409 / 410 +delete-conflict paths land alongside the PATCH-on-divergence wiring +in a subsequent web patch (no client consumes them yet). + +## 14. What is NOT in this file - **API envelope details (`{ data, error, meta }`)** → `17-error-handling.md` - **Coach snapshot construction** → `14-coach-mental-model.md` diff --git a/.planning/v15-strategic-plan.md b/.planning/v15-strategic-plan.md index bb16fd9f6..a474eb650 100644 --- a/.planning/v15-strategic-plan.md +++ b/.planning/v15-strategic-plan.md @@ -171,7 +171,7 @@ with the corresponding HK reads. | Workouts end-to-end on the web — list page (`/insights/workouts`), detail page, dashboard "Recent workouts" tile | R-F T1.1 | M | | Chart cards for 5 of the 10 invisible-but-stored metrics: HRV, RestingHR, SpO2, BodyTemperature, ActiveEnergyBurned | R-F T1.3 | M | -### v1.4.33 — HealthKit Tier 1 web surfaces, wave B + freeze marker +### v1.4.33 — HealthKit Tier 1 web surfaces, wave B Ship 2-3 days after v1.4.32. Closes the breadth wave. @@ -182,19 +182,38 @@ Ship 2-3 days after v1.4.32. Closes the breadth wave. | Walking-steadiness gauge | R-F T1.5 | S | | HKStateOfMind read surface integrated with `/insights/stimmung` (the existing mood page picks up APPLE_HEALTH-sourced mood entries cleanly via the source-priority pipeline) | R-F T1.2 | S | | Source-priority editor — the `/api/auth/me/source-priority` endpoint already locks for iOS in v1.4.25 W8c; the web has nothing today. Add the two-axis editor under `/settings/sources` so the iOS side can mirror the same UI shape | R-E C-5 (web-side helper) | M | + +### v1.4.34 — Apple Health XML import + web freeze marker + +Slots in per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R2. +The iOS-side calendar realignment (v1.5.0 marker ~2026-11 to +~2026-12) leaves multi-month room before the freeze actually needs +to bite; the highest-leverage non-iOS-app deliverable in the broader +research moves up out of v1.6 into the slot immediately preceding +the freeze marker. + +| Item | Source | Effort | +|---|---|---| +| `POST /api/import/apple-health-export` accepting `multipart/form-data` | R-F §2.6 + R2 | L (~3-4 days) | +| Streaming XML parser (Node `sax-stream` or similar) — large files (100 MB - 1 GB) need a background worker | R2 | — | +| Async job model: synchronous endpoint returns `{ jobId }`; `GET /api/import/apple-health-export/{jobId}/status` polls progress | R2 | — | +| Per-`MeasurementType` ingestion stats + duration in the response | R2 | — | +| Idempotent UPSERT keyed on `externalId` so re-imports are no-ops | R2 | — | +| Operator-side admin endpoint variant (support-staff-driven imports) | R2 | — | +| Map HKQuantityTypeSample, HKCategoryTypeSample, HKWorkout to existing MeasurementType + Workout models (HKClinicalRecord defers per R-F T3) | R2 | — | | CHANGELOG **WEB-FREEZE marker** — explicit line noting that web functionality is complete for v1.5 and that subsequent v1.4.x tags are limited to hotfixes + dependency updates until the iOS app ships | this plan | — | -After v1.4.33 tags on `main`, the web enters **freeze**. +After v1.4.34 tags on `main`, the web enters **freeze**. ### Web-freeze posture -Allowed during the freeze (v1.4.32 → v1.5.0): +Allowed during the freeze (v1.4.34 → v1.5.0): - Security patches (CVE feeds, dependency vulnerabilities) - Dependency updates that ride existing test infrastructure - Hotfix-only emergencies (the v1.4.28.1 dashboard-save pattern) - Tightly-scoped reactive fixes if iOS testing surfaces a real gap - on a v1.4.32 endpoint — but only as additive corrections, never + on a v1.4.34 endpoint — but only as additive corrections, never as new feature work Not allowed during the freeze: @@ -208,9 +227,9 @@ Not allowed during the freeze: ### v1.5.0 — version bump marker Ships the day after iOS clears Apple review. Single commit on -`main`: `package.json` `1.4.32` → `1.5.0`, CHANGELOG entry stating +`main`: `package.json` `1.4.34` → `1.5.0`, CHANGELOG entry stating "iOS native client now live on the App Store; web functionality -unchanged since v1.4.32." No source diff outside the version bump +unchanged since v1.4.34." No source diff outside the version bump and CHANGELOG. Triggers GHCR rebuild + auto-deploy so the running image's `/api/version` sentinel matches the public release tag. @@ -343,7 +362,6 @@ navigates to a feature that needs it (R-F §2.5 option 2). | 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 | @@ -367,9 +385,10 @@ navigates to a feature that needs it (R-F §2.5 option 2). | 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.33 tag on `main` (after the Tier 1 wave B closes) | This plan §2 | -| v1.5 release shape | Version-bump-only marker the day after iOS clears Apple review. All functional work lands incrementally in v1.4.29 → v1.4.33. | Maintainer directive 2026-05-16 | -| Web patch sequence | v1.4.29 polish → v1.4.29.1 step hotfix → v1.4.30 iOS-coordinated foundation (Daily-Stats + SyncMode) → v1.4.31 toggles + insights blocking + Coolify auto-deploy → v1.4.32 Tier 1 wave A → v1.4.33 Tier 1 wave B + freeze | This plan §2, maintainer directive 2026-05-16 | +| Web freeze trigger | v1.4.34 tag on `main` (after the Apple Health XML import lands) | This plan §2, R2 | +| v1.5 release shape | Version-bump-only marker the day after iOS clears Apple review. All functional work lands incrementally in v1.4.29 → v1.4.34. | Maintainer directive 2026-05-16 | +| Web patch sequence | v1.4.29 polish → v1.4.29.1 step hotfix → v1.4.30 iOS-coordinated foundation (Daily-Stats + SyncMode) → v1.4.30.1 categories endpoint + conflict-resolution lock → v1.4.31 toggles + insights blocking + Coolify auto-deploy → v1.4.32 Tier 1 wave A → v1.4.33 Tier 1 wave B → v1.4.34 freeze | This plan §2, maintainer directive 2026-05-16 + R1 + R2 | +| Apple Health XML import slot | v1.4.34 (before freeze) | R2 + maintainer 2026-05-16 | | R-A Option A iOS daily-stats | Rides v1.4.30 (server-side helper + drain script + handoff-doc lock) coordinated with iOS TestFlight cutover — NOT a v1.5 sprint item | Maintainer directive 2026-05-16 | | AVG/SUM cumulative-type fix | Server-side rode v1.4.29; client-side daily aggregator rode v1.4.29.1 | R-A §6 + R-B C2 sequence + maintainer report | | C2 P0 sequencing | Lands before any client wires `aggregate=daily` | R-B Critical | @@ -400,13 +419,15 @@ changes. Nothing in this plan flags as breaking. deferring the proactive scheduler to a v1.5.x iOS-side release is survivable. -3. **Whether the Apple-Health-XML import (`export.zip`) belongs - before the freeze.** 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.4.32 if the - calendar permits; defer to v1.6 if not. +3. ~~**Whether the Apple-Health-XML import (`export.zip`) belongs + before the freeze.**~~ **Resolved 2026-05-16** per + `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R2 — slots into + v1.4.34 immediately before the web-freeze marker. The iOS-side + calendar realignment (v1.5.0 marker ~2026-11 to ~2026-12) leaves + multi-month room before the freeze actually bites; the highest- + leverage non-iOS-app deliverable in the broader research moves + up out of v1.6 into the slot preceding the freeze. Effort: L + (~3-4 days). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e2784dc..c8962ae33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # Changelog +## [1.4.30.1] — 2026-05-16 — Categories endpoint + conflict-resolution lock + +A two-item follow-up to v1.4.30. The categorisation overlay shipped +yesterday as a TypeScript map; the new `GET /api/measurement-categories` +endpoint projects it over HTTP so the iOS-side picker can fetch the +canonical shape rather than carry a hard-coded mirror — the +unblocker the iOS team flagged as the one missing piece. In parallel +the SyncMode conflict-resolution policy that the v0.6.0 +standalone-first track depends on lands as §13 of +`08-locked-contracts.md`. + +### Added + +- **`GET /api/measurement-categories`.** Thin projection of + `src/lib/measurements/categories.ts` over HTTP. Response carries + `version: 1`, an ordered `categories` array with `labelKey` + translation keys, and the `assignments` map keyed on + `MeasurementType`. `requireAuth()` gates the route — any logged-in + user passes — and `Cache-Control: public, max-age=600` enables a + 10-minute edge cache. Locked per + `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. + +### Documented + +- **SyncMode conflict-resolution policy.** + `.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per + R9 of the iOS-team response doc. Hard-spec covers bulk-backfill, + steady-state bidirectional sync via `(updatedAt, syncVersion)` + optimistic concurrency, the 409 write-conflict + 410 Gone + delete-conflict envelope shapes, the LWW-by-`updatedAt` resolution + with server-wins on millisecond tie, and the + `GET /api/sync/state` response. The "What is NOT in this file" + stub renumbers to §14. +- **iOS contributor brief.** §3 bumps the live-production marker to + v1.4.30 and adds the four v1.4.30 endpoints plus the new + v1.4.30.1 endpoint to the iOS-consumable list. §5b / §6 reframe + the Coach SSE story per R6 — the server endpoint stays live; the + iOS native server-Coach drawer is deferred pending MDR Class-IIa + pre-review. v1.5.0 iOS ships Apple Foundation Models on-device + Daily Briefing + Trend Observations as the primary assistant + surface. +- **v1.5 strategic plan.** §2 slots Apple Health XML import as + v1.4.34 immediately before the web-freeze marker (per R2), + detailing the multipart endpoint, the streaming XML parser, the + async job model, the per-MeasurementType ingestion stats, the + idempotent UPSERT on `externalId`, and the admin endpoint + variant. Effort L (~3-4 days). The freeze trigger renumbers from + v1.4.33 to v1.4.34; the decision-log row "Apple Health XML + import slot" lands; the §4 deferred row and §6 open question #3 + close out. + ## [1.4.30] — 2026-05-16 — iOS-coordinated foundation (Daily-Stats + SyncMode) Server-side prep for the next iOS TestFlight build. Five surfaces diff --git a/package.json b/package.json index 2e7ffb11a..6439d6c1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.30", + "version": "1.4.30.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/measurement-categories/__tests__/route.test.ts b/src/app/api/measurement-categories/__tests__/route.test.ts new file mode 100644 index 000000000..dc99bb6c6 --- /dev/null +++ b/src/app/api/measurement-categories/__tests__/route.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function req(): NextRequest { + return new NextRequest("http://localhost/api/measurement-categories", { + method: "GET", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/measurement-categories", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("returns the categories + assignments envelope to a logged-in user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { + version: number; + categories: Array<{ id: string; labelKey: string; order: number }>; + assignments: Record; + }; + error: null; + }; + + expect(body.error).toBeNull(); + expect(body.data.version).toBe(1); + expect(body.data.categories.length).toBe(8); + // Stable order — vitals first, metabolic last. + expect(body.data.categories[0]).toEqual({ + id: "vitals", + labelKey: "categories.vitals", + order: 0, + }); + expect(body.data.categories.at(-1)).toEqual({ + id: "metabolic", + labelKey: "categories.metabolic", + order: 7, + }); + // Every category carries a `categories.` translation key. + for (const entry of body.data.categories) { + expect(entry.labelKey).toBe(`categories.${entry.id}`); + } + }); + + it("returns an assignments map covering every MeasurementType in the overlay", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req()); + expect(res.status).toBe(200); + + const body = (await res.json()) as { + data: { assignments: Record }; + }; + + // Parity with the source map — the endpoint is a thin projection, + // so the assignment count and per-key category must match exactly. + expect(Object.keys(body.data.assignments).length).toBe( + MEASUREMENT_CATEGORIES.size, + ); + for (const [type, category] of MEASUREMENT_CATEGORIES) { + expect(body.data.assignments[type]).toBe(category); + } + }); + + it("sets the 10-minute public cache header", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req()); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("public, max-age=600"); + }); +}); diff --git a/src/app/api/measurement-categories/route.ts b/src/app/api/measurement-categories/route.ts new file mode 100644 index 000000000..ef7f55f56 --- /dev/null +++ b/src/app/api/measurement-categories/route.ts @@ -0,0 +1,89 @@ +/** + * `GET /api/measurement-categories` — public categorisation overlay. + * + * Exposes the UI-side `MEASUREMENT_CATEGORIES` map at + * `src/lib/measurements/categories.ts` as an HTTP contract. iOS reads + * this on cold start to drive the HealthKit permission picker + * (one consent screen per category instead of a flat enum list) and + * caches the response client-side; the web Insights nav and the Coach + * evidence shelf will read the same shape post-v1.5. + * + * Response envelope: + * + * { + * "data": { + * "version": 1, + * "categories": [ + * { "id": "vitals", "labelKey": "categories.vitals", "order": 0 }, + * … + * ], + * "assignments": { + * "BLOOD_PRESSURE_SYS": "vitals", + * "WEIGHT": "body", + * … + * } + * }, + * "error": null + * } + * + * - `requireAuth()` — any logged-in user. No admin gate; no PII. + * - `Cache-Control: public, max-age=600` — the overlay is stable across + * a 10-minute window. Clients that need a forced refresh can bypass + * their HTTP cache. + * - `version: 1` is an additive marker; the categorisation shape will + * evolve additively, never by reshuffling existing assignments. + * + * Locked contract per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + MEASUREMENT_CATEGORIES, + type MeasurementCategory, +} from "@/lib/measurements/categories"; +import { annotate } from "@/lib/logging/context"; + +/** + * Stable category ordering for the iOS picker. Mirrors the + * insertion order of `MEASUREMENT_CATEGORIES` so the UX is identical + * across clients that read the canonical map directly and clients + * that consume this endpoint. + */ +const CATEGORY_ORDER: readonly MeasurementCategory[] = [ + "vitals", + "body", + "activity", + "sleep", + "hearing", + "environment", + "cardiovascular", + "metabolic", +] as const; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const GET = apiHandler(async (_request: NextRequest) => { + await requireAuth(); + + const categories = CATEGORY_ORDER.map((id, order) => ({ + id, + labelKey: `categories.${id}`, + order, + })); + + const assignments: Record = {}; + for (const [type, category] of MEASUREMENT_CATEGORIES) { + assignments[type] = category; + } + + annotate({ action: { name: "measurement-categories.read" } }); + + const response = apiSuccess({ + version: 1 as const, + categories, + assignments, + }); + response.headers.set("Cache-Control", "public, max-age=600"); + return response; +});