mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
docs(planning): tick Wave 2 + record backend foundation report
STATE.md flips F1 / F2 / F3 to done with the inline narrative needed for the next wave to pick up cleanly: 7 net new measurement types (W1's 6 plus BODY_TEMPERATURE the iOS DTO already shipped), unit shift on SLEEP_DURATION from hours to minutes, the new SleepStage enum + composite dedup index. W2 report enumerates the six commits, the +19 unit / +8 integration test delta, the migration name + SQL summary, the W1 recommendations adopted verbatim, and the open questions for the maintainer about the iOS DTO contract (the rename HEALTHKIT → APPLE_HEALTH stays the load-bearing item). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5470649d01
commit
2ce1a85622
2 files changed
+212
-15
No files matched your search
+33
-15
@@ -1,7 +1,7 @@
|
||||
# v1.4.23 marathon — state log
|
||||
|
||||
Status: kickoff
|
||||
Last update: 2026-05-10T23:00+02:00
|
||||
Status: Wave 2 shipped (backend foundation)
|
||||
Last update: 2026-05-11T00:25+02:00
|
||||
|
||||
> Previous milestone: v1.4.22 live (image digest
|
||||
> `sha256:865154614303…`, `/api/version=1.4.22`).
|
||||
@@ -24,25 +24,43 @@ Last update: 2026-05-10T23:00+02:00
|
||||
|
||||
### F1 — `MeasurementType` enum extension
|
||||
|
||||
- [ ] Add `HEART_RATE_VARIABILITY`, `SLEEP_DURATION_DETAILED`,
|
||||
`RESTING_HEART_RATE`, `STEP_COUNT`, `ACTIVE_ENERGY_BURNED`,
|
||||
`FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `VO2_MAX`
|
||||
- [ ] Prisma migration `0036_apple_health_measurement_types`
|
||||
- [ ] Unit conversions documented in
|
||||
`src/lib/measurements/apple-health-mapping.ts`
|
||||
- [x] Added `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
|
||||
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
|
||||
`WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`
|
||||
(W1 recommended reusing `ACTIVITY_STEPS` + `SLEEP_DURATION`
|
||||
verbatim; the iOS DTO already shipped `BODY_TEMPERATURE` so
|
||||
that joined the additive set — net new = 7)
|
||||
- [x] Prisma migration `0036_apple_health_measurement_types`
|
||||
- [x] Sleep unit shifted from hours to minutes; new `SleepStage`
|
||||
enum + nullable `Measurement.sleepStage` column (CHECK
|
||||
constraint scopes it to SLEEP_DURATION rows)
|
||||
- [x] Composite unique index `(user_id, type, source, external_id)`
|
||||
— Apple Health batch dedup key
|
||||
- [x] Unit conversions in
|
||||
`src/lib/measurements/apple-health-mapping.ts` (16 unit tests)
|
||||
|
||||
### F2 — `MeasurementSource` enum expansion
|
||||
|
||||
- [ ] `apple_health` joins existing `manual / withings / moodlog`
|
||||
- [ ] UI badges + ingest validators + analytics segmenters updated
|
||||
- [x] `APPLE_HEALTH` appended to the enum
|
||||
- [x] UI badge in measurement-list (Dracula-pink chip) + bilingual
|
||||
`measurements.sourceAppleHealth` keys + admin restore-route
|
||||
enum guard sync
|
||||
|
||||
### F3 — `POST /api/measurements/batch`
|
||||
|
||||
- [ ] New route, idempotent via `Idempotency-Key`
|
||||
- [ ] Validates batch ≤ 500 entries
|
||||
- [ ] Dedupes via `(userId, type, source, externalId)` composite
|
||||
- [ ] Returns per-entry status + idempotency-replay-safe
|
||||
- [ ] Integration test (testcontainers Postgres)
|
||||
- [x] New route at `src/app/api/measurements/batch/route.ts`,
|
||||
idempotent via `Idempotency-Key`
|
||||
- [x] Validates batch ≤ 500 entries (returns 422
|
||||
`coach.batch.too_large`)
|
||||
- [x] Dedupes via `(userId, type, source, externalId)` composite
|
||||
- [x] Returns per-entry status (`inserted` / `duplicate` /
|
||||
`skipped`) + idempotency-replay-safe
|
||||
- [x] Integration test (`tests/integration/measurements-batch.test.ts`,
|
||||
6 cases including idempotency replay)
|
||||
- [x] Sleep-stage aggregation in `/api/analytics`:
|
||||
per-Berlin-day summary + `sleepStages` block
|
||||
(`tests/integration/analytics-sleep-stages.test.ts`)
|
||||
- Detailed report: `.planning/phase-W2-v1423-report.md`
|
||||
|
||||
## Wave 3 — APNs scaffolding (F4)
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Wave 2 — v1.4.23 backend foundation report
|
||||
|
||||
Status: shipped on `develop` (2026-05-11).
|
||||
Scope: F1 (`MeasurementType` extension), F2 (`MeasurementSource`
|
||||
expansion), F3 (`POST /api/measurements/batch`) plus the
|
||||
analytics-side sleep-stage aggregation the new schema unblocks.
|
||||
|
||||
## What landed
|
||||
|
||||
### Six atomic commits
|
||||
|
||||
1. **`feat(schema)` — Apple Health measurement types + sleep stages
|
||||
+ dedup index.** `prisma/schema.prisma` gains seven additive
|
||||
`MeasurementType` values, `APPLE_HEALTH` joins `MeasurementSource`,
|
||||
a new `SleepStage` enum mirrors the existing `GlucoseContext`
|
||||
pattern, and `Measurement` picks up nullable `sleepStage` +
|
||||
`externalSourceVersion` columns plus a composite unique index
|
||||
`(user_id, type, source, external_id)`. Migration
|
||||
`0036_apple_health_measurement_types` is strictly additive — no
|
||||
row mutations.
|
||||
2. **`feat(measurements)` — Apple Health identifier mapping helper.**
|
||||
New `src/lib/measurements/apple-health-mapping.ts` translates each
|
||||
HealthKit identifier into the row shape the `Measurement` model
|
||||
expects. Mirrors the Withings `MEASURE_TYPE_MAP` but keyed by
|
||||
string identifier. Unit conversion (0..1 fraction → percent for
|
||||
oxygen saturation and body fat) sits per-entry next to the type.
|
||||
16 unit tests, including the sleep-stage codepoint map.
|
||||
3. **`feat(api)` — batch measurement ingest for iOS Apple Health.**
|
||||
`POST /api/measurements/batch` accepts ≤500 entries per call,
|
||||
wraps in `withIdempotency()` for HTTP-level retry safety, and
|
||||
relies on the composite unique index for per-entry dedup. Returns
|
||||
per-entry status (`inserted` / `duplicate` / `skipped`) so the
|
||||
iOS client can advance its sync cursor accurately. Six integration
|
||||
tests under `tests/integration/measurements-batch.test.ts`.
|
||||
4. **`feat(ui)` — Apple Health source badge + restore-route enum
|
||||
sync.** Dracula-pink chip in the measurements list,
|
||||
`measurements.sourceAppleHealth` translation keys (EN + DE),
|
||||
admin backup-restore guard updated for the new enum values.
|
||||
Three regression-guard tests for the badge.
|
||||
5. **`feat(analytics)` — per-stage sleep aggregation.**
|
||||
`/api/analytics` aggregates SLEEP_DURATION rows per Berlin day
|
||||
before summarising so multi-stage nights surface as one datapoint.
|
||||
New optional `sleepStages` block exposes the per-stage minute
|
||||
breakdown over the trailing 30 days. Two integration tests.
|
||||
6. **`docs(planning)` — STATE.md tick + this report.**
|
||||
|
||||
### Test count delta
|
||||
|
||||
- Unit tests: 2111 → 2130 (+19; 16 mapping + 3 badge regression).
|
||||
- Integration tests: 89 → 97 (+8; 6 batch ingest + 2 sleep stage).
|
||||
- `pnpm typecheck`, `pnpm lint`, `pnpm test --run`, and
|
||||
`pnpm test:integration` are all green between every commit.
|
||||
|
||||
### Migration name + SQL summary
|
||||
|
||||
`prisma/migrations/0036_apple_health_measurement_types/migration.sql`.
|
||||
|
||||
- `ALTER TYPE measurement_type ADD VALUE IF NOT EXISTS …` × 7 (the
|
||||
seven new HealthKit-shaped values).
|
||||
- `ALTER TYPE measurement_source ADD VALUE IF NOT EXISTS
|
||||
'APPLE_HEALTH'`.
|
||||
- `CREATE TYPE sleep_stage AS ENUM (…)`.
|
||||
- `ALTER TABLE measurements ADD COLUMN external_source_version
|
||||
TEXT, ADD COLUMN sleep_stage sleep_stage` (both nullable).
|
||||
- CHECK constraint scoping `sleep_stage` to SLEEP_DURATION rows
|
||||
(mirrors the glucose_context constraint from migration 0021).
|
||||
- `CREATE UNIQUE INDEX measurements_user_id_type_source_external_id_key
|
||||
ON measurements (user_id, type, source, external_id)` — Apple
|
||||
Health batch dedup key. Postgres treats NULL externalId rows as
|
||||
distinct, so manual entries don't collide.
|
||||
|
||||
The legacy `(user_id, type, measured_at, source)` unique index stays
|
||||
in place as the manual-UI "no duplicate at the same wall-clock"
|
||||
guard.
|
||||
|
||||
## W1 recommendations adopted verbatim
|
||||
|
||||
| W1 recommendation | Status |
|
||||
|---|---|
|
||||
| Source enum value = `APPLE_HEALTH` (not `HEALTHKIT`) | Adopted |
|
||||
| Reuse existing `ACTIVITY_STEPS` for HK step count | Adopted |
|
||||
| Reuse existing `SLEEP_DURATION`, shift unit hours → minutes | Adopted |
|
||||
| Per-stage sleep rows with new `sleep_stage` column | Adopted |
|
||||
| `apple-health-mapping.ts` keyed by HK string identifier | Adopted |
|
||||
| Apple ships 0..1 fraction for SpO2 + body fat → × 100 | Adopted |
|
||||
| `BODY_TEMPERATURE` enum value alongside the other 6 | Adopted |
|
||||
|
||||
## Decisions made beyond W1's scope
|
||||
|
||||
- **Per-entry status as `inserted | duplicate | skipped`** with a
|
||||
documented `reason` field for skips (`unmappable_identifier`,
|
||||
`value_out_of_range`). The iOS client checkpoints past `inserted`
|
||||
AND `duplicate` and surfaces `skipped` for diagnostics. W1 didn't
|
||||
pin the exact wire shape; I picked the names that read clearest in
|
||||
the iOS-client perspective.
|
||||
- **Plausibility-range guard runs per-entry, never fails the batch.**
|
||||
A single bogus sensor reading becomes a `skipped` entry rather than
|
||||
crashing the whole upload. The validator inherits HealthLog's
|
||||
existing `validateMeasurementRange()` thresholds; new ranges were
|
||||
added for HRV (1–200 ms), resting HR (25–220 bpm), active energy
|
||||
(0–10 000 kcal), flights (0–1000), distance (0–200 km), VO2 max
|
||||
(5–100 mL/(kg·min)), body temperature (28–45 °C).
|
||||
- **Skip-duplicates reconciliation when `createMany` races a concurrent
|
||||
retry.** The endpoint performs a pre-flight existence check, then
|
||||
uses `createMany({ skipDuplicates: true })`. If the count returned
|
||||
by `createMany` doesn't match the planned-insert count (a race),
|
||||
the endpoint re-reads the DB and downgrades the affected per-entry
|
||||
statuses from `inserted` to `duplicate` so the response stays
|
||||
truthful.
|
||||
- **`sleepStages` block returns `null` when the user has no
|
||||
stage-tagged rows** rather than an empty object. The UI consumer
|
||||
can then keep painting the totals-only path verbatim.
|
||||
|
||||
## What I couldn't ship and why
|
||||
|
||||
Nothing in scope was dropped. Three adjacent items are explicitly
|
||||
deferred per the brief:
|
||||
|
||||
- **APNs scaffolding (Wave 3 F4)** stays untouched here — the brief
|
||||
noted I should be aware of the `apnsEnvironment` decision but not
|
||||
implement it.
|
||||
- **OpenAPI generator + Coach schema extension (Wave 4 F5/F6)** —
|
||||
outside this wave's scope. F6 wants the new metric tokens in the
|
||||
`sourceMetric` enum; `chart-tokens.ts` already accepts them so the
|
||||
AI surface can reference them, but the Coach prompt + provenance
|
||||
ratchet ships in W4.
|
||||
- **Doctor PDF rendering for the new HealthKit metrics** —
|
||||
intentionally excluded for v1.4.23 (see
|
||||
`measurement-type-enum-coverage.test.ts`); ships in v1.5 alongside
|
||||
the iOS app's first paying-customer sync once layout + reference
|
||||
ranges are agreed.
|
||||
|
||||
## Open questions for the maintainer about the iOS DTO contract
|
||||
|
||||
1. **DTO rename `HEALTHKIT` → `APPLE_HEALTH`.** Server-side is now
|
||||
locked. The iOS repo (`~/Projects/healthlog-iOS/`) needs the
|
||||
`MeasurementDTO.swift` enum value rename in lock-step. The
|
||||
maintainer's encoded sim payloads should be cleared after the
|
||||
rename (one-line in the iOS dev README). Confirm the maintainer
|
||||
will land this in the same release window — once a TestFlight
|
||||
build ships, the rename becomes a breaking change.
|
||||
2. **`externalId` shape.** Server treats it as an opaque string
|
||||
≤120 chars. The iOS contract should pin `HKSample.uuid.uuidString`
|
||||
as the value, NOT a synthesised "type-id::date" composite —
|
||||
otherwise two iOS devices syncing the same Apple-Cloud sample
|
||||
would generate different `externalId`s and the dedup index can't
|
||||
collapse them.
|
||||
3. **`sleepStage` codepoint domain.** Server accepts integers 0–20
|
||||
to leave headroom for Apple's future additions; the documented
|
||||
iOS-16+ codepoints are 0–5. If the iOS DTO ships an enum, confirm
|
||||
it serialises as the numeric codepoint rather than a string label
|
||||
so the server-side `APPLE_HEALTH_SLEEP_STAGE_MAP` does the
|
||||
translation.
|
||||
4. **Pre-conversion vs post-conversion on the wire.** Server
|
||||
currently expects Apple's native units verbatim (0..1 fraction for
|
||||
SpO2 / body fat, kcal for active energy, etc.) and converts to DB
|
||||
units inside the mapping. Confirm the iOS app does NOT pre-convert
|
||||
on its side — otherwise we double-multiply and SpO2 lands at 9 700.
|
||||
5. **Behaviour when iOS pushes a sample with a HealthKit identifier
|
||||
the server doesn't know yet** (e.g. a hypothetical new metric Apple
|
||||
adds in iOS 18). Today the server marks the entry as
|
||||
`skipped`/`unmappable_identifier`. Does the iOS app drop those
|
||||
samples from its sync cursor (one-shot retry on next server
|
||||
release with the new mapping), or does it park them for re-upload
|
||||
after the next app update? Recommendation: park-for-retry so a
|
||||
server-side mapping addition automatically backfills.
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
pnpm typecheck # green
|
||||
pnpm lint # green (only pre-existing warnings)
|
||||
pnpm test --run # 2130 passed (was 2111)
|
||||
pnpm test:integration # 97 passed (was 89)
|
||||
```
|
||||
|
||||
Six commit SHAs on `develop` are visible via `git log
|
||||
origin/develop..HEAD` once pushed (W2 sequence is `95209eb`, `30c0f76`,
|
||||
`5cc0a00`, `86a80c5`, `5470649` + this docs commit).
|
||||
Reference in new issue
Block a user