chore(release): v1.4.30.1

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`.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 13:20:49 +02:00
1 parent 3ece95f192
commit 175e3aea97
8 files changed
+618 -30

No files matched your search

+32 -11
View File
@@ -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
+159
View File
@@ -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: <https://github.com/MBombeck/HealthLog/releases/tag/v1.4.30>.
- 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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"` 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:<text>"]` 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<MeasurementType,
MeasurementCategory>` 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).
@@ -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/<entity>/{id}
If-Match: <syncVersion>
```
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/<entity>/{id}
Content-Type: application/json
{ "deletedAt": "<ISO 8601>" }
```
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`
+38 -17
View File
@@ -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).
---
+51
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -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<string, string>;
};
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.<id>` 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<string, string> };
};
// 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");
});
});
@@ -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<string, MeasurementCategory> = {};
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;
});