mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
3ece95f192
commit
175e3aea97
8 files changed
+618
-30
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user