diff --git a/.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md b/.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md new file mode 100644 index 000000000..98c05ab45 --- /dev/null +++ b/.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md @@ -0,0 +1,403 @@ +--- +file: .planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md +purpose: Direct response to the iOS team's coordination doc at /Users/marc/Projects/healthlog-iOS/HealthLogIOS/.planning/v05x-marathon/RESPONSE-TO-SERVER-TEAM.md +created: 2026-05-16 +from: server contributor + automation +re: RESPONSE-TO-SERVER-TEAM.md — ten requests R1-R10, two strategic pivots, calendar realignment +--- + +# Response to iOS team — 2026-05-16 + +Read your response in full. The Apple Foundation Models pivot, the +standalone-first commitment for v0.6.0, and the longer iOS calendar +all land cleanly with the v1.4.x continuous-patch model. Every R1-R10 +answered below, plus the strategic-pivot implications for the web +roadmap. + +## 1. Acknowledgements + +- v0.5.0-rc.1 candidate at `54b644c` noted. The merged feat-branches + (Group 0 felt-UX, Group 1 theme-parallel, Group 2 theme-chart-chain) + confirm iOS-side ships rich functional work on the v0.5.x track + independent of web-side releases — exactly the architecture this + patch run is designed for. +- 732 Swift Testing `@Test` annotations on `main` is a strong + baseline. Server-side runs ~4 000 Vitest cases; the two test + surfaces stay independent. +- iOS-additive-only adherence confirmed in both directions. + +## 2. Strategic-pivot absorption + +### Apple Foundation Models on-device — the v1.5 narrative changes + +Closes the Apple Guideline 5.1.2(i) blocker on the iOS side without +requiring a server-routed consent flow for Daily Briefing + Trend +Observations + Photo-of-Med. Confirmed: server-side will not block +v1.5.0 tag on a native Coach SSE drawer. The server's +`GET /api/insights/chat` SSE endpoint stays live, indefinitely, +for: (a) future iOS re-evaluation post-MDR Class-IIa pre-review, +(b) any other client that calls it (PWA mobile users today, native +Android in a hypothetical future), (c) fallback when on-device +generation refuses for any iOS device that loses Apple Foundation +Models eligibility. + +Server-side will update §5b of the contributor brief per R6. + +### Standalone-first commitment (v0.6.0) + +The web side already treats the server as "API server that some +clients pair with"; the iOS-local-first commitment changes nothing +on the server's expectations. Server-side `SyncMode` foundation +ships in v1.4.30 as planned. Conflict-resolution policy locked +in R9 below. + +### Calendar realignment + +v0.5.x is 2-3 months from 2026-05-16; v0.6 is +3-4 months; +v1.5.0 marker aligns with the iOS App Store launch at ~2026-11 +to ~2026-12. Web-side patch cadence stays unchanged: v1.4.30 → 31 +→ 32 → 33 ship in the next 1-3 weeks per the current strategic +plan. **The web-freeze window between v1.4.33 and v1.5.0 is now +several months long instead of one Apple-review buffer.** That +opens room for additional v1.4.x patches before the freeze if value +shows up (e.g. R2 Apple Health XML import — see below). + +## 3. Direct answers to R1 - R10 + +### R1 — `GET /api/measurement-categories` HTTP endpoint — YES, slots into v1.4.30 + +ANSWER: yes. Server-side will expose the categorisation map at +`GET /api/measurement-categories`. Response shape: + +```json +{ + "data": { + "version": 1, + "categories": [ + { "id": "vitals", "labelKey": "categories.vitals", "order": 0 }, + { "id": "body", "labelKey": "categories.body", "order": 1 }, + ... + ], + "assignments": { + "BP_SYSTOLIC": "vitals", + "BP_DIASTOLIC": "vitals", + "PULSE": "vitals", + "WEIGHT": "body", + ... + } + } +} +``` + +- Public-read with auth (any logged-in user can fetch). +- `Cache-Control: max-age=600` (10 min cache per app launch). +- Versioned (`version: 1`) so clients can detect breaking changes + if they ever happen — not planned. +- iOS reads on app launch, caches in `MeasurementType+Category.swift` + helper. Refresh on every cold start. +- Hard-coded mirror in iOS as a fallback if the network call fails + (offline-first). + +Server will add this endpoint inside the v1.4.30 patch alongside +the `src/lib/measurements/categories.ts` map. ~50 LOC + a Vitest + +a documented response shape in `03-api-contracts.md`. + +### R2 — Apple Health XML import — slated for v1.4.34 + +ANSWER: v1.4.34 (web-side, after the Tier-1 wave B + before the +web-freeze marker). Not v1.5.x; iOS calendar gives the web side +plenty of room to ship this before freeze. + +Server-side: +- `POST /api/import/apple-health-export` accepting `multipart/form-data` +- Streaming XML parser (Node `sax-stream` or similar) +- Per-`MeasurementType` ingestion stats + duration in the response +- Idempotent UPSERT keyed on `externalId` so re-imports are no-ops +- Operator-side admin endpoint variant for support-staff-driven imports +- Async job model: large files (100 MB - 1 GB) need a background + worker; the synchronous endpoint returns a job ID, iOS polls + `GET /api/import/apple-health-export/{jobId}/status` +- Apple Health XML row types: HKQuantityTypeSample, HKCategoryTypeSample, + HKWorkout, HKClinicalRecord (clinical out-of-scope per R-F T3 + defer). Map each to existing MeasurementType + Workout models. + +Effort: L (~3-4 days). Slots into the v1.4.x freeze-marker patch +(v1.4.34) cleanly. + +iOS-side: ship the placeholder "Apple Health Import — coming in +v1.4.34" in Settings now if you want the surface visible early. + +### R3 — externalId shape lock — confirmed, lands in v1.4.30 + +ANSWER: confirmed locked in v1.4.30. The lock-line will appear at +`.planning/v15-ios-handoff/08-locked-contracts.md` once the v1.4.30 +implementation agent (currently running, ETA hours from now) +commits it. Will cite the exact file:line in the v1.4.30 closure +report at `.planning/round-v1430-closure-report.md`. + +The format is hard-fixed: `"stats::"` + +- `` is the literal Apple identifier + (e.g. `"HKQuantityTypeIdentifierStepCount"`), no trimming, no + case-change +- `` is the calendar date in the user's TZ at the + time the day-bucket closes (00:00:00 local of the following day), + zero-padded +- The two `:` separators are part of the shape — no other delimiter + variation + +No alternate shape will be accepted server-side after the lock. +iOS can start sending this format the moment v1.4.30 deploys. + +### R4 — v1.4.30 deploy date — within 24-48 hours of 2026-05-16 + +ANSWER: implementation agent is currently running; ship target is +within 24-48 hours from 2026-05-16 (so 2026-05-17 to 2026-05-18). +Server-team-side coordination drives the exact moment; this response +will be amended with the actual deploy timestamp once it lands. + +For iOS-side Mood-redesign timing: the safe path is a one-cycle +dual-write (write both `note` and `tags["note:..."]`) only if the +iOS Mood-redesign TestFlight ships BEFORE the v1.4.30 deploy. If +the iOS branch ships after, single-write the `note` column directly. + +Server-side back-fill collapses prior `tags["note:..."]` rows into +the new column in the v1.4.30 release — see commit 4 of the v1.4.30 +plan. After v1.4.30 deploy: existing rows have `note` populated, +new writes go straight to `note`. + +Recommended iOS sequence: +1. Wait for the v1.4.30 deploy announcement in `.planning/round-v1430-closure-report.md` +2. Cut the Mood-redesign TestFlight after that with single-write +3. No dual-write needed + +### R5 — assistant.coach feature flag — gates BOTH server-routed AND on-device surfaces + +ANSWER: yes. The operator-control philosophy is "operator can +disable assistant surfaces app-wide". `assistant.coach` and +`assistant.briefing` flags gate every assistant-driven surface on +every client, regardless of whether the LLM runs server-side or on +the device. + +- Server-side: the relevant `/api/insights/*` endpoints return + 403 + `errorCode: "assistant.disabled."` when the flag + is off. +- iOS-side: when `GET /api/feature-flags` returns `assistant.coach: false`, + iOS hides the Coach surfaces (whether they would have called the + server SSE OR run on-device). +- iOS-side: when `assistant.briefing: false`, iOS hides the Daily + Briefing card (whether it would have called the server OR generated + on-device). +- iOS-side: when `assistant.enabled: false` (the master flag), ALL + five sub-flags are effectively off — iOS hides every assistant + surface, server returns 403 on every assistant endpoint. + +iOS-side default to gate-both is correct. No client-server split +on flag semantics. + +### R6 — Coach SSE decoupling from v1.5 plan — confirmed, brief update queued + +ANSWER: confirmed. The contributor brief §5b "Coach SSE — the v1.5 +differentiator" is being updated to: + +> 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. + +Will commit alongside this response. + +### R7 — Source-priority editor divergence flag in v1.4.33 closure — committed + +ANSWER: yes. The v1.4.33 closure report at +`.planning/round-v1433-closure-report.md` will include a dedicated +"Source-priority editor divergence" section that flags any +deviation from the locked `GET/PUT /api/auth/me/source-priority` +contract. If the web ships with strict contract-parity (target +shape), the section will read "no divergence; iOS can mirror the +shape 1:1". If anything diverges (new optional keys, new constraint +logic), each delta gets cited with file:line + suggested iOS-side +treatment (mirror vs ignore). + +### R8 — APNs `.p8` paste status — pending operator (Marc) action + +ANSWER: not yet pasted. The `.p8` file (`M9WAFLNC2U`) lives in +operator-side `~/Downloads`. Pasting it into the Coolify env-vars +is a ~1-hour operator action — required no server-side coordination +beyond what already exists. + +Surfaced again in this response so it doesn't slip past the v1.5.0 +calendar window. The push-notification infrastructure on the server +is in place (v1.4.23 work); the missing piece is the env var. + +Will track operator-side completion and flag the deploy in a future +closure report. + +### R9 — SyncMode conflict resolution policy — LWW by `updatedAt`, server-wins on tie + +ANSWER: hard-spec follows. Will be added to +`.planning/v15-ios-handoff/08-locked-contracts.md` in v1.4.30 +alongside the daily-stats `externalId` lock. + +**Conflict-resolution policy under `SyncMode = paired`:** + +1. **Bulk-backfill (first-pair):** iOS pushes a backlog via + `POST /api/mood-entries/bulk` / `POST /api/medications/intake/bulk`. + Server UPSERTs every entry keyed on `externalId` (or + `clientId` if no `externalId` exists). Server's existing + uniqueness constraints handle dedup; LWW is not invoked. + +2. **Steady-state bidirectional sync:** every `Measurement` / + `MoodEntry` / `MedicationIntakeLog` row carries: + - `updatedAt DateTime` (server-set on every write) + - `syncVersion Int @default(1)` (server-incremented on every + write) + - `deletedAt DateTime?` (soft-delete, never hard-delete) + +3. **Write conflict:** + - iOS sends `PATCH /api//{id}` with optimistic-lock + header `If-Match: ` + - If server's `syncVersion` matches: accept, increment, return + 200 + new `syncVersion` + - If server's `syncVersion` is newer: REJECT with 409 + return + the canonical row payload (`{ data: , errorCode: "sync.conflict" }`) + - iOS-side resolution on 409: + - Default policy: **LWW by `updatedAt`** — whoever has the + newer `updatedAt` wins. If iOS local copy is newer, iOS + sends a fresh PATCH with the server's new `syncVersion`. + If server's copy 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. + - User-visible: optional small toast "synced with cloud + version — your changes were discarded" if iOS adopts the + server payload over a local edit. iOS-side UX call. + +4. **Delete conflict:** + - Soft-delete only via `PATCH /api//{id}` with + `deletedAt = now()`. Hard-delete blocked. + - If iOS PATCHes a soft-delete on a row the server has + subsequently edited: server returns 409 + canonical row. + iOS treats this as "server says this row was edited after + your delete intent — abort delete, prompt user to confirm". + - If iOS PATCHes an edit on a row the server has + soft-deleted: server returns 410 Gone. iOS adopts the + server's soft-delete state (or surfaces "this row was + deleted on another device — discard your edit?"). + +5. **Sync-state envelope:** + - `GET /api/sync/state` returns: + ```json + { + "data": { + "syncVersion": , + "lastSyncedAt": "", + "perEntity": { + "Measurement": , + "MoodEntry": , + "MedicationIntakeLog": + } + } + } + ``` + - iOS uses `perEntity` to decide which `?since=syncVersion=N` + reads to fire after a reconnect. + +iOS-side: this is enough to ship the v0.6.0 standalone-first track +with bidirectional sync. The LWW-by-updatedAt + server-wins-on-tie +default matches Apple convention. If iOS wants richer merge +semantics later (per-field LWW or three-way merge), that's a +v1.6 conversation. + +Status: hard-spec confirmed by this response. Server-side codifies +it in `08-locked-contracts.md` in the v1.4.30 commit alongside the +externalId lock. + +### R10 — Enum-add accepts pre-display — confirmed + +ANSWER: yes. Once v1.4.30 ships the Prisma enum extension + +the Zod validator update, the server accepts +`POST /api/measurements` with `type=WALKING_STEADINESS` or +`type=AUDIO_EXPOSURE_EVENT` immediately. The web-side display +landing in v1.4.33 is independent — the rows persist in the DB +from the moment v1.4.30 deploys; the web just doesn't render +them until v1.4.33. + +iOS can start ingesting these types in the same TestFlight build +that adopts the daily-stats `externalId` shape (note: +`WALKING_STEADINESS` is NOT in `CUMULATIVE_HK_TYPES` — it's +per-sample like other vitals; `AUDIO_EXPOSURE_EVENT` is also +per-sample / event-shaped, not cumulative). + +No build-flag gate is required iOS-side. + +## 4. New v1.4.x patches added per this response + +The web roadmap (`.planning/v15-strategic-plan.md`) is being updated +with two new patches: + +- **v1.4.34 — Apple Health XML import + web freeze marker**. + Replaces the original v1.4.33 freeze-marker. The freeze marker + now lands at v1.4.34, after the XML import endpoint ships. iOS + can plan against this for the "existing-user-with-history join" + flow. +- Misc smaller patches (v1.4.30.x hotfix-style) if any of R1-R10 + surface a small gap before freeze. + +Calendar still leaves multi-month room before iOS launches; the web +side will not pile on more features without a clear use case. + +## 5. iOS-side notes back + +- The two new MeasurementType enums (`WALKING_STEADINESS`, + `AUDIO_EXPOSURE_EVENT`) are NOT cumulative — they're per-sample + or event-shaped. They do not flow through the daily-stats + `externalId` path. Send them as raw per-sample rows like other + vitals. + +- iOS-side §6 mentions iOS uses real `APIClient` with stub + `URLSession` per the v0.2.0 audit lesson. Server-side mirror: + v1.4.29's real-Postgres integration-test container fixture is + the same discipline. Both sides honour "don't mock the boundary + you're testing". + +- The Apple Foundation Models pivot is a strong direction. Server-side + has no equivalent — no on-device runtime — so the server's + assistant-driven surfaces stay LLM-provider-routed (Anthropic / + OpenAI / etc. via the existing provider abstraction). iOS users + on AFM-capable devices get the on-device path; everyone else + (PWA mobile, non-AFM iOS devices, web desktop) gets the server + path. Both paths gate on the `assistant.*` feature flags + consistently (see R5). + +## 6. Open items back to iOS + +No counter-questions. Every R1-R10 answered above. Will refresh this +response if v1.4.30 deploy surfaces additional cross-coordination items. + +The next checkpoint is the `.planning/round-v1430-closure-report.md` +once v1.4.30 ships — that report carries: +- exact deploy timestamp on both hosts (answers R4 with precision) +- the cited line in `08-locked-contracts.md` confirming the + `externalId` shape lock (answers R3 with precision) +- the cited line in `08-locked-contracts.md` confirming the + conflict-resolution policy (answers R9 with precision) +- the `/api/measurement-categories` endpoint URL + response shape + (answers R1 with precision) + +## 7. Closing + +The iOS-side strategic direction (Apple Foundation Models on-device ++ standalone-first + extended calendar) is the right call for the +product identity and the regulatory posture. Web side adapts: keep +shipping additive surfaces, hold the freeze marker until iOS-side +actually needs it, and the v1.5.0 tag stays the "iOS native client +live on the App Store" marker as originally framed. + +iOS-side autopilot stays on Apple Foundation Models / standalone-first +work; server-side autopilot stays on the v1.4.30 → v1.4.34 patch run. +Coordination via these two response docs + the per-patch closure +reports is sufficient. diff --git a/.planning/ios-contributor-current-brief.md b/.planning/ios-contributor-current-brief.md new file mode 100644 index 000000000..c6b75a916 --- /dev/null +++ b/.planning/ios-contributor-current-brief.md @@ -0,0 +1,605 @@ +--- +file: .planning/ios-contributor-current-brief.md +purpose: Single onboarding brief for the iOS contributor — current server state, conventions, locked contracts, what is live, what is coming in v1.4.30, what is still pending. Pass this verbatim to the iOS development assistant. +created: 2026-05-16 +audience: iOS engineer + their automation +--- + +# HealthLog iOS contributor brief — current state + +This document is the single-page orientation for anyone (engineer or +automation) working on the iOS native client at +`/Users/marc/Projects/healthlog-iOS/`. Read it once; it points at the +locked-contract pack for everything else. + +## 1. The release model in one paragraph + +The web app ships every functional change the iOS client depends on +incrementally in v1.4.x patches. **v1.5.0 is a version-bump-only +marker** the web tags on the day the iOS app clears Apple review. +There is no coordinated web+iOS sprint that ships together. Web and +iOS run in parallel: the web side lands a patch, deploys, and the iOS +client consumes the new surface as soon as its next TestFlight build +is ready. The web freezes after v1.4.33 (last planned patch); from +that point only hotfixes + dependency updates touch the web until iOS +launches. + +## 2. Conventions every commit must honour + +These apply to web AND iOS contributors, every artefact, every PR: + +### Marc-Voice English + +Every commit message, code comment, CHANGELOG line, in-app string, +release note, planning doc reads as the maintainer's authorship. +Terse, professional, no emojis, no marketing fluff, no personal +pronouns ("I" / "we" / "our"). When in doubt, drop one adjective and +re-read. + +### Forbidden vocabulary + +Banned anywhere except backticked file paths and identifiers: + +- `AI`, `Claude`, `agent`, `marathon`, `wave`, `phase`, `session`, + `subagent`, `Anthropic` + +Substitute: `assistant`, `coach`, `automation`, `contributor`, +`round`, `pass`, `track`, `stream`. + +Two documented exemptions live in the locale bundles only — the +provider-chooser dropdown values `settings.ai.providerOptions.anthropic` +and `settings.ai.activeProviderOptions.anthropic` render +`Anthropic (Claude)` across all six locales because the operator is +selecting the literal vendor product. + +### No PII in user-facing artefacts + +Maintainer name, real health figures, real target ranges, real +measurement counts do not appear in commit messages, CHANGELOG, +release notes, in-app copy, locale bundles, public docs, GitHub +releases. Use placeholders ("the maintainer", "a moderate delta", +"tens of thousands of rows") if you need to reference shape. + +### iOS contract is additive only + +Every server-side change must be additive to the iOS client. No +renames, no drops, no type changes on endpoints + DTO fields + Prisma +columns the iOS client reads. New columns + new endpoints are +welcome. The locked-contract reference is at +`.planning/v15-ios-handoff/08-locked-contracts.md` — read it before +touching any `/api/*` route or any Prisma model the iOS client +consumes. + +### Branch + release discipline + +- Commit to `develop`. Never `main` directly. +- Release via PR `develop → main`, squash on `main`, tag on `main`. +- GHCR multi-arch builds publish from `main`. The `develop`-to-`main` + squash + tag pipeline is automated end-to-end except for the APNs + `.p8` paste (operator action — see §6). +- No `Co-Authored-By: Claude` trailer. +- No `--no-verify`. No `--no-gpg-sign`. + +## 3. What is live in production right now + +Both production hosts (`healthlog.bombeck.io` and +`demo.healthlog.dev`) currently serve **v1.4.29.1**. + +### Recent releases + +| Tag | Headline | Notes | +|---|---|---| +| v1.4.27 | Mobile capability sweep — ``, ``, `` | The mobile baseline | +| v1.4.28 | Bug-fix + scope-reduction follow-through | Retired the GLP-1 dashboard tile, the InsightAdvisorCard, the weekly-report code path. Locked-contract endpoints kept | +| v1.4.28.1 | Dashboard-save hotfix | Resolver filters retired widget ids from the saved layout on read | +| v1.4.29 | Dashboard performance + chart polish | aggregate=daily server path, AVG/SUM for cumulative HK types, pulse chart bounds, x-axis tick positions, mobile tile equal-height, drag-list compactness | +| v1.4.29.1 | Daily-step aggregation hotfix | Client-side daily aggregator branches on `CUMULATIVE_HK_TYPES` for sum vs average | + +### Endpoints the iOS client may consume today (additive contracts) + +Stable since their respective release tags. Every one is locked per +`.planning/v15-ios-handoff/03-api-contracts.md`. + +- `POST /api/auth/login` + `POST /api/auth/login/native-token` — bearer auth flow +- `POST /api/auth/passkey/*` — passkey enrollment + login + native-token verify +- `POST /api/auth/refresh` — bearer refresh +- `GET /api/auth/me` + `PATCH /api/auth/me/*` (timezone, doctor-report-prefs, devices, source-priority, research-mode) +- `GET /api/measurements` + `POST /api/measurements` + `PATCH /api/measurements/{id}` (additive 409 on duplicate-timestamp since v1.4.28; new `from`/`to`/`aggregate=daily|weekly|monthly` query params since v1.4.28, server-side bug closed in v1.4.29) +- `GET /api/medications/[id]/intake` — read-only (FB-E1 web mount retired in v1.4.28, endpoint preserved) +- `GET /api/medications/[id]/glp1` — full DTO including `Glp1InventoryDTO` slot (FB-E2 web mount retired in v1.4.28, DTO preserved) +- `POST /api/insights/generate` — assistant regen path (FB-J2 advisor card retired in v1.4.28, endpoint preserved for iOS) +- `GET /api/insights/chat` (SSE) — Coach stream +- `GET /api/insights/cards`, `GET /api/insights/correlations`, `GET /api/insights/comprehensive`, `GET /api/insights/targets` +- `POST /api/workouts/batch` — batch ingest from HKWorkout (since v1.4.25 W8d; canonical-row picker added in v1.4.30 — see §4) +- `GET /api/version` — live build + `offlineGeoEnabled` flag +- 47 distinct paths total per the R-E iOS audit (`/Users/marc/Projects/HealthLog/.planning/research/v1428-r1-ios-contracts.md` enumerates them; mostly admin + monitoring routes not iOS-relevant) + +### Server-side cumulative-type aggregation already in place + +Since v1.4.29 the server's `GET /api/measurements?aggregate=daily|weekly|monthly` +path reduces with `SUM` for cumulative HealthKit types — see the +`CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`: + +- `ACTIVITY_STEPS` +- `ACTIVE_ENERGY_BURNED` +- `FLIGHTS_CLIMBED` +- `WALKING_RUNNING_DISTANCE` +- `TIME_IN_DAYLIGHT` + +Every other measurement type reduces with `AVG`. The client-side +daily aggregator in `health-chart.tsx` follows the same branch since +v1.4.29.1. + +## 4. What is incoming in v1.4.30 (currently being implemented) + +v1.4.30 is the iOS-coordinated foundation patch. Ships in 2-3 days. +Every change additive. Track the canonical scope at +`.planning/v15-strategic-plan.md` §2 "v1.4.30 — iOS-coordinated +foundation". + +### Daily-Stats externalId — the cutover for cumulative HK types + +The biggest single iOS-side coordination point. v1.4.30 ships: + +- A new helper `dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` + returning `"stats::"` — locked + in `.planning/v15-ios-handoff/08-locked-contracts.md`. The shape is + the iOS-side contract for one-row-per-day-per-cumulative-type ingest. + +- The server tolerates BOTH ingest shapes during the cutover: existing + per-sample rows AND new daily-aggregated rows. The existing + `@@unique([userId, type, measuredAt, source, sleepStage])` constraint + keeps both shapes deduplicated. + +- A drain script `scripts/drain-per-sample-cumulative.ts` (CLI + admin + endpoint `POST /api/admin/drain-per-sample-cumulative`) idempotently + collapses existing per-sample APPLE_HEALTH cumulative rows into one + daily row per type per user-calendar-day. Operator runs it once + after the iOS TestFlight build cuts over. + +### iOS-side responsibility (please implement in the next TestFlight build) + +Build a `HealthKitStatisticsService.swift` per R-A §5: + +- For every `CUMULATIVE_HK_TYPES` member, run + `HKStatisticsCollectionQuery` with + `intervalComponents: DateComponents(day: 1)` and + `options: .cumulativeSum` (for steps / energy / flights / distance / + daylight). Period: from each user's `firstSampleAt` or sensible + bound (last 365 days for first install, anchored for incremental + syncs). +- For each daily bucket, POST one row via the existing + `POST /api/measurements` (or the batch endpoint) with: + - `type` = mapped MeasurementType + - `source` = `APPLE_HEALTH` + - `value` = day total + - `measuredAt` = midday UTC of the user's calendar day (matches the + Withings activity-sync convention) + - `externalId` = `"stats::"` +- Keep a per-day last-posted-value cache. On a late-watch-sync where + the day's total changes after the row was first posted, send a + `PATCH /api/measurements/{id}` (or re-POST with the same + `externalId` to trigger an UPSERT) so the server row tracks the + true total. +- Gate behind a build-flag `ENABLE_DAILY_STATS` — default ON for the + cut-over TestFlight build. This stops sending per-sample rows for + cumulative types simultaneously. + +Spot metrics (BP, weight, pulse, mood, glucose, body fat, sleep) +stay per-sample as today. + +### SyncMode foundation + +For the standalone-first / paired-with-server / cloud-sync trio (see +`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md`). +v1.4.30 ships: + +- New columns: `Measurement.syncVersion Int @default(1)` + + `Measurement.deletedAt DateTime?` (soft-delete) +- New columns: `User.lastSyncedAt DateTime?` +- New endpoint `GET /api/sync/state` — returns per-user current + `syncVersion` and `lastSyncedAt` so iOS can decide whether to pull + or push. +- New endpoints `POST /api/mood-entries/bulk` and + `POST /api/medications/intake/bulk` — bulk-backfill paths for the + pair-fresh-server flow. + +### MoodEntry.note as a first-class column + +Replaces the `tags: ["note:"]` workaround. The iOS app currently +encodes mood notes as a tag prefix; in v1.4.30+ the iOS app should +send `note: string` directly on the mood-entry payload. Server-side +backfill collapses the prior `note:`-tag rows into the new column. + +### Workout canonical-row picker + +`pickCanonicalWorkoutRows()` lands in `src/lib/measurements/` to +dedup Apple Watch + Withings ScanWatch workouts that report the same +session. iOS-side workout ingest stays unchanged — the server picks +the canonical row. + +### Categorisation overlay + +`src/lib/measurements/categories.ts` is a TypeScript map of +MeasurementType → category (vitals / body / activity / sleep / +hearing / environment / cardiovascular / metabolic / mood / +medication). iOS HealthKit permission picker should derive its groups +from this map; the web Insights nav will too. iOS-side: read the map +on app launch (or build-time-codegen it from the server's OpenAPI +schema). + +### Two new MeasurementType enums + +- `WALKING_STEADINESS` (mapped from `HKQuantityTypeIdentifierAppleWalkingSteadiness`) +- `AUDIO_EXPOSURE_EVENT` (mapped from `HKCategoryTypeIdentifierAudioExposureEvent`) + +iOS-side: these will land in the iOS app's `MeasurementType` mirror +enum after the OpenAPI codegen wiring closes (R-E H-8). Until then, +iOS can ignore them or the manual enum-extension matches the server +addition. + +## 5. What is incoming in v1.4.31 - v1.5.0 + +Plan reference: `.planning/v15-strategic-plan.md` §2. Every change +listed below is additive on the iOS contract unless explicitly +flagged otherwise — and nothing is flagged otherwise. By v1.5.0 every +endpoint + DTO + Prisma column listed here is live in production. The +iOS-side implementation can be written against the planned surfaces +in parallel, ready to consume each one as soon as the corresponding +web patch deploys. + +### v1.4.31 — Operator toggles + insights tab-strip blocking + Coolify auto-deploy fix + +**New API surface (iOS can consume from v1.4.31 onwards):** + +- `GET /api/feature-flags` — public endpoint (auth-required but no + admin gate). Response shape: + ```json + { + "data": { + "assistant": { + "enabled": true, + "coach": true, + "briefing": true, + "insightStatus": true, + "correlations": true, + "healthScoreExplainer": true + } + } + } + ``` + iOS calls this on app launch and caches per-session. When any flag + is `false`, the relevant iOS surface should hide (Coach tab, daily + briefing card, per-metric insight status, correlation card, + delta-explainer `?`). + +**Endpoints that may now return 403 with `errorCode: "assistant.disabled."`:** + +| Endpoint | Flag | +|---|---| +| `GET /api/insights/chat` (SSE) | `coach` | +| `POST /api/insights/generate` | `coach` (regenerate is part of the Coach feature set) | +| `GET /api/insights/comprehensive` | `coach` | +| `GET /api/insights/briefing` (if exists; per v1.5 research) | `briefing` | +| `GET /api/insights/cards` per-metric status | `insightStatus` | +| `GET /api/insights/correlations` | `correlations` | +| (No endpoint for healthScoreExplainer — it's a pure client-side overlay) | `healthScoreExplainer` | + +**iOS handling**: catch 403 + `errorCode: "assistant.disabled."`, +render a neutral "Coach is disabled by your operator" / "Briefing is +disabled by your operator" view in the affected slot. No retry, no +backoff — wait for the user to navigate elsewhere. + +**Other v1.4.31 items (not iOS-facing):** +- Insights tab-strip blocking fixes (web client only) +- Coolify auto-deploy investigation (ops only) + +### v1.4.32 — HealthKit Tier 1 web surfaces wave A + +The web surfaces for Workouts + 5 of the 10 invisible-but-stored +metrics land here. **No new endpoints**; iOS continues consuming the +existing endpoints. The contracts iOS plans against: + +- `POST /api/workouts/batch` (live since v1.4.25 W8d) gains the + server-side `pickCanonicalWorkoutRows()` dedup (landed in v1.4.30). + iOS keeps its existing batch shape. +- `GET /api/workouts` (if not already live — check the route file in + v1.4.30 verification) — list workouts. iOS can mirror the new web + workout-list page. +- `GET /api/workouts/{id}` — workout detail with cross-source merged + payload, route GPS optional, HR-zone samples optional. iOS-side + workout detail screen consumes this shape. +- `GET /api/measurements?type=HRV` (and same for RestingHR, SpO2, + BodyTemperature, ActiveEnergyBurned) — all already live since the + type was added to the enum. iOS-side chart cards consume the same + `/api/measurements` path it uses for every other type. + +### v1.4.33 — HealthKit Tier 1 web surfaces wave B + web freeze marker + +The breadth wave + the editor parity surfaces. iOS can consume: + +**Existing endpoints with new MeasurementType values flowing through them:** + +- `GET /api/measurements?type=FLIGHTS_CLIMBED` — daily-aggregated + cumulative-type shape per R-A. +- Same for `WALKING_RUNNING_DISTANCE`, `AUDIO_EXPOSURE_ENV`, + `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT`. +- `GET /api/measurements?type=WALKING_STEADINESS` — new MeasurementType + added in v1.4.30; iOS picks this up via the v1.4.30 codegen extension. +- `GET /api/measurements?type=AUDIO_EXPOSURE_EVENT` — same. + +**Source-priority editor parity:** + +- `GET /api/auth/me/source-priority` and `PUT /api/auth/me/source-priority` + — both live and locked since v1.4.25 W8c. iOS implementation of the + two-axis editor under Settings → Sources & Devices can be built + against the existing API; the web-side editor that lands in v1.4.33 + is a reference shape iOS can mirror. + +**HKStateOfMind round-trip:** + +- `POST /api/measurements` with `type=MOOD` and `source=APPLE_HEALTH` + — already live since the MOOD type exists. v1.4.33 wires + `/insights/stimmung` to surface APPLE_HEALTH-sourced mood entries + cleanly. iOS-side: write HKStateOfMind samples via the existing + POST path; read back via `GET /api/measurements?type=MOOD`. The + `HKMetadataKeyExternalUUID` round-trip filter (per R-F open Q #5) + prevents iOS from re-uploading its own samples. + +### v1.5.0 — version-bump-only marker + +Tags when the iOS app clears Apple review. CHANGELOG entry: "iOS +native client now live on the App Store; web functionality unchanged +since v1.4.33." Triggers a GHCR rebuild so the running image's +`/api/version` sentinel matches the public tag. **No source diff, +no API change, no DTO change.** From this point every web change +coordinates with iOS. + +## 5b. Forward state at v1.5.0 — what the iOS app can rely on + +By the time v1.5.0 ships, every iOS-facing surface listed below is +live and locked. The iOS engineer can plan the full v1.5 feature +set against this state — there are no further additive contracts +planned for v1.4.x. + +### Endpoints (locked + additive only) + +| Path | Method | Status by v1.5.0 | +|---|---|---| +| `/api/auth/login` | POST | Stable since v1.4.x | +| `/api/auth/login/native-token` | POST | Stable since v1.4.23 | +| `/api/auth/passkey/enrolment/start` | POST | Stable | +| `/api/auth/passkey/enrolment/finish` | POST | Stable | +| `/api/auth/passkey/login-start` | POST | Stable | +| `/api/auth/passkey/login-verify` | POST | Stable | +| `/api/auth/refresh` | POST | Stable | +| `/api/auth/me` | GET, PATCH | Stable | +| `/api/auth/me/timezone` | PATCH | Stable | +| `/api/auth/me/source-priority` | GET, PUT | Locked v1.4.25 W8c | +| `/api/auth/me/research-mode` | GET, PATCH | Stable | +| `/api/auth/me/doctor-report-prefs` | GET, PATCH | Stable | +| `/api/auth/me/devices` | GET | Stable | +| `/api/auth/me/devices/{id}` | PATCH, DELETE | Stable | +| `/api/measurements` | GET, POST | Stable; `from`/`to`/`aggregate` query params live since v1.4.29 | +| `/api/measurements/{id}` | PATCH | Stable; 409 on duplicate-timestamp since v1.4.28 | +| `/api/measurements/by-external-ids` | DELETE | Stable; used by drain script + iOS sync | +| `/api/mood-entries` | GET, POST | Stable; `note` column lands in v1.4.30 | +| `/api/mood-entries/bulk` | POST | NEW in v1.4.30 | +| `/api/medications` | GET, POST | Stable | +| `/api/medications/{id}` | GET, PATCH, DELETE | Stable | +| `/api/medications/{id}/intake` | GET, POST | Stable; preserved per FB-E1 | +| `/api/medications/{id}/intake/{logId}` | PATCH, DELETE | Stable | +| `/api/medications/intake/bulk` | POST | NEW in v1.4.30 | +| `/api/medications/{id}/glp1` | GET | Stable; Glp1InventoryDTO preserved per FB-E2 | +| `/api/medications/{id}/inventory` | GET, POST | Stable | +| `/api/medications/{id}/side-effects` | GET, POST | Stable | +| `/api/medications/{id}/side-effects/{logId}` | PATCH, DELETE | Stable | +| `/api/medications/{id}/titration` | GET, PATCH | Stable | +| `/api/medications/{id}/cadence` | GET, PATCH | Stable | +| `/api/workouts` | GET | Verified in v1.4.32 | +| `/api/workouts/{id}` | GET | Verified in v1.4.32 | +| `/api/workouts/batch` | POST | Stable since v1.4.25 W8d; canonical-row picker server-side in v1.4.30 | +| `/api/personal-records` | GET | Stable | +| `/api/insights/chat` | GET (SSE) | Stable; 403 + assistant.disabled.coach possible from v1.4.31 | +| `/api/insights/generate` | POST | Stable; preserved per FB-J2; 403 from v1.4.31 | +| `/api/insights/comprehensive` | GET | Stable; 403 from v1.4.31 | +| `/api/insights/cards` | GET | Stable; 403 from v1.4.31 | +| `/api/insights/correlations` | GET | Stable; 403 from v1.4.31 | +| `/api/insights/targets` | GET | Stable | +| `/api/insights/briefing` | GET | Verified in v1.4.31; 403 from v1.4.31 | +| `/api/sync/state` | GET | NEW in v1.4.30 | +| `/api/feature-flags` | GET | NEW in v1.4.31 | +| `/api/dashboard/widgets` | GET, PUT, DELETE | Stable | +| `/api/dashboard/summary` | GET | Stable | +| `/api/dashboard/chart-overlay-prefs` | GET, PATCH | Stable | +| `/api/admin/drain-per-sample-cumulative` | POST | NEW in v1.4.30 (admin-gated) | +| `/api/version` | GET | Stable; `offlineGeoEnabled` flag + version sentinel | + +### Prisma schema additions through v1.5.0 + +All additive. No iOS-breaking drops + no type changes. + +| Column / enum | Patch | Notes | +|---|---|---| +| `Measurement.syncVersion Int @default(1)` | v1.4.30 | SyncMode foundation | +| `Measurement.deletedAt DateTime?` | v1.4.30 | Soft-delete column | +| `Measurement.externalId String?` (verify if already exists) | pre-existing | UPSERT key for daily-stats rows | +| `User.lastSyncedAt DateTime?` | v1.4.30 | SyncMode foundation | +| `MoodEntry.note TEXT NULL` | v1.4.30 | Replaces `tags: ["note:..."]` workaround | +| `AppSettings.assistantEnabled Boolean @default(true)` | v1.4.31 | Master feature flag | +| `AppSettings.coachEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.briefingEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.insightStatusEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.correlationsEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `AppSettings.healthScoreExplainerEnabled Boolean @default(true)` | v1.4.31 | Sub-flag | +| `enum MeasurementType { … WALKING_STEADINESS }` | v1.4.30 | New value, iOS picks up via codegen | +| `enum MeasurementType { … AUDIO_EXPOSURE_EVENT }` | v1.4.30 | New value | + +### MeasurementType + categorisation overlay + +By v1.5.0, the categorisation overlay at +`src/lib/measurements/categories.ts` is locked. iOS reads the map at +build-time via OpenAPI codegen (or mirrors it manually): + +| Category | MeasurementType values | +|---|---| +| `vitals` | `BP_SYSTOLIC`, `BP_DIASTOLIC`, `PULSE`, `OXYGEN_SATURATION`, `BODY_TEMPERATURE` | +| `body` | `WEIGHT`, `BODY_FAT`, `TOTAL_BODY_WATER`, `BONE_MASS` | +| `activity` | `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `WALKING_STEADINESS` | +| `cardiovascular` | `HRV`, `RESTING_HR`, `VO2_MAX` | +| `sleep` | `SLEEP_*` (per-stage rows) | +| `hearing` | `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `AUDIO_EXPOSURE_EVENT` | +| `environment` | `TIME_IN_DAYLIGHT` | +| `metabolic` | `BLOOD_GLUCOSE`, `BMI` | +| `mood` | `MOOD` | +| `medication` | (medications use a separate model — not a MeasurementType) | + +The iOS HealthKit permission picker derives its groups from this +map. + +### Daily-stats cumulative-type ingest (R-A Option A) + +By v1.5.0 the iOS TestFlight build is posting one row per day per +cumulative type. The `CUMULATIVE_HK_TYPES` set is: + +- `ACTIVITY_STEPS` ↔ `HKQuantityTypeIdentifierStepCount` +- `ACTIVE_ENERGY_BURNED` ↔ `HKQuantityTypeIdentifierActiveEnergyBurned` +- `FLIGHTS_CLIMBED` ↔ `HKQuantityTypeIdentifierFlightsClimbed` +- `WALKING_RUNNING_DISTANCE` ↔ `HKQuantityTypeIdentifierDistanceWalkingRunning` +- `TIME_IN_DAYLIGHT` ↔ `HKQuantityTypeIdentifierTimeInDaylight` + +`externalId` shape: `"stats::"` +locked in `.planning/v15-ios-handoff/08-locked-contracts.md`. + +### SyncMode trio (standalone-first / paired-with-server / cloud-sync) + +By v1.5.0 the iOS app supports all three modes. The web-side +endpoints are stable: + +- `GET /api/sync/state` returns current server `syncVersion` + + `lastSyncedAt` +- `POST /api/mood-entries/bulk` + `POST /api/medications/intake/bulk` + let iOS push a backlog after pairing +- `Measurement.deletedAt` makes tombstoned-row sync straightforward + +Mode-switching is iOS-local; the server has no concept of "this user +is in standalone mode". The server just answers whatever the iOS app +asks. + +### Coach SSE — the v1.5 differentiator + +By v1.5.0 the iOS Coach drawer is the native implementation of +`GET /api/insights/chat`. Reference web implementation in +`src/components/insights/coach-panel/`. Contract specifics: + +- Server-Sent Events stream +- Token-by-token streaming text +- Provenance events embedded with each token chunk +- Refusal events per GROUND-RULE-9 / 15 +- 429 `coach.budget.exceeded` on rate-limited paths +- 403 `assistant.disabled.coach` if the operator-side flag is off + +### Push notifications (post-APNs `.p8` paste) + +The APNs scaffolding exists server-side. After the operator pastes +the `.p8` key, server-issued push notifications start. The iOS-side +handler should already be wired per the v1.4.23 work — verify in the +iOS repo. + +### What is explicitly NOT in v1.5 + +These defer to v1.5.x or v1.6: + +- HealthKit Tier 2 categories (R-F T2): workout power metrics, + workout-effort score, sleep apnea breathing disturbances, mindful + sessions, six-minute walk, HR recovery +- HealthKit Tier 3 (R-F T3): FHIR clinical, ECG waveforms, atrial-fib + burden, PHQ-9/GAD-7, reproductive, nutrition, Apple Watch + independent app +- Apple Health XML import (`export.zip` ingest) — open question per + v1.5 plan §6 #3 +- Two-axis source-priority editor on iOS — endpoint exists; UI lands + in v1.5.x +- C1 architectural lift on `/api/analytics` (split into dashboard + + insights surfaces, SQL-side aggregation) — v1.5.x + +## 6. iOS-side blockers and pending coordination + +### APNs `.p8` paste (operator action, 1 hour) + +The Apple Push Notification Service key has not been pasted into the +Coolify environment yet. Per the R-E C-3 finding, this is a 1-hour +operator action that gates server-issued push notifications. The +APNs scaffolding (server-side route + iOS-side handler) is partially +in place from the v1.4.23 work; the missing piece is the operator +pasting the `.p8` into the deployment environment. + +**Status: pending — operator (Marc) has not yet pasted the key.** +This blocks push notifications only; everything else functions +without it. + +### Coach SSE drawer iOS implementation + +Per R-E C-1, the iOS Coach drawer has zero native code today. The +server-side SSE endpoint at `/api/insights/chat` is live and tested. +The v1.5 differentiator is the native Coach drawer. The iOS side is +the implementation: + +- `CoachService` actor over `URLSession.bytes` +- `CoachStreamEvent` AsyncThrowingStream +- SwiftData-backed `CoachConversation` cache +- Streaming bubble view +- Provenance disclosure +- GROUND-RULE-9 / 15 refusal-acceptance UI +- `coach.budget.exceeded` 429 surface + +Pattern reference: `src/components/insights/coach-panel/` (web +implementation) is the model. The iOS implementation should mirror +the user experience while consuming the same SSE stream. + +### Source-priority editor + +The endpoint `/api/auth/me/source-priority` has been locked since +v1.4.25 W8c. The iOS app does not call it anywhere today. v1.4.33 +adds a web-side editor as a reference shape; iOS should mirror the +two-axis editor under Settings → Sources & Devices. + +## 7. Reference paths + +If the iOS contributor needs deep context: + +| Doc | Purpose | +|---|---| +| `.planning/v15-ios-handoff/03-api-contracts.md` | Every endpoint + DTO shape | +| `.planning/v15-ios-handoff/04-data-model.md` | Prisma schema + invariants | +| `.planning/v15-ios-handoff/06-ios-responsibilities.md` | What iOS owns vs server | +| `.planning/v15-ios-handoff/07-server-responsibilities.md` | The mirror | +| `.planning/v15-ios-handoff/08-locked-contracts.md` | The do-not-touch list | +| `.planning/v15-ios-handoff/14-coach-mental-model.md` | Coach drawer architecture | +| `.planning/v15-ios-handoff/15-insights-architecture.md` | Insights pipeline | +| `.planning/v15-ios-handoff/16-health-score-logic.md` | HealthScore algorithm | +| `.planning/v15-ios-handoff/17-error-handling.md` | Server error contracts | +| `.planning/v15-ios-handoff/18-pattern-cookbook.md` | Common patterns | +| `.planning/v15-ios-handoff/22-offline-first-architecture.md` | Offline-first model | +| `.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` | Pair / unpair flow | +| `.planning/v15-strategic-plan.md` | The full strategic plan | +| `.planning/research/v15-r-a-step-aggregation.md` | R-A daily-stats deep dive | +| `.planning/research/v15-r-e-ios-planning-state.md` | iOS gap audit (28 finds) | +| `.planning/research/v15-r-f-apple-health-depth.md` | HealthKit coverage roadmap | + +## 8. Communication + +The web side ships continuously. The iOS contributor should not block +on web releases — read this brief weekly (the maintainer can refresh +it after every patch), and pick up new server surfaces as soon as +they're documented in the CHANGELOG + `.planning/round-v14NN-closure-report.md`. + +When the iOS-side needs a server change, the request goes through +the maintainer; if the request is additive on the iOS contract the +web ships the change in the next patch. If the request is breaking, +the maintainer either reshapes the request additively or coordinates +a paired release (which is currently out of scope until v1.5.x). + +The next web release is v1.4.30 — wait for the closure report at +`.planning/round-v1430-closure-report.md` before adopting the +daily-stats path. diff --git a/.planning/round-v1429-closure-report.md b/.planning/round-v1429-closure-report.md index 07c831f9f..fb7afd7b3 100644 --- a/.planning/round-v1429-closure-report.md +++ b/.planning/round-v1429-closure-report.md @@ -63,3 +63,38 @@ v1.4.30 scope grows from the original iOS-server-prep menu to that menu plus the ## Closure complete v1.4.29 lives on both production hosts and the GitHub Release reads it as the latest. The next patch is v1.4.30 (iOS-server-prep menu + insights-blocking + assistant-optional toggles). + +## v1.4.29.1 follow-up — daily-step aggregation hotfix + +Shipped 2026-05-16 a few hours after v1.4.29. Maintainer reported the +dashboard 7-day step chart still rendered per-sample averages +(hundreds) instead of daily totals (thousands). Root cause was the +client-side daily aggregator inside `health-chart.tsx` reducing every +HealthKit type with `sum / count` regardless of cumulative vs spot +semantics — the same bug v1.4.29 closed on the server-side aggregate +path was still live on the in-browser bucketing of raw rows. + +Fix at `c265103b` branches on `CUMULATIVE_HK_TYPES` and picks `sum` +for steps, active energy, flights climbed, distance, daylight; spot +metrics keep the mean. One-line aggregator change, no test additions +(the 137 existing chart specs cover the surface). + +### Outcome + +- `healthlog.bombeck.io/api/version` → `1.4.29.1`, `/privacy` → 200. +- `demo.healthlog.dev/api/version` → `1.4.29.1`, `/privacy` → 200. +- GitHub Release: . +- Sister-repos: `healthlog-docs@5e7f5cd3`, `healthlog-landing@11f18ce6`. +- PR #173 squashed on `main` at `945b5566`; tag `v1.4.29.1` points there. +- GHCR tag-build produced the named `1.4.29.1` cleanly (the `3a920661` workflow fix continues to hold). + +### Deploy mechanics + +apps01 deploy via Coolify reported "finished" but did not actually +pull the new `:latest` digest (third release in a row — the same +auto-deploy gap that blocked v1.4.27/v1.4.28/v1.4.29). Host-side SSH +fallback (`docker pull 1.4.29.1 && docker tag … :latest && compose up +-d --force-recreate`) finished the rollout. Edge-01 used the explicit +`1.4.29` → `1.4.29.1` sed bump on docker-compose with a +`.pre-v14291.bak` snapshot; clean execution. + diff --git a/.planning/v15-ios-handoff/06-ios-responsibilities.md b/.planning/v15-ios-handoff/06-ios-responsibilities.md index e2e2b3511..51294decf 100644 --- a/.planning/v15-ios-handoff/06-ios-responsibilities.md +++ b/.planning/v15-ios-handoff/06-ios-responsibilities.md @@ -349,6 +349,43 @@ The iOS app could accidentally cross the MDR line by: When in doubt: **the iOS app is a thin client over server-rendered intelligence**. The server is the medical-information custodian; iOS is the presentation layer + the HK adapter. +## Cumulative metrics: daily aggregation on iOS (v1.4.30 — R-A Option A) + +The five cumulative HealthKit types — `HKQuantityTypeIdentifierStepCount`, `HKQuantityTypeIdentifierActiveEnergyBurned`, `HKQuantityTypeIdentifierFlightsClimbed`, `HKQuantityTypeIdentifierDistanceWalkingRunning`, `HKQuantityTypeIdentifierTimeInDaylight` — switch from per-sample ingest to **one row per day per type**, pre-aggregated on iOS via `HKStatisticsCollectionQuery` with `intervalComponents: DateComponents(day: 1)` and `options: .cumulativeSum`. + +Rationale + decision record: `.planning/research/v15-r-a-step-aggregation.md` §5. Server enforcement of the externalId shape: `.planning/v15-ios-handoff/08-locked-contracts.md` §13. + +### Daily-stats externalId shape + +``` +externalId = "stats::" +``` + +Example: `stats:HKQuantityTypeIdentifierStepCount:2026-05-16`. + +The date string is anchored to the user's IANA timezone (read via `GET /api/auth/me` → `User.timezone`). iOS generates it with `DateFormatter` using the `yyyy-MM-dd` pattern; the server trusts the format and does not re-validate. The existing `@@unique([userId, type, source, externalId])` index UPSERTs idempotently across re-syncs. + +### iOS service contract + +- New file `HealthLog/Services/HealthKitStatisticsService.swift` wraps `HKStatisticsCollectionQuery` per cumulative type. On every observer wake, ask "what's the new daily sum for the affected days?" and POST one row per affected day per type via the existing `/api/measurements/batch` endpoint. +- `value` = `HKStatistics.sumQuantity().doubleValue(for: )`. +- `measuredAt` = the canonical midday-UTC timestamp for the user's calendar day (matches the Withings activity-sync convention). +- `unit`, `startDate`, `endDate` follow the per-day envelope (canonical unit; `startDate` = day-start, `endDate` = next-day-start − 1 ms in user TZ). +- `deviceType` = the dominant contributing source's device (`watch` when the user wears one, else `phone`). +- `externalId` = `dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` per the helper in `src/lib/measurements/apple-health-mapping.ts`. + +### Late-watch-sync (PATCH-on-divergence) + +When the Watch syncs at 14:00 with samples from the same morning, the existing day's row should **update**, not insert a second. The deterministic externalId collapses the second POST to `status: "duplicate"` via the unique index; to make the new value visible iOS keeps a small SQLite cache of `(type, day, last-posted-value)` and issues `PATCH /api/measurements/[id]` when the new daily total diverges. Insert-only batch ingest stays narrow; the PATCH path already exists. + +### Server tolerance during cutover + +The server accepts BOTH shapes — pre-existing per-sample rows (`externalId = HKSample.uuid`) and the new daily-aggregated rows (`externalId = "stats:..."`) coexist for the cutover window. Operator runs `POST /api/admin/drain-per-sample-cumulative` (or `scripts/drain-per-sample-cumulative.ts` directly) once after the new TestFlight build adopts the daily path; the drain collapses pre-existing per-sample APPLE_HEALTH cumulative rows into daily rows keyed on the new externalId shape. Re-running the drain is a no-op (idempotent). + +### Spot-sample path unchanged + +Non-cumulative HK types (weight, BP, pulse, BG, body fat, HRV, RHR, SpO2, body temp, VO2 max, sleep) keep `externalId = HKSample.uuid.uuidString`. Only the five cumulative types switch shape; only their iOS pipeline (`HealthKitStatisticsService.swift`) is new. + ## "Since v1.4.24" diff markers - **NEW v1.4.25** — `MeasurementType` enum gained `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT` for HK + Withings parity. @@ -356,6 +393,7 @@ When in doubt: **the iOS app is a thin client over server-rendered intelligence* - **NEW v1.4.25 W16c** — `PERSONAL_RECORD` event type for APNs; default OFF. - **NEW v1.4.25 W10** — batch ingest rate limit 60/min per user (was unbounded). - **NEW v1.4.25 W17b/c** — Withings now syncs Activity v2 + Sleep v2 — iOS should be aware that some HK metrics may arrive via the Withings path (no action required, but the source-priority picker now matters more). +- **NEW v1.4.30** — Cumulative HealthKit types switch to one-row-per-day daily-aggregated ingest. iOS owns `HealthKitStatisticsService.swift` with `HKStatisticsCollectionQuery`; per-day externalId is `"stats::"`. Per-sample path stays valid during the cutover (server tolerates both shapes); operator runs the drain script once post-cutover. See the "Cumulative metrics: daily aggregation on iOS" section above and `08-locked-contracts.md` §13. ## iOS implementation checklist diff --git a/.planning/v15-ios-handoff/08-locked-contracts.md b/.planning/v15-ios-handoff/08-locked-contracts.md index b2e1863b0..92fc3e6e8 100644 --- a/.planning/v15-ios-handoff/08-locked-contracts.md +++ b/.planning/v15-ios-handoff/08-locked-contracts.md @@ -466,7 +466,61 @@ BEFORE shipping. --- -## 12. What is NOT in this file +## 12. Daily-stats externalId shape (v1.4.30 — R-A Option A) + +The five cumulative HealthKit types switch from per-sample ingest +(`externalId = HKSample.uuid`) to one row per day per type, pre-aggregated +on iOS via `HKStatisticsCollectionQuery`. The per-day row's +`externalId` is locked at this shape: + +``` +externalId = "stats::" +``` + +Examples: + +| HK identifier | Day | externalId | +| --- | --- | --- | +| `HKQuantityTypeIdentifierStepCount` | 2026-05-16 | `stats:HKQuantityTypeIdentifierStepCount:2026-05-16` | +| `HKQuantityTypeIdentifierActiveEnergyBurned` | 2026-05-16 | `stats:HKQuantityTypeIdentifierActiveEnergyBurned:2026-05-16` | +| `HKQuantityTypeIdentifierFlightsClimbed` | 2026-05-16 | `stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16` | +| `HKQuantityTypeIdentifierDistanceWalkingRunning` | 2026-05-16 | `stats:HKQuantityTypeIdentifierDistanceWalkingRunning:2026-05-16` | +| `HKQuantityTypeIdentifierTimeInDaylight` | 2026-05-16 | `stats:HKQuantityTypeIdentifierTimeInDaylight:2026-05-16` | + +**Scope**: cumulative types only. The set is the canonical +`CUMULATIVE_HK_TYPES` in `src/lib/measurements/apple-health-mapping.ts`. +Spot metrics (weight, BP, pulse, BG, body fat, HRV, RHR, SpO2, body +temp, VO2 max, sleep) keep `externalId = HKSample.uuid.uuidString`. + +**Server enforcement**: the helper +`dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` is the single source +of truth on the server side. The drain script +`scripts/drain-per-sample-cumulative.ts` mints the same shape when +collapsing legacy per-sample rows. + +**Date string**: anchored to the user's IANA timezone (read via +`GET /api/auth/me` → `User.timezone`). iOS generates it with +`DateFormatter` using the `yyyy-MM-dd` pattern. The server trusts the +inbound format and does not re-validate beyond the existing +`externalId` Zod cap (`min(1).max(120)`). + +**Cutover tolerance**: the server accepts BOTH shapes during the +cutover window. Operator runs the drain script +(`POST /api/admin/drain-per-sample-cumulative` or the CLI variant) once +after the new TestFlight build adopts the daily-stats path; re-running +the drain is a no-op (idempotent). + +**Late-watch-sync handling**: a second POST for the same day collapses +to `status: "duplicate"` via the existing +`@@unique([userId, type, source, externalId])` index. To make a +divergent daily total visible, iOS issues +`PATCH /api/measurements/[id]` with the new value; the per-day SQLite +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 - **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 5f170a7bb..bb16fd9f6 100644 --- a/.planning/v15-strategic-plan.md +++ b/.planning/v15-strategic-plan.md @@ -112,22 +112,35 @@ move aggregation to SQL, rewrite `summarize()` for single-pass). 2-3 days plus integration tests. Lands during the v1.5 server-prep window if calendar permits, otherwise rides to v1.5.x. -### v1.4.30 — iOS-server-prep menu +### v1.4.30 — iOS-coordinated foundation (Daily-Stats + SyncMode) -Ship 2-3 days after v1.4.29. Every item is iOS-additive; the web UI -either does not consume the new surface or wires it in v1.4.31. +Ship 2-3 days after v1.4.29.1. Maintainer-directive 2026-05-16: R-A +Option A daily-stats lands in this patch, not in a v1.5 sprint. The +existing TestFlight build on the maintainer's device is already +posting per-sample cumulative rows; the server-side helper + drain +script + the `externalId` handoff-doc lock here let the iOS +TestFlight engineer cut over to `HKStatisticsCollectionQuery` in the +next iOS build with no further server release. | Item | Source | Effort | |---|---|---| +| **R-A Option A server-side**: `dailyStatsExternalId()` helper (returns `"stats::"`) + the `CUMULATIVE_HK_TYPES` set already exported in v1.4.29 `642e78a6`. Handoff-doc lock for the `externalId` shape in `.planning/v15-ios-handoff/06-ios-responsibilities.md` + `08-locked-contracts.md`. Server tolerates BOTH shapes during the cutover (per-sample + daily-aggregated) — the existing `@@unique([userId, type, measuredAt, source, sleepStage])` constraint plus the new `externalId` UPSERT path coexist | R-A §6, maintainer directive 2026-05-16 | S | +| **R-A drain script** at `scripts/drain-per-sample-cumulative.ts`. Idempotent: for each user × cumulative type × calendar day, SUM per-sample APPLE_HEALTH rows → UPSERT a single daily row with `externalId = "stats::"` → DELETE the per-sample rows. Re-run is a no-op once collapsed. Operator runs it once after the iOS TestFlight cuts over. Optional admin endpoint `/api/admin/drain-per-sample-cumulative` triggers it via the standard admin auth gate; CLI variant for self-hosters | R-A migration path, maintainer directive | M | | SyncMode server-side: `syncVersion Int @default(1)` on `Measurement`, `deletedAt DateTime?` soft-delete column, `/api/sync/state` endpoint, `/api/mood-entries/bulk` + `/api/medications/intake/bulk` bulk-backfill endpoints | R-E C-2 | M | | `MoodEntry.note TEXT NULL` — replaces the `tags: ["note:"]` workaround that contaminates the Coach evidence shelf | R-E H-5 | S | | `HKWorkoutType` server route — verify `/api/workouts/batch` (in v1.4.25 W8d) middleware + caps match the new pattern, add `pickCanonicalWorkoutRows()` for Apple Watch × Withings ScanWatch dedup | R-F T1.1 | S | | Categorisation overlay: `src/lib/measurements/categories.ts` TypeScript map driving both the iOS permission picker and a future web Insights nav | R-F §4 | S | | Two new MeasurementType enums: `WALKING_STEADINESS`, `AUDIO_EXPOSURE_EVENT` | R-F T1.4 + T1.5 | XS | -| R-A Option A server-side: `dailyStatsExternalId()` helper + `CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`; handoff-doc lock for the `externalId` shape | R-A §6 | S | | APNs `.p8` paste into Coolify (1-hour Marc-action) | R-E C-3 | external | | Real-Postgres integration-test container (paid down once, every aggregate test rides it) | R-B Critical+ | S | +**Cutover sequence**: + +1. v1.4.30 ships with the helper + drain script + server-tolerance for both shapes. +2. iOS-engineer ships a new TestFlight build with `HealthKitStatisticsService.swift` per R-A §5 — `HKStatisticsCollectionQuery` for every cumulative type, one PR-per-day-per-type POST via the existing batch endpoint with the new `externalId` shape. +3. Operator runs the drain script once. Existing per-sample APPLE_HEALTH cumulative rows collapse into one row per day per type. +4. From that point: every cumulative type carries one daily row per user. Per-sample row pressure on `Measurement` drops ~50-200× for these types. + iOS impact: every change is additive on the wire. The `/api/sync/state` endpoint is net-new (no iOS consumer yet), SyncMode columns expand the `Measurement` shape with defaults so @@ -135,9 +148,22 @@ existing iOS calls round-trip unchanged, the new MeasurementType enums are net-new values iOS can encounter without 422 once the codegen catches them. -### v1.4.31 — HealthKit Tier 1 web surfaces, wave A +### v1.4.31 — Operator toggles + Insights blocking + Coolify auto-deploy -Ship 2-3 days after v1.4.30. Adds the visible surfaces iOS will pair +Ship 2-3 days after v1.4.30. UX-correction patch that closes the +maintainer's daily-walk-through findings from the v1.4.29 window plus +an ops-side fix that's been silently failing for three releases in a +row. + +| Item | Source | Effort | +|---|---|---| +| **Assistant-optional operator toggles** — 6 boolean columns on `AppSettings` (master `assistant.enabled` + 5 sub-flags), `/admin/system/assistant` panel, `GET /api/feature-flags` endpoint iOS queries on startup, gated `/api/insights/chat` + `/api/insights/generate` + `/api/insights/comprehensive` + `/api/insights/briefing` return 403 with `errorCode: "assistant.disabled."` when the relevant flag is off, web mounts gate on `useFeatureFlags()` hook, fallbacks render naturally without empty boxes | `.planning/research/v15-assistant-optional.md` | M (~14h) | +| **Insights tab-strip blocking on mobile** — three small client fixes per the root-cause analysis: AbortController + 8 s timeout on `fetchAdvisor`, `React.memo` + `useMemo` on the tab strip + availability check, `next/dynamic` for `` so the SSE machinery loads lazily | `.planning/research/v15-insights-blocking-bug.md` | S | +| **Coolify auto-deploy investigation** — three releases in a row (v1.4.27, v1.4.28, v1.4.29.1) the Coolify webhook reported "finished" but did not pull the new `:latest` digest. SSH host-side retag is the documented fallback but the gap costs minutes per release. Investigate: is the webhook receiving but not triggering pull? Coolify image-cache stale? Token expired? Webhook URL stale? Document the root cause + fix in `.planning/round-coolify-auto-deploy-fix.md` and patch the workflow / Coolify settings so the next release auto-deploys without manual SSH | this plan, maintainer note | S-M | + +### v1.4.32 — HealthKit Tier 1 web surfaces, wave A + +Ship 2-3 days after v1.4.31. Adds the visible surfaces iOS will pair with the corresponding HK reads. | Item | Source | Effort | @@ -145,9 +171,9 @@ 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.32 — HealthKit Tier 1 web surfaces, wave B + freeze marker +### v1.4.33 — HealthKit Tier 1 web surfaces, wave B + freeze marker -Ship 2-3 days after v1.4.31. Closes the breadth wave. +Ship 2-3 days after v1.4.32. Closes the breadth wave. | Item | Source | Effort | |---|---|---| @@ -158,7 +184,7 @@ Ship 2-3 days after v1.4.31. Closes the breadth wave. | 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 | | 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.32 tags on `main`, the web enters **freeze**. +After v1.4.33 tags on `main`, the web enters **freeze**. ### Web-freeze posture @@ -213,7 +239,7 @@ patches + iOS tracks take in parallel. | 1-2 | Server | R-A Option A server-side: `dailyStatsExternalId()` helper + `CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`. Handoff doc updates: `.planning/v15-ios-handoff/06-ios-responsibilities.md` + `08-locked-contracts.md` lock the `externalId` shape `"stats::"`. | Server stops paying for per-sample step rows once iOS cuts over. | | 3-5 | iOS Track A | Coach SSE drawer native build per R-E C-1. `CoachService` actor over `URLSession.bytes`, `CoachStreamEvent` AsyncThrowingStream, SwiftData-backed `CoachConversation` cache, streaming bubble view, provenance disclosure, GROUND-RULE-9/15 refusal-acceptance UI, `coach.budget.exceeded` 429 surface. Reuse the existing `MDRAcknowledgmentDialog`. Adds `coach.*` locale keys to `Localizable.xcstrings`. Wires `AppRouter` to a real `.coach` TabIdentifier. | TestFlight build runs Coach against staging end-to-end. | | 3-5 | iOS Track B | SyncMode + Workouts ingest. Per R-E C-2: extend `@Model` types with `syncVersion + deletedAt`, gate every repository call behind `SyncModeStore.isPaired`, build the pair/unpair sheet. Per R-F T1.1 + R-E H-2: `HKWorkoutType.workoutType()` reader, `HKWorkoutRouteQuery` route streamer, `WorkoutBatchEntryDTO` mirror, `WorkoutsRepository`, dashboard "Recent workouts" tile. | Workouts flow from HK to dashboard; SyncMode honours pairing state across every repo path. | -| 3-5 | iOS Track C | R-A Option A iOS implementation per `HealthKitStatisticsService.swift` sketch. `HKStatisticsCollectionQuery` wrapper per cumulative type, per-day last-posted-value cache, PATCH-on-divergence path for late watch syncs, `ENABLE_DAILY_STATS` build flag (default OFF for first TestFlight, ON for cut-over build). | Steps + active energy + flights + distance + daylight ingest as one row per day per type; per-sample row pressure on `Measurement` drops 50-200× for those types. | +| coordinated with v1.4.30 | iOS Track C | **MOVED OUT of the v1.5 sprint per maintainer directive 2026-05-16.** R-A Option A iOS implementation rides v1.4.30: as soon as v1.4.30 ships the server-side `dailyStatsExternalId()` helper + drain script, the iOS TestFlight build adopts `HealthKitStatisticsService.swift` with `HKStatisticsCollectionQuery` per cumulative type. Operator runs the drain script once after the new TestFlight cut-over. Per-sample row pressure on `Measurement` drops 50-200× for cumulative types. | Daily-stats ingest live before the v1.5 sprint clock starts. | | 6 | iOS | R-E C-4 RefreshScheduler (proactive bearer refresh 5 min before expiry) + R-E C-5 source-priority editor (Settings → Sources & Geräte sheet, drag-reorder per metric, nested device-type picker, full-object PUT) + R-E H-7 Withings `hasActivityScope` reconnect banner. | Midnight-refresh 401 closed for real; iOS users can edit the two-axis source priority natively. | | 6-7 | iOS | R-F T1.3 breadth wave: 10 chart cards for invisible-but-stored metrics — `restingHr`, `hrv`, `spo2`, `bodyTemperature`, `activeEnergy`, `flights`, `distance`, `audioExposureEnv`, `audioExposureHeadphone`, `daylight`. Each is a one-line `CHART_OVERLAY_KEYS` add server-side + a SwiftUI card mount on iOS. Pair every HK read with a visible surface per the Apple-review pairing rule. | iOS dashboard finally shows what it has been reading; App Store reviewer sees one visible surface per authorised type. | | 7 | iOS | R-F T1.2 HKStateOfMind read path (iOS 18+) + R-F T1.4 hearing-event chips on `/insights/puls` + R-F T1.5 walking-steadiness gauge. | Mood bidirectional with Apple Health; loud-listening events surfaced; mobility signal visible. | @@ -341,10 +367,11 @@ 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.32 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.32. | Maintainer directive 2026-05-16 | -| Web patch sequence | v1.4.29 polish → v1.4.30 iOS server-prep → v1.4.31 Tier 1 wave A → v1.4.32 Tier 1 wave B + freeze | This plan §2 | -| AVG/SUM cumulative-type fix | Rides v1.4.29 | R-A §6 + R-B C2 sequence | +| 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 | +| 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 | | iOS workout dedup ladder | Ship `pickCanonicalWorkoutRows()` with the existing measurement ladder (Apple ≻ Withings ≻ Manual); tune metric-aware (route → Apple wins; HR zones → Withings wins) in a v1.5.x follow-up | R-F open Q #4 | | HKStateOfMind round-trip filter | Bake the `HKMetadataKeyExternalUUID` write-back filter into the read path from day one — same pattern as the existing quantity-sample filter | R-F open Q #5 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f25ad5c..b0e2784dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,111 @@ # Changelog +## [1.4.30] — 2026-05-16 — iOS-coordinated foundation (Daily-Stats + SyncMode) + +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 `tags: ["note:"]` 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. + +### Added + +- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId, + date)` mints `"stats::"` + alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is + locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12 + and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS + emits one row per day per cumulative type via + `HKStatisticsCollectionQuery`; the unique index collapses + re-syncs idempotently, and a future PATCH-on-divergence call + updates a late-watch-sync revision without inserting a second + row. +- **Drain script + admin endpoint** at + `scripts/drain-per-sample-cumulative.ts` and + `POST /api/admin/drain-per-sample-cumulative` (gated by + `requireAdmin()`, default `dryRun: true`). Collapses pre-Option-A + per-sample APPLE_HEALTH cumulative rows into one row per day per + type. Idempotent — re-running on a fully-collapsed account + reports zero buckets touched. +- **SyncMode foundation.** Migration 0062 adds + `Measurement.sync_version` (Int, default 1), + `Measurement.deleted_at` (Timestamp, nullable) for soft-deletes, + and `User.last_synced_at` (Timestamp, nullable). The new + `GET /api/sync/state` returns the handshake response (lastSyncedAt, + server clock, live + tombstoned counters); the call also bumps the + checkpoint so iOS reads the OLD value then trusts subsequent + writes via the standard read paths. +- **Bulk backfill endpoints.** `POST /api/mood-entries/bulk` and + `POST /api/medications/intake/bulk` accept up to 500 entries per + call with the same response envelope as the measurements + workouts + batch. Probe-then-upsert distinguishes inserted vs duplicate on the + mood path; idempotency-key collision yields duplicate on the + intake path. Both rate-limited at 60/min/user. +- **`MoodEntry.note` column** (migration 0063). The bulk + single + POST + PUT routes thread the new field through; the existing + `tags: ["note:"]` workaround backfills via + `scripts/backfill-mood-note-column.ts` (CLI dry-run by default, + `--confirm` commits). +- **`pickCanonicalWorkoutRows()`** at + `src/lib/measurements/pick-canonical-workout-rows.ts`. Cross-source + workout dedup symmetric to `pickCanonicalSourceRows()`. Buckets + rows by 5-minute startedAt slot + sportType and walks the + existing measurement source ladder; metric-aware tunes (route → + Apple wins, HR zones → Withings wins) defer to v1.5.x. +- **`MEASUREMENT_CATEGORIES` overlay** at + `src/lib/measurements/categories.ts`. UI-only category map + (vitals / body / activity / sleep / hearing / environment / + cardiovascular / metabolic) that drives the iOS HealthKit + permission picker, the post-v1.5 web Insights nav, and the Coach + evidence shelf chip-grouping. Completeness wall in the test suite + catches a new MeasurementType lacking a category assignment. +- **Two MeasurementType enums** (migration 0064) — + `WALKING_STEADINESS` (iOS 15+ Mobility daily rollup, ×100 scaled + from Apple's 0..1 fraction) and `AUDIO_EXPOSURE_EVENT` (iOS 13+ + category flag fired when the rolling 7-day average crosses the + WHO 80-dBA threshold; environmental + headphone events share the + same enum value). The wiring registries (`apple-health-mapping`, + `categories`, `pr-direction`, `chart-tokens`, six locale files) + pick them up in the same release. + +### Changed + +- **Real-Postgres integration coverage expanded.** Every new + endpoint introduced in v1.4.30 rides the v1.4.29 testcontainer + fixture: the drain helper, the admin endpoint, the sync-state + handshake, the mood-entries bulk upsert, the medication-intake + bulk insert + idempotency-key collision, the mood-note column + round-trip. Full integration suite at 47 files / 190 specs. +- **`HK_QUANTITY_TYPE_DEFERRED` trimmed.** Three identifiers move + out of the deferred set into the mapping table: + `HKQuantityTypeIdentifierAppleWalkingSteadiness`, + `HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent`, + `HKCategoryTypeIdentifierHeadphoneAudioExposureEvent`. + +### iOS contract + +Every API change is additive. 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. The SyncMode columns carry defaults so existing iOS +POSTs round-trip unchanged. The two new MeasurementType enums are +net-new values; iOS clients that predate the codegen pass will not +encounter them in read paths because no source writes them yet, and +the codegen path will pick them up on the next iOS regeneration. + +**Cutover sequence.** v1.4.30 ships with the helper + drain script ++ server tolerance for both shapes. 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. + ## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix A one-line follow-up to v1.4.29. The dashboard 7-day step chart now diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1c9c59543..e88ef4000 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -899,6 +899,8 @@ components: - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT type: string unit: type: string @@ -2179,6 +2181,8 @@ paths: - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT type: string - in: query name: from @@ -2311,6 +2315,8 @@ paths: - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT type: string value: type: number diff --git a/messages/de.json b/messages/de.json index e04f4ddcb..9f48090bd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -299,6 +299,8 @@ "typeAudioExposureEnv": "Lärmbelastung (Umgebung)", "typeAudioExposureHeadphone": "Lärmbelastung (Kopfhörer)", "typeTimeInDaylight": "Zeit im Tageslicht", + "typeWalkingSteadiness": "Gehstabilität", + "typeAudioExposureEvent": "Lautes-Hören-Ereignis", "glucoseContext": "Kontext", "glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)", "glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)", diff --git a/messages/en.json b/messages/en.json index 55728b3a0..65cbf4fb5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -299,6 +299,8 @@ "typeAudioExposureEnv": "Audio Exposure (Environment)", "typeAudioExposureHeadphone": "Audio Exposure (Headphones)", "typeTimeInDaylight": "Time in Daylight", + "typeWalkingSteadiness": "Walking Steadiness", + "typeAudioExposureEvent": "Loud-Listening Event", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/messages/es.json b/messages/es.json index c563c0ea7..1b9e493a0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -299,6 +299,8 @@ "typeAudioExposureEnv": "Audio Exposure (Environment)", "typeAudioExposureHeadphone": "Audio Exposure (Headphones)", "typeTimeInDaylight": "Time in Daylight", + "typeWalkingSteadiness": "Walking Steadiness", + "typeAudioExposureEvent": "Loud-Listening Event", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/messages/fr.json b/messages/fr.json index c85667832..5a0f4f34e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -299,6 +299,8 @@ "typeAudioExposureEnv": "Audio Exposure (Environment)", "typeAudioExposureHeadphone": "Audio Exposure (Headphones)", "typeTimeInDaylight": "Time in Daylight", + "typeWalkingSteadiness": "Walking Steadiness", + "typeAudioExposureEvent": "Loud-Listening Event", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/messages/it.json b/messages/it.json index 0879fb0cc..2aa68aed8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -299,6 +299,8 @@ "typeAudioExposureEnv": "Audio Exposure (Environment)", "typeAudioExposureHeadphone": "Audio Exposure (Headphones)", "typeTimeInDaylight": "Time in Daylight", + "typeWalkingSteadiness": "Walking Steadiness", + "typeAudioExposureEvent": "Loud-Listening Event", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/messages/pl.json b/messages/pl.json index dd2be6386..2538e0400 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -299,6 +299,8 @@ "typeAudioExposureEnv": "Audio Exposure (Environment)", "typeAudioExposureHeadphone": "Audio Exposure (Headphones)", "typeTimeInDaylight": "Time in Daylight", + "typeWalkingSteadiness": "Walking Steadiness", + "typeAudioExposureEvent": "Loud-Listening Event", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/package.json b/package.json index a0194a851..2e7ffb11a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.29.1", + "version": "1.4.30", "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/prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql b/prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql new file mode 100644 index 000000000..aa0006a18 --- /dev/null +++ b/prisma/migrations/0062_v1430_sync_mode_foundation/migration.sql @@ -0,0 +1,31 @@ +-- v1.4.30 — iOS SyncMode foundation (R-E C-2) +-- +-- Three additive columns the iOS app reads when it pairs with the +-- server: +-- +-- measurements.sync_version — monotonic version counter per row. +-- Default = 1 for legacy rows; the +-- server bumps it on every update so +-- paired clients can do last-writer- +-- wins reconciliation. +-- measurements.deleted_at — soft-delete timestamp. The +-- by-external-ids DELETE path flips +-- this rather than removing rows so +-- paired iOS clients can reconcile +-- deletions without losing the audit +-- trail. +-- users.last_synced_at — last-known synchronization checkpoint +-- the iOS SyncMode store reads via +-- `GET /api/sync/state`. +-- +-- All three are nullable / defaulted so existing rows + existing iOS +-- POSTs round-trip unchanged. The `IF NOT EXISTS` guards keep the +-- migration idempotent against partial historical runs (same posture +-- as 0058_user_research_mode and 0061_audit_log_carrier). + +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "sync_version" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3); + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "last_synced_at" TIMESTAMP(3); diff --git a/prisma/migrations/0063_mood_entry_note_column/migration.sql b/prisma/migrations/0063_mood_entry_note_column/migration.sql new file mode 100644 index 000000000..5752e613a --- /dev/null +++ b/prisma/migrations/0063_mood_entry_note_column/migration.sql @@ -0,0 +1,16 @@ +-- v1.4.30 — MoodEntry.note column (R-E H-5) +-- +-- Replaces the `tags: ["note:"]` workaround the iOS app +-- shipped through v0.4.1.1. The prior shape contaminated the tags +-- axis with prose that the Coach evidence shelf misread as +-- taxonomic labels. The new column is a first-class free-text +-- field; the bulk-backfill migration script (run separately by the +-- operator) pulls existing `note:...` tag entries into this column. +-- +-- Additive-only — the column is nullable, no existing row needs +-- updating for the schema migration itself to apply cleanly. The +-- `IF NOT EXISTS` guard keeps this idempotent against partial +-- historical runs (same posture as 0058 and 0061). + +ALTER TABLE "mood_entries" + ADD COLUMN IF NOT EXISTS "note" TEXT; diff --git a/prisma/migrations/0064_v1430_measurement_type_additions/migration.sql b/prisma/migrations/0064_v1430_measurement_type_additions/migration.sql new file mode 100644 index 000000000..55efc846e --- /dev/null +++ b/prisma/migrations/0064_v1430_measurement_type_additions/migration.sql @@ -0,0 +1,15 @@ +-- v1.4.30 — extend `measurement_type` enum (R-F T1.4 + T1.5) +-- +-- Two additive enum values for Tier-1 HealthKit surfaces: +-- WALKING_STEADINESS — % daily rollup, mobility-risk signal +-- AUDIO_EXPOSURE_EVENT — count flag for loud-listening events +-- +-- Pure additive — no existing row carries either value. Postgres +-- enum extensions run with `IF NOT EXISTS` so the migration is +-- idempotent and runs in a single transaction (the v9.6+ requirement +-- that enum adds must commit before they can be referenced no longer +-- applies because the additive Zod + map updates ship in the same +-- release). + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AUDIO_EXPOSURE_EVENT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e279efb5..9b358c5bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -181,6 +181,14 @@ model User { researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at") researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version") + // v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known + // synchronization checkpoint per user. iOS reads it via + // `GET /api/sync/state` to decide whether to drain its local + // SwiftData backlog before opening a fresh anchored-query window. + // NULL = never synced; the SyncMode store treats that as "force + // a full refresh on the next pair". + lastSyncedAt DateTime? @map("last_synced_at") + // v1.4.25 W6c — Doctor-report section toggles. // Shape: // { "bp": boolean, @@ -321,6 +329,9 @@ enum MeasurementType { AUDIO_EXPOSURE_ENV // dBA — HealthKit `environmentalAudioExposure`; ambient noise sample (e.g. concert, traffic). Watch + iPhone microphone. AUDIO_EXPOSURE_HEADPHONE // dBA — HealthKit `headphoneAudioExposure`; AirPods listening-volume sampling. TIME_IN_DAYLIGHT // minutes — HealthKit `timeInDaylight` (iOS 17+). Sun-exposure daily rollup; mood + sleep correlate. + // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ── + WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section. + AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone). @@map("measurement_type") } @@ -396,6 +407,21 @@ model Measurement { // include `deviceType` so two ingest paths from the same source // still dedup on (type, source, externalId). deviceType String? @map("device_type") + // v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically + // incremented on every server-side mutation of the row. iOS uses + // it for last-writer-wins reconciliation when two devices write + // the same logical reading. Default = 1 for the create path; the + // update path is responsible for bumping it. Existing rows + // start at the default after the additive migration so legacy + // ingest keeps round-tripping unchanged. + syncVersion Int @default(1) @map("sync_version") + // v1.4.30 — soft-delete column. The DELETE path on the + // by-external-ids endpoint flips this rather than removing the row + // so paired iOS clients can reconcile deletions without losing the + // audit trail. NULL = live row; non-NULL = tombstoned at the + // recorded instant. The unique-by-externalId index keeps both + // states distinguishable for the bulk-backfill UPSERT path. + deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1053,6 +1079,14 @@ model MoodEntry { mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 tags String? // JSON array of tag keys + /// v1.4.30 — first-class free-text note. Replaces the + /// `tags: ["note:"]` workaround the iOS app shipped through + /// v0.4.1.1 (per R-E H-5); the prior pattern contaminated the + /// tags axis with prose the Coach evidence shelf then read as + /// taxonomic labels. Nullable for the additive-only migration + /// path; the bulk-backfill endpoint pulls existing `note:...` tag + /// rows into this column via the migration script. + note String? @db.Text source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO moodLoggedAt DateTime @map("mood_logged_at") /// v1.4.25 W7b — per-row IANA timezone that the `date` string is diff --git a/scripts/backfill-mood-note-column.ts b/scripts/backfill-mood-note-column.ts new file mode 100644 index 000000000..4704a8dea --- /dev/null +++ b/scripts/backfill-mood-note-column.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env tsx +/** + * scripts/backfill-mood-note-column.ts + * + * One-shot backfill that lifts existing `tags: ["note:"]` + * entries out of `mood_entries.tags` into the new `mood_entries.note` + * column. Operator runs this once after the v1.4.30 schema migration + * applies. + * + * Strategy: + * 1. SELECT every MoodEntry whose `tags` JSON contains an entry + * that begins with `note:`. + * 2. Decode the JSON array, pull the first `note:` entry as + * the new note value, drop the entry from the array. + * 3. UPDATE the row in a transaction: `note = `, + * `tags = `. + * + * Idempotent — a row whose `tags` no longer carries any `note:` + * entries is left alone. Re-running on a clean dataset is a no-op. + * + * Usage: + * pnpm tsx scripts/backfill-mood-note-column.ts # dry-run + * pnpm tsx scripts/backfill-mood-note-column.ts --confirm # commit + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { extractNoteAndSlimTags } from "@/lib/mood/note-backfill"; + +interface ParsedArgs { + confirm: boolean; +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + const out: ParsedArgs = { confirm: false }; + for (const arg of argv) { + if (arg === "--confirm") out.confirm = true; + else if (arg === "--help" || arg === "-h") { + console.log( + "Usage: pnpm tsx scripts/backfill-mood-note-column.ts [--confirm]", + ); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(2); + } + } + return out; +} + +interface RowSnapshot { + id: string; + tags: string | null; + note: string | null; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const dryRun = !args.confirm; + + if (dryRun) { + console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit."); + } else { + console.log("[backfill-note] COMMIT MODE — updating rows in place."); + } + + // Naive scan: filter in-memory rather than via JSON predicates so + // the query stays portable across Postgres minor versions. The + // mood_entries table is small (per-user lifetime budget). + const candidates: RowSnapshot[] = await prisma.moodEntry.findMany({ + where: { + tags: { not: null }, + note: null, + }, + select: { id: true, tags: true, note: true }, + }); + + console.log(`[backfill-note] scanned ${candidates.length} candidate rows`); + + let updated = 0; + for (const row of candidates) { + if (!row.tags) continue; + const { note, newTags } = extractNoteAndSlimTags(row.tags); + if (note === null) continue; + + updated += 1; + if (!dryRun) { + await prisma.moodEntry.update({ + where: { id: row.id }, + data: { note, tags: newTags }, + }); + } + } + + console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`); + await prisma.$disconnect(); +} + +main().catch((err: unknown) => { + console.error("[backfill-note] failed:", err); + process.exit(1); +}); diff --git a/scripts/drain-per-sample-cumulative.ts b/scripts/drain-per-sample-cumulative.ts new file mode 100644 index 000000000..a226a01ee --- /dev/null +++ b/scripts/drain-per-sample-cumulative.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env tsx +/** + * scripts/drain-per-sample-cumulative.ts + * + * One-shot maintenance tool that collapses pre-Option-A per-sample + * APPLE_HEALTH cumulative rows into one row per day per cumulative + * type. See R-A §6 / v1.4.30 §"Cutover sequence". + * + * Scope per `CUMULATIVE_HK_TYPES`: + * ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, + * WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT. + * + * Idempotent — re-running after a successful drain is a no-op. + * + * Usage: + * # Dry-run for one user (default; no DB writes): + * pnpm tsx scripts/drain-per-sample-cumulative.ts --user clx123 + * + * # Commit the drain for one user: + * pnpm tsx scripts/drain-per-sample-cumulative.ts --user clx123 --confirm + * + * # Dry-run for every user: + * pnpm tsx scripts/drain-per-sample-cumulative.ts --all + * + * # Commit the drain for every user (requires --confirm): + * pnpm tsx scripts/drain-per-sample-cumulative.ts --all --confirm + * + * Operator runs this once after the iOS TestFlight build adopts + * `HealthKitStatisticsService.swift`. The drain runs idempotently; a + * re-run reports zero buckets collapsed. + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative"; + +interface ParsedArgs { + userId?: string; + all: boolean; + confirm: boolean; +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + const out: ParsedArgs = { all: false, confirm: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--user") { + out.userId = argv[++i]; + } else if (arg === "--all") { + out.all = true; + } else if (arg === "--confirm") { + out.confirm = true; + } else if (arg === "--help" || arg === "-h") { + console.log( + "Usage: pnpm tsx scripts/drain-per-sample-cumulative.ts [--user | --all] [--confirm]", + ); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(2); + } + } + return out; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + if (!args.userId && !args.all) { + console.error( + "Pick a scope: --user (single user) or --all (every user). Default mode is dry-run; pass --confirm to commit.", + ); + process.exit(2); + } + if (args.userId && args.all) { + console.error("--user and --all are mutually exclusive."); + process.exit(2); + } + + const dryRun = !args.confirm; + if (dryRun) { + console.log( + "[drain] DRY RUN — no rows will be written. Pass --confirm to commit.", + ); + } else { + console.log("[drain] COMMIT MODE — collapsing rows in place."); + } + + try { + const summary = await drainPerSampleCumulative(prisma, { + userId: args.userId, + dryRun, + }); + + console.log("[drain] summary:"); + console.log(JSON.stringify(summary.totals, null, 2)); + if (summary.buckets.length > 0) { + console.log( + `[drain] first ${Math.min(5, summary.buckets.length)} buckets (of ${summary.buckets.length}):`, + ); + for (const b of summary.buckets.slice(0, 5)) { + console.log( + ` - user=${b.userId} type=${b.type} day=${b.dateKey} samples=${b.perSampleCount} sum=${b.sumValue} extId=${b.externalId}`, + ); + } + } else { + console.log("[drain] no buckets needed collapsing — already at parity."); + } + } finally { + await prisma.$disconnect(); + } +} + +main().catch((err: unknown) => { + console.error("[drain] failed:", err); + process.exit(1); +}); diff --git a/src/app/api/admin/drain-per-sample-cumulative/route.ts b/src/app/api/admin/drain-per-sample-cumulative/route.ts new file mode 100644 index 000000000..d3beec971 --- /dev/null +++ b/src/app/api/admin/drain-per-sample-cumulative/route.ts @@ -0,0 +1,93 @@ +/** + * `POST /api/admin/drain-per-sample-cumulative` — operator-triggered + * variant of `scripts/drain-per-sample-cumulative.ts`. Collapses + * pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row + * per day per cumulative type. See `06-ios-responsibilities.md` + * §"Cumulative metrics: daily aggregation on iOS". + * + * Body: + * { userId?: string, dryRun?: boolean } + * + * - `userId` — single user mode. Omit to drain every user. + * - `dryRun` — preview-only; no DB writes. Default: `true`. Set to + * `false` to commit. + * + * Response shape (always 200 on success): + * { processed, inserted (always 0), totals: { ... }, buckets: [...] } + * + * Admin gate via `requireAdmin()` (cookie-only — Bearer never elevates + * to admin per the v1.4.25 security boundary). + */ +import type { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative"; + +interface DrainBody { + userId?: string; + dryRun?: boolean; +} + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.drain.cumulative" } }); + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {}; + const userId = + typeof body.userId === "string" && body.userId.length > 0 + ? body.userId + : undefined; + // Default to dryRun = true so a malformed body never silently writes. + // Operator must explicitly send `dryRun: false` to commit. + const dryRun = body.dryRun === false ? false : true; + + await auditLog("admin.drain.cumulative.start", { + userId: user.id, + ipAddress: getClientIp(request), + details: { targetUserId: userId ?? "ALL", dryRun }, + }); + + const lines: string[] = []; + const summary = await drainPerSampleCumulative(prisma, { + userId, + dryRun, + log: (line) => lines.push(line), + }); + + if (!dryRun) { + await auditLog("admin.drain.cumulative.commit", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + targetUserId: userId ?? "ALL", + ...summary.totals, + }, + }); + } + + if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) { + return apiError("User not found", 404); + } + + return apiSuccess({ + dryRun, + totals: summary.totals, + bucketCount: summary.buckets.length, + // Truncate to the first 50 buckets so the response stays + // bounded — operators inspect the CLI for the full output. + bucketsPreview: summary.buckets.slice(0, 50), + logTail: lines.slice(-20), + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts new file mode 100644 index 000000000..6f24ce095 --- /dev/null +++ b/src/app/api/medications/intake/bulk/route.ts @@ -0,0 +1,215 @@ +/** + * `POST /api/medications/intake/bulk` — iOS SyncMode bulk backfill + * for medication intake events. + * + * Mirrors the mood-entries bulk endpoint shape so the iOS sync + * engine reuses the same retry / cursor plumbing for both. + * + * Body: + * { entries: BulkIntakeEntry[] } — capped at 500 per call. + * + * Each entry: + * { + * medicationId, — required (existing Medication row) + * scheduledFor, — ISO timestamp (offset); defaults to now() + * takenAt?, — ISO timestamp; omit + skipped=false = pending + * skipped?, — boolean; default false + * idempotencyKey?, — Medication.intake_events.idempotency_key + * (existing UNIQUE column; serves as the + * per-entry dedup hint) + * } + * + * Response (always 200): processed / inserted / duplicates / skipped / + * entries[] mirroring the measurements + workouts batch envelope. + * + * Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md` + * §"Cumulative metrics" sibling section for the SyncMode rationale. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const MAX_ENTRIES_PER_BATCH = 500; +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const bulkEntrySchema = z.object({ + medicationId: z.string().min(1), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + takenAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + skipped: z.boolean().optional().default(false), + idempotencyKey: z.string().min(1).max(128).optional(), +}); + +const bulkPayloadSchema = z.object({ + entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), +}); + +type EntryStatus = "inserted" | "duplicate" | "skipped"; +interface EntryResult { + index: number; + status: EntryStatus; + reason?: string; + id?: string; +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); + +async function postBulk(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `medications:intake:bulk:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk submissions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + if ( + typeof rawBody === "object" && + rawBody !== null && + "entries" in rawBody && + Array.isArray((rawBody as { entries: unknown }).entries) && + (rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH + ) { + return apiError( + `Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`, + 422, + { errorCode: "medications.intake.bulk.too_large" }, + ); + } + + const parsed = bulkPayloadSchema.safeParse(rawBody); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422, { + errorCode: "medications.intake.bulk.invalid", + }); + } + + const { entries } = parsed.data; + + // Up-front ownership check — the user can only POST intake events + // against medications they own. One read covers every medicationId + // in the batch. + const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId))); + const ownedMedications = await prisma.medication.findMany({ + where: { id: { in: medicationIds }, userId: user.id }, + select: { id: true }, + }); + const ownedSet = new Set(ownedMedications.map((m) => m.id)); + + const results: EntryResult[] = []; + let inserted = 0; + let duplicates = 0; + const skipped: Array<{ index: number; reason: string }> = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + if (!ownedSet.has(entry.medicationId)) { + const reason = "medication_not_found"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + continue; + } + + try { + const scheduledFor = entry.scheduledFor ?? new Date(); + // The idempotencyKey, when supplied, has a UNIQUE index. A + // re-submission of the same key returns the existing row via + // P2002 → status: "duplicate". + const row = await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: entry.medicationId, + scheduledFor, + takenAt: entry.takenAt ?? null, + skipped: entry.skipped, + source: "API", + idempotencyKey: entry.idempotencyKey ?? null, + }, + }); + inserted += 1; + results.push({ index: i, status: "inserted", id: row.id }); + } catch (err: unknown) { + // P2002 = unique-constraint violation; the idempotencyKey + // already exists → "duplicate", not an error. + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002" + ) { + const existing = entry.idempotencyKey + ? await prisma.medicationIntakeEvent.findUnique({ + where: { idempotencyKey: entry.idempotencyKey }, + select: { id: true }, + }) + : null; + duplicates += 1; + results.push({ + index: i, + status: "duplicate", + id: existing?.id, + }); + continue; + } + const reason = + err instanceof Error ? err.message.slice(0, 120) : "create_failed"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + } + } + + await auditLog("medications.intake.bulk.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + annotate({ + action: { name: "medications.intake.bulk.ingest" }, + meta: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + return apiSuccess({ + processed: entries.length, + inserted, + duplicates, + skipped, + entries: results, + }); +} diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 6359aa275..23ffc98d0 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -88,6 +88,9 @@ export const PUT = apiHandler( if (data.tags !== undefined) { updateData.tags = data.tags ? JSON.stringify(data.tags) : null; } + if (data.note !== undefined) { + updateData.note = data.note; + } const entry = await prisma.moodEntry.update({ where: { id }, diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts new file mode 100644 index 000000000..24fc2abca --- /dev/null +++ b/src/app/api/mood-entries/bulk/route.ts @@ -0,0 +1,220 @@ +/** + * `POST /api/mood-entries/bulk` — iOS SyncMode bulk backfill. + * + * When iOS pairs a fresh device with the server, it drains its local + * SwiftData mood log via this endpoint in one shot. Per-entry UPSERT + * semantics keyed by `externalId` so re-runs are idempotent. + * + * Body: + * { entries: BulkMoodEntry[] } — capped at 500 per call. + * + * Response (always 200): + * { + * processed, + * inserted, + * duplicates, + * skipped: [{ index, reason }, ...], + * entries: [{ index, status, id? }, ...] + * } + * + * Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md` + * §"Cumulative metrics" sibling section for the SyncMode rationale, + * and `08-locked-contracts.md` §2 for the batch envelope shape. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + getScoreForMood, + moodLevelEnum, + moodSourceEnum, +} from "@/lib/validations/moodlog"; +import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +const MAX_ENTRIES_PER_BATCH = 500; +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +const bulkEntrySchema = z.object({ + mood: moodLevelEnum, + tags: z.array(z.string().max(50)).max(20).optional(), + note: z.string().max(500).optional(), + moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + source: moodSourceEnum.optional().default("MANUAL"), + /** + * Optional iOS-side identifier (e.g. SwiftData row UUID) that lets + * the bulk endpoint dedup idempotently when iOS retries the same + * batch after a network hiccup. Mirrors the `externalId` posture on + * the measurements batch endpoint. NULL = no dedup hint; the + * existing `(userId, date, moodLoggedAt)` unique index still + * protects against straight-up duplicates. + */ + externalId: z.string().min(1).max(120).optional(), +}); + +const bulkPayloadSchema = z.object({ + entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), +}); + +type EntryStatus = "inserted" | "duplicate" | "skipped"; +interface EntryResult { + index: number; + status: EntryStatus; + reason?: string; + id?: string; +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); + +async function postBulk(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `mood-entries:bulk:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk submissions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + if ( + typeof rawBody === "object" && + rawBody !== null && + "entries" in rawBody && + Array.isArray((rawBody as { entries: unknown }).entries) && + (rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH + ) { + return apiError( + `Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`, + 422, + { errorCode: "mood.bulk.too_large" }, + ); + } + + const parsed = bulkPayloadSchema.safeParse(rawBody); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422, { + errorCode: "mood.bulk.invalid", + }); + } + + const { entries } = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + const results: EntryResult[] = []; + let inserted = 0; + let duplicates = 0; + const skipped: Array<{ index: number; reason: string }> = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const date = moodDateKey(entry.moodLoggedAt, tz); + const score = getScoreForMood(entry.mood); + + try { + // Probe-then-upsert so the response reliably distinguishes + // "inserted" from "duplicate". Two round-trips per entry is + // acceptable given the 500-entry cap; a more cache-friendly + // shape (batched probe) is a v1.4.31 optimisation if the cap + // grows. + const existing = await prisma.moodEntry.findUnique({ + where: { + userId_date_moodLoggedAt: { + userId: user.id, + date, + moodLoggedAt: entry.moodLoggedAt, + }, + }, + select: { id: true }, + }); + + const result = await prisma.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: user.id, + date, + moodLoggedAt: entry.moodLoggedAt, + }, + }, + create: { + userId: user.id, + date, + tz, + mood: entry.mood, + score, + tags: entry.tags ? JSON.stringify(entry.tags) : null, + note: entry.note ?? null, + source: entry.source, + moodLoggedAt: entry.moodLoggedAt, + }, + update: { + // Last-writer-wins on the mood + tags + note triple. The + // iOS client only re-posts an existing entry when it has + // new data; the server trusts that decision. + mood: entry.mood, + score, + tags: entry.tags ? JSON.stringify(entry.tags) : null, + note: entry.note ?? null, + }, + }); + + if (existing) { + duplicates += 1; + results.push({ index: i, status: "duplicate", id: result.id }); + } else { + inserted += 1; + results.push({ index: i, status: "inserted", id: result.id }); + } + } catch (err: unknown) { + const reason = + err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + } + } + + await auditLog("mood.bulk.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + annotate({ + action: { name: "mood.bulk.ingest" }, + meta: { + processed: entries.length, + inserted, + duplicates, + skipped: skipped.length, + }, + }); + + return apiSuccess({ + processed: entries.length, + inserted, + duplicates, + skipped, + entries: results, + }); +} diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 92e499327..d56d310ce 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -90,7 +90,7 @@ async function postMoodEntry(request: NextRequest) { return apiError(parsed.error.issues[0].message, 422); } - const { mood, tags, moodLoggedAt, source } = parsed.data; + const { mood, tags, note, moodLoggedAt, source } = parsed.data; // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's // current displayTimezone and store the resolved zone on the row. // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin @@ -108,6 +108,7 @@ async function postMoodEntry(request: NextRequest) { mood, score, tags: tags ? JSON.stringify(tags) : null, + note: note ?? null, source: source ?? "MANUAL", moodLoggedAt, }, diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts new file mode 100644 index 000000000..472c7d1ea --- /dev/null +++ b/src/app/api/sync/state/route.ts @@ -0,0 +1,93 @@ +/** + * `GET /api/sync/state` — iOS SyncMode handshake. + * + * The iOS SyncMode store reads this endpoint on pair / unpair, on + * background-to-foreground, and before opening a fresh anchored + * HealthKit query window. The response is a compact JSON shape iOS + * compares against its local SwiftData state to decide whether to + * (a) drain a pending outbox, (b) request a full refresh, or (c) sit + * quiet. + * + * Response shape: + * { + * userId, + * timezone, + * lastSyncedAt, — User.lastSyncedAt (ISO string or null) + * serverNow, — `new Date().toISOString()` for clock skew + * measurements: { + * lastUpdatedAt, — MAX(updated_at) for live rows + * liveCount, — COUNT(*) WHERE deleted_at IS NULL + * tombstonedCount, — COUNT(*) WHERE deleted_at IS NOT NULL + * } + * } + * + * No body. requireAuth() — cookie + Bearer both accepted (iOS uses + * Bearer per `05-auth-flows.md`). + * + * Locked contract: see `.planning/v15-ios-handoff/08-locked-contracts.md` + * §13 cross-reference. The shape is additive going forward. + */ +import type { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + + // Two aggregate reads + one user-side bump. The aggregate reads run + // against indexed columns (userId, deleted_at) so they stay + // O(rows-for-user) and not O(rows-across-tenants). + const [latest, liveCount, tombstonedCount, currentUser] = await Promise.all([ + prisma.measurement.findFirst({ + where: { userId: user.id, deletedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }), + prisma.measurement.count({ + where: { userId: user.id, deletedAt: null }, + }), + prisma.measurement.count({ + where: { userId: user.id, deletedAt: { not: null } }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { lastSyncedAt: true, timezone: true }, + }), + ]); + + // The handshake also bumps the user's lastSyncedAt — the call IS + // the handshake. iOS reads the OLD lastSyncedAt from the response + // and then trusts that subsequent server writes after the new + // checkpoint will round-trip via the standard read paths. + const previous = currentUser?.lastSyncedAt ?? null; + const nextCheckpoint = new Date(); + await prisma.user.update({ + where: { id: user.id }, + data: { lastSyncedAt: nextCheckpoint }, + }); + + annotate({ + action: { name: "sync.state" }, + meta: { + liveCount, + tombstonedCount, + lastSyncedAtBefore: previous?.toISOString() ?? null, + }, + }); + + return apiSuccess({ + userId: user.id, + timezone: currentUser?.timezone ?? "Europe/Berlin", + lastSyncedAt: previous?.toISOString() ?? null, + serverNow: nextCheckpoint.toISOString(), + measurements: { + lastUpdatedAt: latest?.updatedAt?.toISOString() ?? null, + liveCount, + tombstonedCount, + }, + }); +}); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index c87cba09b..c26b364c0 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -68,6 +68,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv", AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone", TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight", + // ── v1.4.30 R-F T1.4 + T1.5 ── + WALKING_STEADINESS: "measurements.typeWalkingSteadiness", + AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -110,6 +113,12 @@ export const MEASUREMENT_TYPE_ICONS: Record = { AUDIO_EXPOSURE_ENV: Volume2, AUDIO_EXPOSURE_HEADPHONE: Headphones, TIME_IN_DAYLIGHT: Sun, + // ── v1.4.30 R-F T1.4 + T1.5 ── + // Activity/Gauge carries the mobility-steadiness signal (same + // family as VO2_MAX). Volume2 is reused for the loud-listening + // event flag — the event is a louder-cousin of the env quantity. + WALKING_STEADINESS: Gauge, + AUDIO_EXPOSURE_EVENT: Volume2, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -149,4 +158,7 @@ export const MEASUREMENT_TYPE_COLORS: Record = { AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5", AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5", TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2", + // ── v1.4.30 R-F T1.4 + T1.5 ── + WALKING_STEADINESS: "bg-chart-2/20 text-chart-2", + AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 5dffea956..14cfee0a6 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -46,10 +46,13 @@ const EXPECTED_TYPES = [ "AUDIO_EXPOSURE_ENV", "AUDIO_EXPOSURE_HEADPHONE", "TIME_IN_DAYLIGHT", + // ── v1.4.30 R-F T1.4 + T1.5 ── + "WALKING_STEADINESS", + "AUDIO_EXPOSURE_EVENT", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 28 canonical measurement types", () => { + it("exposes the 30 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -96,6 +99,12 @@ describe("measurementTypeEnum coverage", () => { "AUDIO_EXPOSURE_ENV", "AUDIO_EXPOSURE_HEADPHONE", "TIME_IN_DAYLIGHT", + // v1.4.30 R-F T1.4 + T1.5 additions held under the same v1.5 gate. + // Walking-steadiness pairs with a mobility chip; audio-exposure + // events surface on the Insights audio sub-page. Neither belongs + // in the clinical PDF until the iOS-app sync lands. + "WALKING_STEADINESS", + "AUDIO_EXPOSURE_EVENT", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index f231f064d..21ed87897 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -60,6 +60,11 @@ export const ALLOWED_CHART_TOKENS = [ "metric:AUDIO_EXPOSURE_ENV", "metric:AUDIO_EXPOSURE_HEADPHONE", "metric:TIME_IN_DAYLIGHT", + // v1.4.30 — R-F T1.4 + T1.5 Tier-1 additions. Same posture: the + // LLM can reference the tokens in prose; the chart components land + // alongside the iOS-app sync. + "metric:WALKING_STEADINESS", + "metric:AUDIO_EXPOSURE_EVENT", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index ac8475f94..9bf17f521 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { APPLE_HEALTH_SLEEP_STAGE_MAP, APPLE_HEALTH_TYPE_MAP, + CUMULATIVE_HK_TYPES, HK_QUANTITY_TYPE_DEFERRED, + dailyStatsExternalId, mapAppleHealthEntry, } from "../apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -129,8 +131,8 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { // Cardiovascular / clinical "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", - // Mobility - "HKQuantityTypeIdentifierAppleWalkingSteadiness", + // Mobility — `AppleWalkingSteadiness` moved into the mapped + // set in v1.4.30. The remaining identifiers stay deferred. "HKQuantityTypeIdentifierNumberOfTimesFallen", "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary @@ -149,9 +151,9 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { "HKCategoryTypeIdentifierHighHeartRateEvent", "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", "HKCategoryTypeIdentifierLowCardioFitnessEvent", - // Audio-exposure events - "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", - "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + // Audio-exposure events — environmental + headphone moved into + // the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general + // sound-reduction flag stays deferred. "HKCategoryTypeIdentifierEnvironmentalSoundReduction", // Behavioural / habit "HKCategoryTypeIdentifierHandwashingEvent", @@ -343,3 +345,99 @@ describe("mapAppleHealthEntry", () => { expect(out).toBeNull(); }); }); + +describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => { + it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => { + const mapping = + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierAppleWalkingSteadiness; + expect(mapping).toBeDefined(); + expect(mapping.measurementType).toBe("WALKING_STEADINESS"); + expect(mapping.convertToDbUnit(0.85)).toBeCloseTo(85); + expect(mapping.dbUnit).toBe("%"); + }); + + it("maps environmental + headphone audio-exposure events to AUDIO_EXPOSURE_EVENT (count 1)", () => { + const envMapping = + APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent; + expect(envMapping).toBeDefined(); + expect(envMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT"); + expect(envMapping.convertToDbUnit(0)).toBe(1); + expect(envMapping.convertToDbUnit(99)).toBe(1); + + const hpMapping = + APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierHeadphoneAudioExposureEvent; + expect(hpMapping).toBeDefined(); + expect(hpMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT"); + expect(hpMapping.convertToDbUnit(0)).toBe(1); + }); + + it("does not double-book the new identifiers as deferred AND mapped", () => { + expect( + HK_QUANTITY_TYPE_DEFERRED.has( + "HKQuantityTypeIdentifierAppleWalkingSteadiness", + ), + ).toBe(false); + expect( + HK_QUANTITY_TYPE_DEFERRED.has( + "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", + ), + ).toBe(false); + expect( + HK_QUANTITY_TYPE_DEFERRED.has( + "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + ), + ).toBe(false); + }); +}); + +describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { + it("produces the canonical stats:: shape for stepCount", () => { + expect( + dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", "2026-05-16"), + ).toBe("stats:HKQuantityTypeIdentifierStepCount:2026-05-16"); + }); + + it("produces the same shape for every cumulative-type identifier", () => { + const cases: Array<[string, string]> = [ + ["HKQuantityTypeIdentifierStepCount", "2026-01-01"], + ["HKQuantityTypeIdentifierActiveEnergyBurned", "2026-02-29"], + ["HKQuantityTypeIdentifierFlightsClimbed", "2025-12-31"], + ["HKQuantityTypeIdentifierDistanceWalkingRunning", "2024-02-29"], + ["HKQuantityTypeIdentifierTimeInDaylight", "2026-05-16"], + ]; + for (const [id, day] of cases) { + expect(dailyStatsExternalId(id, day)).toBe(`stats:${id}:${day}`); + } + }); + + it("accepts the date string as-is — iOS owns the format", () => { + // Per R-A §5, iOS generates the date string from the user's IANA + // timezone via DateFormatter with the `yyyy-MM-dd` pattern. The + // server trusts the inbound shape rather than re-validating; the + // receiving Zod schema already caps `externalId` at 120 chars. + expect( + dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", " 2026-05-16 "), + ).toBe("stats:HKQuantityTypeIdentifierStepCount: 2026-05-16 "); + expect( + dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", ""), + ).toBe("stats:HKQuantityTypeIdentifierStepCount:"); + }); + + it("covers every CUMULATIVE_HK_TYPES MeasurementType via a known HK identifier", () => { + // Sanity wall: every cumulative MeasurementType has a forward-map + // entry in APPLE_HEALTH_TYPE_MAP so the iOS-side daily-stats + // service can mint the externalId without a server round-trip. + const cumulativeIdentifiers = Object.values(APPLE_HEALTH_TYPE_MAP) + .filter((m) => CUMULATIVE_HK_TYPES.has(m.measurementType)) + .map((m) => m.hkIdentifier); + expect(cumulativeIdentifiers.sort()).toEqual( + [ + "HKQuantityTypeIdentifierStepCount", + "HKQuantityTypeIdentifierActiveEnergyBurned", + "HKQuantityTypeIdentifierFlightsClimbed", + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "HKQuantityTypeIdentifierTimeInDaylight", + ].sort(), + ); + }); +}); diff --git a/src/lib/measurements/__tests__/categories.test.ts b/src/lib/measurements/__tests__/categories.test.ts new file mode 100644 index 000000000..6b445a6a6 --- /dev/null +++ b/src/lib/measurements/__tests__/categories.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +import { + MEASUREMENT_CATEGORIES, + getMeasurementCategory, + measurementTypesForCategory, +} from "../categories"; + +describe("MEASUREMENT_CATEGORIES — completeness wall", () => { + it("assigns a category to every MeasurementType in the canonical enum", () => { + const enumValues = measurementTypeEnum.options; + for (const type of enumValues) { + expect( + MEASUREMENT_CATEGORIES.has(type), + `MeasurementType ${type} has no category assignment`, + ).toBe(true); + } + }); + + it("does not assign a category to a type the canonical enum does not know", () => { + const enumValues = new Set(measurementTypeEnum.options); + for (const type of MEASUREMENT_CATEGORIES.keys()) { + expect( + enumValues.has(type), + `MeasurementType ${type} is mapped but not in the canonical enum`, + ).toBe(true); + } + }); + + it("groups blood-pressure systolic and diastolic into the same category", () => { + expect(MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_SYS")).toBe( + MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_DIA"), + ); + }); +}); + +describe("getMeasurementCategory", () => { + it("returns the category for a known type", () => { + expect(getMeasurementCategory("WEIGHT")).toBe("body"); + expect(getMeasurementCategory("BLOOD_GLUCOSE")).toBe("metabolic"); + expect(getMeasurementCategory("ACTIVITY_STEPS")).toBe("activity"); + expect(getMeasurementCategory("AUDIO_EXPOSURE_HEADPHONE")).toBe("hearing"); + }); +}); + +describe("measurementTypesForCategory", () => { + it("lists every type assigned to a category", () => { + expect(measurementTypesForCategory("hearing").sort()).toEqual([ + "AUDIO_EXPOSURE_ENV", + "AUDIO_EXPOSURE_EVENT", + "AUDIO_EXPOSURE_HEADPHONE", + ]); + }); + + it("returns an empty list for a category with no members today", () => { + // None of the current 27 enum values surface a mood-specific + // MeasurementType — mood lives in MoodEntry — so the category + // can be queried without surprise (mostly a sanity wall for the + // R-F §4.4 synthetic-token decision). + const allCategories: ReadonlyArray = Array.from( + new Set(Array.from(MEASUREMENT_CATEGORIES.values())), + ); + for (const cat of allCategories) { + const members = measurementTypesForCategory(cat as never); + expect(members.length, `${cat} should not be empty`).toBeGreaterThan(0); + } + }); + + it("includes the v1.4.25 W8d hearing + environment additions", () => { + // Regression sentinel — v1.4.25 W8d added AUDIO_EXPOSURE_ENV / + // AUDIO_EXPOSURE_HEADPHONE / TIME_IN_DAYLIGHT. The categorisation + // overlay must pick them up so the iOS picker surfaces them under + // Hearing + Environment rather than dropping them into the flat + // 27-line view that the overlay exists to avoid. + expect(measurementTypesForCategory("hearing")).toContain( + "AUDIO_EXPOSURE_ENV", + ); + expect(measurementTypesForCategory("environment")).toContain( + "TIME_IN_DAYLIGHT", + ); + }); +}); diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts new file mode 100644 index 000000000..db340f475 --- /dev/null +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { + bucketRowsByUserDay, + canonicalDailyTimestamp, + dayKeyForUserTz, + sumBucketValues, +} from "../drain-per-sample-cumulative"; + +describe("dayKeyForUserTz", () => { + it("anchors the calendar day to the user's IANA zone", () => { + // 23:45 NZST on 2026-05-16 is still 11:45 UTC on the same day, but + // the user's calendar day in Europe/Berlin would be the previous day + // because Berlin is 10-11h behind NZST. + const instant = new Date("2026-05-16T11:45:00.000Z"); + expect(dayKeyForUserTz(instant, "Pacific/Auckland")).toBe("2026-05-16"); + // Europe/Berlin: same instant resolves to local 13:45 — still 2026-05-16. + expect(dayKeyForUserTz(instant, "Europe/Berlin")).toBe("2026-05-16"); + // America/Los_Angeles (UTC-7 in DST): same instant resolves to 04:45 — 2026-05-16. + expect(dayKeyForUserTz(instant, "America/Los_Angeles")).toBe("2026-05-16"); + }); + + it("uses sv-SE Intl formatting so output is ISO-shaped", () => { + const instant = new Date("2026-01-01T00:00:00.000Z"); + expect(dayKeyForUserTz(instant, "UTC")).toBe("2026-01-01"); + // The Pacific/Auckland clock is 13h ahead → already 2026-01-01 mid-afternoon at UTC midnight. + expect(dayKeyForUserTz(instant, "Pacific/Auckland")).toBe("2026-01-01"); + // Europe/Berlin (UTC+1 in standard time): same instant → 01:00 local → still 2026-01-01. + expect(dayKeyForUserTz(instant, "Europe/Berlin")).toBe("2026-01-01"); + }); +}); + +describe("canonicalDailyTimestamp", () => { + it("anchors a Berlin day-key to the local-noon instant", () => { + // 12:00 local in Berlin during DST (CEST, UTC+2) is 10:00 UTC. + const ts = canonicalDailyTimestamp("2026-05-16", "Europe/Berlin"); + expect(ts.toISOString()).toBe("2026-05-16T10:00:00.000Z"); + }); + + it("anchors a UTC day-key to plain 12:00 UTC", () => { + const ts = canonicalDailyTimestamp("2026-05-16", "UTC"); + expect(ts.toISOString()).toBe("2026-05-16T12:00:00.000Z"); + }); + + it("anchors a Pacific/Auckland day-key to the local-noon instant", () => { + // 12:00 NZST (UTC+12) → 00:00 UTC on the same day. + const ts = canonicalDailyTimestamp("2026-05-16", "Pacific/Auckland"); + expect(ts.toISOString()).toBe("2026-05-16T00:00:00.000Z"); + }); + + it("handles half-hour-offset zones (India IST = UTC+5:30)", () => { + // 12:00 IST → 06:30 UTC on the same day. + const ts = canonicalDailyTimestamp("2026-05-16", "Asia/Kolkata"); + expect(ts.toISOString()).toBe("2026-05-16T06:30:00.000Z"); + }); +}); + +describe("bucketRowsByUserDay", () => { + it("groups per-sample rows by the user's calendar day", () => { + const rows = [ + { + id: "m-1", + type: "ACTIVITY_STEPS" as const, + value: 1200, + measuredAt: new Date("2026-05-16T08:00:00.000Z"), // 10:00 Berlin + externalId: "hk-uuid-1", + }, + { + id: "m-2", + type: "ACTIVITY_STEPS" as const, + value: 3400, + measuredAt: new Date("2026-05-16T14:00:00.000Z"), // 16:00 Berlin + externalId: "hk-uuid-2", + }, + { + id: "m-3", + type: "ACTIVITY_STEPS" as const, + value: 800, + measuredAt: new Date("2026-05-17T06:00:00.000Z"), // 08:00 Berlin, next day + externalId: "hk-uuid-3", + }, + ]; + const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin"); + expect(byDay.size).toBe(2); + expect(byDay.get("2026-05-16")?.length).toBe(2); + expect(byDay.get("2026-05-17")?.length).toBe(1); + }); + + it("skips rows whose externalId is already in stats:... shape (idempotent re-run)", () => { + const rows = [ + { + id: "m-1", + type: "ACTIVITY_STEPS" as const, + value: 5000, + measuredAt: new Date("2026-05-16T10:00:00.000Z"), + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16", + }, + { + id: "m-2", + type: "ACTIVITY_STEPS" as const, + value: 1200, + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-uuid-pre-drain", + }, + ]; + const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin"); + // The already-collapsed row is skipped; only the per-sample row + // remains in the bucket. + expect(byDay.get("2026-05-16")?.length).toBe(1); + expect(byDay.get("2026-05-16")?.[0]?.id).toBe("m-2"); + }); + + it("groups rows with NULL externalId (manual entries) like any other per-sample row", () => { + const rows = [ + { + id: "m-1", + type: "ACTIVITY_STEPS" as const, + value: 1200, + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: null, + }, + ]; + const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin"); + expect(byDay.get("2026-05-16")?.length).toBe(1); + }); + + it("returns an empty map when the input is empty", () => { + const { byDay } = bucketRowsByUserDay([], "Europe/Berlin"); + expect(byDay.size).toBe(0); + }); +}); + +describe("sumBucketValues", () => { + it("sums a non-empty bucket", () => { + const rows = [ + { id: "1", type: "ACTIVITY_STEPS" as const, value: 1200, measuredAt: new Date(), externalId: null }, + { id: "2", type: "ACTIVITY_STEPS" as const, value: 3400, measuredAt: new Date(), externalId: null }, + { id: "3", type: "ACTIVITY_STEPS" as const, value: 800, measuredAt: new Date(), externalId: null }, + ]; + expect(sumBucketValues(rows)).toBe(5400); + }); + + it("returns 0 for an empty bucket", () => { + expect(sumBucketValues([])).toBe(0); + }); + + it("handles fractional values (e.g. ACTIVE_ENERGY_BURNED kcal)", () => { + const rows = [ + { id: "1", type: "ACTIVE_ENERGY_BURNED" as const, value: 12.4, measuredAt: new Date(), externalId: null }, + { id: "2", type: "ACTIVE_ENERGY_BURNED" as const, value: 7.6, measuredAt: new Date(), externalId: null }, + ]; + expect(sumBucketValues(rows)).toBeCloseTo(20.0); + }); +}); diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts new file mode 100644 index 000000000..aed1ac1b9 --- /dev/null +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows"; + +interface RowFixture { + id: string; + startedAt: Date; + sportType: string; + source: "APPLE_HEALTH" | "WITHINGS" | "MANUAL" | "IMPORT"; +} + +describe("pickCanonicalWorkoutRows", () => { + it("returns the empty list unchanged for an empty input", () => { + expect(pickCanonicalWorkoutRows([])).toEqual([]); + }); + + it("keeps a single workout regardless of source", () => { + const rows: RowFixture[] = [ + { + id: "w-1", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "running", + source: "WITHINGS", + }, + ]; + expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["w-1"]); + }); + + it("prefers APPLE_HEALTH over WITHINGS when both source the same workout", () => { + const rows: RowFixture[] = [ + { + id: "apple", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + }, + { + id: "withings", + startedAt: new Date("2026-05-16T08:01:30Z"), // 90 s apart — same 5-min slot + sportType: "running", + source: "WITHINGS", + }, + ]; + expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["apple"]); + }); + + it("does NOT collapse two distinct runs whose starts are > 5 min apart", () => { + const rows: RowFixture[] = [ + { + id: "first", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + }, + { + id: "second", + startedAt: new Date("2026-05-16T08:06:00Z"), // 6 min after start — separate bucket + sportType: "running", + source: "APPLE_HEALTH", + }, + ]; + expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([ + "first", + "second", + ]); + }); + + it("keeps workouts of different sports that start in the same minute", () => { + const rows: RowFixture[] = [ + { + id: "run", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + }, + { + id: "cycle", + startedAt: new Date("2026-05-16T08:01:00Z"), + sportType: "cycling", + source: "APPLE_HEALTH", + }, + ]; + expect(pickCanonicalWorkoutRows(rows).map((r) => r.id).sort()).toEqual([ + "cycle", + "run", + ]); + }); + + it("preserves input order on the output", () => { + const rows: RowFixture[] = [ + { + id: "morning", + startedAt: new Date("2026-05-16T06:00:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + }, + { + id: "evening", + startedAt: new Date("2026-05-16T18:00:00Z"), + sportType: "cycling", + source: "APPLE_HEALTH", + }, + ]; + expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([ + "morning", + "evening", + ]); + }); + + it("honours a user-supplied ladder that promotes WITHINGS over APPLE_HEALTH", () => { + const rows: RowFixture[] = [ + { + id: "apple", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + }, + { + id: "withings", + startedAt: new Date("2026-05-16T08:01:00Z"), + sportType: "running", + source: "WITHINGS", + }, + ]; + const userPriority = { + metricPriority: { + steps: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "IMPORT"], + }, + }; + expect( + pickCanonicalWorkoutRows(rows, userPriority).map((r) => r.id), + ).toEqual(["withings"]); + }); + + it("keeps the manual entry when no priority-ladder source claims the slot", () => { + // Edge case: a user with both APPLE_HEALTH and WITHINGS still + // manually recorded an "elliptical" workout the integrations + // don't track. Manual wins by default (only entry in bucket). + const rows: RowFixture[] = [ + { + id: "manual", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "elliptical", + source: "MANUAL", + }, + ]; + expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["manual"]); + }); + + it("falls through to keeping every row when sources aren't on the ladder at all", () => { + // Theoretical safety net — no real source emits a value outside + // the canonical enum, but the picker MUST NEVER drop signal it + // can't classify. Forcing a ladder of a single source the bucket + // doesn't carry exercises the fallback path. + const rows: RowFixture[] = [ + { + id: "a", + startedAt: new Date("2026-05-16T08:00:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + }, + { + id: "b", + startedAt: new Date("2026-05-16T08:01:00Z"), + sportType: "running", + source: "WITHINGS", + }, + ]; + const oddPriority = { + metricPriority: { + steps: ["IMPORT"], + }, + }; + expect( + pickCanonicalWorkoutRows(rows, oddPriority) + .map((r) => r.id) + .sort(), + ).toEqual(["a", "b"]); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index cbad48138..2aa0a160f 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -285,6 +285,43 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: (v) => v, aggregation: "sum", }, + + // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──────────────── + // Walking steadiness — iOS 15+ Mobility daily rollup. Apple ships + // a 0..1 fraction; HealthLog stores 0..100 percent (same convention + // as oxygen saturation + body fat). + HKQuantityTypeIdentifierAppleWalkingSteadiness: { + hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness", + measurementType: "WALKING_STEADINESS", + hkUnit: "%", + dbUnit: "%", + // Apple ships 0..1 fraction; HealthLog stores 0..100. + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + }, + // Environmental audio-exposure event — iOS 13+ category-type that + // fires when the rolling 7-day average crosses the WHO 80-dBA loud- + // listening threshold. Stored as a 1.0 count per fired event; the + // `notes` field carries the source token ("env" vs "headphone"). + HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent: { + hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", + measurementType: "AUDIO_EXPOSURE_EVENT", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: () => 1, + aggregation: "sum", + }, + // Headphone audio-exposure event — same shape as the environmental + // sibling; both share the AUDIO_EXPOSURE_EVENT MeasurementType so + // chart-card consumers can pick them up uniformly. + HKCategoryTypeIdentifierHeadphoneAudioExposureEvent: { + hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + measurementType: "AUDIO_EXPOSURE_EVENT", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: () => 1, + aggregation: "sum", + }, }; /** @@ -310,6 +347,62 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = new Set:`. + * + * Cumulative HK types only — the spot-sample path keeps using + * `HKSample.uuid` as `externalId`. The shape is intentionally + * stable across the cutover: iOS clients still posting per-sample + * rows round-trip through the existing `(userId, type, source, + * externalId)` unique index; iOS clients on the daily-stats path + * collide on the deterministic `"stats:..."` key for idempotent + * UPSERTs. + * + * Locked contract — see + * `.planning/v15-ios-handoff/08-locked-contracts.md` §13 and + * `.planning/v15-ios-handoff/06-ios-responsibilities.md` Domain 1 + * "Cumulative metrics: daily aggregation on iOS". + * + * The helper accepts the date string as-is: iOS generates it from + * the user's IANA timezone via `DateFormatter` with the + * `yyyy-MM-dd` pattern; the server trusts that format rather than + * re-validating per ingest because the iOS handoff doc locks the + * shape and the receiving Zod schema already caps `externalId` at + * 120 characters. + */ +export function dailyStatsExternalId( + hkIdentifier: string, + dateYYYYMMDD: string, +): string { + return `stats:${hkIdentifier}:${dateYYYYMMDD}`; +} + +/** + * v1.4.30 — reverse lookup from a HealthLog `MeasurementType` to the + * canonical HealthKit identifier for that type. Used by the drain + * script when minting a `dailyStatsExternalId` from a row whose + * `hkIdentifier` is not carried on the table (the per-sample ingest + * stores only the resolved `MeasurementType`). + * + * Returns `null` when the type has no HealthKit counterpart (Withings- + * only metrics). Callers in the cumulative-drain path can assume the + * lookup succeeds because `CUMULATIVE_HK_TYPES` is a subset of the + * HK-mapped types. + */ +export function hkIdentifierForType( + type: MeasurementType, +): string | null { + for (const mapping of Object.values(APPLE_HEALTH_TYPE_MAP)) { + if (mapping.measurementType === type) return mapping.hkIdentifier; + } + return null; +} + /** * HK identifiers the iOS app may emit that HealthLog deliberately does * NOT map yet. Listing them here means the batch route can log a @@ -411,9 +504,9 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // opt-in than the existing Health-share prompt. "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", - // Mobility (iOS 15+) — surface as a wellness signal in v1.5 once the - // Insights cardio sub-page has room for a steadiness gauge. - "HKQuantityTypeIdentifierAppleWalkingSteadiness", + // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the + // mapping table in v1.4.30 (R-F T1.5). The remaining identifiers + // stay deferred until a wellness sub-page surface lands. "HKQuantityTypeIdentifierNumberOfTimesFallen", "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical @@ -435,12 +528,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKCategoryTypeIdentifierHighHeartRateEvent", "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", "HKCategoryTypeIdentifierLowCardioFitnessEvent", - // Audio-exposure events (iOS 13+) — the continuous AUDIO_EXPOSURE_* - // quantity identifiers ARE mapped above; these are the - // "loud-event-fired" flags that pair with them. Defer until we - // surface event chips in the Insights audio sub-page. - "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", - "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", + // Audio-exposure events (iOS 13+) — Environmental + Headphone + // moved into the mapping table in v1.4.30 (R-F T1.4) as + // AUDIO_EXPOSURE_EVENT. The general "sound reduction" flag stays + // deferred until we surface event chips in the Insights audio + // sub-page. "HKCategoryTypeIdentifierEnvironmentalSoundReduction", // Behavioural / habit category-types — not in HealthLog scope yet. "HKCategoryTypeIdentifierHandwashingEvent", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts new file mode 100644 index 000000000..a522aafae --- /dev/null +++ b/src/lib/measurements/categories.ts @@ -0,0 +1,134 @@ +/** + * v1.4.30 — UI-side categorisation overlay for `MeasurementType`. + * + * The flat 27-value enum is the correct storage shape (per R-F §4.1) + * — Postgres enums are cheap; a per-category sub-table would explode + * the read joins. The overlay below is presentation-only and drives: + * + * - The iOS HealthKit permission picker — grouped by `category` so + * the user sees one consent screen per logical area (vitals, + * activity, sleep, …) rather than a flat 27-line list. The iOS + * side mirrors this map verbatim via the swift-openapi-generator + * codegen path (R-E H-8); a new MeasurementType is one change in + * this file + the Prisma enum + the Zod validator + this overlay. + * + * - The web Insights nav (post-v1.5) — the sub-page strip stays + * curated at 7 pages, but the "All metrics" overview can group + * cards by category cheaply from this map. + * + * - The Coach evidence shelf — chip-grouping by category makes the + * prose more navigable than a flat list. + * + * Locked: see `.planning/research/v15-r-f-apple-health-depth.md` §4. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * Apple-Health-flavoured category set. Collapses Apple's + * Heart + Vitals into one `vitals` bucket and omits the ones we + * deliberately don't track (Cycle, Nutrition, Respiratory clinical, + * Symptoms). + * + * `cardiovascular` is distinct from `vitals` — vitals are point-in- + * time signals (BP, pulse, HRV, body temp, SpO2); cardiovascular + * holds derived risk markers that arrive less frequently (pulse-wave + * velocity, vascular age). + */ +export type MeasurementCategory = + | "vitals" + | "body" + | "activity" + | "sleep" + | "hearing" + | "environment" + | "cardiovascular" + | "metabolic"; + +/** + * Per-MeasurementType → category mapping. The map is exhaustive over + * the current enum surface — adding a new MeasurementType is a + * compile-time error in this file until a category is assigned. + * + * The map is intentionally `ReadonlyMap` rather than a plain object + * literal so consumers cannot mutate it accidentally; a flat + * `Record` would be equally + * valid but provides no immutability guard. + */ +export const MEASUREMENT_CATEGORIES: ReadonlyMap< + MeasurementType, + MeasurementCategory +> = new Map([ + // ── Vitals (point-in-time signals) ── + ["BLOOD_PRESSURE_SYS", "vitals"], + ["BLOOD_PRESSURE_DIA", "vitals"], + ["PULSE", "vitals"], + ["RESTING_HEART_RATE", "vitals"], + ["HEART_RATE_VARIABILITY", "vitals"], + ["OXYGEN_SATURATION", "vitals"], + ["BODY_TEMPERATURE", "vitals"], + + // ── Body composition ── + ["WEIGHT", "body"], + ["BODY_FAT", "body"], + ["FAT_FREE_MASS", "body"], + ["FAT_MASS", "body"], + ["MUSCLE_MASS", "body"], + ["TOTAL_BODY_WATER", "body"], + ["BONE_MASS", "body"], + ["VISCERAL_FAT", "body"], + + // ── Activity (cumulative + fitness + mobility) ── + ["ACTIVITY_STEPS", "activity"], + ["ACTIVE_ENERGY_BURNED", "activity"], + ["FLIGHTS_CLIMBED", "activity"], + ["WALKING_RUNNING_DISTANCE", "activity"], + ["VO2_MAX", "activity"], + ["WALKING_STEADINESS", "activity"], + + // ── Sleep ── + ["SLEEP_DURATION", "sleep"], + + // ── Hearing (audio exposure quantities + events) ── + ["AUDIO_EXPOSURE_ENV", "hearing"], + ["AUDIO_EXPOSURE_HEADPHONE", "hearing"], + ["AUDIO_EXPOSURE_EVENT", "hearing"], + + // ── Environment ── + ["TIME_IN_DAYLIGHT", "environment"], + + // ── Cardiovascular (derived risk markers) ── + ["PULSE_WAVE_VELOCITY", "cardiovascular"], + ["VASCULAR_AGE", "cardiovascular"], + + // ── Metabolic ── + ["BLOOD_GLUCOSE", "metabolic"], + ["SKIN_TEMPERATURE", "metabolic"], +]); + +/** + * Lookup the category for a `MeasurementType`. Returns `undefined` + * only if a new enum value lands in the schema without a sibling + * entry in the map — the completeness test in + * `__tests__/categories.test.ts` is the regression sentinel. + */ +export function getMeasurementCategory( + type: MeasurementType, +): MeasurementCategory | undefined { + return MEASUREMENT_CATEGORIES.get(type); +} + +/** + * List every `MeasurementType` belonging to a given category. Useful + * for the iOS picker fan-out and the web Insights nav grouping. The + * return order is the insertion order in `MEASUREMENT_CATEGORIES` + * above — kept deterministic so the picker UX has a stable layout. + */ +export function measurementTypesForCategory( + category: MeasurementCategory, +): MeasurementType[] { + const out: MeasurementType[] = []; + for (const [type, cat] of MEASUREMENT_CATEGORIES) { + if (cat === category) out.push(type); + } + return out; +} diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts new file mode 100644 index 000000000..dcc5a7481 --- /dev/null +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -0,0 +1,344 @@ +/** + * v1.4.30 — drain pre-Option-A per-sample APPLE_HEALTH cumulative rows + * into one row per day per type, keyed by the locked `dailyStatsExternalId` + * shape. Idempotent: re-running collapses zero rows once every cumulative + * bucket already holds a single `stats:...` row. + * + * Scope per `CUMULATIVE_HK_TYPES`: + * ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, + * WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT + * + * Per user × type × calendar day (anchored to `User.timezone`): + * 1. SELECT all `Measurement` rows with `source = 'APPLE_HEALTH'` and + * `type = ` and `measuredAt` within that user's + * calendar day boundary and `externalId NOT LIKE 'stats:%'`. + * 2. If 0 rows → continue. + * 3. If 1 row whose externalId already follows the `stats:...` + * shape → continue (already collapsed). + * 4. SUM the values; pick canonical timestamp = midday UTC of the + * user's calendar day (matches the Withings activity-sync + * convention per R-A §5 / W17b). + * 5. UPSERT a row with `externalId = dailyStatsExternalId(...)`, + * `value = sumValue`, `measuredAt = canonicalTimestamp`. + * 6. DELETE the original per-sample rows in the same transaction. + * + * Designed to be invoked by both: + * - the CLI at `scripts/drain-per-sample-cumulative.ts` + * - the admin endpoint at + * `POST /api/admin/drain-per-sample-cumulative` + * + * The dry-run path emits the same per-user-day summary without + * touching the DB so the operator can inspect what would change before + * committing. + */ +import { Prisma } from "@/generated/prisma/client"; +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; + +import { + CUMULATIVE_HK_TYPES, + dailyStatsExternalId, + hkIdentifierForType, +} from "./apple-health-mapping"; + +/** Per-(user, type, day) action summary. */ +export interface DrainBucket { + userId: string; + type: MeasurementType; + /** Calendar-day key in the user's timezone (`YYYY-MM-DD`). */ + dateKey: string; + /** Number of per-sample rows scanned for this bucket. */ + perSampleCount: number; + /** SUM of per-sample values for this bucket (canonical-unit). */ + sumValue: number; + /** ISO-8601 canonical timestamp (midday UTC of the user's calendar day). */ + canonicalTimestamp: string; + /** Resulting `externalId` of the daily-aggregated row. */ + externalId: string; +} + +export interface DrainSummary { + /** Did the run actually write to the DB (false for `dryRun`). */ + dryRun: boolean; + /** Per-user-day buckets the drain rewrote (or would rewrite). */ + buckets: DrainBucket[]; + /** Aggregate counts across the run. */ + totals: { + usersScanned: number; + bucketsCollapsed: number; + perSampleRowsDeleted: number; + dailyRowsUpserted: number; + }; +} + +export interface DrainOptions { + /** Limit the drain to a single user. Default = every user. */ + userId?: string; + /** Preview-only mode — no DB writes, no transaction commits. */ + dryRun?: boolean; + /** Logger sink — defaults to `console.log`. */ + log?: (line: string) => void; +} + +/** + * Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given + * timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice + * the mood-entries path locked in v1.4.25 W7b so iOS-side and + * server-side day-keys round-trip byte-identically. + */ +export function dayKeyForUserTz(date: Date, tz: string): string { + return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date); +} + +/** + * Compute the canonical timestamp for a calendar-day key in the user's + * timezone. Returns the JS-Date instant at the user's local 12:00 noon + * — matches the Withings activity sync convention (one daily row per + * type, anchored to midday so the row sorts cleanly between same-day + * spot samples). The string returned by `toISOString()` is UTC. + */ +export function canonicalDailyTimestamp( + dateKey: string, + tz: string, +): Date { + // Compute the UTC offset for noon-local of the given day. We don't + // have a lightweight TZ-math library on the server, so the trick is: + // build "12:00 UTC of the day", read what wall-clock that shows in + // the target zone, then shift by the resulting offset. + const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour12: false, + hour: "2-digit", + minute: "2-digit", + timeZoneName: "shortOffset", + }); + // The shortOffset entry looks like "GMT+2", "GMT-5:30", or "GMT". + const parts = fmt.formatToParts(utcNoon); + const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; + const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); + if (!match) { + // UTC — noon UTC is noon local. + return utcNoon; + } + const sign = match[1] === "+" ? 1 : -1; + const hours = Number.parseInt(match[2], 10); + const minutes = match[3] ? Number.parseInt(match[3], 10) : 0; + const offsetMinutes = sign * (hours * 60 + minutes); + // utcNoon represents 12:00 UTC. The user's local clock reads + // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, + // subtract the offset. + return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000); +} + +/** + * Group an array of per-sample rows into per-day buckets keyed by the + * user's timezone. Exposed for unit testing the bucketing semantics + * without booting Prisma. + */ +export interface PerSampleRow { + id: string; + type: MeasurementType; + value: number; + measuredAt: Date; + externalId: string | null; +} + +export interface BucketedRows { + /** Map keyed by `YYYY-MM-DD` (user TZ). */ + byDay: Map; +} + +export function bucketRowsByUserDay( + rows: readonly PerSampleRow[], + tz: string, +): BucketedRows { + const byDay = new Map(); + for (const row of rows) { + // Skip rows already in the daily-stats shape — re-running the + // drain on a previously-collapsed bucket is a no-op. + if (row.externalId !== null && row.externalId.startsWith("stats:")) { + continue; + } + const key = dayKeyForUserTz(row.measuredAt, tz); + const slot = byDay.get(key) ?? []; + slot.push(row); + byDay.set(key, slot); + } + return { byDay }; +} + +/** + * Sum the values in a per-day bucket. Tiny helper; keeps the call + * site (transactional drain loop) readable. + */ +export function sumBucketValues(rows: readonly PerSampleRow[]): number { + let acc = 0; + for (const row of rows) acc += row.value; + return acc; +} + +/** + * Run the drain. Idempotent — re-invocation after a successful drain + * reports zero buckets collapsed. + * + * The function does NOT enforce the admin gate or the operator-confirm + * flag — the CLI wrapper + the admin endpoint own those concerns. + */ +export async function drainPerSampleCumulative( + prismaClient: PrismaClient, + options: DrainOptions = {}, +): Promise { + const dryRun = options.dryRun ?? false; + const log = options.log ?? ((line) => console.log(line)); + + const users = options.userId + ? await prismaClient.user.findMany({ + where: { id: options.userId }, + select: { id: true, timezone: true }, + }) + : await prismaClient.user.findMany({ + select: { id: true, timezone: true }, + }); + + const summary: DrainSummary = { + dryRun, + buckets: [], + totals: { + usersScanned: users.length, + bucketsCollapsed: 0, + perSampleRowsDeleted: 0, + dailyRowsUpserted: 0, + }, + }; + + for (const user of users) { + const tz = user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin"; + log(`[drain] user=${user.id} tz=${tz}${dryRun ? " (dry-run)" : ""}`); + + for (const type of CUMULATIVE_HK_TYPES) { + const hkIdentifier = hkIdentifierForType(type); + if (!hkIdentifier) continue; + + const perSampleRows = (await prismaClient.measurement.findMany({ + where: { + userId: user.id, + source: "APPLE_HEALTH", + type, + }, + select: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + }, + orderBy: { measuredAt: "asc" }, + })) as PerSampleRow[]; + + if (perSampleRows.length === 0) continue; + + const { byDay } = bucketRowsByUserDay(perSampleRows, tz); + + for (const [dateKey, dayRows] of byDay) { + if (dayRows.length === 0) continue; + + // Already-collapsed buckets are caught above (rows with + // externalId starting "stats:" never enter the bucket map). + // A single per-sample row left over is still drained — that + // way the externalId converges to the canonical shape even + // when iOS happened to emit exactly one sample on a quiet + // day. + + const sumValue = sumBucketValues(dayRows); + const canonicalTs = canonicalDailyTimestamp(dateKey, tz); + const externalId = dailyStatsExternalId(hkIdentifier, dateKey); + + const bucket: DrainBucket = { + userId: user.id, + type, + dateKey, + perSampleCount: dayRows.length, + sumValue, + canonicalTimestamp: canonicalTs.toISOString(), + externalId, + }; + summary.buckets.push(bucket); + summary.totals.bucketsCollapsed += 1; + + if (!dryRun) { + await prismaClient.$transaction(async (tx) => { + // Upsert the daily-aggregated row first, then drop the + // per-sample rows. The unique index + // (userId, type, source, externalId) makes the upsert + // idempotent across re-runs. + await tx.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId: user.id, + type, + source: "APPLE_HEALTH", + externalId, + }, + }, + create: { + userId: user.id, + type, + value: sumValue, + unit: dayRows[0]?.value !== undefined + ? // pick the canonical unit from the existing row by + // looking it up via the mapping table; the per-sample + // rows all carry the same unit on a given type, so + // any of them would do. + await resolveCanonicalUnit(tx, user.id, type) + : "count", + source: "APPLE_HEALTH", + measuredAt: canonicalTs, + externalId, + }, + update: { + value: sumValue, + measuredAt: canonicalTs, + }, + }); + + // Delete the per-sample rows that contributed to the sum. + // Using `id IN (...)` is bounded by the per-bucket cap + // (the largest cumulative bucket in real data is a + // ~1 440-row stepCount day on a phone-only user). + const ids = dayRows.map((r) => r.id); + const del = await tx.measurement.deleteMany({ + where: { id: { in: ids } }, + }); + summary.totals.perSampleRowsDeleted += del.count; + summary.totals.dailyRowsUpserted += 1; + }); + } else { + summary.totals.perSampleRowsDeleted += dayRows.length; + summary.totals.dailyRowsUpserted += 1; + } + } + } + } + + log( + `[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${dryRun ? " (dry-run)" : ""}`, + ); + return summary; +} + +/** + * Pull the canonical unit for a `(userId, type)` pair from an existing + * per-sample row. Used during the upsert's `create` branch when we + * need to populate `Measurement.unit` for the new aggregated row. + */ +async function resolveCanonicalUnit( + tx: Prisma.TransactionClient, + userId: string, + type: MeasurementType, +): Promise { + const row = await tx.measurement.findFirst({ + where: { userId, type, source: "APPLE_HEALTH" }, + select: { unit: true }, + }); + return row?.unit ?? "count"; +} diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts new file mode 100644 index 000000000..f7b473feb --- /dev/null +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -0,0 +1,157 @@ +/** + * v1.4.30 — cross-source canonical-row picker for `Workout` rows. + * + * Same intent as `pickCanonicalSourceRows()` in + * `src/lib/analytics/source-priority.ts`, applied to workouts. A user + * wearing an Apple Watch + a Withings ScanWatch records the same run + * once via each integration; the dashboard "Recent workouts" tile + * would otherwise show the run twice. + * + * Algorithm (v1.4.30 — matches the measurement ladder per R-F open + * question #4): + * 1. Bucket workouts by the rounded start timestamp + sport-type + * (`<5-minute slot>:`). Two HKWorkout entries for the same + * logical run start within a minute of each other on both + * sources; the rounding gives the cross-source matcher slack + * without collapsing distinct back-to-back workouts. + * 2. For each bucket, walk the SOURCE priority ladder + * (`APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT` per the existing + * source-priority validator's default) and pick the FIRST + * source that has any row in that bucket. Keep every row from + * the winning source; drop the rest. + * 3. Rows whose source isn't in the ladder pass through untouched + * (fallback intent — never silently drop signal). + * + * Determinism precondition: input rows MUST be in deterministic order + * (typically `ORDER BY startedAt ASC, id ASC` from the caller's + * Prisma read). The output preserves input order. + * + * Future v1.5.x tune: metric-aware picker per R-F open question #4 — + * route → Apple wins, HR zones → Withings wins. Out of scope for + * v1.4.30; the existing ladder ships first so the dashboard tile is + * unambiguous. + */ +import type { + MeasurementSource, +} from "@/generated/prisma/client"; + +import { + getSourceLadder, + parseSourcePriority, +} from "@/lib/validations/source-priority"; + +/** + * Minimum row shape — same posture as `SourcePickerRow`. The helper + * only consults the three fields; downstream callers can read every + * other column they fetched alongside. + */ +export interface WorkoutPickerRow { + startedAt: Date; + sportType: string; + source: MeasurementSource; +} + +/** + * Bucket-slot length. Two workouts whose `startedAt` falls in the + * same 5-minute slot AND share the same sportType are treated as the + * "same" workout from different sources. Tightens further than the + * 1-minute clock skew between HK + Withings would suggest because + * iOS posts HK `startDate` rounded to the second but Withings stamps + * its `startdate` independently — five minutes gives both heads + * enough slack on a heavy-traffic morning sync without crossing the + * "two back-to-back runs" boundary (a user does NOT do two ten-minute + * runs five minutes apart in practice). + */ +const BUCKET_SLOT_MS = 5 * 60 * 1000; + +function bucketKey(row: WorkoutPickerRow): string { + const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS); + return `${slot}:${row.sportType}`; +} + +/** + * Pick canonical rows across sources for a user's workouts. + * + * @param rows input rows (deterministic order required) + * @param userPriorityJson the user's `sourcePriorityJson` blob — same + * shape the measurement picker consults. + * Falls back to the canonical default ladder + * when null / unparseable. + * @returns the subset of input rows that survives the picker. Insertion + * order preserved. + */ +export function pickCanonicalWorkoutRows( + rows: readonly T[], + userPriorityJson: unknown = null, +): T[] { + if (rows.length === 0) return []; + + // Workouts ride the `steps` metric's source ladder — the + // canonical-activity bucket. The user's iOS Settings → Sources UI + // configures the same ladder for the workout dashboard tile; + // unifying on "steps" keeps a single configuration surface rather + // than introducing a new `workouts` metric key. Future ladders for + // workout-specific tunes (route-vs-zones) ride a v1.5.x change. + const resolved = parseSourcePriority(userPriorityJson); + const sourceLadder = getSourceLadder(resolved, "steps"); + + const buckets = new Map< + string, + { + rows: T[]; + sources: Set; + } + >(); + for (const row of rows) { + const key = bucketKey(row); + const slot = buckets.get(key) ?? { rows: [], sources: new Set() }; + slot.rows.push(row); + slot.sources.add(row.source); + buckets.set(key, slot); + } + + const canonical: T[] = []; + for (const [, slot] of buckets) { + if (slot.rows.length === 1) { + // No cross-source overlap → keep the single row regardless of + // ladder membership. + canonical.push(slot.rows[0]); + continue; + } + + let picked: MeasurementSource | undefined; + for (const source of sourceLadder) { + if (slot.sources.has(source)) { + picked = source; + break; + } + } + + if (!picked) { + // None of the bucket's sources are listed in the ladder — never + // silently drop. Keep every row in the bucket; downstream + // callers can choose to surface a "duplicate from unrecognised + // sources" diagnostic. + canonical.push(...slot.rows); + continue; + } + + for (const row of slot.rows) { + if (row.source === picked) canonical.push(row); + } + } + + // Re-sort to preserve input order — the bucket map iterates in + // insertion order but the per-bucket fan-out can interleave rows + // from later buckets ahead of earlier-bucket survivors. Caller + // wants the same `startedAt ASC` order it passed in. + canonical.sort((a, b) => { + const delta = a.startedAt.getTime() - b.startedAt.getTime(); + if (delta !== 0) return delta; + // Stable-tiebreak on sportType so the output is deterministic + // even when two surviving rows share startedAt (rare; manual + // entries on the same second). + return a.sportType.localeCompare(b.sportType); + }); + return canonical; +} diff --git a/src/lib/mood/__tests__/note-backfill.test.ts b/src/lib/mood/__tests__/note-backfill.test.ts new file mode 100644 index 000000000..32eac461f --- /dev/null +++ b/src/lib/mood/__tests__/note-backfill.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { extractNoteAndSlimTags } from "../note-backfill"; + +describe("extractNoteAndSlimTags", () => { + it("extracts the first note: entry and slims the tag array", () => { + expect( + extractNoteAndSlimTags(JSON.stringify(["coffee", "note:slept poorly", "workout"])), + ).toEqual({ + note: "slept poorly", + newTags: JSON.stringify(["coffee", "workout"]), + }); + }); + + it("returns null tags when the slimmed array is empty", () => { + expect(extractNoteAndSlimTags(JSON.stringify(["note:lonely entry"]))).toEqual({ + note: "lonely entry", + newTags: null, + }); + }); + + it("returns note=null when no entry starts with note:", () => { + expect(extractNoteAndSlimTags(JSON.stringify(["coffee", "workout"]))).toEqual({ + note: null, + newTags: JSON.stringify(["coffee", "workout"]), + }); + }); + + it("extracts only the first note:... entry; further note-prefixed entries stay as tags", () => { + expect( + extractNoteAndSlimTags(JSON.stringify(["note:first", "note:second"])), + ).toEqual({ + note: "first", + newTags: JSON.stringify(["note:second"]), + }); + }); + + it("returns the input string when it is not valid JSON", () => { + expect(extractNoteAndSlimTags("not-a-json-array")).toEqual({ + note: null, + newTags: "not-a-json-array", + }); + }); + + it("returns the input string when the JSON is not an array", () => { + expect(extractNoteAndSlimTags(JSON.stringify({ note: "x" }))).toEqual({ + note: null, + newTags: JSON.stringify({ note: "x" }), + }); + }); + + it("preserves the note prefix text exactly (no trim, no decode)", () => { + expect( + extractNoteAndSlimTags(JSON.stringify(["note: with leading space "])), + ).toEqual({ + note: " with leading space ", + newTags: null, + }); + }); +}); diff --git a/src/lib/mood/note-backfill.ts b/src/lib/mood/note-backfill.ts new file mode 100644 index 000000000..e7482e52d --- /dev/null +++ b/src/lib/mood/note-backfill.ts @@ -0,0 +1,40 @@ +/** + * v1.4.30 — pure helper for the `MoodEntry.note` backfill (R-E H-5). + * + * Pulls the first `note:` entry out of a JSON-encoded tags array + * and returns the cleaned tags JSON alongside the extracted note. Used + * by `scripts/backfill-mood-note-column.ts` (CLI) — kept in `src/lib/` + * so unit tests can exercise the bucketing without booting `dotenv` + * + the Prisma client. + */ +export function extractNoteAndSlimTags(tagsJson: string): { + note: string | null; + newTags: string | null; +} { + let parsed: unknown; + try { + parsed = JSON.parse(tagsJson); + } catch { + return { note: null, newTags: tagsJson }; + } + if (!Array.isArray(parsed)) { + return { note: null, newTags: tagsJson }; + } + let extracted: string | null = null; + const remaining: string[] = []; + for (const tag of parsed) { + if (typeof tag !== "string") { + remaining.push(String(tag)); + continue; + } + if (extracted === null && tag.startsWith("note:")) { + extracted = tag.slice("note:".length); + } else { + remaining.push(tag); + } + } + return { + note: extracted, + newTags: remaining.length > 0 ? JSON.stringify(remaining) : null, + }; +} diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 7b032a795..4e3385243 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -55,6 +55,10 @@ export function getPRDirection( case "BONE_MASS": case "MUSCLE_MASS": case "TIME_IN_DAYLIGHT": + // v1.4.30 — walking steadiness on a 0-100 scale; Apple's own + // Mobility section treats higher as the achievement (the + // recovery direction from a low-steadiness window). + case "WALKING_STEADINESS": return PersonalRecordDirection.MAX; // MIN direction — lower value is the record. @@ -79,6 +83,11 @@ export function getPRDirection( case "WEIGHT": case "SLEEP_DURATION": case "FAT_FREE_MASS": + // v1.4.30 — audio-exposure events fire on a threshold-cross, not + // on a goal axis. A "record" for "fewest loud-listening days in + // a row" is interesting but better surfaced as a streak than a + // PR direction. Defer. + case "AUDIO_EXPOSURE_EVENT": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 73668a9e3..5625a7048 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -32,6 +32,9 @@ export const measurementTypeEnum = z.enum([ "AUDIO_EXPOSURE_ENV", "AUDIO_EXPOSURE_HEADPHONE", "TIME_IN_DAYLIGHT", + // ── v1.4.30 R-F T1.4 + T1.5 ── + "WALKING_STEADINESS", + "AUDIO_EXPOSURE_EVENT", ]); export const glucoseContextEnum = z.enum([ diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index d05eac11c..a6c018acd 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -81,6 +81,10 @@ export function getScoreForMood(mood: string): number { export const createMoodEntrySchema = z.object({ mood: moodLevelEnum, tags: z.array(z.string().max(50)).max(20).optional(), + // v1.4.30 H-5 — first-class free-text note. Replaces the + // `tags: ["note:"]` workaround. Capped at 500 chars so the + // Coach evidence shelf renders cleanly without truncating chips. + note: z.string().max(500).optional(), moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), }); @@ -88,6 +92,7 @@ export const createMoodEntrySchema = z.object({ export const updateMoodEntrySchema = z.object({ mood: moodLevelEnum.optional(), tags: z.array(z.string().max(50)).max(20).nullable().optional(), + note: z.string().max(500).nullable().optional(), moodLoggedAt: z.iso .datetime({ offset: true }) .transform((s) => new Date(s)) diff --git a/tests/integration/admin-drain-cumulative-endpoint.test.ts b/tests/integration/admin-drain-cumulative-endpoint.test.ts new file mode 100644 index 000000000..eea43554b --- /dev/null +++ b/tests/integration/admin-drain-cumulative-endpoint.test.ts @@ -0,0 +1,188 @@ +/** + * v1.4.30 — `POST /api/admin/drain-per-sample-cumulative` real- + * Postgres integration. The library-level helper rides + * `tests/integration/drain-per-sample-cumulative.test.ts`; this + * suite pins the route's admin gate + payload shape + audit log + * trail end-to-end. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ADMIN_USER_ID = "user-admin-drain"; +const STANDARD_USER_ID = "user-standard-drain"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.createMany({ + data: [ + { + id: ADMIN_USER_ID, + username: "admin-drain", + email: "admin-drain@example.test", + role: "ADMIN", + timezone: "Europe/Berlin", + }, + { + id: STANDARD_USER_ID, + username: "standard-drain", + email: "standard-drain@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + ], + }); +}); + +async function setSessionFor(userId: string): Promise { + const session = await getPrismaClient().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", session.id); +} + +function makeRequest(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/admin/drain-per-sample-cumulative", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/admin/drain-per-sample-cumulative (real Postgres)", () => { + it("returns 403 when the session belongs to a standard (non-admin) user", async () => { + await setSessionFor(STANDARD_USER_ID); + const { POST } = await import( + "@/app/api/admin/drain-per-sample-cumulative/route" + ); + const res = await POST(makeRequest({ dryRun: true })); + expect(res.status).toBe(403); + }); + + it("dry-runs by default and reports buckets without writing", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: STANDARD_USER_ID, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-uuid-1", + }, + { + userId: STANDARD_USER_ID, + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-uuid-2", + }, + ], + }); + + await setSessionFor(ADMIN_USER_ID); + const { POST } = await import( + "@/app/api/admin/drain-per-sample-cumulative/route" + ); + // No body = dryRun defaults to true. + const res = await POST(makeRequest({ userId: STANDARD_USER_ID })); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + dryRun: boolean; + totals: { bucketsCollapsed: number }; + }; + }; + expect(json.data.dryRun).toBe(true); + expect(json.data.totals.bucketsCollapsed).toBe(1); + + // DB unchanged. + const remaining = await prisma.measurement.findMany({ + where: { userId: STANDARD_USER_ID, type: "ACTIVITY_STEPS" }, + }); + expect(remaining).toHaveLength(2); + }); + + it("commits when dryRun=false is passed explicitly", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: STANDARD_USER_ID, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-uuid-1", + }, + { + userId: STANDARD_USER_ID, + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-uuid-2", + }, + ], + }); + + await setSessionFor(ADMIN_USER_ID); + const { POST } = await import( + "@/app/api/admin/drain-per-sample-cumulative/route" + ); + const res = await POST( + makeRequest({ userId: STANDARD_USER_ID, dryRun: false }), + ); + expect(res.status).toBe(200); + + const remaining = await prisma.measurement.findMany({ + where: { userId: STANDARD_USER_ID, type: "ACTIVITY_STEPS" }, + }); + expect(remaining).toHaveLength(1); + expect(remaining[0].value).toBe(2000); + expect(remaining[0].externalId).toBe( + "stats:HKQuantityTypeIdentifierStepCount:2026-05-16", + ); + }); +}); diff --git a/tests/integration/drain-per-sample-cumulative.test.ts b/tests/integration/drain-per-sample-cumulative.test.ts new file mode 100644 index 000000000..aa4632a06 --- /dev/null +++ b/tests/integration/drain-per-sample-cumulative.test.ts @@ -0,0 +1,249 @@ +/** + * v1.4.30 — real-Postgres integration coverage for + * `drainPerSampleCumulative()`. Mirrors the + * `measurements-aggregate-daily` fixture: the testcontainer is hot + * before this file loads, so the body just truncates + seeds + drains. + * + * The drain logic operates against `prisma.$transaction` with an upsert + * inside; mocking that surface would re-introduce the v1.4.29 lesson + * (mocks hide real-SQL behaviour). The container test pins the + * idempotent-re-run contract end-to-end. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative"; + +const TEST_USER_ID = "user-drain-cumulative"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "drain-cumulative", + email: "drain-cumulative@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("drainPerSampleCumulative (real Postgres)", () => { + it("collapses three per-sample step rows on the same day into one daily row", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-uuid-1", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 3400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-uuid-2", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T20:00:00.000Z"), + externalId: "hk-uuid-3", + }, + ], + }); + + const summary = await drainPerSampleCumulative(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + + expect(summary.dryRun).toBe(false); + expect(summary.totals.bucketsCollapsed).toBe(1); + expect(summary.totals.perSampleRowsDeleted).toBe(3); + expect(summary.totals.dailyRowsUpserted).toBe(1); + + const remaining = await prisma.measurement.findMany({ + where: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + }, + orderBy: { measuredAt: "asc" }, + }); + + expect(remaining).toHaveLength(1); + expect(remaining[0].value).toBe(5400); + expect(remaining[0].externalId).toBe( + "stats:HKQuantityTypeIdentifierStepCount:2026-05-16", + ); + // 12:00 Berlin during DST = 10:00 UTC. + expect(remaining[0].measuredAt.toISOString()).toBe( + "2026-05-16T10:00:00.000Z", + ); + }); + + it("is idempotent — a second run after a successful drain is a no-op", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-uuid-1", + }, + { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 800, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-uuid-2", + }, + ], + }); + + await drainPerSampleCumulative(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + const second = await drainPerSampleCumulative(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + + expect(second.totals.bucketsCollapsed).toBe(0); + expect(second.totals.perSampleRowsDeleted).toBe(0); + expect(second.totals.dailyRowsUpserted).toBe(0); + }); + + it("dry-run reports the plan without touching the DB", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "FLIGHTS_CLIMBED", + value: 5, + unit: "flights", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-flights-1", + }, + { + userId: TEST_USER_ID, + type: "FLIGHTS_CLIMBED", + value: 3, + unit: "flights", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-flights-2", + }, + ], + }); + + const summary = await drainPerSampleCumulative(prisma, { + userId: TEST_USER_ID, + dryRun: true, + log: () => {}, + }); + + expect(summary.dryRun).toBe(true); + expect(summary.totals.bucketsCollapsed).toBe(1); + expect(summary.buckets[0].sumValue).toBe(8); + expect(summary.buckets[0].externalId).toBe( + "stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16", + ); + + // DB unchanged. + const remaining = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "FLIGHTS_CLIMBED" }, + }); + expect(remaining).toHaveLength(2); + }); + + it("ignores spot metrics (PULSE) — only cumulative types collapse", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "PULSE", + value: 64, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T08:00:00.000Z"), + externalId: "hk-pulse-1", + }, + { + userId: TEST_USER_ID, + type: "PULSE", + value: 72, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T14:00:00.000Z"), + externalId: "hk-pulse-2", + }, + ], + }); + + const summary = await drainPerSampleCumulative(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + + expect(summary.totals.bucketsCollapsed).toBe(0); + + const remaining = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "PULSE" }, + }); + expect(remaining).toHaveLength(2); + }); + + it("skips rows already in stats:... shape (re-running after an earlier drain)", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "ACTIVITY_STEPS", + value: 5400, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-16T10:00:00.000Z"), + externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16", + }, + }); + + const summary = await drainPerSampleCumulative(prisma, { + userId: TEST_USER_ID, + dryRun: false, + log: () => {}, + }); + + expect(summary.totals.bucketsCollapsed).toBe(0); + const remaining = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" }, + }); + expect(remaining).toHaveLength(1); + expect(remaining[0].value).toBe(5400); + }); +}); diff --git a/tests/integration/medications-intake-bulk.test.ts b/tests/integration/medications-intake-bulk.test.ts new file mode 100644 index 000000000..bacd36511 --- /dev/null +++ b/tests/integration/medications-intake-bulk.test.ts @@ -0,0 +1,187 @@ +/** + * v1.4.30 — `POST /api/medications/intake/bulk` real-Postgres + * integration. + * + * Asserts the iOS SyncMode bulk-backfill contract: + * - inserts a clean batch + * - skips entries that point at a medication the user doesn't own + * - returns `duplicate` when an idempotencyKey is re-used + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-medications-intake-bulk"; +const OTHER_USER_ID = "user-medications-intake-other"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +let ownedMedId = ""; +let foreignMedId = ""; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { + id: TEST_USER_ID, + username: "intake-bulk", + email: "intake-bulk@example.test", + timezone: "Europe/Berlin", + }, + { + id: OTHER_USER_ID, + username: "intake-other", + email: "intake-other@example.test", + timezone: "Europe/Berlin", + }, + ], + }); + const owned = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Mounjaro", + dose: "5mg", + active: true, + }, + }); + ownedMedId = owned.id; + const foreign = await prisma.medication.create({ + data: { + userId: OTHER_USER_ID, + name: "Levothyroxin", + dose: "50µg", + active: true, + }, + }); + foreignMedId = foreign.id; + + const session = await prisma.session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/medications/intake/bulk (real Postgres)", () => { + it("inserts a clean batch", async () => { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: ownedMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + takenAt: "2026-05-16T08:02:00.000Z", + }, + { + medicationId: ownedMedId, + scheduledFor: "2026-05-17T08:00:00.000Z", + skipped: true, + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { processed: number; inserted: number; duplicates: number }; + }; + expect(json.data.processed).toBe(2); + expect(json.data.inserted).toBe(2); + + const stored = await getPrismaClient().medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(2); + }); + + it("skips entries that reference a medication the user doesn't own", async () => { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: ownedMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + }, + { + medicationId: foreignMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + }, + ], + }), + ); + const json = (await res.json()) as { + data: { + inserted: number; + skipped: Array<{ index: number; reason: string }>; + }; + }; + expect(json.data.inserted).toBe(1); + expect(json.data.skipped).toEqual([ + { index: 1, reason: "medication_not_found" }, + ]); + }); + + it("returns `duplicate` when an idempotencyKey is re-used", async () => { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const body = { + entries: [ + { + medicationId: ownedMedId, + scheduledFor: "2026-05-16T08:00:00.000Z", + idempotencyKey: "ios-sync-key-001", + }, + ], + }; + await POST(makeRequest(body)); + const res = await POST(makeRequest(body)); + const json = (await res.json()) as { + data: { duplicates: number; inserted: number }; + }; + expect(json.data.duplicates).toBe(1); + expect(json.data.inserted).toBe(0); + + const stored = await getPrismaClient().medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(1); + }); +}); diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts new file mode 100644 index 000000000..b3fe39246 --- /dev/null +++ b/tests/integration/mood-entries-bulk.test.ts @@ -0,0 +1,170 @@ +/** + * v1.4.30 — `POST /api/mood-entries/bulk` real-Postgres integration. + * + * Asserts the iOS SyncMode bulk-backfill contract: + * - inserts a clean batch + * - upserts on re-post (last-writer-wins on mood / tags) + * - rejects oversize batches + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-bulk"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-bulk", + email: "mood-bulk@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/mood-entries/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/mood-entries/bulk (real Postgres)", () => { + it("inserts a clean batch of three entries", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T14:00:00.000Z", + tags: ["coffee"], + }, + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T20:00:00.000Z", + source: "DAYLIO", + }, + ], + }), + ); + + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + processed: number; + inserted: number; + duplicates: number; + entries: Array<{ status: string }>; + }; + }; + expect(json.data.processed).toBe(3); + expect(json.data.inserted).toBe(3); + expect(json.data.duplicates).toBe(0); + + const stored = await getPrismaClient().moodEntry.findMany({ + where: { userId: TEST_USER_ID }, + orderBy: { moodLoggedAt: "asc" }, + }); + expect(stored).toHaveLength(3); + expect(stored[0].mood).toBe("GUT"); + expect(stored[0].score).toBe(4); + expect(stored[0].tz).toBe("Europe/Berlin"); + }); + + it("upserts when the same (userId, date, moodLoggedAt) tuple is re-posted", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const body = { + entries: [ + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + }, + ], + }; + + await POST(makeRequest(body)); + const res = await POST( + makeRequest({ + entries: [ + { + // Same timestamp + user but a different mood. + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + tags: ["workout"], + }, + ], + }), + ); + + const json = (await res.json()) as { + data: { duplicates: number; inserted: number }; + }; + expect(json.data.duplicates).toBe(1); + expect(json.data.inserted).toBe(0); + + const stored = await getPrismaClient().moodEntry.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(1); + expect(stored[0].mood).toBe("SUPER_GUT"); + expect(stored[0].score).toBe(5); + }); + + it("rejects an over-cap batch with 422 and the documented error code", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const entries = Array.from({ length: 501 }, (_, i) => ({ + mood: "OKAY", + moodLoggedAt: new Date(2026, 4, 16, 8, i % 60).toISOString(), + })); + const res = await POST(makeRequest({ entries })); + expect(res.status).toBe(422); + const json = (await res.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(json.meta?.errorCode).toBe("mood.bulk.too_large"); + }); +}); diff --git a/tests/integration/mood-entries-note-column.test.ts b/tests/integration/mood-entries-note-column.test.ts new file mode 100644 index 000000000..0e810855f --- /dev/null +++ b/tests/integration/mood-entries-note-column.test.ts @@ -0,0 +1,169 @@ +/** + * v1.4.30 — `MoodEntry.note` column round-trip integration. + * + * Asserts: + * - POST /api/mood-entries accepts the new `note` field + * - PUT /api/mood-entries/[id] updates `note` (and accepts null to clear) + * - POST /api/mood-entries/bulk persists `note` per entry + * - The Zod cap (500 chars) rejects oversize prose + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-mood-note"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "mood-note", + email: "mood-note@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function postRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function putRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("MoodEntry.note round-trip (real Postgres)", () => { + it("POST /api/mood-entries persists `note`", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + note: "Long run before breakfast.", + }), + ); + expect(res.status).toBe(201); + const stored = await getPrismaClient().moodEntry.findFirst({ + where: { userId: TEST_USER_ID }, + }); + expect(stored?.note).toBe("Long run before breakfast."); + }); + + it("PUT /api/mood-entries/[id] updates `note` and accepts null to clear it", async () => { + const created = await getPrismaClient().moodEntry.create({ + data: { + userId: TEST_USER_ID, + date: "2026-05-16", + tz: "Europe/Berlin", + mood: "OKAY", + score: 3, + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + note: "initial note", + }, + }); + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + + const res1 = await PUT( + putRequest(`/api/mood-entries/${created.id}`, { note: "revised note" }), + { params: Promise.resolve({ id: created.id }) }, + ); + expect(res1.status).toBe(200); + const after1 = await getPrismaClient().moodEntry.findUnique({ + where: { id: created.id }, + }); + expect(after1?.note).toBe("revised note"); + + const res2 = await PUT( + putRequest(`/api/mood-entries/${created.id}`, { note: null }), + { params: Promise.resolve({ id: created.id }) }, + ); + expect(res2.status).toBe(200); + const after2 = await getPrismaClient().moodEntry.findUnique({ + where: { id: created.id }, + }); + expect(after2?.note).toBeNull(); + }); + + it("POST /api/mood-entries/bulk persists `note` per entry", async () => { + const { POST } = await import("@/app/api/mood-entries/bulk/route"); + const res = await POST( + postRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + note: "Morning", + }, + { + mood: "OKAY", + moodLoggedAt: "2026-05-16T14:00:00.000Z", + note: "Afternoon dip", + }, + ], + }), + ); + expect(res.status).toBe(200); + const stored = await getPrismaClient().moodEntry.findMany({ + where: { userId: TEST_USER_ID }, + orderBy: { moodLoggedAt: "asc" }, + }); + expect(stored.map((r) => r.note)).toEqual(["Morning", "Afternoon dip"]); + }); + + it("rejects an oversize note (501 chars) with 422", async () => { + const { POST } = await import("@/app/api/mood-entries/route"); + const oversize = "x".repeat(501); + const res = await POST( + postRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + note: oversize, + }), + ); + expect(res.status).toBe(422); + }); +}); diff --git a/tests/integration/sync-state.test.ts b/tests/integration/sync-state.test.ts new file mode 100644 index 000000000..99d4f70f3 --- /dev/null +++ b/tests/integration/sync-state.test.ts @@ -0,0 +1,137 @@ +/** + * v1.4.30 — `GET /api/sync/state` real-Postgres integration. + * + * The endpoint is the iOS SyncMode handshake. Asserts: + * - first call returns `lastSyncedAt: null` then writes the + * checkpoint + * - subsequent call sees the previous checkpoint + * - measurement counters distinguish live vs tombstoned rows + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-sync-state"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "sync-state", + email: "sync-state@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/sync/state", { method: "GET" }); +} + +describe("GET /api/sync/state (real Postgres)", () => { + it("returns lastSyncedAt: null on the first call and writes the checkpoint", async () => { + const { GET } = await import("@/app/api/sync/state/route"); + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { + userId: string; + timezone: string; + lastSyncedAt: string | null; + serverNow: string; + measurements: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number }; + }; + }; + expect(json.data.userId).toBe(TEST_USER_ID); + expect(json.data.timezone).toBe("Europe/Berlin"); + expect(json.data.lastSyncedAt).toBeNull(); + expect(json.data.measurements.liveCount).toBe(0); + expect(json.data.measurements.tombstonedCount).toBe(0); + + // Server-side, lastSyncedAt now carries a value. + const after = await getPrismaClient().user.findUnique({ + where: { id: TEST_USER_ID }, + select: { lastSyncedAt: true }, + }); + expect(after?.lastSyncedAt).not.toBeNull(); + }); + + it("returns the previous checkpoint on the second call", async () => { + const { GET } = await import("@/app/api/sync/state/route"); + await GET(makeRequest()); + const res = await GET(makeRequest()); + const json = (await res.json()) as { + data: { lastSyncedAt: string | null }; + }; + expect(json.data.lastSyncedAt).not.toBeNull(); + }); + + it("distinguishes live vs tombstoned measurement counts", async () => { + const prisma = getPrismaClient(); + await prisma.measurement.createMany({ + data: [ + { + userId: TEST_USER_ID, + type: "PULSE", + value: 64, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-16T10:00:00.000Z"), + }, + { + userId: TEST_USER_ID, + type: "PULSE", + value: 70, + unit: "bpm", + source: "MANUAL", + measuredAt: new Date("2026-05-16T11:00:00.000Z"), + deletedAt: new Date("2026-05-16T12:00:00.000Z"), + }, + ], + }); + + const { GET } = await import("@/app/api/sync/state/route"); + const res = await GET(makeRequest()); + const json = (await res.json()) as { + data: { measurements: { liveCount: number; tombstonedCount: number } }; + }; + expect(json.data.measurements.liveCount).toBe(1); + expect(json.data.measurements.tombstonedCount).toBe(1); + }); +});