diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..4d387a8b6 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +# Funding links for the GitHub "Sponsor" button. +custom: ["https://buymeacoffee.com/mbombeck"] diff --git a/.gitignore b/.gitignore index 03244c0dc..ec6b028a4 100644 --- a/.gitignore +++ b/.gitignore @@ -67,22 +67,11 @@ next-env.d.ts .codex/ .aider* -# Planning scratch — per-release reports, QA findings, ad-hoc backlog -# files. These accumulate during a release and aren't useful to anyone -# outside the immediate work. The curated planning docs (PROJECT.md, -# ROADMAP.md, STATE.md, ios-coord/, research/, v15-*, sb-*, feature-*) -# stay tracked. -/.planning/phase-* -/.planning/QA-* -/.planning/round-* -/.planning/v14*-* -/.planning/v15*-* -/.planning/V0*-* -/.planning/ios-coord/V*-QA-* -/.planning/research/v14*-* -/.planning/marathon-* -/.planning/security-audit-* -/.planning/session-* +# Planning scratch — all internal per-release reports, QA findings, ad-hoc +# backlog, and the iOS coordination channel. These are internal working +# notes; everything user-facing lives in the public surfaces (README, +# CHANGELOG, docs/). Kept on disk for the working session, never tracked. +/.planning/ # Docs scratch — temporary spec drafts that live in the repo only # while a release is in flight. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md deleted file mode 100644 index 7a7b739dd..000000000 --- a/.planning/PROJECT.md +++ /dev/null @@ -1,31 +0,0 @@ -# HealthLog v1.4.6 marathon — project - -Personal health-tracking PWA. v1.4.5 live in production. v1.4.6 marathon -brief is `docs/audit/v146-findings.md` — that file is the source of truth -for every fix, feature, and gate. `CLAUDE.md` covers project-wide rules -(tooling, conventions, hard constraints). - -## Scope - -- Tier 1 (T1-T9): release blockers -- New feature: chart bucketing for ranges > 1 year -- Tier 2 (P1-P20): polish -- Multi-agent QA + simplify pass -- Release & deploy v1.4.6 -- GitHub releases backfill (v1.4.2-v1.4.6) + GHCR housekeeping -- Docs + landing site sync - -## Hard rules (Marc, verbatim) - -1. Niemals `--no-verify`. -2. Niemals `--no-gpg-sign` ohne Anfrage. -3. Niemals force-push to main. -4. Changelog/Release-Notes user-facing — kein "Claude", keine internen - Phasennamen. -5. Out-of-scope > typo → defer to v1.5. - -## References - -- `CLAUDE.md` — project conventions -- `docs/audit/v146-findings.md` — release spec -- `CHANGELOG.md` — release tone & format diff --git a/.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md b/.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md deleted file mode 100644 index 98c05ab45..000000000 --- a/.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md +++ /dev/null @@ -1,403 +0,0 @@ ---- -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/ROADMAP.md b/.planning/ROADMAP.md deleted file mode 100644 index 8e19fa7e3..000000000 --- a/.planning/ROADMAP.md +++ /dev/null @@ -1,124 +0,0 @@ -# HealthLog — roadmap - -Current line: **v1.9.0** (shipping — Insights time-ranges, deeper mood, medication -drug-coding into FHIR, advanced-settings rebuild, connection-pool stability). -Trunk: `main`, always releasable. Release model in CLAUDE.md. - -Next milestone: **v1.10.0 — derived / synthesized wellness metrics + provenance + docs**. - ---- - -## v1.10.0 — derived metrics, provenance, docs, discoverability - -The headline is derived wellness metrics that are honest on the data HealthLog -actually has — daily snapshots, not continuous streams. The governing constraint: -HRV is a single nightly SDNN value, there is no continuous HR, no EDA, no raw ECG/PPG, -and the nightly drain tombstones per-sample rows to a daily mean. So anything built on -intraday physiology is out of scope; day-scale deviation-from-personal-baseline -statistics are the sweet spot. Full synthesis in -`.planning/v1.10-derived-metrics-PROPOSAL.md`, implementation plan in -`.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0, not -1.9.1), research reports under `.planning/v1.10-research/`. - -### Ordering (data-availability first) - -A derived metric appears only when its required inputs exist; composites degrade -gracefully and never present a headline number secretly computed from one of N signals. -Precedent: the Personal Health Score null-redistributes missing pillars. -(`.planning/v1.10-research/DESIGN-PRINCIPLE-data-availability.md`.) - -1. **Per-metric Vitals dashboard** (flagship) — each available signal shows its own - personal-baseline card; absent signals don't render, so it scales 1→30 metrics with - no data assumptions. Pure rolling stats over inputs we 100% have, plus a multi-signal - early-strain flag. Build this first. -2. **Pre-computed easy wins** — age/sex-adjusted reference ranges (cross-cutting - enabler: `dateOfBirth` / `gender` are stored but unused for norms), Fitness Age / - cardio band from VO2max, Vascular Age framing from PWV. Render only when the device - value exists. -3. **Composites** — Sleep Score and a Readiness / Wellness index (extending the Personal - Health Score), each with a minimum-inputs threshold, reweighting around missing - inputs, a visible coverage / confidence indicator, and a "track BP/HRV to sharpen - this" nudge. -4. **Broader correlation discovery** — expand the fixed-hypothesis correlation engine - into an FDR-controlled behaviour↔outcome discovery engine, surfacing only pairs with - enough paired n (the existing n≥20 gate pattern). - -Out of scope by construction (require sensing we deliberately don't retain): Body -Battery, all-day / real-time Stress, Strain / Training Load, a faithful Recovery score, -AFib / ECG derivation, breathing-disturbance / AHI, Oura Resilience, minute-by-minute -time-in-zone. State this plainly so we never over-promise; surface a device-computed -value as a passthrough where one exists, never re-derive it. - -### Provenance / transparency (acceptance criterion, not an afterthought) - -Every derived metric openly states what it's based on and links the basis: -- exact inputs + method / formula (plain-language + the math), -- why this method (rationale), -- a citation / link to the underlying authority (IEEE / RFC / WHO / LOINC / - peer-reviewed studies / Wikipedia — whichever is right), -- confidence / limitations stated honestly (SDNN≠RMSSD, snapshot-not-continuous, - consumer-sensor validity caveats), -- rendered through the existing collapsible provenance / explainer pattern — no - black-box number. - -Needs a shared provenance / citation data model so each derived metric carries its -sources, surfaced via the explainer component everywhere it appears. -(`.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`.) - -### Documentation audit + continuation - -- Audit the docs site (`healthlog-docs`), in-repo `docs/`, README, and the OpenAPI - contract for accuracy vs the shipped state — much drifted across the v1.8.x → v1.9.0 - arc. -- Continue / extend docs to cover the new derived metrics with the same provenance: - meaning, computation, linked standard, required data, graceful-degradation behaviour. -- This `ROADMAP.md` / `STATE.md` refresh folds into the audit. - -### Discoverability / growth - -A discoverability / growth workstream stands alongside the milestone (scope to be -shaped during planning). - ---- - -## Standing deferred items - -Carried forward; pick up as they become relevant or fold into a milestone. - -- **BYOK AI fallback** — resilience gap: no provider fallback is configured, so a single - provider outage stalls assessment generation. -- **`MedicationAdministration` per-export cap tuning** — the per-dose administration - emission added in v1.9.0 needs a sensible cap for high-volume histories. -- **SNOMED route / site upgrade** — route-of-administration + injection-site could carry - SNOMED codes rather than the current local enum. -- **German bfarm ATC URI behind a locale toggle** — the export codes ATC against the WHO - system; a German deployment may prefer the bfarm URI. - ---- - -## Shipped (coarse — CHANGELOG.md is the record) - -- **v1.9.0** (2026-06-02) — Insights time-ranges + period-over-period deltas, deeper - mood insights, medication ATC + RxNorm into the FHIR export (+ MedicationAdministration - per dose, Coverage-from-bare-KVNR, Composition narrative), advanced-settings rebuild, - inline targets (`/targets` retired), connection-pool sizing fix, decoupled insight - warm. Migration `0103`. -- **v1.8.7.1** (2026-06-02) — assessment on every HealthKit metric page, one-tap - pre-generation. -- **v1.8.x** (2026-05-31 → 06-02) — Insights big release (reliable assessments, graded - compression, embedded targets, explainers, English slugs), compliance + intake-slot - medical fixes, injection-site tracking, FHIR codesystem + Coverage, instant - assessments, GLP-1 blood-level chart. -- **v1.7.x** (2026-05-31) — health-record PDF + FHIR R4 export, PRN + cyclic schedules, - unified dashboard snapshot + nightly insight pre-gen, offline sync delta feed, full - HealthKit chart coverage, Coach data clustering. -- **v1.6.0** (2026-05-30) — medication editor overhaul + route of administration, - one-time injection, today-tile read-flip, profile-photo upload. -- **v1.5.x** (2026-05-24 → 29) — native iOS client public beta + Apple Health sync, - medication scheduling (RRULE / rolling / one-shot), step consolidation, `safeFetch` - egress hardening, avatar storage, `SESSION_COOKIE_SECURE` through compose. -- **v1.4.24 → v1.4.50** (2026-05-11 → 24) — rollup tiers + perf, APNs + `push_attempts`, - reminder suppression, self-healing stale-shell, localisation, MoodLog reverse-sync. -- **v1.4.23** (2026-05-11) — pre-iOS backend foundation + hygiene (Apple Health enum + - batch ingest + APNs scaffolding + OpenAPI generator). Earlier history archived. - diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index 8f8fdb82a..000000000 --- a/.planning/STATE.md +++ /dev/null @@ -1,117 +0,0 @@ -# State log — where we are now - -Status: **v1.9.0 shipping** — on branch `release/v1.9.0`, Draft PR #240 → `main`, -about to tag + docker-publish + deploy. -Last update: 2026-06-02 - -> Single trunk: `main` is always releasable. `develop` is retired (archived as the -> tag `archive/develop-pre-v1.5`; never recreate it). Releases cut on a short-lived -> `release/vX.Y.Z` branch → PR → merge → tag → `docker-publish.yml` → manual deploy → -> verify `/api/version` on every target. See CLAUDE.md for the full model. - ---- - -## v1.9.0 — what it carries - -Insights time-ranges + period-over-period deltas, deeper mood insights, medication -drug-coding into the FHIR export, an advanced-settings rebuild, an inline-targets -move, and a stability fix to the connection pool. - -### Added - -- **Selectable time ranges on the Insights pages** — week / month / quarter / year - range pills per metric, with a period-over-period delta (the change in the average, - stated plainly with its direction). -- **Deeper mood insights** — time-of-day pattern (highest / lowest), a stability read, - and correlation cards against weight and blood pressure. Every correlation is - paired-n gated (only shown when there are enough paired days to mean anything). -- **Standard drug codes on a medication** — an optional ATC and RxNorm code (entered, - never guessed; migration `0103` adds the nullable `atc_code` / `rxnorm_code`). - These flow into the health-record export: the `MedicationStatement` codes the drug - with the WHO ATC system + RxNorm alongside the plain name, and the export adds a - `MedicationAdministration` for every dose actually taken or skipped. -- **`scripts/assert-deploy.ts`** — a deploy-verification script that checks a target - reports the expected version after a release. - -### Changed - -- **Medication advanced-settings page rebuilt** — import / intake-import / export in - their own group; the external-API endpoints listed one by one with collapsible - request examples; tidier layout. -- **Targets edited inline** — the standalone `/targets` page is retired; a metric's - target range is set from the metric itself. -- **Health-record export emits insurer Coverage from a bare KVNR** (not only with a - full insurer organisation present) and carries a top-level Composition narrative - summary. - -### Fixed - -- **App stays responsive under load** — `DB_CONNECTION_LIMIT` is now sized for real - concurrency (the 9 default could starve while a background insight warm and a - foreground request competed, surfacing as the self-recovering "server not - responding"); tunable for larger self-hosts. -- **Background insight warm decoupled + sync-burst invalidation debounced** — a warm - no longer blocks a page, and a burst of Apple Health / Withings sync no longer - triggers a storm of regenerations. -- **Medication card rows hold an equal height** ("last dose" / "next dose" alignment). -- **Glucose entered in mmol/L converts correctly** in the editor. -- **Scrollbar-gutter reserved** — admin + global layout no longer shifts when a - scrollbar appears. -- **Six legacy `*-status` routes documented** in the OpenAPI contract. - ---- - -## What is live (the release arc since v1.4.23) - -Coarse summary; CHANGELOG.md is the accurate per-release record. - -- **v1.4.24 → v1.4.50** — perf + rollup tiers (measurement / mood / compliance / - cumulative-sum rollups, read-swap with live fallback, auto-converging boot - backfill), APNs delivery + per-channel test endpoint + `push_attempts` ledger, - server-side reminder suppression (`clientManaged`), self-healing stale-shell after - deploy, full localisation push, MoodLog reverse-sync. -- **v1.5.x** — native iOS client public beta + Apple Health sync (`POST /api/measurements/batch`, - per-day cumulative `stats:` overwrite), medication scheduling (RRULE / rolling / - one-shot lifecycle, creation wizard → modal-dialog compose-mode), per-day-cumulative - step consolidation, `safeFetch` egress hardening, self-hosted avatar storage, - `SESSION_COOKIE_SECURE` plumbed through compose. -- **v1.6.0** — medication editor overhaul + route of administration (`deliveryForm`, - migration `0088` ORAL backfill), one-time injection, today-tile read-flip onto the - canonical recurrence engine, profile-photo upload. -- **v1.7.x** — health-record export (enriched PDF + HL7 FHIR R4 bundle), PRN + cyclic - schedules, unified dashboard snapshot + nightly insight pre-generation, offline sync - delta feed (tombstones), full HealthKit chart coverage + display-unit preference, - Coach data clustering, multi-time compliance fixes. -- **v1.8.x** — Insights big release (reliable data-driven assessments, graded - compression, embedded targets, explainers, English slugs + tile-id aliases), - multi-time compliance, one-intake-row-per-dose-slot medical fix, injection-site - tracking, FHIR codesystem for HealthKit-only metrics, FHIR `Coverage`, instant - assessments (stale-while-revalidate), GLP-1 blood-level chart. -- **v1.8.7.1** — a plain-language assessment on every HealthKit metric page (~30 - metrics), one-tap pre-generation of every assessment. - -apps01 + demo are LIVE on the latest tag; v1.9.0 deploy is pending the tag. - ---- - -## Deferred set (carry-forward from v1.9.0) - -| Item | Where it goes | Note | -|---|---|---| -| Derived / synthesized wellness metrics | **v1.10.0** | per-metric Vitals dashboard first, then composites with coverage/confidence gating. Plans in `.planning/v1.10-derived-metrics-PROPOSAL.md` + `.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0) + `.planning/v1.10-research/*`. | -| Docs / provenance audit | **v1.10.0** | docs site + in-repo `docs/` + README + OpenAPI drifted across the v1.8.x → v1.9.0 arc; audit + continue, and surface every derived metric's sources via the explainer pattern. Requirement: `.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`. | -| Discoverability / growth workstream | **v1.10.0** | standing alongside the derived-metrics milestone. | -| BYOK AI fallback | open | resilience gap — no provider fallback is configured, so a single provider outage stalls assessment generation. | -| `MedicationAdministration` per-export cap tuning | open | the per-dose administration emission needs a sensible cap for high-volume histories. | -| SNOMED route / site upgrade | open | route-of-administration + injection-site could carry SNOMED codes rather than the current local enum. | -| German bfarm ATC URI behind a locale toggle | open | the export codes ATC against the WHO system; a German deployment may prefer the bfarm URI — gate behind a locale toggle. | - ---- - -## Next milestone — v1.10.0 - -Derived / synthesized wellness metrics, honest on the daily-snapshot data HealthLog -actually stores; full provenance + standards-linking; full docs audit; -discoverability / growth. See `ROADMAP.md`. - - diff --git a/.planning/coolify-auto-deploy-howto.md b/.planning/coolify-auto-deploy-howto.md deleted file mode 100644 index 40f987a22..000000000 --- a/.planning/coolify-auto-deploy-howto.md +++ /dev/null @@ -1,109 +0,0 @@ -# Coolify image-digest auto-deploy — maintainer runbook - -Status: maintainer-task — the toggle lives in Coolify's UI; neither -the Coolify MCP nor the GitHub workflow can flip it. - -## The problem - -Releases v1.4.19, v1.4.20, and v1.4.21 all required a host-side -`docker tag :latest` + `docker compose up -d --no-deps` fallback -after GHCR pushed the new image. Coolify pulled `:latest` from its -local cache without re-checking the GHCR registry digest, so the -freshly published image never reached production until the maintainer -SSH'd into the host and forced the retag. - -Longer history: `docs/audit/v1416-auto-deploy-fix.md` documents the -v1.4.15 partial fix (workflow-side webhook trigger) and why it -half-solved the problem. v1.4.22 commit `b281c06` added the explicit -`?force=true` query parameter to the webhook call so Coolify skips -its image-cache on every workflow-triggered deploy. - -## Repo secrets (GitHub Settings → Secrets → Actions) - -Set these once. Both required. - -1. **`COOLIFY_WEBHOOK`** - - Value: `https:///api/v1/deploy?uuid=&force=false` - - Find: Coolify UI → Application → **Webhooks** tab → "Deploy" URL. - The workflow appends `&force=true` if your stored URL doesn't - already carry a `force=` parameter, so either value is fine. - - The actual values live in repo secrets only. - -2. **`COOLIFY_TOKEN`** - - Value: a Bearer token from Coolify UI → **Keys & Tokens** → - "Create new token" → grant read + deploy scope. - - Treat as a long-lived secret; rotate alongside any host - credential rotation. - -If either secret is missing the GitHub Actions step short-circuits -with a `::warning::` line in the workflow log — image is still -published to GHCR, but no Coolify call is made. - -## Coolify UI toggle (one-time) - -Open Coolify UI → Application → **Configuration** tab → -**"Watch image registry for new digests"** → **ON**. - -This is the load-bearing piece. Without the toggle, `:latest` pulls -return the locally-cached digest even when the workflow webhook fires -with `force=true` — Coolify's own pull short-circuits before any -registry round-trip. Flip it once, save, never touch again. - -The toggle's exact wording shifts between Coolify v4 point-releases; -look for "image registry", "digest auto-deploy", or -"auto-update" in the same tab. - -## Pre-deploy data check (v1.4.23 onwards) - -Migration `0036_apple_health_measurement_types` documents a unit -semantics shift for `SLEEP_DURATION` (hours → minutes) and explicitly -relies on **zero** pre-existing rows of that type. Before tagging -v1.4.23 (or any future release that re-applies migration 0036 against -a fresh database): - -``` -psql "$DATABASE_URL" -c "select count(*) from measurements where type = 'SLEEP_DURATION'" -``` - -If the count is non-zero, write a one-shot data-migration multiplying -every existing `SLEEP_DURATION` row's `value` column by 60 (hours → -minutes) BEFORE running `prisma migrate deploy`. Skipping the check -will silently shrink displayed sleep duration by 60× without -rewriting the stored numeric. - -## Verification - -Push a tag `vX.Y.Z` to main → wait for the GHCR build to finish → -wait ~30s → -`curl -s https://healthlog.bombeck.io/api/version | jq .data.version` -should return `X.Y.Z` automatically. If it doesn't: - -1. Coolify UI → Application → **Deployments** tab → check the most - recent deploy says "Pulled fresh image" not "Image already up to - date". The latter means the registry-digest toggle isn't on. -2. If "already up to date" is the message, re-flip the toggle. -3. Last resort — host-side retag fallback: - ``` - ssh apps-01 - docker pull ghcr.io/mbombeck/healthlog:vX.Y.Z - docker tag ghcr.io/mbombeck/healthlog:vX.Y.Z ghcr.io/mbombeck/healthlog:latest - cd /path/to/coolify/app && docker compose up -d --no-deps - ``` - (see v1.4.21+ release summaries for the canonical command). - -## Why not solve it in the workflow - -Two non-options were ruled out earlier: - -- **Push the explicit version tag in `docker-compose.yml`** — would - require a workflow edit on every release. Worse than the current - state (a one-time UI toggle). -- **Pre-deployment `docker pull` hook in the Coolify app config** — - duplicates the registry-digest auto-deploy feature but worse: the - pull runs on every deploy trigger (including doc-only pushes) - instead of only when the digest changed. - -The workflow file (`.github/workflows/docker-publish.yml`) already -pings Coolify's webhook with `?force=true` after the GHCR push, so -the trigger half of the contract is in place — the missing half is -the registry-digest check, which is a Coolify-side feature. diff --git a/.planning/feature-user-timezone.md b/.planning/feature-user-timezone.md deleted file mode 100644 index 06aa8fcbb..000000000 --- a/.planning/feature-user-timezone.md +++ /dev/null @@ -1,345 +0,0 @@ -# Feature proposal — per-user timezone - -**Status:** documented, not scheduled. -**Trigger:** GitHub issue -[#167](https://github.com/MBombeck/HealthLog/issues/167) (Warsaw user, -v1.4.23). -**Captured:** 2026-05-12, during the v1.4.24 release retrospective. -**Owner:** unassigned — picks up in v1.5 product-lead planning. - -This document is the long-form record so a future planning pass does -not start from zero. Read together with -`.planning/v15-backlog.md` (which only carries the one-line entry). - ---- - -## 1. Background - -Issue #167 reports that a Warsaw user enters a measurement at 11:05 -local time, then sees `09:05` in the CSV export. He set -`TZ=Europe/Warsaw` + `PGTZ=Europe/Warsaw` on the container; the -discrepancy persisted. - -That is not a deployment bug. The export writes ISO-8601 with the `Z` -suffix (UTC), and his viewer (Excel / LibreOffice) drops the marker, -so the user reads UTC as local time. The CSV is technically correct, -the framing is hostile. See `src/lib/export.ts:53, :103, :104, :129`. - -The interesting part of the issue is what it surfaces: HealthLog is -hardcoded to `Europe/Berlin` end-to-end. The Warsaw reporter is in the -same UTC offset as Berlin year-round (CET/CEST), so he is the easiest -case. The same code path mis-labels every reading for a user in -London, New York, Tokyo or São Paulo — and does it silently. - ---- - -## 2. Current state — where Berlin is wired in - -Hardcoded `"Europe/Berlin"` references at the time of writing -(`grep -rn "Europe/Berlin" src/`): - -| File | Role | -| ----------------------------------------------------- | --------------------------------------------------------- | -| `src/lib/format-locale.ts:18` | `DISPLAY_TIMEZONE` constant — single source for display. | -| `src/lib/analytics/berlin-day.ts:17` | Day-bucket key for analytics (`YYYY-MM-DD` in Berlin). | -| `src/lib/analytics/correlations.ts:109, :117` | Weekly ISO-Monday bucketing for correlations. | -| `src/lib/analytics/bp-in-target.ts:64, :70, :160` | SYS/DIA pairing fallback when timestamps drift ≥ 5 min. | -| `src/lib/charts/bucket-time-series.ts:38` | Daily / weekly / monthly chart buckets. | -| `src/lib/charts/comparison-shift.ts` | `chartData_compare` overlay anchored at Berlin-day-noon. | -| `src/components/charts/health-chart.tsx:195` | X-axis tick formatter. | -| `src/components/medications/medication-card.tsx` | "Last taken" + next-window time labels. | -| `src/components/admin/api-token-overview-section.tsx` | "Created / last used" timestamps in the admin table. | -| `Dockerfile` | `ENV TZ=Europe/Berlin` — container TZ for the Node clock. | -| `CLAUDE.md` | Convention: "Berlin for display, UTC for storage". | - -Reminder schedules: - -| File | Role | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `src/lib/jobs/reminder-worker.ts` | pg-boss cron tick interpreted in container TZ. | -| `src/lib/medications/window-resolution.ts` (or equivalent) | `windowStart` / `windowEnd` strings (`"07:00"`–`"09:00"`) are container-time. | -| `ReminderPhaseConfig` rows in `prisma/schema.prisma:387` | Window thresholds per user, schema-naive ("just a string"). | - -Persistence: - -- `Measurement.measuredAt`, `MedicationIntakeEvent.scheduledFor` / - `.takenAt`, `MoodEntry.moodLoggedAt` are `DateTime` (`timestamptz`). - Postgres stores UTC; correct. No write-path changes needed. -- `MoodEntry.date` is a `String` formatted `YYYY-MM-DD` — anchored to - Berlin at the time of write. **This is the migration risk**: the - same calendar moment falls into different `date` strings depending - on the chosen anchor. See §7. -- `MedicationSchedule.windowStart` / `.windowEnd` are `String` - `"HH:MM"` — same problem: a "08:00" window is Berlin local today. - ---- - -## 3. Symptom catalogue — beyond the CSV - -Closing the CSV alone is a 4-files patch. The architectural problem is -broader. Each row below is a separate symptom that an end-user can -hit: - -1. **CSV / JSON export reads as UTC.** Issue #167. Fix in `export.ts`. -2. **Reminders fire at Berlin time, not user time.** A Tokyo user sets - a 08:00 morning reminder; the pg-boss cron tick checks against - container TZ, so the message arrives at 16:00 Tokyo. Hidden today - because the only known users are CET/CEST. -3. **"Today" buckets misalign.** A user logs a 23:30 reading in their - local zone that maps to "tomorrow" UTC and "tomorrow" Berlin → - their day-summary card carries the reading on the wrong calendar - day. Same effect on the streak counter (achievements) and the - "today vs yesterday" comparison. -4. **Mood-entry date drift.** `MoodEntry.date` is a `String` written - in Berlin. A New York user entering at 21:00 EDT (= 03:00 next-day - Berlin) gets the wrong `date` string, which then disagrees with the - `moodLoggedAt` timestamp. -5. **Withings sync day-mapping.** Withings ships UTC timestamps; - `withings-sync` buckets them via `berlin-day.ts`. A reading taken - at 23:50 local in Tokyo (14:50 UTC) lands on yesterday-Berlin - instead of today-Tokyo. The user sees their evening reading on - yesterday's chart. -6. **Charts X-axis labels.** Mobile chart x-axis label formatter - passes `timeZone: "Europe/Berlin"` directly. A New York user sees - `Mai 11` for a reading they remember entering on `May 10` 22:30 EDT. -7. **Email / push notification timestamps.** Notification body - templates render times in the dispatcher, container TZ. Same - mis-attribution as #2 once #2 is fixed. -8. **PDF doctor report.** `src/app/api/doctor-report/pdf/route.ts` - renders rows with `formatInTimeZone` against Berlin. A PDF a US - user takes to their doctor lists every reading at a Berlin - timestamp. -9. **AI Coach + analytics context.** The Coach prompt context says - "yesterday" and "this week" against Berlin-anchored buckets. Same - day-misalignment problem as #3, but tucked inside the prompt where - nobody will see it until a user complains the recommendation is - for the wrong day. -10. **OpenAPI / iOS DTO contract.** `docs/api/openapi.yaml` ships - timestamps as `string($date-time)` without a contract on offset. - iOS will round-trip them as UTC. If the future iOS app renders - its own clock, it will diverge from the web UI until the web UI - learns user TZ too. - -That is ten distinct surfaces, not one. - ---- - -## 4. Design space - -### Option A — leave display TZ alone, fix the export only - -CSV/JSON write ISO-8601 with offset (`2026-05-11T11:05:00+02:00`) -instead of `Z`. Same instant, machine-parseable, human readable. - -- Scope: 4 lines in `src/lib/export.ts` + 2 test updates. -- Closes issue #167. -- Does not fix the 9 other symptoms. -- Sensible if the product stays Europe-only. -- **Cost:** ~2 h. - -### Option B — per-user `displayTimezone` setting, opt-in, default Berlin - -Add a `User.displayTimezone` column (DEFAULT `"Europe/Berlin"`). -Surface a picker in `/profile`. Every display-side helper takes the -user pref instead of `DISPLAY_TIMEZONE`. Reminder cron + Withings -day-mapping + Coach context all become user-TZ-aware. - -- Scope: large. ~30 files touch the constant, ~10 buckets need to - parametrise their bucketing helper, ~5 surfaces switch from `Intl` - literal to a hook/util. -- Existing data is unaffected by storage (still `timestamptz`). -- Existing `MoodEntry.date` strings and `MedicationSchedule.windowStart` - / `.windowEnd` are the migration risk — see §7. -- Reminder worker rewrite: pg-boss cron is timezone-naive; choose - between (a) per-user cron rows, (b) global 1-minute tick with - in-app "is it time in user TZ?", (c) precompute the next absolute - UTC fire time per reminder on every settings change and on each fire. -- **Cost:** 1–2 weeks elapsed, conservative. The honest number when - every symptom from §3 is closed and the test suite is parametrised - per TZ branch. -- **Recommended.** - -### Option C — auto-detect from browser - -`Intl.DateTimeFormat().resolvedOptions().timeZone` from the client, -stored in a cookie or sent with every request. No user-visible -picker. - -- Implicit: the user never opted in, never sees what they are set to, - cannot fix a wrong guess. -- Useless for the iOS app, CLI consumers, API-token clients. -- **Rejected** as a primary mechanism. Reasonable as the _default - value_ for the §B picker on signup. - ---- - -## 5. Recommended path - -1. **v1.4.25 quick fix** (Option A, separate from this proposal): - ship the ISO-8601-with-offset export so issue #167 closes - immediately. Adds an `aria-label` / footer note in the export-UI - that timestamps carry the user's display zone. Documented in the - v1.4.25 changelog as a follow-on; no roadmap impact. -2. **v1.5 (or later) full feature** (Option B): plan as a dedicated - wave. The migration risk in §7 dominates the schedule. - -The two are independent — A can ship next week, B can sit on the -backlog without blocking it. - ---- - -## 6. Migration strategy for Option B - -### 6.1 Schema additions (forward-only, no breakage) - -```prisma -model User { - ... - displayTimezone String @default("Europe/Berlin") - ... -} -``` - -Migration `00XX_user_display_timezone` — additive, DEFAULT covers -every existing row, no backfill needed. - -### 6.2 Constant → user-pref accessor - -Replace every direct `DISPLAY_TIMEZONE` import with a context-aware -helper: - -```ts -// Server side -const tz = await getUserTimezone(); // reads from session.user.displayTimezone - -// Client side -const tz = useUserTimezone(); // hook, reads from auth context -``` - -`format-locale.ts` keeps exporting `DISPLAY_TIMEZONE` as a _fallback_ -for non-user-scoped surfaces (admin tables, audit log viewer, -analytics QA dashboard — every place where "whose timezone?" has no -answer). Worker code (`reminder-worker.ts`, `withings-sync`) reads the -target user's pref before any bucketing. - -### 6.3 Per-surface fixes (order matches §3) - -| # | Surface | Change | -| --- | ---------------------- | ---------------------------------------------------------------------- | -| 1 | CSV/JSON export | `formatInTimeZone(..., userTz, "yyyy-MM-dd'T'HH:mm:ssXXX")` | -| 2 | Reminder worker | recompute next-fire on settings change, in user TZ | -| 3 | "Today" buckets | `berlin-day.ts` → `user-day.ts` taking `tz` arg | -| 4 | `MoodEntry.date` drift | see §7 | -| 5 | Withings sync | bucket via user-tz instead of `BERLIN_TZ` | -| 6 | Chart x-axis | tick formatter takes `userTz` prop, falls back to Berlin | -| 7 | Notification body | dispatcher reads recipient's tz before formatting | -| 8 | PDF doctor report | header reads user's tz; if export is for a target user, use theirs | -| 9 | Coach prompt context | "yesterday" / "this week" computed in user tz | -| 10 | OpenAPI / iOS DTO | document timestamps as `string($date-time)` w/ note: always UTC offset | - -### 6.4 Test strategy - -`berlin-day.test.ts` has the right shape but pins to Berlin. The -parametrised version runs each test under 3–5 representative zones: - -- `Europe/Berlin` — DST boundary cases stay green. -- `UTC` — sanity baseline. -- `America/New_York` — large negative offset, DST. -- `Asia/Tokyo` — large positive offset, no DST. -- `Pacific/Kiritimati` — UTC+14, extreme positive offset (catches - off-by-one-day bugs in date-only strings). - -A test helper `withTimezone(tz, () => { ... })` wraps the existing -suites. Expect ~30 existing tests to be parametrised; total suite size -roughly doubles for the TZ-sensitive units. Acceptable. - ---- - -## 7. The real risk — date-only strings - -The only thing in the schema that is _not_ time-instant data is the -two strings: - -- `MoodEntry.date` (`YYYY-MM-DD`) — written from - `formatInTimeZone(loggedAt, BERLIN_TZ, "yyyy-MM-dd")`. -- `MedicationSchedule.windowStart` / `.windowEnd` (`HH:MM`) — set by - the user via the medication form; they read as "Berlin local time" - today. - -When the user switches their `displayTimezone` from `Europe/Berlin` to -`America/New_York`: - -- **Mood entries:** every historical `MoodEntry.date` is now a Berlin - calendar day, but the UI will render its day-summary card by - New-York-calendar-day. Existing entries near midnight will appear - to "jump" between days. - - **Decision A — freeze:** TZ is captured at the time of write. - Schema gains `MoodEntry.tz TEXT NOT NULL DEFAULT 'Europe/Berlin'`, - UI groups by `(tz, date)`. Historical entries stay where the user - logged them. **Recommended.** - - **Decision B — recompute:** treat `date` as derived from - `moodLoggedAt`, redo the string under the new TZ. Cleaner code, - but historical "day" assignments shift, which can change streak - counts and break achievements awarded retroactively. -- **Medication windows:** "07:00" today means "07:00 in Berlin". A - user moving to Tokyo legitimately wants their morning dose at - Tokyo-07:00, not Berlin-07:00. **Decision A — freeze** is wrong - here, because the user's expectation is that their reminder follows - them. **Decision B — re-anchor** is correct: schedules are stored - as "wall clock in current display TZ" and reinterpreted under the - new TZ. -- The asymmetry is real: historical observations want freeze, future - schedules want re-anchor. The schema needs to express both - contracts. - -### 7.1 DST edge cases - -The existing `berlin-day.test.ts` already covers Berlin DST. The -parametrised version inherits this. **Open question:** what happens -when a reminder window straddles a DST boundary in the user's TZ? -Today this is a Berlin-only concern and pg-boss handles it implicitly -via container TZ. With per-user TZ, the resolver has to make a choice -("fire at the wall-clock time on both sides" vs "fire at the absolute -UTC time of the pre-DST occurrence"). Document and pick a convention -before implementation. - ---- - -## 8. Out-of-scope for this proposal - -- Per-tenant / per-org TZ: HealthLog is single-tenant. -- TZ-by-IP fallback for unauthenticated surfaces: pricing dashboards - and the auth pages stay Berlin. -- Localised number formats (German 1.234,56 vs US 1,234.56): tracked - separately under i18n. -- Calendar locale (Monday-start vs Sunday-start week): same. - ---- - -## 9. Open questions for the v1.5 product-lead pass - -1. Is per-user TZ a v1.5 commitment, or does it slip to v1.6+ behind - the iOS work? -2. If v1.5, does it ship before or after Apple Health import? Apple - Health entries carry their own source-TZ metadata — could simplify - the migration if we lift that as the canonical representation. -3. Decision A (freeze) vs Decision B (re-anchor) split per surface - needs a product call, not an engineering call. Pre-fill from §7. -4. Acceptance criterion for "done": pick 1–2 user complaints we are - willing to ship with, and 1–2 that block release. Probably "Coach - prompt context drift" is acceptable for an MVP; "reminder fires at - wrong wall-clock time" is not. - ---- - -## 10. Pointers for the next agent - -- This file lives at `.planning/feature-user-timezone.md`. -- One-line carry in `.planning/v15-backlog.md` under "From issue - triage". -- One-line carry in `.planning/ROADMAP.md` under v1.5 / v1.6+ reserved - themes. -- Triggering issue: GitHub #167 (Warsaw user, v1.4.23, CSV export - shows UTC). -- The v1.4.25 quick fix (Option A) is independent — file it as its - own backlog row, not blocked on this proposal. diff --git a/.planning/ios-contributor-current-brief.md b/.planning/ios-contributor-current-brief.md deleted file mode 100644 index e9d293af5..000000000 --- a/.planning/ios-contributor-current-brief.md +++ /dev/null @@ -1,626 +0,0 @@ ---- -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.30**. - -### 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 | -| v1.4.30 | iOS-coordinated foundation | Daily-stats externalId lock + SyncMode columns + bulk-backfill endpoints + `MoodEntry.note` first-class column + workouts canonical-row picker + categorisation overlay + two new MeasurementType enums | -| v1.4.30.1 | Categories endpoint + conflict-resolution spec lock | `GET /api/measurement-categories` exposes the overlay over HTTP; `08-locked-contracts.md` §13 locks the SyncMode conflict-resolution policy (LWW by `updatedAt`, server-wins on tie) | - -### Endpoints the iOS client may consume today (additive contracts) - -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/sync/state` — SyncMode handshake (since v1.4.30). Returns `lastSyncedAt`, server clock for skew, live + tombstoned counters; the call also bumps the user-side checkpoint. -- `POST /api/mood-entries/bulk` — bulk mood-entry backfill (since v1.4.30). Up to 500 entries per call; probe-then-upsert distinguishes inserted vs duplicate; rate-limited at 60/min/user. -- `POST /api/medications/intake/bulk` — bulk medication-intake backfill (since v1.4.30). Same envelope shape, idempotency-key collision yields duplicate; rate-limited at 60/min/user. -- `POST /api/admin/drain-per-sample-cumulative` — operator-only drain endpoint (since v1.4.30, `requireAdmin()`). Collapses pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row per day per type. Idempotent; default `dryRun: true`. -- `GET /api/measurement-categories` — HTTP projection of the categorisation overlay (since v1.4.30.1). Returns `{ version, categories[], assignments }` per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1. `Cache-Control: public, max-age=600`. iOS reads on cold start to drive the HealthKit permission picker grouping; hard-coded mirror in the iOS app stays as the offline fallback. -- `GET /api/version` — live build + `offlineGeoEnabled` flag -- 47 distinct paths total per the R-E iOS audit (`/Users/marc/Projects/HealthLog/.planning/research/v1428-r1-ios-contracts.md` enumerates them; mostly admin + monitoring routes not iOS-relevant) - -### 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 — server endpoint stays live; iOS native drawer deferred - -Coach SSE remains live as a server endpoint. iOS native server-Coach -drawer is deferred pending MDR Class-IIa pre-review. v1.5.0 ships -iOS with Apple Foundation Models on-device Daily Briefing + Trend -Observations as the primary assistant surface. The server's -`GET /api/insights/chat` SSE endpoint stays live for: PWA users on -non-AFM-capable devices, future iOS reevaluation post-MDR, any other -client that adopts the SSE protocol. - -Contract for any future iOS adoption (or for the PWA + non-iOS -consumers today): - -- Server-Sent Events stream -- Token-by-token streaming text -- 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 - -Reference web implementation: `src/components/insights/coach-panel/`. - -### 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 — deferred past v1.5 - -Per R-E C-1, the iOS Coach drawer has zero native code today. The -server-side SSE endpoint at `/api/insights/chat` stays live and -tested. v1.5.0 ships iOS with Apple Foundation Models on-device -Daily Briefing + Trend Observations as the primary assistant -surface; a native server-Coach drawer is deferred pending the MDR -Class-IIa pre-review. No server-side coordination needed for the -deferral — the SSE endpoint is already shaped for the PWA + any -future client that re-enters this work. - -If a future iOS revisit lands post-MDR, the contract is: - -- `CoachService` actor over `URLSession.bytes` -- `CoachStreamEvent` AsyncThrowingStream -- 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) stays the model whenever iOS reopens the work. - -### 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/ios-coord/V054-SR-merge-deploy-report.md b/.planning/ios-coord/V054-SR-merge-deploy-report.md deleted file mode 100644 index 6141c3b22..000000000 --- a/.planning/ios-coord/V054-SR-merge-deploy-report.md +++ /dev/null @@ -1,206 +0,0 @@ -# V054 — Server-side coord merge + deploy + hotfix-bundle closure - -**Run window:** 2026-05-17 evening → 2026-05-18 early morning UTC. -**Scope:** Admin-merge of PR #190 (iOS v0.5.4 push-notification coord), -follow-on Coolify deploy, multi-agent QA reconcile, two hotfix releases -(v1.4.38.2 + v1.4.38.3), backlog wipe of the three pre-existing main -CI reds, and a stale-shell auto-recover for the chunk-load paper-cut. - ---- - -## Phase 1 — PR #190 admin-merge - -- **PR:** #190 `feat(notifications): APNs category + MOOD_REMINDER event for iOS v0.5.4` -- **Base / head:** `main` ← `ios-coord/v054-apns-mood` -- **Diff:** 14 files, +932/-10. -- **Pre-existing CI reds at merge time:** `integration` (rollup-test), - `e2e` (4 spec files), `No TODO markers` (correlations TODO). All - three were Marc-confirmed pre-existing on `main` HEAD `a550031a` - (v1.4.38), NOT introduced by PR #190. -- **Merge:** `gh pr merge 190 --squash --admin --delete-branch`. -- **Merge commit:** `4049c6c7` on main. Local-branch delete failed - initially because a worktree pointed at it - (`.claude/worktrees/v054-server-coord`); cleaned with - `git worktree remove --force` then `git branch -D`. -- **Back-merge into develop:** clean apart from four - trivially-additive conflicts at file tail - (`messages/{es,fr,it,pl}.json` new `moodReminders` namespace + - `src/lib/jobs/reminder-worker.ts` new `MOOD_REMINDER_QUEUE` - constants). All resolved with `--theirs`. Develop and main - re-aligned. - -## Phase 2 — v1.4.38.1 release + deploy - -PR #190 was authored against `main` without a version bump. -`docker-publish.yml` only fires on tag push (not on plain `main` -push), so the new commit had no published image and Coolify would -not have pulled it. Bumped `v1.4.38 → v1.4.38.1` on main with a -CHANGELOG entry describing the iOS-coord scope, then tagged and -pushed. - -- **Tag:** `v1.4.38.1`, commit `c4f2e0bc`. -- **Release:** https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.1 -- **Docker:** built clean (multi-arch amd64 + arm64). -- **Coolify deploy:** UUID `pg8wggwogo8c4gc4ks0kk4ss`, force-redeploy - via MCP. Live at 21:14 UTC. -- **Migration 0069 applied:** Coolify logs show - `Applying migration 0069_v054_mood_reminder` then - `All migrations have been successfully applied.` 70 migrations - total (was 69), commit hash `c4f2e0bc...` matches the bump - commit. -- **Smoke:** `/api/health` 200 in 100 ms (CDN edge) / - 2-16 ms (container). 20× `/api/version` burst: median ≈ 88 ms. - Boot trace clean (`reminder_worker started, duration_ms: 747`). -- **iOS v0.5.4 connected on first deploy:** `HealthLog-iOS/0.5.4` - user-agent surfaced on `/api/feature-flags`, `/api/devices` POST - 201 (device registered), `/api/measurements`, - `/api/integrations/healthkit`, `/api/user/profile`, - `/api/user/ai-provider` within minutes of the redeploy. -- **Observed warn:** 2× `PUT /api/dashboard/widgets` returned 422 from - iOS-0.5.4 (iOS-side payload validation mismatch, NOT introduced - by PR #190). Tracked for an iOS-side hotfix; server unaffected. - -## Phase 3 — Multi-agent QA reconcile - -Six reviewers dispatched in parallel against the squash-merge commit -on main: - -| Reviewer | Severity counts | Headline | -| --- | --- | --- | -| Senior Dev | 1 C / 4 H / 5 M / 3 L | Ledger commits BEFORE delivery — silent fail blocks retries. | -| UX | 2 C / 3 H / 3 M / 2 L | FR `aujourdhui` typo on lockscreen; no Settings toggle = dead opt-in. | -| Specialist (APNs + Prisma) | 0 C / 2 H / 4 M / 3 L | Locale resolver dropped 4 of 6 locales; DST arithmetic 1-h drift. | -| Security | 0 C / 2 H / 4 M / 3 L | APNs payload leaks medication name + Telegram `replyMarkup` to Apple + lockscreen. | -| Product Lead | GO + 2 v1.5 P1 | Settings toggle + `notifications.eventMoodReminder` key required. | -| Simplifier | 8 candidates (3 safe + 5 worth-considering) | Dead candidate-interface, dead state field, locale-resolver behaviour issue (also Senior-Dev H1). | - -Convergent findings (≥ 2 reviewers): locale resolver drops 4/6 -locales (3 reviewers), FR `aujourdhui` typo (2 reviewers), missing -Settings toggle (3 reviewers). - -Findings written to -`.planning/ios-coord/V054-QA-{senior-dev,ux,specialist,security,product-lead,simplifier}-findings.md`. - -## Phase 4 — v1.4.38.2 hotfix bundle - -Twelve fixes shipped as `v1.4.38.2` to close every Critical + High -from the QA pass: - -1. FR `aujourd'hui` typo restored. -2. Locale resolver accepts every supported locale instead of - demoting to English. -3. `dispatchNotification` returns `DispatchOutcome` - (`{ dispatched, channelsAttempted, channelsSucceeded }`); the - mood-reminder handler writes the dedup ledger only after a - confirmed delivery so transient APNs blips don't silently nuke - the day's nudge. -4. Per-user `try` wrapper around the mood-reminder tick so one bad - row cannot abort the 22:00 candidate pass. -5. P2002 race semantics: a worker that delivers but loses the - ledger insert race counts as `dispatched` (the user got the - push). -6. `localHmAsUtc` helper in `@/lib/timezone` makes the - medication-reminder `scheduledFor` and the iOS-snooze - `scheduledAt` ISO DST-safe. -7. `sendViaApns` whitelists iOS-relevant metadata keys - (`scheduledAt`, `localDate`, `medicationId`, `scheduleId`, - `phase`, `date`); the Telegram `replyMarkup` and ad-hoc extras - no longer reach Apple. -8. Settings toggle UI: `MoodReminderCard` under - `/settings/notifications` with a single Switch wired to - `users.mood_reminder_enabled` via the existing profile-update - path (`PUT /api/auth/profile` + `PATCH /api/user/profile`). Six - locale strings (de/en/es/fr/it/pl). -9. Daily 03:25 Europe/Berlin retention cron for - `mood_reminder_dispatches`: 90-day horizon. -10. Dead-code drop: unused `MoodReminderCandidate` interface and - redundant `moodReminderEnabled` select field. -11. CHANGELOG entry for v1.4.38.1 rewritten to drop the - `EVENT_DEFAULT_ENABLED` identifier leak; describes the - default-off posture in user-readable language. -12. `mood-reminder.test.ts` rewritten for the new contract - (outcome bubble, ledger-after-delivery, per-user try, P2002 - semantics, six-locale dispatch) plus an FR-apostrophe - regression test. - -- **Tag:** `v1.4.38.2`, commit `d224811a` (squash). -- **Release:** https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.2 -- **Docker:** built clean. -- **Coolify deploy:** auto-fired on tag push; live 21:21 UTC. -- **Smoke:** `/api/version` returns `1.4.38.2`; `/api/health` - green. - -## Phase 5 — v1.4.38.3 CI green-up + chunk-load auto-recover - -Closed the three pre-existing main CI reds plus a small -chunk-load-on-stale-shell paper-cut Marc reported during the -v1.4.38.2 window: - -- **`No TODO markers` workflow** — the `TODO(v1.5):` comment on - `src/lib/analytics/correlations-fast-path.ts:99` that landed in - v1.4.38 was rejected by the repo's gate. Rewritten as prose. -- **`Integration tests`** — the rollup-aggregate test asserted - `dailyByType` before `ensureUserRollupsFresh` (fire-and-forget - since v1.4.37.1) had a chance to write. Test now calls - `recomputeUserRollups` explicitly so the rollup-driven branch is - exercised deterministically. -- **`e2e`** — five of seven failing specs fixed: - - `e2e/doctor-report.spec.ts` testid `export-action-doctor-report` - renamed to `export-hero-doctor-report-action` in v1.4.37; spec - updated. - - `e2e/settings-export.spec.ts` same hero-card rename. - - `e2e/mobile-viewport.spec.ts` "View all" link on the - recent-achievements card was 46×16 px; lifted to - `min-h-11 inline-flex items-center` for the 44 px floor. - - `e2e/measurement-flow.spec.ts` mock omitted `unit` + `source`; - the list-page render crashed before painting the row and the - poll for "78.4" timed out. Mock now returns the full shape. -- **`AppError` chunk-load auto-recover** — `src/app/error.tsx` - detects the chunk-load error family (`ChunkLoadError`, `Loading - chunk`, `Failed to load chunk`, `Failed to fetch dynamically - imported module`) and triggers a single - `window.location.reload()` to fetch the fresh shell. - `sessionStorage` gates it to once per session. - -- **Tag:** `v1.4.38.3`, commit `a16bb4b7` (direct on main — six - atomic Marc-Voice commits between v1.4.38.2 and the release - bump). -- **Release:** https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.3 -- **Docker:** built clean. -- **Coolify deploy:** auto-fired; live at 2026-05-18 00:01 UTC. -- **Smoke:** `/api/version` returns `1.4.38.3`; `/api/health` - green. - -## Carry-overs / explicit defers - -- iOS `PUT /api/dashboard/widgets` 422s observed live — iOS-side - hotfix candidate (server-validation envelope unchanged). -- v1.4.38.3 e2e set: `measurement-flow` desktop + mobile fixed via - the mock shape but full re-run pending the next CI window. The - five other previously-red specs should now pass; observe on the - next push. - -## Quality gates per release - -All three releases met: - -- `pnpm typecheck` — 0 errors. -- `pnpm lint` — 0 errors, 0 warnings. -- `pnpm test --run` — 4524 → 4565 → 4551 → 4551 unit tests passing - (the v1.4.38.2 dip is a `mood-reminder.test.ts` rewrite, not a - coverage loss). -- No `--no-verify`, no Co-Authored-By trailer, Marc-Voice English - throughout. - -## Operator notes - -- `0069_v054_mood_reminder` migration applied cleanly on first - deploy (additive + idempotent IF-NOT-EXISTS guards). -- No env-var change across any of the three releases. -- Coolify auto-deploy fired correctly on every tag push; no - host-side retag fallback needed. -- Branch-model deviation noted: PR #190 was authored against - `main` rather than `develop`, and v1.4.38.3's six fix commits - landed directly on `main` after the v1.4.38.2 squash. Both - back-merged into develop afterwards so the long-lived branch - stays aligned for the next feature cycle. diff --git a/.planning/ios-coord/V054-server-apns-mood-report.md b/.planning/ios-coord/V054-server-apns-mood-report.md deleted file mode 100644 index fabf919f5..000000000 --- a/.planning/ios-coord/V054-server-apns-mood-report.md +++ /dev/null @@ -1,126 +0,0 @@ -# V0.5.4 iOS Coord — Server-side APNs + MOOD_REMINDER Patches - -**Branch:** `ios-coord/v054-apns-mood` -**PR:** https://github.com/MBombeck/HealthLog/pull/190 -**Date:** 2026-05-17 -**Status:** Open for operator review (do NOT merge to main without sign-off) - -## Mission - -Two server-side patches that unblock iOS HealthLog v0.5.4 push-notification -functionality: - -1. **SR-1** — APNs `aps.category = "MEDICATION_REMINDER"` so iOS renders the - three action-buttons wired in iOS v0.5.3 (Genommen / Snooze 15 min / - Übersprungen). -2. **SR-2** — `MOOD_REMINDER` event-type + daily 22:00-local-time cron - (opt-in, idempotent, locale-aware). - -## Patches landed - -### Commit 1 — `feat(notifications): set APNs category for med-reminders` - -- `src/lib/notifications/senders/apns.ts` - - `ApnsPayload` extended with optional `category` + `mutableContent` - fields. - - `sendApnsPush` writes the category through node-apn's setter - (cast wraps the d.ts gap in node-apn 8.1; runtime setter at - `apsProperties.js#166` lands the value at `aps.category`). - - `sendViaApns` auto-forwards `payload.eventType` as the category, - so every event-type the iOS app registers becomes actionable - without per-event-type plumbing. `mutableContent: true` is set - by default (NSE-ready). -- `src/lib/notifications/senders/__tests__/apns.test.ts` - - 3 new tests pinning `aps.category = MEDICATION_REMINDER` on the - dispatcher path, `aps.category = MOOD_REMINDER` for the new - event-type, and the explicit-override path on `sendApnsPush`. - -### Commit 2 — `feat(prisma): add moodReminderEnabled + MoodReminderDispatch ledger` - -- `prisma/schema.prisma` - - `users.mood_reminder_enabled BOOLEAN DEFAULT FALSE` (opt-in flag). - - New model `MoodReminderDispatch` with `@@unique([userId, date])` - — idempotency anchor for the daily cron. -- `prisma/migrations/0069_v054_mood_reminder/migration.sql` - - Additive migration with `IF NOT EXISTS` + `DO $$ EXCEPTION WHEN - duplicate_object` guards (idempotent on re-apply, matching the - pattern from `0061` / `0068`). - -### Commit 3 — `feat(notifications): add MOOD_REMINDER event type with daily 22:00 cron` - -- `src/lib/notifications/types.ts` - - `EVENT_TYPES` extended with `MOOD_REMINDER`. `EVENT_DEFAULT_ENABLED` - sets it to `false` as a defence-in-depth gate behind the per-user - `moodReminderEnabled` flag. -- `src/lib/jobs/mood-reminder.ts` (new module, 220 LOC) - - `evaluateMoodReminderWindow(user, now)` — pure predicate for the - 22:00 window across any IANA timezone (DST-safe via - `getLocalDateParts`). - - `buildMoodReminderPayload(locale)` — locale-aware title/body - (DE: "Stimmung erfassen" / "Wie geht es dir heute?"; - EN: "Log your mood" / "How are you feeling today?"). - - `runMoodReminderTick(prisma, now)` — orchestrator: pulls - opted-in users, applies window + already-logged + already- - dispatched filters, reserves the dedup row, dispatches. -- `src/lib/jobs/reminder-worker.ts` - - Registers the `mood-reminder-check` queue with cron `*/15 * * * *`, - `localConcurrency: 1`. Cadence matches the medication-reminder - cron so any user's 22:00 boundary is caught within 15 min of - wall-clock without one cron entry per IANA zone. - - Existing medication-reminder dispatch now enriches metadata with - a `scheduledAt` ISO 8601 string so the iOS snooze-15-min action - pins against the schedule slot, not wall-clock delivery time. -- `src/lib/jobs/__tests__/mood-reminder.test.ts` (new, 13 tests) - - Window-boundary: 21:59 / 22:00 / 22:59 / 23:00, plus a DST-bearing - `America/New_York` check. - - Opt-in gating, logged-today skip, ledger-already-exists skip, - P2002 lost-race handling, multi-user multi-locale fan-out. -- `messages/{de,en,es,fr,it,pl}.json` - - New `moodReminders.dailyTitle` + `moodReminders.dailyBody` keys - in all six locales. - -## Quality gates - -| Gate | Result | -|---|---| -| `pnpm typecheck` | clean | -| `pnpm lint` | clean | -| `pnpm test` | 4542 tests pass, 1 skipped (430 test files) | -| `pnpm openapi:check` | spec in sync | -| `pnpm format:check` | pre-existing warnings unchanged; my files prettier-formatted | - -## Migration - -`prisma/migrations/0069_v054_mood_reminder/migration.sql` ships with the -PR. Applied via `pnpm db:migrate:deploy` on staging — `IF NOT EXISTS` -guards make re-apply a no-op. - -## Blockers - -None. - -## Follow-ups for the iOS contributor - -- iOS app must register `UNNotificationCategory` identifiers - `MEDICATION_REMINDER` (Take / Snooze 15 min / Skip) and - `MOOD_REMINDER` (Log mood) at launch, otherwise iOS will render the - pushes as plain alerts (the category will be present on the payload - but iOS ignores unknown identifiers). -- iOS Settings → Notifications surface needs a toggle for - `User.moodReminderEnabled` (PATCH /api/me or similar — the - preferences route already exists at - `src/app/api/notifications/preferences/route.ts` but only covers - per-event-type matrix; this is a per-user flag, not a per-channel - preference. Surface TBD by operator). -- The `scheduledAt` metadata field is already present on every - outbound APNs payload for med-reminders — iOS just needs to read it - from `userInfo`. - -## Compatibility - -- APNs payload changes are strictly additive. Older iOS builds that - don't register the categories render plain alerts (iOS silently - ignores unknown categories). -- `MOOD_REMINDER` is double-gated (per-event default OFF + per-user - `moodReminderEnabled` defaults FALSE). Users who never opt in see no - behavioural change. diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md b/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md deleted file mode 100644 index 04b6216b2..000000000 --- a/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md +++ /dev/null @@ -1,44 +0,0 @@ -# v1.12.0 — server → iOS: new `FITBIT` MeasurementSource heads-up - -**Date:** 2026-06-04 -**From:** server -**Status:** landing in v1.12.0 (data-model foundation merged; sync/UI follow in -later waves of the same release) - -## What is changing - -v1.12.0 adds a Fitbit/Pixel integration over the Google Health API. Like WHOOP -and Withings, it is a **server-owned, read-only ingest** — there is no client -write path; the batch + single-POST surfaces reject it. The rows surface through -the same read/response shapes the iOS app already decodes (`/api/sync/changes`, -the measurement list, charts). - -The one contract item for iOS in this wave: - -- **New `MeasurementSource` enum value: `FITBIT`** (exact spelling, uppercase). - Add the `FITBIT` case to the iOS DTO's `MeasurementSource` decoding so - `/api/sync/changes` and the measurement reads decode Fitbit rows. This is the - same contract that gated `WHOOP` and `APPLE_HEALTH`. - -> **Mismatched spelling = silent client-side decode drop.** A row tagged -> `source: "FITBIT"` that the DTO cannot decode is silently skipped, so the -> measurement count diverges from the server with no error surfaced. Pin the -> exact spelling. - -## What is NOT changing - -- **No new `MeasurementType` values.** Every Fitbit launch metric maps onto an - existing type (`WEIGHT`, `BODY_FAT`, `OXYGEN_SATURATION`, - `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`, `RESPIRATORY_RATE`, `PULSE`, - `WRIST_TEMPERATURE`, `ACTIVITY_STEPS`, `WALKING_RUNNING_DISTANCE`, - `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `VO2_MAX`, `SLEEP_DURATION`, plus - `Workout` rows). No DTO `MeasurementType` change is needed. -- **Blood glucose / blood pressure / body temperature are deferred.** Those - slots exist but are NOT in the launch sync set (gated on the Google Health - API roadmap). No action for iOS now. - -## Confirmation requested - -Confirm the iOS DTO carries the `FITBIT` case ahead of the v1.12.0 deploy. A -follow-up `v1.12.0-server-to-ios-fitbit-LIVE.md` will land once the enum is live -on apps01 (verified via `/api/version`). diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md b/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md deleted file mode 100644 index edd1db5ae..000000000 --- a/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md +++ /dev/null @@ -1,191 +0,0 @@ -# server → iOS — v1.12.0 — Mood v2 rated factors (ingestion contract) - -**From:** server. **Date:** 2026-06-04. **Re:** your v0.14.1 item 3 — "send the -ingestion contract NOW so iOS can build the rating sliders." - -Landing in **v1.12.0**. This is the **ingestion half**: the write contract + -catalog metadata. You can build the rating UI against it today. The analytics -side (F4 rated-factor → mood correlation, better-days board) follows in a later -wave — it does NOT change anything you send. Nothing here is breaking: every -field is additive and optional; an iOS build that ignores it behaves exactly as -v1.11.x. - -Migration **0119** (additive: 5 columns + a seeded `factors` category with 5 -factors). No auth / route / scope change — same Bearer path as every mood write. - ---- - -## 1. Catalog metadata — `GET /api/mood/tags` - -Each tag in every category now carries four extra fields: - -```jsonc -{ - "categories": [ - { - "key": "factors", - "labelKey": "mood.tagCategory.factors", - "icon": "SlidersHorizontal", - "tags": [ - { - "key": "factor_work", - "labelKey": "mood.tag.factorWork", - "icon": "Briefcase", - "kind": "RATED", // NEW — "BINARY" (default) | "RATED" - "scaleMin": 1, // NEW — inclusive lower bound (RATED only) - "scaleMax": 5, // NEW — inclusive upper bound (RATED only) - "inverse": false // NEW — true = higher score means a WORSE day - } - ] - } - ] -} -``` - -- **`kind`** — `"BINARY"` is a present/absent flag (every tag you already - render). `"RATED"` is a *factor* the user scores per entry. Branch your - rendering on this: `BINARY` → today's toggle chip; `RATED` → a segmented - control from `scaleMin` to `scaleMax` (render a **Yes/No toggle** when - `scaleMax == 2`). -- **`scaleMin` / `scaleMax`** — the factor's own scale. Most are `1..5`; - `factor_conflict` is `1..2`. Pre-validate the slider against these; the - server also enforces them (see §3). -- **`inverse`** — `true` for factors where a higher score is worse (stress, - conflict). Capture stays literal (a high stress score is still a "5"); add a - directional hint in the UI ("Stress — higher = worse") so the user reads it - right. The (later) analytics layer flips the sign internally; you do nothing. -- Every existing `BINARY` tag now reports `kind:"BINARY"`, `scaleMin:1`, - `scaleMax:5`, `inverse:false` — ignore those fields for binary tags. - -The `labelKey` / `icon` resolve client-side exactly as today (the server stays -icon-library-agnostic — `icon` is a Lucide name). - ---- - -## 2. The 5 MVP rated factors (seeded global catalog) - -| key | labelKey | icon | scale | inverse | -|------------------------|-------------------------------|---------------------|-------|---------| -| `factor_work` | `mood.tag.factorWork` | `Briefcase` | 1..5 | false | -| `factor_social` | `mood.tag.factorSocial` | `Users` | 1..5 | false | -| `factor_sleep_quality` | `mood.tag.factorSleepQuality` | `Moon` | 1..5 | false | -| `factor_stress` | `mood.tag.factorStress` | `Zap` | 1..5 | **true**| -| `factor_conflict` | `mood.tag.factorConflict` | `Swords` | 1..2 | **true**| - -All sit in the new `factors` category (`labelKey: mood.tagCategory.factors`, -icon `SlidersHorizontal`). Labels ship in all 6 locales (de/en/es/fr/it/pl). -`factor_conflict` is a Yes/No factor (1 = no conflict, 2 = conflict) — render -it as a two-state toggle, not a 1..5 slider. - -These five match your ask (work / social / sleep / conflict). We followed the -server-side design doc's MVP set, which uses **stress** (inverse) as the fifth -rather than family — stress is the stronger correlation signal and family is -already a binary tag (`family`). If you specifically want a rated `factor_family` -too, say so and we'll seed it (trivial additive follow-up). - ---- - -## 3. How to send a rated factor - -### `POST /api/mood-entries` (single entry) - -Add a `ratedFactors` array, parallel to the existing `tagKeys` (binary). Both -are optional; send either, both, or neither. - -```jsonc -POST /api/mood-entries -{ - "mood": "GUT", - "moodLoggedAt": "2026-06-04T18:30:00+02:00", - "tags": ["coffee"], // unchanged flat free-text - "tagKeys": ["happy", "worked_out"], // unchanged binary structured tags - "ratedFactors": [ // NEW - { "key": "factor_work", "rating": 4 }, - { "key": "factor_social", "rating": 5 }, - { "key": "factor_stress", "rating": 2 }, - { "key": "factor_conflict", "rating": 1 } // 1 = no conflict (scale 1..2) - ] -} -``` - -- Each element is `{ "key": string, "rating": int }`. -- **Value range:** `rating` must be an integer within the factor's own - `scaleMin..scaleMax` (from the catalog). The outer envelope the server's Zod - accepts is `1..5`; a value outside the *factor's* scale (e.g. `rating:5` on - `factor_conflict` whose max is 2) is rejected — see error handling below. -- **Array bound:** up to 30 rated factors per entry. -- **Unknown / non-RATED keys are dropped silently** (same posture as - `tagKeys`) — only a key that resolves to a `kind:"RATED"` catalog tag lands. - So passing a binary key in `ratedFactors`, or a `factor_*` key in `tagKeys`, - is a no-op for that key. -- A factor the user didn't touch should simply be **omitted** — there is no - "0 / null" rating. No link row is written. - -**Response** (201) echoes the persisted set so you can hydrate without a -refetch — binary keys and rated factors are split by kind: - -```jsonc -{ - "data": { - "id": "…", "mood": "GUT", "date": "2026-06-04", "tags": ["coffee"], - "tagKeys": ["happy", "worked_out"], - "ratedFactors": [ { "key": "factor_work", "rating": 4 }, … ] - } -} -``` - -The list `GET /api/mood-entries` carries the same `ratedFactors` array per -entry — use it to pre-fill the edit form's sliders. - -### Out-of-scale rating → 422 - -If a rating falls outside the factor's `scaleMin..scaleMax`, the single-entry -POST returns: - -```jsonc -HTTP 422 -{ "data": null, "error": "Rating 5 for factor \"factor_conflict\" is outside its scale 1..2", - "meta": { "errorCode": "mood.ratedFactor.out_of_range" } } -``` - -Branch on `meta.errorCode == "mood.ratedFactor.out_of_range"`. Best avoided by -clamping the slider to the catalog scale client-side. - -### `POST /api/mood-entries/bulk` (adopt-on-pair backfill) - -Same `ratedFactors: [{ key, rating }]` field per entry in the `entries[]` -array (same bounds, same drop-unknown posture): - -```jsonc -POST /api/mood-entries/bulk -{ - "entries": [ - { - "mood": "GUT", - "moodLoggedAt": "2026-05-16T08:00:00.000Z", - "tagKeys": ["movies"], - "ratedFactors": [ { "key": "factor_work", "rating": 4 } ] - } - ] -} -``` - -**Per-entry isolation:** an out-of-scale rating marks **that one entry** -`"skipped"` in the per-entry `entries[]` status array — it never fails the -whole batch. The mood row itself still upserts; only the bad rated link is -dropped. So clamp client-side to keep entries from being skipped. - ---- - -## 4. Notes - -- The `moodLog` webhook stays free-text-tags only (as before); rated factors - require the authenticated `POST /api/mood-entries` / `/bulk` path. -- These five `factor_*` keys + the 4 new catalog fields + the `ratedFactors` - write field are the **complete** contract. Nothing else changed. -- The mood-entries write routes are not in `docs/api/openapi.yaml` (they never - were), so there's no codegen surface for this — work from this note. - -**Ask back:** confirm the 5-factor set works for your slider UI, and whether -you want a rated `factor_family` seeded alongside (vs the existing binary -`family` tag). Ping per item. Thanks — server. diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md b/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md deleted file mode 100644 index 482f1dfd9..000000000 --- a/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md +++ /dev/null @@ -1,78 +0,0 @@ -# v1.12.0 — server → iOS: tag × health-metric crosstab on `GET /api/mood/insights` - -**Date:** 2026-06-05 -**From:** server -**Status:** landing in v1.12.0 - -## What is changing - -`GET /api/mood/insights` gains one additive top-level field: `tagMetricCrosstab`. -It extends the existing mood-relations surface (Daylio's "Activities & Mood") from -mood to a health METRIC — for each structured mood tag, the metric's mean on -tag-present vs tag-absent days, the delta, and a confidence band. It reuses the -same Welch t-test + per-group day floors + Benjamini-Hochberg FDR the -`tagInfluence` / `betterDays` fields already use; only FDR-surviving rows surface. - -This endpoint is the server-rendered Insights aggregate (force-dynamic, not in -`openapi.yaml`). It is **additive** — every existing field is unchanged, so a -client that ignores the new field keeps working. No new `MeasurementType` or -`MeasurementSource` values. - -## Response field shape - -`tagMetricCrosstab` is an array (possibly empty) under `data`: - -```jsonc -{ - "data": { - // ... existing fields (summary, heatmap, tagInfluence, betterDays, ...) ... - "tagMetricCrosstab": [ - { - "tag": "workout", // stable structured-tag key - "labelKey": "mood.tag.workout", // i18n label key (structured tags only) - "categoryKey": "activities", // parent category key - "icon": "Dumbbell", // Lucide icon name, or null - "metricKey": "activeEnergy", // "activeEnergy" | "sleepDuration" | "nextDayRecovery" - "display": "kcal", // "hours" | "kcal" | "score" — unit hint for the formatter - "mode": "sameDay", // "sameDay" | "nextDay" (D→D+1 lag) - "withDays": 18, // tag-present days with a paired metric value - "withoutDays": 41, // tag-absent days with a paired metric value - "withAvg": 612.4, // mean metric on tag-present days, in `display` unit - "withoutAvg": 388.1, // mean metric on tag-absent days, in `display` unit - "delta": 224.3, // withAvg − withoutAvg, in `display` unit - "pValue": 0.004, // Welch two-sided p-value - "qValue": 0.021, // Benjamini-Hochberg adjusted q across the tested family - "confidence": "high" // "low" | "medium" | "high" - } - ] - }, - "error": null -} -``` - -### Field notes - -- **Pairs supported this wave** (the `metricKey` → metric type + lag): - - `activeEnergy` → `ACTIVE_ENERGY_BURNED`, same-day. `display: "kcal"`. - - `sleepDuration` → `SLEEP_DURATION`, same-day. `display: "hours"` — the server - has already converted the stored minutes to hours; `withAvg`/`withoutAvg`/`delta` - are in hours. - - `nextDayRecovery` → `RECOVERY_SCORE`, next-day (the metric on day D+1 after a - tag on day D). `display: "score"` (0..100 points). -- **`display` is the unit hint.** Render `hours` as e.g. `7.4 h`, `kcal` as a whole - number `612 kcal`, `score` as `+8 pts`. `delta` carries the sign. -- **`mode`** lets the client caption next-day rows distinctly ("the day after"). -- **Structured tags only.** Flat free-text tags are deliberately excluded — the - crosstab needs a stable `labelKey`. `labelKey` is always present here (never null, - unlike `tagInfluence` where flat-tag rows carry `labelKey: null`). -- **Gating.** A row only appears when both sides cleared 5 present + 5 absent days - AND it survived `p < 0.05` and the BH-FDR control (`q ≤ 0.10`). Capped at 8 rows. -- **Framing is mandatory.** Render the standing observational caption ("associations - in your own data, not proof of cause"). Do not phrase any row as causal. - -## What iOS needs to do - -Nothing is required — the field is additive and the web client renders it. If the -iOS app wants to surface the crosstab natively, decode `tagMetricCrosstab` with the -shape above; treat an absent or empty array as "no card". Pin the `metricKey`, -`display`, `mode`, and `confidence` string sets exactly. diff --git a/.planning/ios-coord/v1.7.0-ios-ack-live.md b/.planning/ios-coord/v1.7.0-ios-ack-live.md deleted file mode 100644 index d9ef206a7..000000000 --- a/.planning/ios-coord/v1.7.0-ios-ack-live.md +++ /dev/null @@ -1,35 +0,0 @@ -# iOS → Server — ack: v1.7.0 LIVE received - -Date: 2026-05-31. iOS branch `feat/v0100-marathon`. Got `v1.7.0-server-final.md` — -thanks, all locks confirmed live. iOS plan: - -## Pinning this cycle (v0.10.0) -iOS was built forward-compatible + field-presence-gated, so it's already correct -against live v1.7.0. We're doing the cleanup pin pass in our QA wave (W10): -- Decode `scheduleType` (`SCHEDULED|PRN|CYCLIC`) as the clean cadence discriminator. -- Remove the widget workarounds `filteringForServer()` / `byRestoringIosOnlyWidgets()` - / `byMergingIosOnlyDefaults()` (server round-trips all 27 ids now). -- Verify the compliance `min(cap)` + `.noSchedule` drop fires against live `due`/ - `expectedCount`. -- Flip full passkey trust on the default host (AASA confirmed 200). - -## Snapshot cold-launch seed -`GET /api/dashboard/snapshot` with `metricStates` + `layoutCatalogue` — thanks for -adding the iOS-shaped block. Adopting as a one-key cold-launch seed is a perf win; -we'll wire it opportunistically (this cycle if time permits in W10, else early v0.11). -Per-store endpoints stay regardless. - -## Mood reminder -Acknowledged — the card now delivers on its own + `notificationPrefs.mood.reminderHour` -is the source of truth. iOS still ships a LOCAL evening reminder (offline reliability, -fires only if no entry today) but will READ `reminderHour` as the authoritative time -rather than its own default. Belt-and-suspenders, no double-fire (local cancels when an -entry exists; server respects explicit opt-out). - -## Offline sync — all three domains noted -Great that mood + intakes are now served too (single multi-domain cursor; tombstone -identity measurements=`externalId`, mood/intakes=server `id`). iOS consumer is a v0.11 -item (measurements-first), building against this now-frozen contract. Updated our v0.11 -backlog accordingly — the server side is no longer the gating piece. - -All clear. We'll pin from `openapi.yaml` @ v1.7.0. diff --git a/.planning/ios-coord/v1.7.0-ios-convergence-locks.md b/.planning/ios-coord/v1.7.0-ios-convergence-locks.md deleted file mode 100644 index aef9ac5e9..000000000 --- a/.planning/ios-coord/v1.7.0-ios-convergence-locks.md +++ /dev/null @@ -1,175 +0,0 @@ -# iOS Convergence Locks — v1.7.0 - -**Author:** iOS team (HealthLog iOS, `feat/v0100-marathon`) -**Audience:** Server team (HealthLog `v1.7.0`, parallel build) -**Status:** Authoritative iOS-side lock. Evidence-cited to iOS source `file:line`. -**Purpose:** Lock three convergence surfaces so the parallel server build targets byte-identical shapes — no reconcile drift. (1) Widget IDs, (2) FHIR LOINC/UCUM codes, (3) medication-contract field names. - -> iOS will pin the **final** field names from `docs/api/openapi.yaml` @ the `v1.7.0` tag. Where a name below is marked **PROPOSED**, it is iOS's request that the server adopt that exact spelling. Where marked **LOCKED**, iOS already emits/consumes it on the wire today and the server must not diverge. - ---- - -## 1. FHIR R4 LOINC / UCUM lock (LOCKED) - -iOS emits FHIR R4 `Observation` resources from a hardcoded `MetricKind → LOINC + UCUM` table. **The server FHIR export must be byte-identical to this table** — same `Observation.code.coding[].code`, same `display`, same `valueQuantity.{code,unit}`. - -- Code system: `http://loinc.org` — `HealthLog/FHIR/LOINCCode.swift:30` -- Unit system (UCUM): `http://unitsofmeasure.org` — `HealthLog/FHIR/LOINCCode.swift:67` -- Mapping table source: `HealthLog/FHIR/MetricFHIRMapper.swift:102-374` -- HK type column source: `HealthLog/Services/HealthKitWireConverter.swift:129-233` - -UCUM strings are **case-sensitive, canonical-form, brackets-are-load-bearing**: `mm[Hg]`, `/min` (leading slash), `Cel` (not `°C`), `kg/m2`, `{steps}`, `dB[A]` (`LOINCCode.swift:67-80`). - -### 1a. Standard LOINC-backed metrics - -| Metric | LOINC code | LOINC display | UCUM | HK type | Evidence | -|---|---|---|---|---|---| -| Body weight | `29463-7` | Body weight | `kg` | `HKQuantityTypeIdentifierBodyMass` | MetricFHIRMapper.swift:105-110 | -| Blood pressure (panel) | `85354-9` | Blood pressure panel with all children optional | _(panel: no unit)_ | — | MetricFHIRMapper.swift:37-40 | -| → BP systolic (component) | `8480-6` | Systolic blood pressure | `mm[Hg]` | `…BloodPressureSystolic` | MetricFHIRMapper.swift:43-46, 119-123 | -| → BP diastolic (component) | `8462-4` | Diastolic blood pressure | `mm[Hg]` | `…BloodPressureDiastolic` | MetricFHIRMapper.swift:49-52 | -| Heart rate (pulse) | `8867-4` | Heart rate | `/min` | `HKQuantityTypeIdentifierHeartRate` | MetricFHIRMapper.swift:125-130 | -| Resting heart rate | `40443-4` | Heart rate --resting | `/min` | `…RestingHeartRate` | MetricFHIRMapper.swift:209-217 | -| HRV (SDNN) | `80404-7` | R-R interval.standard deviation (Heart rate variability) | `ms` | `…HeartRateVariabilitySDNN` | MetricFHIRMapper.swift:219-228 | -| Body temperature | `8310-5` | Body temperature | `Cel` | `…BodyTemperature` | MetricFHIRMapper.swift:152-157 | -| SpO₂ | `59408-5` | Oxygen saturation in Arterial blood by Pulse oximetry | `%` | `…OxygenSaturation` | MetricFHIRMapper.swift:159-171 | -| Respiratory rate | `9279-1` | Respiratory rate | `/min` | `…RespiratoryRate` | MetricFHIRMapper.swift:290-295 | -| Body fat % | `41982-0` ⚠ | Percentage of body fat Measured | `%` | `…BodyFatPercentage` | MetricFHIRMapper.swift:141-150 | -| Body water | `73704-9` ⚠ | Body water by Bioelectrical impedance analysis | `kg` | _(scale-derived)_ | MetricFHIRMapper.swift:173-182 | -| Bone mass | `73708-0` ⚠ | Bone mineral content by DXA | `kg` | _(scale-derived)_ | MetricFHIRMapper.swift:184-193 | -| BMI | `39156-5` | Body mass index (BMI) [Ratio] | `kg/m2` | `…BodyMassIndex` | MetricFHIRMapper.swift:324-329 | -| Sleep duration | `93832-4` | Sleep duration | `h` | `HKCategoryTypeIdentifierSleepAnalysis` | MetricFHIRMapper.swift:195-200 | -| Steps | `41950-7` | Number of steps in 24 hour Measured | `{steps}` | `…StepCount` | MetricFHIRMapper.swift:202-207 | -| VO₂ max | `96402-2` ⚠ | Oxygen consumption maximum during exercise | `mL/min/kg` | `…VO2Max` | MetricFHIRMapper.swift:230-239 | -| Walking speed | `41957-2` ⚠ | Gait speed [Velocity] Measured | `m/s` | `…WalkingSpeed` | MetricFHIRMapper.swift:241-250 | -| Walking asymmetry | `91557-1` ⚠ | Walking asymmetry percentage | `%` | `…WalkingAsymmetryPercentage` | MetricFHIRMapper.swift:252-261 | -| Walking step length | `41955-6` ⚠ | Step length Measured | `m` | `…WalkingStepLength` | MetricFHIRMapper.swift:263-272 | -| Active energy | `41981-2` | Calories burned | `kcal` | `…ActiveEnergyBurned` | MetricFHIRMapper.swift:335-340 | - -⚠ = `physicianReviewPending: true` in iOS (`LOINCCode.swift:47-61`). Code still **emitted** — server must match the code; the flag drives UI disclaimers only. - -### 1b. Glucose — context-discriminated LOINC (LOCKED) - -iOS picks the LOINC by `GlucoseContext`. Default (random / no context) = `2339-0`. UCUM `mg/dL` for all. Source: `MetricFHIRMapper.swift:73-89, 132-139, 388-403`. - -| Glucose context | LOINC | display | UCUM | -|---|---|---|---| -| random / unspecified / bedtime | `2339-0` ⚠ | Glucose [Mass/volume] in Blood | `mg/dL` | -| fasting / beforeMeal | `1558-6` ⚠ | Fasting glucose [Mass/volume] in Serum or Plasma | `mg/dL` | -| afterMeal (postprandial) | `1521-4` ⚠ | Glucose [Mass/volume] in Serum or Plasma --2 hours post meal | `mg/dL` | - -### 1c. HK-placeholder codes (no published LOINC term) - -For these, iOS emits the **HealthKit identifier string as the `code`** (in `http://loinc.org` slot) as a clinician-reviewable placeholder until LOINC publishes a term. **Server must emit the identical placeholder string**, not invent a LOINC. Source: `MetricFHIRMapper.swift:274-373`. - -| Metric | `code` (placeholder) | UCUM | -|---|---|---| -| Walking double support | `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` | `%` | -| Audio exposure (environment) | `HKQuantityTypeIdentifierEnvironmentalAudioExposure` | `dB[A]` | -| Audio exposure (headphone) | `HKQuantityTypeIdentifierHeadphoneAudioExposure` | `dB[A]` | -| Flights climbed | `HKQuantityTypeIdentifierFlightsClimbed` | `{flights}` | -| Distance walking/running | `HKQuantityTypeIdentifierDistanceWalkingRunning` | `m` | -| Time in daylight | `HKQuantityTypeIdentifierTimeInDaylight` | `min` | - -**Count of distinct LOINC codes extracted: 23** (20 standard incl. BP panel + 2 components, + 3 glucose-context). Plus 6 HK-placeholder codes. - ---- - -## 2. Dashboard widget-ID lock (LOCKED) - -Canonical enum: `HealthLog/Models/DashboardWidgetLayout.swift:73-118` (`DashboardWidgetId`). Wire shape `GET/PUT /api/dashboard/widgets` → `DashboardWidgetLayout` (`DashboardWidgetLayout.swift:16-56`). Server source-of-truth referenced by iOS: `src/lib/dashboard-layout.ts` → `DASHBOARD_WIDGET_IDS`. - -**Total widget IDs: 26.** (The default layout array, `DashboardWidgetLayout.swift:259-357`, materialises all 26.) - -### 2a. Server-known IDs (16) — already in the server Zod enum - -`serverKnownIds` (`DashboardWidgetLayout.swift:137-142`): `weight`, `bp`, `pulse`, `bodyFat`, `mood`, `medications`, `sleep`, `steps`, `glucose`, `totalBodyWater`, `boneMass`, `bpInTarget`, `oxygenSaturation`, `achievements`, `vo2Max`, `recentWorkouts`. - -### 2b. iOS-only IDs (10) — NOT yet in the server enum → **server must widen `DASHBOARD_WIDGET_IDS` to accept these on PUT** - -Added by the v0.5.2 / v0.7.0 HK-completeness sweeps (`DashboardWidgetLayout.swift:100-118`). Today iOS filters these off the PUT payload (`filteringForServer()`, line 423) because the server 422s on them — this is a **temporary defensive guard (TODO issue #11)**. Adopting them server-side retires the guard. - -| Widget ID | Maps to MetricKind | Evidence | -|---|---|---| -| `restingHeartRate` | `.restingHeartRate` | DashboardWidgetLayout.swift:100, 180 | -| `hrv` | `.hrv` | :101, :181 | -| `walkingSpeed` | `.walkingSpeed` | :102, :182 | -| `walkingAsymmetry` | `.walkingAsymmetry` | :103, :183 | -| `walkingStepLength` | `.walkingStepLength` | :104, :184 | -| `bmi` | `.bmi` | :105, :189 | -| `bodyTemperature` | `.bodyTemperature` | :106, :190 | -| `walkingDoubleSupport` | `.walkingDoubleSupport` | :110, :185 | -| `respiratoryRate` | `.respiratoryRate` | :114, :186 | -| `audioExposureEnvironment` | `.audioExposureEnvironment` | :116, :187 | -| `audioExposureHeadphone` | `.audioExposureHeadphone` | :118, :188 | - -> **Note:** this is **11 rows**, not 10. The earlier "11 iOS-only" count is correct — `bmi` belongs to the v0.5.2 wave and is iOS-only too. Reconciled iOS-only count = **11**. (16 server-known + 11 iOS-only = 27 distinct IDs in the catalogue; the default-layout array omits one server-known non-tile id — see below.) - -### 2c. Catalogue reconciliation - -- Distinct IDs defined in `DashboardWidgetId`: **27** (16 server-known + 11 iOS-only). -- IDs materialised in `DashboardWidgetLayout.default`: **26** — the default array (`:259-357`) carries all 16 server-known **except** `recentWorkouts` (iOS has no workouts surface yet; `metricKind(forId:)` returns `nil` for it, `:194`) is **present** in default but `glucose`/etc. all present; the 26 vs 27 delta is `recentWorkouts` being a non-tile server widget. Server should treat the **27-ID catalogue** as authoritative for the Zod enum. - -**Lock for server:** widen `DASHBOARD_WIDGET_IDS` to the full 27-ID set (16 current + 11 iOS-only above). Once accepted, iOS removes `filteringForServer()` / `byRestoringIosOnlyWidgets()` / `byMergingIosOnlyDefaults()` (`DashboardWidgetLayout.swift:423-487`). - ---- - -## 3. Medication-contract field-name lock - -iOS specification to the server. **PROPOSED** names = iOS asks server to adopt this exact spelling in `openapi.yaml`. Current iOS wire shape: `HealthLog/Models/Medication.swift:9-136`. - -### 3a. Medication GET + detail — delivery booleans (PROPOSED) - -iOS already has the DTO seam (`MedicationDeliveryDefaultsDTO`, `DeliveryPreferences.swift:70-92`) awaiting the server fields **SB-LA-1 / SB-AK-1** (`DeliveryDefaultsProviding.swift:11-54`). - -| Field | Type | Default | Direction | Notes | -|---|---|---|---|---| -| `liveActivityEnabled` | `Bool` | `false` | GET/detail + PUT | Lock-Screen/Dynamic-Island dose countdown. Default OFF (`DeliveryPreferences.swift:29-34, 72`). | -| `criticalAlarmEnabled` | `Bool` | `false` | GET/detail + PUT | AlarmKit break-through alarm. Default OFF (`DeliveryPreferences.swift:33, 73`). | -| `nextDueAt` | `String?` (ISO8601) | `null` | **read-only** (GET/detail) | Server-computed next scheduled instant. **`null` for PRN.** Client **never** sends it on PUT. | - -PUT accepts **only** the two booleans. `nextDueAt` is server-authoritative read-only. - -### 3b. Schedule object — cadence fields (PROPOSED) - -iOS's canonical names for cadence shapes the current wire (`MedicationScheduleDTO`, `Medication.swift:84-136`) does not yet express first-class. Ask server to confirm/adopt in `openapi.yaml`: - -| Field | Type | Semantics | -|---|---|---| -| `asNeeded` | `Bool` | PRN. **Excluded from expected-count + reminder projection; `nextDueAt` null.** (Today iOS infers PRN from empty `schedules`/`times`, `MedicationCadence.swift:263`, `MedicationsStore+CardCompliance.swift:42-59`. A first-class `asNeeded` flag removes the inference.) | -| `cycleWeeksOn` | `Int?` | Cyclic-cadence ON-week count. | -| `cycleWeeksOff` | `Int?` | Cyclic-cadence OFF-week count. | -| `cycleAnchor` | `String?` (ISO date) | Anchor date for the on/off-week cycle. | - -These extend the existing cadence vocabulary (`Cadence` enum, `MedicationCadence.swift:11-47`: daily / weekdays / everyNWeeks / monthly / everyNMonths / yearly / rolling / oneShot / legacy). The cycle fields express on/off-week dosing the current `iN;` interval encoding cannot. - -### 3c. Compliance payload — per-day additive fields (PROPOSED) - -Current per-day bucket: `DailyComplianceBucket` (`Medication.swift:534-560`) — `expected, taken, skipped, onTime, late, veryLate, early?`. iOS asks the server to **add** two fields: - -| Field | Type | iOS use | -|---|---|---| -| `due` | `Bool` | iOS reads `due` (or `expectedCount > 0`) to **suppress empty history marks on non-due days** in the Verlauf strip. | -| `expectedCount` | `Int` | Same suppression signal; per-day expected dose count. | - -**Why it matters:** today iOS recomputes the denominator locally and applies a `min(scheduleExpected, serverExpected)` cap because the server's `calculateCompliance` builds `totalExpected = schedules.length × effectiveDays` and **ignores `daysOfWeek` + `intervalWeeks`** (documented at `MedicationCadence.swift:354-399`; cap logic in `MedicationsStore.swift:816, 855` + `MedicationsStore+CardCompliance.swift`). When the server emits authoritative per-day `due`/`expectedCount`, iOS **drops its local cap** behind a `v1.7.0` capability gate. This is the highest-value reconcile item — it removes a whole client-side correction layer. - ---- - -## 4. iOS units + `/api/dashboard/snapshot` note - -### 4a. Walking-speed unit — convergence caveat (LOCKED) - -- **Wire + UCUM + iOS display are all `m/s`.** iOS sends raw HK `m/s` (`HealthKitWireConverter.swift:209-211`, `wireSymbol: "m/s"`), the FHIR UCUM is `m/s` (§1a), and the iOS tile/detail display unit is `m/s` (`MetricKind.unit` → `Measurement.swift:144`, `MetricKind.swift:144`). **There is no km/h conversion anywhere in the iOS codebase** (verified: no `km/h`, `UnitSpeed`, `kilometersPerHour`, or `× 3.6` exists). -- **Lock:** if the server **displays km/h**, that is a presentation-layer choice and **must not** change the stored/wire/FHIR value — those stay `m/s`. The server should convert for display only (`km/h = m/s × 3.6`) and continue to persist + export `m/s`. -- No metric/imperial unit-toggle exists on iOS today; iOS metrics are fixed-unit (`MetricKind.unit`, `Measurement.swift:128-156`). No toggle expectation to honour. - -### 4b. `GET /api/dashboard/snapshot` (if offered) - -If the server ships a per-metric seed snapshot, it should carry **all 27 widget IDs** (§2), including the 11 iOS-only ones, so a cold-launch first-paint can seed every tile without a second round-trip. Per-metric seed should use the same `MetricKind` raw values iOS decodes (`MetricKind.swift:30-122`) — note the non-obvious raw values: `spo2 = "oxygenSaturation"`, `bodyWater = "totalBodyWater"`, `hrv = "heartRateVariability"`, `bmi = "bodyMassIndex"`, `walkingAsymmetry = "walkingAsymmetryPercentage"`, `walkingDoubleSupport = "walkingDoubleSupportPercentage"`, `audioExposureEnvironment = "environmentalAudioExposure"`, `audioExposureHeadphone = "headphoneAudioExposure"`, `activeEnergy = "activeEnergyBurned"`. - ---- - -## 5. iOS final-pin commitment - -iOS pins the **final** wire field names from `docs/api/openapi.yaml` @ the `v1.7.0` tag. This document is iOS's **request** that the server adopt the PROPOSED names verbatim to minimise reconcile drift. LOCKED surfaces (FHIR LOINC/UCUM table, the 27-ID widget catalogue, walking-speed = m/s) are non-negotiable for byte-identical export — please match exactly. diff --git a/.planning/ios-coord/v1.7.0-ios-offline-sync-answers.md b/.planning/ios-coord/v1.7.0-ios-offline-sync-answers.md deleted file mode 100644 index 3612eb844..000000000 --- a/.planning/ios-coord/v1.7.0-ios-offline-sync-answers.md +++ /dev/null @@ -1,376 +0,0 @@ -# iOS answers — v1.7.0 offline / server-optional sync-reconcile contract - -Date: 2026-05-31. iOS team → server team. Reply to -`.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md` §7 -("iOS must specify / provide"). Companion grounding: -`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` (Pattern A) and -the iOS R4 research `.planning/v0100-marathon/R4-offline-architecture.md`. - -All claims below are grounded in the **iOS** repo at -`/Users/marc/Projects/healthlog-iOS/HealthLogIOS`, cited `file:line`. Where the -proposal's stated iOS position is wrong, it is corrected here, not echoed. - -**Read §0 first.** It bounds what iOS actually ships in v0.10.0 and reframes the -"BLOCKED on §7.1–§7.4" framing: the server is not blocked on iOS *implementing* -the consumer this cycle — only on iOS *deciding the contract shape*, which is -what this doc settles. The consumer lands in a later iOS release. - ---- - -## 0. iOS v0.10.0 scope vs this contract - -> **SCOPE UPDATE (operator directive 2026-05-31 — supersedes the "groundwork only" -> wording in this section and the "later release" mentions further down):** iOS -> v0.10.0 builds the FULL offline path THIS cycle — the groundwork primitives PLUS -> the `/api/sync/changes` consumer, push, tombstone-apply and cursor handling — -> **forward-compatible against this proposed contract, behind a standalone gate that -> is OFF by default**, unit-tested with a stub URLSession (no mock server). Live -> activation follows the server deploy. Exact field/endpoint shapes are pinned from -> `docs/api/openapi.yaml` @ v1.7.0; a final iOS→server handover -> (`v1.7.0-ios-to-server-FINAL-handover.md`) will lock every shape iOS built -> against. The §7 decisions below stand unchanged; only the "ships later" framing -> becomes "this cycle, behind a default-off gate." - -The groundwork primitives are still built first (R4 §3 Phase 0) — three additive, -paired-safe primitives: - -1. A **`BackendAvailability`** capability abstraction (R4 §3.1) — a - `@MainActor @Observable` surface views consult instead of hard-reading - `SyncModeStore.isStandalone`. **Does not exist yet** — confirmed: the only - occurrence of the name in-repo is a doc comment - (`HealthLog/Sync/SyncLifecycle.swift:75`), no type. v0.10.0 creates it. Ships - returning `true` for every capability in paired mode → zero behaviour change. -2. **401-cascade gating** (R4 §3.3) — route the doomed-in-standalone store - loads behind `BackendAvailability.hasServer`, generalising the - already-shipped Settings/Profile pattern (R4 §1.1). The 401→one-shot-refresh - bridge it must not fight already exists (`HealthLog/Services/APIClient.swift:179-201`). -3. **`HLCloudDerivedPlaceholder`** primitive (R4 §3.2) — neutral "pair to - enable" card for the ~9 Tier-B server-derived surfaces. **Does not exist - yet.** v0.10.0 builds it + a snapshot test, adopts it behind an always-true - capability flag. - -**Explicitly NOT in v0.10.0:** the `/api/sync/changes` delta consumer, the -opaque-cursor persistence, the local SwiftData mirror for manual entries -(R4 §3.4 — the one real data-layer task, deferred to a later wave), the -first-pair backfill orchestrator, and the Release standalone flip -(`OnboardingFlow.advanceFromWelcome` stays `#if DEBUG` per R4 §3.5). The full -`/api/sync/changes` consumer + standalone Release enable is a **subsequent iOS -release**, wired once the endpoints land in `docs/api/openapi.yaml` at the -v1.7.0 tag. - -**What this means for the server's "BLOCKED on §7.1–§7.4":** the answers below -are the authoritative contract decisions. The server can register -`/api/sync/state`, do the soft-delete conversion (§2.3 item 1), and ship -`/api/sync/changes` on this contract **independently of the iOS consumer -landing** — the consumer reads the contract this doc freezes. Nothing here -requires iOS to ship code first; the contract is forward-compatible with the -v0.10.0 groundwork and the later consumer alike. - ---- - -## §7.1 — First-sync domain slice - -**Recommendation: measurements-only for v1.7.0.** Mood + intakes follow once -their tombstone/version columns land (your §2.3 migration set 2/3). - -Rationale: -- Matches the server recommendation (§6) and your own feasibility table (§1.6): - `Measurement` is the only model with `updatedAt` **and** `deletedAt` **and** - `syncVersion` today. -- It is what the iOS wire layer is ready for: the measurement batch/dedup wire - is mature (`HealthLog/Models/MeasurementDTO.swift`, `MeasurementBatchUploader.swift`), - whereas mood/intakes have no `syncVersion`/tombstone concept on the wire at all - (mood DTO `HealthLog/Models/Mood.swift:32-80` has no version field; intake - bulk entry `HealthLog/Repositories/MedicationsRepository+ReminderIntake.swift:20-50` - has no version field). -- It aligns with R4's tier model: HealthKit-sourced measurements are the - Tier-A common case (R4 §2.1); mood + manual meds are the smaller manual-entry - surface that the local mirror (R4 §3.4) hasn't shipped for yet. - -**Therefore: ship migration set (2)/(3) deferred; only the soft-delete -conversion (§2.3 item 1) ships this cycle.** iOS will not consume mood/intake -deltas in the first consumer release regardless, so the columns are not on the -critical path. - ---- - -## §7.2 — Max offline window - -**Recommendation: align the incremental-delta window to the native -refresh-token lifetime, with a re-pair full backfill past it. Tombstone -retention = same window + margin.** - -Important correction to the proposal's framing: the proposal asserts native -refresh = 60 days as a server constant (§1.5, `native-client.ts:67-70`). **The -iOS client does not hardcode 60 days** — it trusts the server's issued expiry -and persists it verbatim: -`refreshTokenExpiresAt` is stored from the session response -(`HealthLog/Services/AuthService.swift:343-345`) and wiped on logout/revoke -(`AuthService.swift:126-127, 164`). So the window is **whatever the server -issues**, and 60 days is the server's number, not iOS's. iOS will adopt -"incremental-delta window == server-issued refresh-token lifetime" as a derived -value, not a second hardcoded constant. If the server changes -`refreshTokenDays`, the sync window should move with it — please keep them -coupled, or expose the window explicitly in `/api/sync/state` so iOS reads it -rather than inferring it. - -Concrete contract: -- **Incremental delta valid while** `now < refreshTokenExpiresAt` (i.e. the - device could still refresh and stay paired). Beyond that the device must - re-pair anyway (the refresh family is dead — see §7.x / your §4.3), so a - cursor-reset full resync is unavoidable and correct. -- **Tombstone retention ≥ refresh-token lifetime + margin** (server's PROPOSED - retention in §4.2 is right). With a 60-day refresh, retain tombstones ≥ ~75 - days. A device that was offline longer than the refresh lifetime has already - lost its token and re-pairs → backfill, not delta. -- **`cursorExpired: true`** (your §4.2) is the correct signal and iOS will honour - it: drop local cursor, clean initial sync. iOS treats it as authoritative — - no client-side staleness heuristic. - -Net: there is **one** window, not two. It is the refresh-token lifetime. Past -it, re-pair + backfill (idempotency-safe), not delta. - ---- - -## §7.3 — Local store schema + identity keys (tombstone identity field per domain) - -This is the most load-bearing answer; every claim is code-cited. **The identity -key is NOT uniform across domains — do not assume `externalId` everywhere.** - -### Measurements — identity = `externalId` (the cross-device key). CONFIRMED. - -- The wire row carries `externalId: String?` - (`HealthLog/Models/MeasurementDTO.swift:14`); on read-back the client maps it - into `Measurement.externalUUID` (`MeasurementDTO.swift:211, 464`; - `MeasurementsRepository.swift:430`). -- This is the same value used as the HK dedup key - (`HKSample.uuid.uuidString`, `HealthLog/Models/HealthKitBatchDTO.swift:12,22`) - and the bulk-delete reconciliation key - (`MeasurementsRepository.swift:453-461`, body `{ externalIds: [...] }`, - DTO `ExternalIDBatch`/`externalIds: [String]` `MeasurementDTO.swift:565-567`). -- **Tombstone identity field for measurements = `externalId`.** Your §2.2 draft - (`"tombstones":[{"externalId":"…","deletedAt":"…"}]`) is exactly right. - -**One caveat on the pairing-doc claim, corrected:** the pairing doc §4.1 says -the SwiftData `syncIdentifier: UUID` "is exactly the `externalId`." That holds -*as the intended design*, but note iOS today has **no SwiftData `@Model` for -measurements** — `Measurement` is a `Codable` server DTO -(`HealthLog/Models/Measurement.swift:262`, `public struct Measurement: Codable, Sendable`), -not a `@Model`, and there is **no `syncIdentifier` property anywhere in the iOS -tree today** (grep: zero hits). The `syncIdentifier`/`@Model` promotion is the -deferred A2 path (R4 §1.2, §4). For the v1.7.0 **wire** contract this changes -nothing: the cross-device identity on the wire is and stays `externalId` -(a UUID string for sample-class, `stats::` for cumulative — -`MeasurementDTO`/`AppContainer.swift:787`). So: **server keys tombstones on -`externalId`; iOS will key its eventual local mirror's `syncIdentifier` to the -same string.** Confirmed compatible, but the `syncIdentifier == externalId` -equality is a *future-local-model* statement, not a *today* one. - -### Mood entries — identity = server `id` (NOT a client-minted UUID). CONFIRMED, corrects the proposal. - -- iOS mints a **throwaway** local id `"local-"` only for the optimistic - row (`HealthLog/Repositories/MoodRepository.swift:26`; - `HealthLog/Stores/MoodStore.swift:227`), then POSTs to `/api/mood-entries` and - **replaces it with the server-canonical `MoodEntry`** returned by the server - (`MoodRepository.swift:120-127` — `postEntry` returns the decoded server row; - `req: APIRequest`). The `local-` id never reaches the server and is - not a stable cross-device key. -- All subsequent mutations key on the **server `id`**: - `update(id:patch:)` → `PATCH /api/mood-entries/[id]`, `delete(id:)` → - `DELETE …/[id]` (`MoodRepository.swift:52, 79, 103-116`). Server dedup is on - `(userId, date, moodLoggedAt)` (your §1.6) — but the **client mirrors by - server `id`**. -- **Tombstone identity field for mood = `id`** (server cuid), per your §2.2 - `mood` example `{"id":"…","deletedAt":"…"}`. Correct as drafted. iOS does - **not** have a stable client-minted mood UUID to offer; do not key mood - tombstones on anything but server `id`. - -### Medication intakes — identity = server `id`; dedup pre-insert = `idempotencyKey` + `(medicationId, scheduledFor, source)`. CONFIRMED. - -- The bulk-intake wire entry carries **no client UUID identity** — it is - `(medicationId, scheduledFor?, takenAt?, skipped, idempotencyKey?)` - (`HealthLog/Repositories/MedicationsRepository+ReminderIntake.swift:20-50`). - The `idempotencyKey` is an insert-dedup hint - (`…ReminderIntake.swift:31-35` — "server's `intake_events.idempotency_key` - UNIQUE index … duplicate POST returns the existing row"), **not** a stable - cross-device identity for tombstones. -- The natural key is `(medicationId, scheduledFor)` - (`MedicationsStore.swift:55`, `MedicationLiveActivityPlan.swift:150-164`; - server composite `(userId, medicationId, scheduledFor, source)`). The - read-back row is `MedicationIntakeWireDTO` with a server `id` - (`HealthLog/Models/Medication.swift:400-401`). -- **Tombstone identity field for intakes = server `id`.** An intake is an - immutable fact; a "correction" is tombstone + re-insert (matches your §3.2 - intake rule). iOS has no stable client UUID for an intake to offer — key - tombstones on server `id`, and let iOS reconcile via `(medicationId, - scheduledFor)` when it must match a locally-known dose. - -**Summary table (tombstone identity field iOS will dedup on):** - -| Domain | Tombstone identity | Source in iOS code | Note | -|---|---|---|---| -| Measurements | `externalId` (UUID string or `stats:…`) | `MeasurementDTO.swift:14`, `:565`; `HealthKitBatchDTO.swift:12` | cross-device stable; == future local `syncIdentifier` | -| Mood | server `id` (cuid) | `MoodRepository.swift:120-127`; `Mood.swift:33` | `local-…` is throwaway; never the key | -| Intakes | server `id` (cuid) | `Medication.swift:400-401`; `…ReminderIntake.swift:20-50` | `idempotencyKey` is insert-dedup only, not identity | - ---- - -## §7.4 — Conflict UX expectation - -**Recommendation: silent server-wins default. No merge prompt in v1.** - -This is doctrine, not a placeholder. The pairing doc §4.4 is explicit: "No -client-side conflict-prompt UI … Health data with a manual-resolve dialog every -reconnect would be unusable. Apple Health does not show one … we will not invent -one." R4 reinforces the calm-UX posture. So: - -- **Immutable domains (measurements sample-class, intakes):** there is **no - conflict** by construction — first-write-wins immutable (your §3.2; - `route.ts:276-294`). A re-post returns `duplicate`, iOS treats it as success - and advances its cursor. No UX, no prompt. -- **`stats:*` cumulative measurements:** server-overwrite, latest total wins - (your §3.2). iOS expects `updated`, no prompt — this is already how the - client thinks about cumulative rows - (`HealthLog/Models/Measurement.swift:176-187`, `stats:` prefix semantics). -- **Editable domains (mood, medication-definition rows):** **silent - server-wins** on a 409/`conflict` (the higher `syncVersion` wins; the loser's - edit is dropped). The pairing doc §4.3 rule 2 is verbatim this: "higher - `syncVersion` wins; lower is dropped … No merge UI." iOS will adopt the - server row and surface nothing modal — at most a non-blocking, dismissible - note if we later decide the dropped edit is worth a toast (not v1). -- **Deletion vs edit:** tombstone wins (pairing doc §4.3 rule 3). iOS applies - the tombstone, reverts the local edit, no prompt. - -**Payload iOS needs from the server (this is the part that sizes your -response):** -- **Immutable domains:** iOS needs **nothing extra on conflict** — a - `duplicate`/`updated` per-entry status (your `EntryStatus`) is sufficient. - Optionally echo `syncVersion` so iOS can keep its mirror's version monotonic, - but iOS does not branch UX on it. -- **Editable domains (mood / med-definition):** iOS needs the **full conflicting - server row** in the 409/`conflict` body (so it can replace its local row in - one step without a follow-up GET). Your §3.3 already returns "the current - server row in the body" — keep that; that is exactly what iOS consumes. Just - `syncVersion` alone is **insufficient** for editable domains because iOS would - then need a second round-trip to fetch the winning content. - -So: **`syncVersion` echo is enough for immutable domains; the full row is -required for editable domains.** That is the minimal payload that lets iOS stay -silent-server-wins with no extra fetch. - ---- - -## §7.5 — Page size + cursor concurrency - -**Recommendation: `limit=200` (your default), and a single combined cursor -across domains for v1. Per-domain cursors deferred.** - -- `limit=200` is fine. iOS pages until `hasMore:false` on a single cursor; - 200 keeps each page well under the 500 batch cap iOS already lives with on the - push side (measurement batch cap 500, `MeasurementBatchUploader.swift`), and - matches the server's own default. -- **Combined single cursor (your §2.2 as drafted), not per-domain, for v1.** - Reasons: (a) v1.7.0's iOS slice is measurements-only (§7.1) → there is only - one domain to page anyway, so per-domain cursors buy nothing now; (b) combined - is simpler server-side (your own note) and simpler for iOS to persist (one - opaque token, one drain-loop). Per-domain cursors only matter once iOS wants - to prioritise one domain's catch-up over another — defer to the multi-domain - release. -- iOS applies tombstones-before-upserts within each page (your §2.2 design - note) — confirmed, iOS will honour that ordering to avoid resurrecting rows. - ---- - -## §7.6 — Cursor opacity contract - -**CONFIRMED: iOS treats `cursor` as fully opaque. Echo, never parse.** - -iOS will persist the last fully-drained `cursor` verbatim and send it back -unmodified; it will never base64-decode it, inspect `(updatedAt, id)`, or depend -on any internal structure. The server is free to change the keyset/sequence -encoding (e.g. switch to a monotonic `syncVersion` sequence per your §2.1) with -no iOS release. iOS's only cursor invariants: (1) treat it as a token, (2) on -`cursorExpired:true` discard it and re-init, (3) persist it durably so it -survives app restarts (same durability the Outbox idempotency keys already have). - ---- - -## §7.7 — Backfill vs delta on first pair - -**CONFIRMED: first-pair backfill stays on the existing batch endpoints; the -delta feed is incremental catch-up ONLY, never a replacement for the initial -backfill.** - -- First-pair backfill uses `POST /api/measurements/batch`, - `POST /api/mood-entries/bulk`, `POST /api/medications/intake/bulk` exactly as - the pairing doc §3.3 specifies and as the iOS wire already implements - (`MeasurementBatchUploader.swift`; intake bulk - `…ReminderIntake.swift`; mood bulk path). Idempotency keys make re-runs safe - (`duplicate` on retry) — pairing doc §3.3 step 5. -- `/api/sync/changes` is consumed **after** the initial backfill, for - incremental deltas only (your §4.2). iOS will not attempt to bootstrap a - fresh-paired account through the delta feed. -- R4 §2.3 narrows this further for iOS's A1 path: most measurement history is - **already on the server** post-pair via the normal HK→server upload path the - moment HK background-delivery activates — so the explicit backfill iOS uploads - is bounded to manual-entry rows + GLP-1 local data, not the full HK history. - This does not change the server contract (batch endpoints, idempotent) — just - flagging that iOS's backfill volume is smaller than a naive "upload everything" - read of §3.3 implies. - ---- - -## Pushback / conflicts with R4 + pairing doc (the proposal invites it) - -1. **"BLOCKED on §7.1–§7.4" overstates the coupling.** The server is blocked on - the *contract decisions*, which this doc settles — not on iOS shipping the - consumer. iOS v0.10.0 ships groundwork only (§0); the consumer is a later - release. Please proceed with the server work (register `/api/sync/state`, - soft-delete conversion, `/api/sync/changes`) on this frozen contract without - waiting for an iOS consumer PR. - -2. **`syncIdentifier == externalId` is a future-local statement, not a today - one (corrects pairing-doc §4.1 as cited by your §7.3).** iOS has **no** - SwiftData `@Model` and **no** `syncIdentifier` property today - (`Measurement` is a `Codable` DTO, `Measurement.swift:262`; grep for - `syncIdentifier` = 0 hits). The wire identity is and stays `externalId`. Do - not design the tombstone feed around a client `syncIdentifier` field that - does not exist on the wire — key on `externalId` (measurements) / server `id` - (mood, intakes). See §7.3 table. - -3. **Refresh window is server-issued, not an iOS 60-day constant (corrects the - §7.2 framing).** iOS persists `refreshTokenExpiresAt` from the server - (`AuthService.swift:343-345`) — it does not assume 60 days. Couple the sync - window to `refreshTokenDays`, or surface the window in `/api/sync/state` so - iOS reads it. Don't let the two drift. - -4. **Mood/intake migrations are genuinely off the critical path for the first - consumer.** Since iOS's first consumer is measurements-only (§7.1) and iOS - has no mood/intake `syncVersion` on the wire today - (`Mood.swift:32-80`, `…ReminderIntake.swift:20-50`), shipping the §2.3 - set-(2) columns this cycle would be server work iOS cannot exercise yet. - Recommend deferring them with the consumer that uses them — avoids an - untested migration sitting idle. - -5. **No disagreement on the soft-delete conversion (§2.3 item 1).** It is the - right call and iOS depends on it for truthful tombstones on the long-offline - path (your §4.2; R4's data-correctness concern). Ship it this cycle. - ---- - -## One-line confirmations for the server's §8 worklist - -- §8.1 register `/api/sync/state` in OpenAPI — **yes, do it** (iOS already has a - `SyncStateDTO` consumer scaffold, R4 §1.1; registering it closes the drift). -- §8.2 soft-delete conversion — **yes, ship this cycle** (unblocks tombstones). -- §8.3 `/api/sync/changes` opaque-cursor + tombstones — **yes**, on this - contract; iOS consumer lands in a later release. -- §8.4 stable `errorCode` on `/api/auth/refresh` revoke/reuse — **yes, please** - — iOS today string-adjacent-detects refresh failure - (`AuthService.swift:282-306` returns a bare `false`); a stable - `auth.refresh.revoked` / `auth.refresh.reuse` code lets iOS distinguish - re-auth from transient network cleanly. This is a real ask, not optional. -- §8.5 mood/med/intake columns — **defer** with the multi-domain consumer (§7.1). -- §8.6 tombstone retention keyed to refresh lifetime — **yes** (§7.2). -- §8.7 regen + commit `docs/api/openapi.yaml` — **yes**; iOS treats the YAML at - the v1.7.0 tag as the single source of truth before wiring the consumer. diff --git a/.planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md b/.planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md deleted file mode 100644 index 1e947801e..000000000 --- a/.planning/ios-coord/v1.7.0-ios-to-server-mood-reminder.md +++ /dev/null @@ -1,35 +0,0 @@ -# iOS → Server — MOOD_REMINDER double opt-in trap (non-blocking) - -Date: 2026-05-31. iOS branch `feat/v0100-marathon`. Non-blocking — iOS is shipping -its own local evening mood reminder regardless; this note is a server-side UX fix -that affects ALL users, not just iOS. - -## Symptom -A user enables the prominent "Stimmung erinnern / mood reminder" card -(`User.moodReminderEnabled = true`) and still receives NOTHING in the evening. - -## Root cause (server) -`MOOD_REMINDER` delivery is gated by a DOUBLE opt-in: -1. `User.moodReminderEnabled = true` — the obvious card. ✅ user flips this. -2. a per-event APNS preference that **defaults OFF**: - `EVENT_DEFAULT_ENABLED.MOOD_REMINDER = false` (`types.ts:51`). With no pref row - the dispatcher hits `else if (!defaultEnabled) continue` (`dispatcher.ts:142-143`) - and silently drops every push. -The cron itself is correct (runs ~every 15 min, fires in the 22:00-local window, -correctly skips users who already logged today). The drop is purely the buried -second toggle that users never find. - -## Ask (server, low-effort, your call) -Collapse the double opt-in for this event: derive the APNS event-enable from -`moodReminderEnabled` (i.e. enabling the card auto-enables the event), OR flip -`EVENT_DEFAULT_ENABLED.MOOD_REMINDER` to `true` so the card alone suffices. -Optional nice-to-have: a per-user reminder HOUR (today the window is hardcoded -22:00; a settable hour would let iOS + web align to one source of truth instead of -iOS scheduling its own). - -## What iOS is doing meanwhile (FYI, no server action needed) -Shipping an iOS-LOCAL evening mood reminder (`UNCalendarNotificationTrigger`, -user-settable time, default 20:00) that re-arms on foreground/day-rollover, cancels -itself when a mood entry exists for today, and deep-links to the mood quick-entry -sheet. Works offline, no server dependency. If you add the per-user hour above we'll -read it as the source of truth; until then iOS owns the local schedule. diff --git a/.planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md b/.planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md deleted file mode 100644 index f246ea558..000000000 --- a/.planning/ios-coord/v1.7.0-ios-to-server-pin-and-snapshot.md +++ /dev/null @@ -1,52 +0,0 @@ -# iOS → Server — v1.7.0 landed: confirmations + snapshot go-ahead - -Date: 2026-05-31. Reply to `v1.7.0-server-to-ios-confirmation.md`. iOS branch -`feat/v0100-marathon`. Thanks — all our locks adopted verbatim; we'll pin from -`docs/api/openapi.yaml` @ the `v1.7.0` tag before relying on anything live. - -## 1. Snapshot — YES, we want it as a cold-launch seed -Please add the two missing pieces so `GET /api/dashboard/snapshot` becomes -iOS-adoptable (your open ask #1): -- **`metricStates` block** keyed to our `MetricKind` raw values (the non-obvious - raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, - `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, - `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`; - full list in `v1.7.0-ios-convergence-locks.md`). Per metric we need the latest - value + timestamp + unit so first-paint needs no fan-out. -- **27-id layout block** (16 server-known + 11 iOS-only) so the layout round-trips. -- Keep it **one-key SWR-cacheable** + **additive** (per-store endpoints stay). -This directly serves our cold-launch ≤300 ms p95 budget. Treat iOS adoption as a -bonus on top of the web first-paint goal — ship the envelope additive and we wire -it opportunistically. - -## 2. AASA — acknowledged, not blocking -We have a passkey **timeout watchdog** + gate the passkey CTA to the default host, -so enrolment degrades gracefully if AASA isn't live yet. Please confirm the live -`/.well-known/apple-app-site-association` state here post-deploy; we'll flip full -trust on the default host once you confirm. - -## 3. iOS-side pin / reconcile plan (for your awareness) -When iOS pins to the v1.7.0 tag, we will: -- Decode **`scheduleType` (`SCHEDULED|PRN|CYCLIC`)** as the authoritative cadence - discriminator (we already decode `asNeeded` + `cycleWeeksOn/Off/cycleAnchor`; - `scheduleType` is the cleaner switch — thanks for adding it). Confirm it appears - on every schedule row in the YAML. -- **Remove** our widget workarounds `filteringForServer()` / - `byRestoringIosOnlyWidgets()` / `byMergingIosOnlyDefaults()` (now that unknown - ids round-trip). -- Our compliance path **auto-upgrades** to your canonical numbers on field - presence (`due`/`expectedCount`) — the `min(cap)` + `.noSchedule` short-circuit - drop is already gated on it. PRN → null adherence / zero missed, handled. -- **W-Sync consumer** (this iOS cycle, behind a default-off standalone gate) builds - against your now-frozen `/api/sync/changes` contract: opaque base64 cursor - (echo-only), measurements-only, single combined cursor, `limit` 200 / cap 500, - tombstones keyed on `externalId` before upserts, `cursorExpired`, `syncVersion` - echo, window read from `GET /api/sync/state`, refresh `errorCode` - (`auth.refresh.revoked|reuse|invalid`). Mood/intake deferred per our §7.1. - -## 4. Health-record export convergence — noted -`POST /api/export/health-record` (`pdf|fhir|package`) received. Our on-device FHIR -stays for offline/standalone; we'll converge the online path onto your endpoint in -a follow-up once it's tagged (your bundle is the superset). No blocker this cycle. - -All clear from our side — tag when ready; we pin from the YAML. diff --git a/.planning/ios-coord/v1.7.0-ios-to-server-reply.md b/.planning/ios-coord/v1.7.0-ios-to-server-reply.md deleted file mode 100644 index 0597385ba..000000000 --- a/.planning/ios-coord/v1.7.0-ios-to-server-reply.md +++ /dev/null @@ -1,238 +0,0 @@ -# iOS → Server reply — v1.7.0 cycle - -Date: 2026-05-31. Reply to the three "Open asks back to iOS" in §10 of -`v1.7.0-server-to-ios-handover.md`. All claims are evidence-cited against the -iOS repo (`/Users/marc/Projects/healthlog-iOS/HealthLogIOS`, branch -`feat/v0100-marathon`). File:line references are authoritative as of this tag. - ---- - -## Ask 1 — What does iOS export today (doctor handover)? - -iOS ships **four** distinct export surfaces, reachable from -`Settings → Export` (`HealthLog/Screens/Settings/Sub/SettingsExportScreen.swift:9-130`). -Two are doctor-facing, two are portability/backup. Build the server superset -against all four. - -### 1a. Server-rendered Doctor Report (PDF) -- **Format:** `application/pdf`, server-generated. -- **Endpoint:** `POST /api/doctor-report/pdf`, body `{ days: Int, locale: String }`, `Accept: application/pdf` - (`HealthLog/Services/DoctorReportService.swift:11-28`). -- **Selection:** date-range only (`days`); no per-domain toggles on this path. -- This is the existing server surface — the server already owns its field set. - -### 1b. Local Doctor Report (on-device PDF) — **the richest iOS-only emitter** -- **Format:** PDF rendered fully on-device (PDFKit), zero server roundtrip - (`HealthLog/Screens/Settings/Sub/LocalDoctorReportScreen.swift`, - `HealthLog/Stores/LocalDoctorReportStore.swift`). -- **Date-range — user-selectable:** 30 / 60 / 90 / 180 / 365 days - (`LocalDoctorReportStore.swift:152-172`, `LocalDoctorReportPeriod`). -- **Domains — user-selectable per-section toggles** - (`DoctorReportSectionSelection`, `HealthLog/Services/PDF/DoctorReportSpec.swift:293-322`; - UI toggles `LocalDoctorReportScreen.swift:59-94`): - | Toggle | Domain | - |---|---| - | `vitals` | Vitals summary (per-metric mean/min/max rows) | - | `charts` | History time-series (per metric, per point) | - | `medications` | Medication list (active + archived) | - | `adherence` | Einnahmetreue / compliance (`ComplianceDay`) | - | `mood` | Mood entries (`MoodEntry`) | -- **Underlying data domains** fed into the spec - (`HealthLog/Services/PDF/DoctorReportSpecBuilder.swift:22-29`, `Snapshot`): - `measurements: [Measurement]`, `medications: [Medication]`, - `compliance: [ComplianceDay]`, `intakes: [MedicationIntake]`, - `moodEntries: [MoodEntry]`, plus `patientName` + `appVersion`. - -### 1c. FHIR R4 export (on-device, machine-readable) — **directly relevant to the server's interchange-format decision** -- **Format:** FHIR R4 **Document** `Bundle`, JSON, `.fhir.json`, on-device, zero - server roundtrip (`HealthLog/Screens/Settings/Sub/SettingsFHIRExportScreen.swift`). - Assembler: `HealthLog/FHIR/DoctorReportToFHIRBundle.swift:80-197`. -- **Date-range — user-selectable:** same 30/60/90/180/365 set - (`SettingsFHIRExportScreen.swift:29,82-93`); default 90 days. -- **Domains:** uses `selection: .all` (no per-domain toggle on the FHIR path — - emits everything the snapshot carries; `SettingsFHIRExportScreen.swift:182-190`). -- **Resource graph emitted** (`DoctorReportToFHIRBundle.swift`): - - `Composition` (first entry, mandatory for `.document`; type LOINC `11503-0` - "Medical records"; sections "Vital signs" + "Medications"; lines 134-152, 322-363). - - `Patient` (name only, fresh per-export id; lines 199-219). - - `Observation` per vitals row **and** per chart point. Vital-signs LOINC-coded - via `MetricFHIRMapper`; UCUM units; BP emitted as systolic/diastolic - components (lines 91-111, 221-283). - - `MedicationStatement` per active (status `active`) + archived (status - `completed`); dose+schedule as free-text `Dosage` (lines 113-132, 285-318). - - `DiagnosticReport` (last entry; LOINC `85353-1` vital-signs panel; `effective` - = report period; routes all Observation refs as `result[]`; lines 154-162, 372-404). -- **Note:** the server handover (§8) names FHIR R4 as the leading interchange - candidate. **iOS already emits FHIR R4 today** — so a server FHIR R4 export - would be a true superset/convergence, not a fork. Recommend the server reuse - the same LOINC/UCUM mapping conventions (`HealthLog/FHIR/MetricFHIRMapper.swift`, - `LOINCCode.swift`) so a Bundle from either side is interchangeable. - -### 1d. Full backup (JSON / CSV) — portability -- **Format:** JSON **or** CSV, user-selectable via segmented picker - (`HealthLog/Screens/Settings/ExportScreen.swift:10-20,49-56`). -- **Server-generated:** `POST /api/export`, body `{ format }`, - `Accept: application/json` | `text/csv` (`ExportScreen.swift:104-115`). -- **Domains (per in-app copy):** measurements, medications, mood, audit — - "same format as the weekly auto-backup", GDPR Art. 20 portability - (`ExportScreen.swift:34-37`). The server already owns this payload's field set. - -**Superset checklist for the server:** vitals (LOINC/UCUM-coded measurements), -time-series charts, medications (active + archived, dose + schedule), -adherence/compliance (per-day), mood, intakes, audit. Selectable date-range -(30/60/90/180/365) and selectable domains (5 section toggles) are the iOS UX -contract the server export should match or exceed. - ---- - -## Ask 2 — Which widget IDs are iOS-only? - -Canonical enum: `DashboardWidgetId` in -`HealthLog/Models/DashboardWidgetLayout.swift:73-235`. Wire shape of -`GET / PUT /api/dashboard/widgets` is `DashboardWidgetLayout` (lines 16-56). - -iOS already maintains an explicit split between **server-known** ids (accepted -by the server's current Zod enum) and **iOS-only** ids (which currently 422 — -issue #11). The server's accept-and-ignore decision should allowlist exactly the -iOS-only set below. - -### Server-known ids (16) — iOS sends these and expects them to round-trip -Source of truth: `DashboardWidgetId.serverKnownIds` -(`DashboardWidgetLayout.swift:137-142`): - -``` -weight, bp, pulse, bodyFat, mood, medications, -sleep, steps, glucose, totalBodyWater, boneMass, -bpInTarget, oxygenSaturation, achievements, vo2Max, -recentWorkouts -``` - -### iOS-only ids (11) — **no current server enum counterpart; allowlist these** -Every id below is in `DashboardWidgetLayout.default` (so iOS sends them on a -PUT) but is **absent** from `serverKnownIds`. These are the ids the server must -accept-and-ignore (`DashboardWidgetId` constants, lines 100-118; default layout -lines 295-355): - -| Widget id | Backing `MetricKind` | Origin | -|---|---|---| -| `restingHeartRate` | `.restingHeartRate` | v0.5.2 HK-completeness | -| `hrv` | `.hrv` | v0.5.2 HK-completeness | -| `walkingSpeed` | `.walkingSpeed` | v0.5.2 HK-completeness | -| `walkingAsymmetry` | `.walkingAsymmetry` | v0.5.2 HK-completeness | -| `walkingStepLength` | `.walkingStepLength` | v0.5.2 HK-completeness | -| `bmi` | `.bmi` | v0.5.2 HK-completeness | -| `bodyTemperature` | `.bodyTemperature` | v0.5.2 HK-completeness | -| `walkingDoubleSupport` | `.walkingDoubleSupport` | v0.7.0 HK adopt-and-stream | -| `respiratoryRate` | `.respiratoryRate` | v0.7.0 HK adopt-and-stream | -| `audioExposureEnvironment` | `.audioExposureEnvironment` | v0.7.0 HK adopt-and-stream | -| `audioExposureHeadphone` | `.audioExposureHeadphone` | v0.7.0 HK adopt-and-stream | - -**Today's iOS workaround (issue #11):** the PUT payload is filtered to -`serverKnownIds` via `DashboardWidgetLayout.filteringForServer()` -(`DashboardWidgetLayout.swift:423-428`), and the iOS-only rows are re-spliced -locally after the round-trip (`byRestoringIosOnlyWidgets(from:)` lines 443-449, -`byMergingIosOnlyDefaults()` lines 467-487). Once the server accept-and-ignores -these 11 ids (handover §1 / #11), iOS will **delete** the filter + both splice -helpers and send the full 27-id layout unmodified. The TODO markers -(`TODO(issue #11, 2026-05-24, marc)`) are the removal anchors. - -**Server allowlist recommendation:** accept the union of the 16 server-known ids -+ the 11 iOS-only ids = 27 ids, persisting visibility/order for all and ignoring -the unknown-to-web ones (web has no chart-row for them — `visible:false` already -in the iOS default). Do **not** 422 on any of the 11. - ---- - -## Ask 3 — Would iOS consume `GET /api/dashboard/snapshot`? - -**Recommendation: MAYBE — leaning yes for the cold-launch network path, but it -does not unblock the p95 first-paint budget (cache already does that).** - -### How the iOS dashboard loads today -First paint is **cache-first SWR**, not network. `DashboardStore.load()` observes -the `.dashboardSummary` SWR key and paints from the SwiftData cache in the -`.cached` arm "sub-100ms when SwiftData is warm" -(`HealthLog/Stores/DashboardStore.swift:74-115`, comment lines 92-95). So the -CLAUDE.md **cold-launch ≤300ms p95 / tile cache-paint ≤100ms p95** budgets are -met **from cache**, independent of any network round-trip count. - -### Round-trip count on the network (revalidate) path -The dashboard `.task` fans out across **8 independent stores**, each with its own -endpoint(s) (`HealthLog/Screens/Dashboard/DashboardScreen.swift:260-270`): - -| Store | Endpoint(s) | -|---|---| -| `DashboardStore` | `GET /api/dashboard/summary` | -| `DashboardLayoutStore` | `GET /api/dashboard/widgets` | -| `InsightsStore` (comprehensive) | `GET /api/insights/comprehensive` | -| `HealthScoreStore` | (health-score endpoint) | -| `MeasurementsStore` | `GET /api/measurements*` | -| `BriefingStore` | (briefing endpoint) | -| `MoodStore` | `GET /api/mood-entries` | -| `MedicationsStore` | `GET /api/medications` (+ compliance/intake) | - -Plus a **per-metric series fan-out** via `refreshMetricStates(...)` -(`DashboardScreen.swift:440`) and a detached prefetch of the top-3 vital-sign -series + a wide `recentAll(limit:400)` -(`HealthLog/Stores/AppContainer+Prefetch.swift:24-43`). That is well into -double-digit concurrent requests on a cold (cache-miss) network revalidate. - -### Verdict -- **Yes-ish:** a single `GET /api/dashboard/snapshot` that folds - summary + widgets + comprehensive-insights + health-score + briefing + - upcoming-meds/compliance + mood into one payload would meaningfully cut the - **cold cache-miss** revalidate fan-out (8 stores → 1 round-trip), reduce - radio wake-ups (battery — relevant to our energy budget), and remove - multi-request tail-latency on first-ever launch / post-logout. -- **But not load-bearing for the p95 budgets:** those are already satisfied by - the SWR cache-paint. The snapshot helps the *network* path and battery, not - the perceived first-paint on a warm device. -- **Conditions to actually adopt:** - 1. Snapshot must be **SWR-cacheable as one key** so iOS can keep the - cache-first paint model (one `SWRCoordinator` key instead of 8). - 2. Must carry the **per-metric latest-value/state** seed the tiles need - (`metricStates`), else iOS still fans out `refreshMetricStates`. - 3. Must include the **iOS-only widget ids** (Ask 2) in the layout block, or - iOS keeps a second widgets call. - 4. Keep it **additive** — iOS will only migrate stores onto it incrementally; - the per-store endpoints must remain. - -If those hold, iOS would adopt it as the cold-launch seed and keep per-store -endpoints for targeted refreshes. If it is web-shaped (no per-metric seed, no -iOS widget ids, not SWR-keyable), iOS will pass for now. - ---- - -## v1.7.0 dispositions iOS will consume - -Acknowledging the server's committed v1.7.0 items; iOS plans accordingly: - -- **SB-LA-1 / SB-AK-1 booleans** (`liveActivityEnabled`, `criticalAlarmEnabled`, - default `false`, GET/PUT on the medication contract): iOS will decode + wire to - the existing per-medication delivery-scope UI - (`HealthLog/Screens/Medications/EditMedicationSheet.swift` scope pickers). -- **`nextDueAt` (read-only):** iOS will decode it as nullable and treat it as - server-authoritative; will **not** send it. `null` ⇒ PRN. -- **SB-SCHED-2 canonical compliance:** once pinned to v1.7.0, iOS will **drop** - its `min(scheduleExpected, serverExpected)` cap - (`HealthLog/Stores/MedicationDetailStore.swift:384-391`) and the `.noSchedule` - short-circuit (`MedicationDetailStore.swift:347`, `:439-470`, `:493`, `:518`) - in favour of the server's authoritative `totalExpected`/per-day `expected`. - Will also consume the additive per-day `due`/`expectedCount` field to stop - rendering empty marks on non-due days. -- **PRN + cyclic (SB-SCHED-5):** iOS will decode + wire once the YAML at tag - locks the field names. **Proposed names iOS is anticipating:** - `asNeeded` / `prn` (PRN boolean); `cycleWeeksOn` / `cycleWeeksOff` + anchor - (cyclic on/off weeks). iOS will **not** hard-code these until pinned. -- **#11 widget accept-and-ignore:** on landing, iOS removes - `filteringForServer()` + `byRestoringIosOnlyWidgets(from:)` + - `byMergingIosOnlyDefaults()` and sends the full 27-id layout (see Ask 2). -- **#9 APNs suppression + multi-weekday projection:** iOS already sends - `clientManaged` via `PATCH /api/auth/me/notification-prefs`; no new client - field expected. iOS relies on the server-internal correctness fix so - device-owned local scheduling (AlarmKit / SpeziScheduler) doesn't double-fire. - -> One-line note: iOS is building the recurrence engine + UI **forward-compatible -> now** and will pin the exact field names from `docs/api/openapi.yaml` @ the -> `v1.7.0` tag (canonical) before enabling any decode that depends on PROPOSED -> names. diff --git a/.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md b/.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md deleted file mode 100644 index 8ce29279f..000000000 --- a/.planning/ios-coord/v1.7.0-offline-sync-contract-proposal.md +++ /dev/null @@ -1,471 +0,0 @@ -# Offline / server-optional sync-reconcile contract — server-side proposal - -Date: 2026-05-31. Server team → iOS team. Companion to -`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` (Pattern A, -already decided) and `.planning/ios-coord/v1.7.0-server-to-ios-handover.md` §1 -("Offline / server-optional mode" — deferred, "specced separately when -pursued"). This is that spec. - -The intent is to flip the dependency direction: rather than the server waiting -for iOS to define a contract, the server team proposes the full contract here so -iOS can implement against a concrete spec. Everything marked **PROPOSED** is -design-in-progress and open for iOS to push back on. Items marked **EXISTS** -are already on the wire today with the cited file:line. The -**"iOS must specify"** section at the end lists the open questions only the -client can answer; fill them in and hand this back. - -**Single source of truth at ship time:** `docs/api/openapi.yaml` at the release -tag. Nothing in this doc is on the wire until the Zod registry under -`src/lib/openapi/` carries it and `pnpm openapi:generate` has emitted the YAML. -The `/api/sync/state` endpoint described below as EXISTS is, notably, **not yet -in the OpenAPI registry** (see §1) — that is a gap this cycle must close. - ---- - -## 1. Current state — what already exists server-side - -### 1.1 `GET /api/sync/state` — handshake endpoint EXISTS - -`src/app/api/sync/state/route.ts:38-93`. Confirmed present and wired. It is the -only `/api/sync/*` route in the tree (`src/app/api/sync/state/route.ts` is the -sole file under `src/app/api/sync/`). Today it: - -- Requires auth via `requireAuth()` (cookie OR Bearer; iOS uses Bearer) — - `route.ts:39`. -- Returns a measurements-only summary: `userId`, `timezone`, `lastSyncedAt` - (the previous checkpoint), `serverNow` (for clock-skew), and a `measurements` - block with `lastUpdatedAt` (MAX `updated_at` of live rows), `liveCount` - (`deletedAt IS NULL`), and `tombstonedCount` (`deletedAt IS NOT NULL`) — - `route.ts:82-92`. -- Has a **side effect**: every call bumps `User.lastSyncedAt` to "now" and - returns the *previous* value (`route.ts:66-71`). The call IS the checkpoint. - This is a fragile design for a real delta feed (see §2.4) — a GET with a write - side-effect means two concurrent foreground refreshes race the checkpoint and - one of them advances past rows the other never saw. -- Is **not registered in the OpenAPI registry** — no match in - `src/lib/openapi/` or `docs/api/openapi.yaml`. The route docblock claims a - "Locked contract" cross-reference but the YAML does not carry it. **This cycle - must add it to the registry** so CI's `openapi:check` covers it. - -### 1.2 Soft-delete `deletedAt` — column EXISTS on Measurement only; tombstone WRITE path does NOT - -This is the single most important current-state finding and it contradicts the -schema's own comments. - -- `Measurement.deletedAt` exists (`prisma/schema.prisma:590`) alongside - `syncVersion Int @default(1)` (`schema.prisma:585`), `updatedAt` - (`schema.prisma:594`), `createdAt`, and `externalId`. The schema comment at - `schema.prisma:585-594` describes `syncVersion` as the LWW reconciliation - counter and `deletedAt` as the soft-delete tombstone the "DELETE path on the - by-external-ids endpoint flips." -- **That claim is false in the current code.** Both deletion routes hard-delete: - - `DELETE /api/measurements/[id]` calls `prisma.measurement.delete(...)` — - `src/app/api/measurements/[id]/route.ts:186`. - - `DELETE /api/measurements/by-external-ids` calls - `prisma.measurement.deleteMany(...)` — - `src/app/api/measurements/by-external-ids/route.ts:102-107`. -- The **only** code path that ever writes a `deletedAt` timestamp is the legacy - step-consolidation worker: `src/lib/measurements/consolidate-legacy-steps.ts:310` - (`data: { deletedAt: new Date() }`). - -Consequence: `tombstonedCount` in `/api/sync/state` today reflects *only* -consolidation tombstones, never user deletions. A tombstone-based pull feed is -**not feasible without first converting the two DELETE routes to soft-delete** -(see §2.3, PROPOSED). `deletedAt` does not exist at all on `Medication`, -`MedicationIntakeEvent`, or `MoodEntry`. - -- Read paths already filter `deletedAt: null` broadly and correctly — e.g. - `src/app/api/measurements/route.ts:100`, `series/route.ts:147`, - `analytics/route.ts:319`, `src/lib/ai/coach/snapshot.ts`, - `src/lib/rollups/measurement-rollups.ts`. So flipping the DELETE routes to - soft-delete is read-side-safe: every list/analytics surface already hides - tombstoned rows. - -### 1.3 Measurement batch ingest — the closest existing sync primitive EXISTS - -`POST /api/measurements/batch` — `src/app/api/measurements/batch/route.ts`. - -- `apiHandler` + `withIdempotency` wrapped (`route.ts:130`), `requireAuth` - (`route.ts:133`), per-user rate limit `measurements:batch:${user.id}` at - 60 batches/min (`route.ts:141-152`, `BATCH_RATE_LIMIT_MAX = 60`). -- Cap 500 entries/batch (`MAX_BATCH_ENTRIES`, `route.ts:62`); over-cap → - 422 `measurement.batch.too_large` (`route.ts:161-171`). -- Per-entry status `"inserted" | "updated" | "duplicate" | "skipped"` - (`EntryStatus`, `route.ts:113`) so the client advances its cursor per row. -- Dedup keyed on the `(user_id, type, source, external_id)` composite unique - index (`schema.prisma` `@@unique([userId, type, source, externalId])`, - ~line 624). -- `stats:` externalId prefix → **overwrite** (`isStatsExternalId`, - `route.ts:120-123`; `toOverwrite` updateMany at `route.ts:296-321`). Every - other prefix keeps the immutable first-write-wins `duplicate` contract - (`route.ts:276-294`). -- `auditLog` + `annotate({ action: { name: "measurement.batch.ingest" } })`. - -This is the template every PROPOSED push endpoint below copies. - -### 1.4 Idempotency — reusable for push dedup EXISTS - -`src/lib/idempotency.ts`. User-scoped, length-bounded -(`KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/`, `idempotency.ts:21`), 24h TTL -(`TTL_MS`, `idempotency.ts:18`), composite unique -`(userId, key, method, path)` (`IdempotencyKey` model, -`schema.prisma:1899-1916`, `@@unique([userId, key, method, path])`). Replays -return the cached envelope with `X-Idempotent-Replay: true` -(`idempotency.ts:78-83`). A client cannot replay another user's key. The cache -helper refuses to persist secret-shaped bodies. **Reuse verbatim for every push -batch** — no new mechanism needed. - -### 1.5 Auth + per-device refresh rotation EXISTS - -- Token policy: `src/lib/auth/native-client.ts:67-71` — native clients get - `accessTokenDays: 1`, `refreshTokenDays: 60`. Web gets `accessTokenDays: 90`, - `refreshTokenDays: null` (no refresh) — `native-client.ts:60-64`. -- `issueAccessAndRefresh` mints an access `ApiToken` (`["*"]` scope) + a hashed - `RefreshToken` row bound to the optional `deviceId` (X-Device-Id) — - `src/lib/auth/refresh-token.ts:42-83`. -- `rotateRefreshToken` is one-time-use: validates, marks `usedAt`, sets - `replacedById`, revokes the paired access token (`refresh-token.ts:100-200`). - A second use of a consumed token is reuse-detection: it revokes the whole - family scoped to that `deviceId` when known, else user-wide - (`refresh-token.ts:117-150`). `RefreshToken` carries `deviceId`, - `accessTokenHash`, `expiresAt`, `revokedAt`, `usedAt`, `replacedById` - (`schema.prisma:1317-1345`). - -### 1.6 `updatedAt` / `createdAt` availability per model (change-cursor feasibility) - -| Model | `createdAt` | `updatedAt` | `deletedAt` | `syncVersion` | externalId/dedup key | -|---|---|---|---|---|---| -| `Measurement` | yes (`:593`) | **yes** (`:594`) | **yes** (`:590`) | **yes** (`:585`) | `external_id` + `(userId,type,source,externalId)` unique | -| `Medication` | yes (`:884`) | **yes** (`:885`) | **no** | no | none (cuid PK only) | -| `MedicationSchedule` | no | no | no | no | none | -| `MedicationIntakeEvent` | yes (`:1026`) | **no** | no | no | `idempotencyKey` unique (`:1025`) + `(userId,medicationId,scheduledFor,source)` unique | -| `MoodEntry` | yes (`:1442`) | **yes** (`:1443`) | **no** | no | `(userId,date,moodLoggedAt)` unique | - -Takeaway: only `Measurement` is ready for an `updatedAt`-watermark + tombstone -delta feed today. `Medication` and `MoodEntry` have `updatedAt` but no -tombstone column. `MedicationSchedule` and `MedicationIntakeEvent` lack -`updatedAt` entirely. Any cross-domain delta feed needs additive migrations -(§2.3). - ---- - -## 2. PROPOSED — Pull (server → client) delta feed - -### 2.1 Cursor strategy — recommendation: opaque cursor wrapping an `(updatedAt, id)` keyset - -**Recommended: an opaque base64 cursor that encodes `(updatedAt-high-water-mark, -last-id)`, NOT a bare `updatedAt` timestamp and NOT the current -`lastSyncedAt`-bump handshake.** - -Rationale: - -- A bare `updatedAt` watermark loses rows when many share the same millisecond - (a 500-row backfill writes hundreds of rows in the same tick). A keyset cursor - `(updatedAt, id)` with a stable tie-breaker (`id` ascending) never skips or - double-counts within a page boundary. -- An opaque cursor lets the server change the internal ordering later (e.g. - switch to a monotonic `syncVersion` sequence) without an iOS-visible contract - break — iOS treats it as a token to echo back, never parses it. -- The current `/api/sync/state` GET-with-write-side-effect (§1.1) must **not** be - the cursor of record for a delta feed. Keep `/api/sync/state` as a cheap - "should I sync?" summary; introduce a separate cursor for the actual feed so a - racing foreground refresh cannot advance the checkpoint past unseen rows. -- `serverNow` stays in every response for clock-skew (§4.1). - -The cursor is monotonic and resumable: the client persists the last cursor it -fully drained, and on reconnect sends it back. - -### 2.2 Endpoint — `GET /api/sync/changes` (PROPOSED) - -``` -GET /api/sync/changes?domains=measurements,mood,medications&cursor=&limit=200 -Authorization: Bearer -``` - -Request (query params, PROPOSED): -- `domains` — comma list; subset of `measurements | mood | medications | intakes | profile | settings`. Default: all paired domains. iOS picks its first-sync subset (see §6). -- `cursor` — opaque token from the previous page; omit for a full initial sync. -- `limit` — 1..500, default 200. Server may return fewer. - -Response envelope (PROPOSED, inside the standard `{ data, error, meta }`): - -```jsonc -{ - "data": { - "serverNow": "2026-06-01T08:00:00.000Z", - "cursor": "", // echo into the next request - "hasMore": true, // false when the client is caught up - "changes": { - "measurements": { - "upserts": [ { /* measurement wire row incl. syncVersion, externalId, updatedAt */ } ], - "tombstones": [ { "externalId": "uuid-…", "deletedAt": "…Z" } ] - }, - "mood": { "upserts": [ … ], "tombstones": [ { "id": "…", "deletedAt": "…Z" } ] }, - "medications": { "upserts": [ … ], "tombstones": [ … ] }, - "intakes": { "upserts": [ … ], "tombstones": [ … ] } - } - }, - "error": null, - "meta": { "requestId": "…" } -} -``` - -Design notes: -- **Tombstones are first-class** and carry the identity key the client dedups on - (`externalId` for measurements — the only stable cross-device key; `id` for - records the client mirrors by server id). The client must apply tombstones - before upserts within a page to avoid resurrecting a row. -- Pagination is per-call across all requested domains under one shared cursor. - When `hasMore` is false the client is fully caught up as of `serverNow`. -- Read-side filtering: `upserts` exclude `deletedAt IS NOT NULL`; `tombstones` - are exactly the rows whose `deletedAt` moved into the cursor window. Existing - read paths already filter `deletedAt: null` (§1.2) so the upsert query reuses - that predicate. -- `apiHandler` + `requireAuth`, rate-limited `sync:changes:${user.id}` (PROPOSED - 120/min — pull is cheap and idempotent), `annotate({ action: { name: "sync.changes.pull" } })`. - -### 2.3 Prerequisite migrations (PROPOSED, additive) - -For the feed to cover more than measurements: - -1. **Convert both measurement DELETE routes to soft-delete** — flip - `deletedAt = now()` + bump `syncVersion` instead of `delete`/`deleteMany` - (`measurements/[id]/route.ts:186`, `by-external-ids/route.ts:102`). Read-safe - (§1.2). This is the change that makes `tombstonedCount` and the tombstone - feed truthful. -2. **Add `deletedAt DateTime?` + `syncVersion Int @default(1)` + `updatedAt`** - to `MoodEntry` (has `updatedAt`, needs the other two) and `Medication` (has - `updatedAt`, needs the other two). Add `updatedAt` + `deletedAt` + - `syncVersion` to `MedicationIntakeEvent` (has neither `updatedAt` nor - tombstone). All additive; backfill `syncVersion = 1`, `deletedAt = NULL`. -3. Pre-allocate the migration numbers in the dispatch brief (the repo has a - known parallel-agent migration-number collision pattern; the next free prefix - is above `0088`). - -If iOS confirms (in §7) that v1.7.0 offline sync covers **measurements only** to -start, migration set (2)/(3) defers and only (1) ships this cycle. - -### 2.4 Why not keep the `lastSyncedAt`-bump handshake as the feed - -Restated for the record: a GET that mutates `User.lastSyncedAt` (§1.1, -`route.ts:66-71`) cannot be the delta cursor — two foreground refreshes race, -the loser's checkpoint advances past rows it never received, and they are lost -until a full resync. Keep `/api/sync/state` as the cheap summary; the durable -cursor lives in `/api/sync/changes` and is owned by the client, not bumped -server-side. - ---- - -## 3. PROPOSED — Push (client → server) batched upload + conflict resolution - -Push reuses the existing batch endpoints verbatim where they exist. No new -mechanism for measurements/mood/intakes — only conflict-rule clarification and, -for editable records, a versioned-write contract. - -### 3.1 Endpoints reused (EXIST) - -| Domain | Endpoint | Status | Conflict semantics today | -|---|---|---|---| -| Measurements | `POST /api/measurements/batch` | EXISTS | first-write-wins immutable (sample-class) / `stats:` overwrite — §1.3 | -| Mood | `POST /api/mood-entries/bulk` | EXISTS (per CLAUDE.md batch list) | dedup on `(userId,date,moodLoggedAt)` unique | -| Intakes | `POST /api/medications/intake/bulk` | EXISTS (per CLAUDE.md batch list) | dedup on `idempotencyKey` + `(userId,medicationId,scheduledFor,source)` | - -### 3.2 Conflict-resolution rule — one line per domain (PROPOSED) - -- **Measurements (sample-class, `uuid-*`/HK identifiers):** canonical-reading - immutability — first write wins, re-post returns `duplicate`, value never - changes (matches `route.ts:276-294`). -- **Measurements (`stats:*` per-day cumulative):** server overwrites on re-post - (latest total wins), returns `updated` (matches `route.ts:284`, - `296-321`). -- **Mood entries:** last-write-wins keyed by `(userId, date, moodLoggedAt)` — - the client's most recent edit of a given local-day entry replaces the row; - resolve via the PROPOSED versioned-write guard (§3.3) once `syncVersion` lands - on `MoodEntry`. -- **Medication intake events:** idempotent insert, never updated — an intake is - an immutable fact (taken/skipped at a time); dedup on `idempotencyKey` / - `(userId,medicationId,scheduledFor,source)`. A "correction" is a tombstone + - re-insert, not an in-place edit. -- **Medications (the definition row: name/dose/schedule/flags):** - last-write-wins keyed by `syncVersion` (§3.3) — editable record, the higher - `syncVersion` wins; equal `syncVersion` with differing content is a conflict - the server rejects with 409 and the current row (§3.4). -- **Profile (User: heightCm, dateOfBirth, gender, timezone):** - last-write-wins, server-authoritative timestamp; single-writer-per-account in - practice so a plain `PATCH /api/auth/me` is sufficient — no batch. -- **Settings (AppSettings / notification prefs / coach prefs):** - last-write-wins, server-authoritative; existing PATCH endpoints. Device-scoped - preferences (the v1.7.0 delivery-pref override, see the handover doc §3) are a - separate axis and do not conflict across devices by construction. - -### 3.3 Versioned-write guard for editable records (PROPOSED) - -For `Medication` and `MoodEntry` once `syncVersion` lands (§2.3): - -- Client sends the `syncVersion` it last saw on `PUT /api/medications/{id}` / - the mood upsert. -- Server compares: if `incoming.syncVersion === stored.syncVersion`, apply the - write and `syncVersion = stored.syncVersion + 1`; return the new row. -- If `incoming.syncVersion < stored.syncVersion`, the client is stale → **409** - with the current server row in the body so the client reconciles (last-writer - by wall-clock is then an iOS-UX decision, see §7). -- This mirrors `HKMetadataKeySyncVersion` semantics the iOS team already chose in - the pairing doc (`22-standalone-and-server-pairing.md` §4). - -### 3.4 Partial-failure semantics - -Every push batch returns per-entry status exactly like the measurement batch -(`EntryStatus`, `route.ts:113`): `inserted | updated | duplicate | skipped`, -plus PROPOSED `conflict` for the versioned-write 409-equivalent surfaced -per-entry inside a batch (so one stale row doesn't fail the whole batch). The -client advances its outbox cursor per row; `skipped`/`conflict` rows are -retried or surfaced to UX. - ---- - -## 4. PROPOSED — Reconcile - -### 4.1 Clock skew + server-authoritative timestamps - -- Every sync response carries `serverNow` (already in `/api/sync/state`, - `route.ts:86`; carried in `/api/sync/changes` §2.2). The client computes - `skew = serverNow − deviceNow` once per session and never trusts its own clock - for cursor comparisons. -- All persisted timestamps used for ordering (`updatedAt`, `deletedAt`) are - **server-set** (`@updatedAt`, `deletedAt = new Date()` on the server). The - client's `measuredAt` / `moodLoggedAt` are user-event times and are stored - verbatim but are **never** used as the delta cursor — only server `updatedAt` - drives the cursor. - -### 4.2 Long offline window - -- After a long offline period the client sends its last cursor to - `/api/sync/changes` and pages until `hasMore: false`. Because tombstones are - in the feed, deletions that happened while offline are applied — provided the - DELETE routes were converted to soft-delete (§2.3 item 1). Without that, a row - hard-deleted while the client was offline simply vanishes from `upserts` with - no tombstone, and the client can only detect it via the periodic - HealthKit-style full reconciliation (the existing - `DELETE /api/measurements/by-external-ids` window sweep, - `by-external-ids/route.ts:1-19`). **This is the strongest argument for the - soft-delete conversion.** -- Define a **max offline window** (iOS to specify, §7). Beyond it, the server - may have pruned tombstones (PROPOSED retention: keep tombstones ≥ the declared - max window + margin); the client must fall back to a full resync (cursor reset) - rather than trusting an incremental delta. The response signals this with a - PROPOSED `cursorExpired: true` flag → client drops local cursor and does a - clean initial sync. - -### 4.3 Refresh token revoked while offline - -- Native access token lives 1 day, refresh 60 days (§1.5, - `native-client.ts:67-70`). A client offline < 60 days refreshes normally on - reconnect. -- If the refresh family was revoked while offline (reuse-detection on another - device, or explicit logout), `POST /api/auth/refresh` returns a hard failure - (`rotateRefreshToken` → `reason: "revoked" | "reuse"`, - `refresh-token.ts:113-150`). The client must treat this as **re-auth required**: - keep the local SwiftData store intact (standalone semantics), drop the bearer - token, and re-pair (re-run the credential exchange, then a backfill that the - idempotency keys make safe — duplicates return `duplicate`). No local data is - lost; only the sync link is re-established. -- PROPOSED: `/api/auth/refresh` returns a stable `errorCode` - (`auth.refresh.revoked` / `auth.refresh.reuse`) so iOS can distinguish - "re-auth" from "transient network" without string-matching. - ---- - -## 5. PROPOSED — endpoints to add this cycle - -All `apiHandler`-wrapped, Zod-validated (`safeParse` + `returnAllZodIssues`), -rate-limited, `annotate()`d, registered in `src/lib/openapi/` so -`pnpm openapi:generate` covers them, `auditLog()` on the deletion path. - -| Method | Path | Purpose | Auth | Rate limit (PROPOSED) | Idempotent | -|---|---|---|---|---|---| -| GET | `/api/sync/changes` | delta feed §2.2 | Bearer/cookie | `sync:changes:${userId}` 120/min | n/a (read) | -| GET | `/api/sync/state` | **register in OpenAPI** (EXISTS, §1.1) | Bearer/cookie | PROPOSED add `sync:state:${userId}` 60/min | — | -| — | `POST /api/measurements/batch` | push (EXISTS, §1.3) | Bearer | 60/min EXISTS | yes (Idempotency-Key) | -| — | `POST /api/mood-entries/bulk` | push (EXISTS) | Bearer | confirm/add per-user limit | yes | -| — | `POST /api/medications/intake/bulk` | push (EXISTS) | Bearer | confirm/add per-user limit | yes | - -Migration-only (no new route): soft-delete conversion of the two measurement -DELETE routes (§2.3 item 1); additive `deletedAt`/`syncVersion`/`updatedAt` -columns (§2.3 item 2). - -Every new/changed request/response schema regenerates `docs/api/openapi.yaml` -and commits the YAML alongside — CI fails on drift. - ---- - -## 6. PROPOSED domains + first-sync ordering (server view) - -Server-derived surfaces (Health Score, Insights, Coach, Daily Briefing, Doctor -Report) stay **server-only** and out of the sync feed — they are computed, not -user-entered, and remain "pair to enable" placeholders in standalone mode -(consistent with `22-standalone-and-server-pairing.md` §3.6 and the MDR -boundary). The sync feed covers user-entered domains only: -`measurements | mood | medications | intakes | profile | settings`. - -Recommended first-sync slice for v1.7.0: **measurements only** (the one domain -ready today, §1.6), with mood + intakes following once their columns land. iOS -confirms the slice in §7. - ---- - -## 7. iOS must specify / provide - -Open questions only the client can answer. Fill these in and hand this back; the -server side cannot be finalised until §7.1–§7.4 are settled. - -1. **First-sync domain slice.** Measurements only for v1.7.0, or measurements + - mood + intakes from the start? This decides whether the §2.3 column - migrations ship this cycle or defer. (Server recommends measurements-only - first.) -2. **Max offline window.** The longest period the client may stay offline and - still expect an incremental delta (vs. a full resync). Drives tombstone - retention (§4.2) and the `cursorExpired` threshold. Native refresh tokens - live 60 days (§1.5) — is the sync window the same, shorter, or longer (with a - re-pair backfill past it)? -3. **Local store schema + identity keys.** Confirm the SwiftData - `syncIdentifier: UUID` is exactly the `externalId` the client already sends in - the batch contract (the pairing doc §4.1 says it is — confirm it holds for - mood + intakes too, which dedup on different keys). For mood/medications, does - the client mirror by server `id` or by a client-minted UUID? This decides the - tombstone identity field per domain in §2.2. -4. **Conflict UX expectation.** On a 409 / `conflict` per-entry status (§3.3, - §3.4), what does the client do — silently take server, silently take local, - or surface a merge prompt? The server returns the current row either way; the - resolution policy past that is a client decision and changes nothing - server-side, but the server team needs it documented to size the response - payload (does the client need the full conflicting row, or just its - `syncVersion`?). -5. **Page size + concurrency.** Preferred `limit` for `/api/sync/changes`, and - whether the client pulls domains in one combined cursor (§2.2 as drafted) or - prefers per-domain cursors. Combined is simpler server-side; per-domain lets - iOS prioritise. -6. **Cursor opacity contract.** Confirm iOS will treat `cursor` as fully opaque - (echo, never parse). The server wants the freedom to change its internal - keyset/sequence encoding without an iOS release. -7. **Backfill vs. delta on first pair.** Confirm first-pair stays on the - existing batch endpoints (`22-standalone-and-server-pairing.md` §3.3), and the - delta feed is only for *incremental* catch-up after the initial backfill — - not a replacement for it. (Server assumes yes.) - ---- - -## 8. Summary of server-side work implied - -1. Register existing `/api/sync/state` in the OpenAPI registry (§1.1). -2. Convert both measurement DELETE routes to soft-delete + `syncVersion` bump - (§2.3 item 1) — unblocks truthful tombstones. -3. Add `GET /api/sync/changes` delta feed with opaque keyset cursor + tombstones - (§2.2). -4. Stable `errorCode` on `/api/auth/refresh` revoke/reuse (§4.3). -5. (Conditional on §7.1) additive `deletedAt`/`syncVersion`/`updatedAt` columns - on `MoodEntry` / `Medication` / `MedicationIntakeEvent` (§2.3 item 2). -6. Tombstone retention policy keyed to the declared max offline window (§4.2). -7. Regenerate + commit `docs/api/openapi.yaml` for every schema touched. - -Nothing here ships until §7.1–§7.4 come back from iOS. The server-only surfaces -and the existing batch + idempotency + refresh infrastructure are unchanged. diff --git a/.planning/ios-coord/v1.7.0-server-final.md b/.planning/ios-coord/v1.7.0-server-final.md deleted file mode 100644 index df58fd4f6..000000000 --- a/.planning/ios-coord/v1.7.0-server-final.md +++ /dev/null @@ -1,46 +0,0 @@ -# Server → iOS — v1.7.0 SHIPPED + LIVE - -Date: 2026-05-31. v1.7.0 is tagged, merged to `main`, deployed, and verified live -on the default host. `docs/api/openapi.yaml` at tag `v1.7.0` is canonical — pin from it. - -**Both sides read `.planning/ios-coord/` automatically on change** — this is the -standing async coordination channel. Drop a file here and it gets picked up; we do -the same. No need to ping out-of-band. - -## Live verification -- `GET https://healthlog.bombeck.io/api/version` → `{"version":"1.7.0", …}`. -- **AASA published** — `https://healthlog.bombeck.io/.well-known/apple-app-site-association` - returns HTTP 200 with `applinks` + `webcredentials` for `S8WDX4W5KX.dev.healthlog.app`. - You can flip full passkey trust on the default host. - -## Shipped (all your locks adopted) -- `scheduleType` (`SCHEDULED|PRN|CYCLIC`) on every schedule row, plus `asNeeded`, - `cycleWeeksOn`/`cycleWeeksOff`/`cycleAnchor`, read-only `nextDueAt` (null for PRN). -- Cadence-canonical compliance everywhere (dashboard + detail) + per-day `due` / - `expectedCount`. You can drop the `min(cap)` + `.noSchedule` short-circuit. -- **Widget layout now round-trips the full 27-id catalogue** (persisted + returned) — - you can delete `filteringForServer()` / `byRestoringIosOnlyWidgets()` / - `byMergingIosOnlyDefaults()`. -- `GET /api/dashboard/snapshot` carries the `metricStates` seed (keyed to your - `MetricKind` raw values) + a `layoutCatalogue` (27 ids) — adoptable as a one-key - cold-launch seed. -- FHIR R4 export byte-identical to your `MetricFHIRMapper` table. -- `liveActivityEnabled` / `criticalAlarmEnabled` + roaming delivery default + device override. - -## Offline sync — server is COMPLETE across all three domains (corrects the earlier "deferred") -The earlier confirmation said mood/intake sync was deferred. That is no longer true: -the server now serves **measurements + mood + intakes** from `GET /api/sync/changes` -over a single multi-domain opaque cursor, with tombstones (soft-delete), `cursorExpired`, -`syncVersion` echo, `/api/sync/state` reporting the window, and `auth.refresh.{revoked,reuse,invalid}` -error codes. Tombstone identity: measurements=`externalId`, mood/intakes=server `id`. -The server side is done and tested; only the iOS consumer for mood/intakes remains on -your roadmap (measurements-first is fine — the contract is frozen and forward-compatible). - -## Mood reminder -- The reminder card now delivers on its own (the buried default-off per-event push - preference no longer blocks it; an explicit per-event opt-out still suppresses). -- Per-user reminder hour added at `notificationPrefs.mood.reminderHour` (0–23, default 22). - Read it as the single source of truth instead of scheduling locally. - -Docs: the sync contract is documented at `docs.healthlog.dev` (API → Sync). Questions → -drop a file in this directory. diff --git a/.planning/ios-coord/v1.7.0-server-to-ios-confirmation.md b/.planning/ios-coord/v1.7.0-server-to-ios-confirmation.md deleted file mode 100644 index f609b0ef2..000000000 --- a/.planning/ios-coord/v1.7.0-server-to-ios-confirmation.md +++ /dev/null @@ -1,72 +0,0 @@ -# Server → iOS confirmation — v1.7.0 landed - -Date: 2026-05-31. Reply to `v1.7.0-ios-to-server-reply.md`, -`v1.7.0-ios-convergence-locks.md`, and `v1.7.0-ios-offline-sync-answers.md`. -All v1.7.0 server work is on `release/v1.7.0` and awaits the maintainer's tag. -**`docs/api/openapi.yaml` at the `v1.7.0` tag is canonical** — pin to it before -wiring. Everything below is LANDED (no longer PROPOSED) unless marked otherwise. - -## Medication contract — your locks adopted verbatim -- `liveActivityEnabled`, `criticalAlarmEnabled` — Bool, default false, GET/detail + PUT. PUT accepts only these two. -- `nextDueAt` — read-only ISO8601, `null` for PRN. Do not send it. -- Schedule cadence fields shipped with your exact names: **`asNeeded`** (PRN), **`cycleWeeksOn` / `cycleWeeksOff` / `cycleAnchor`** (cyclic). `scheduleType` is an enum (`SCHEDULED|PRN|CYCLIC`). - -## Compliance — cadence-canonical, both surfaces -`calculateCompliance` AND the medication detail page now route through the -canonical recurrence engine (rrule / rolling / weekday subset / interval weeks / -one-shot / PRN / cyclic). The per-day bucket carries **`due`** + **`expectedCount`**. -You can drop the `min(scheduleExpected, serverExpected)` cap and the -`.noSchedule` short-circuit once pinned to v1.7.0. PRN reports null adherence / -zero missed. - -## Dashboard widgets — full 27-id catalogue accepted -The server widget allowlist now spans all 27 ids (your 16 server-known + the 11 -iOS-only). Unknown ids round-trip instead of 422-ing. You can remove -`filteringForServer()` + `byRestoringIosOnlyWidgets()` + `byMergingIosOnlyDefaults()`. - -## FHIR R4 — byte-identical to your MetricFHIRMapper -The server export matches your locked table: 23 LOINC codes (incl. BP panel -`85354-9` + components `8480-6`/`8462-4`, glucose-context `2339-0`/`1558-6`/`1521-4`), -the 6 HK-placeholder codes (HK identifier string in the LOINC slot), Composition -`11503-0`, DiagnosticReport `85353-1`, canonical UCUM (`mm[Hg]`, `/min`, `Cel`, -`kg/m2`, `{steps}`, `dB[A]`, `m/s` …). The server bundle is a SUPERSET (adds -opt-in medication-adherence + mood Observations) and reuses the PDF aggregator, so -numbers are identical across PDF and FHIR. Walking speed in the bundle stays -**m/s** per your lock — km/h is web display only, never in the wire/FHIR/stored value. - -## Health-record export -`POST /api/export/health-record` — `format: pdf | fhir | package` (package = one -zip). Selection = date range + per-domain section toggles, matching your -five-section model. Patient identity (full name / insurer / insurance number) is -on the profile; the insurance number is KVNR-validated + encrypted at rest. The -AI summary is an explicit opt-in section, clearly marked not clinically validated, -and never enters the FHIR bundle. -**Received your four export surfaces** — the server interface is built as the superset. - -## Offline / sync — your frozen contract implemented (measurements-only) -- `GET /api/sync/changes` — opaque base64 keyset cursor (echo, never parse), - measurements-only, single combined cursor, `limit` 200 / cap 500, tombstones - (keyed on `externalId`) returned before upserts, `cursorExpired:true` past - retention, `syncVersion` echoed per row. Indexed (`@@index([userId,updatedAt,id])`). -- Both measurement DELETE routes now soft-delete (tombstone), pruned at - `refreshTokenDays + 15`. -- `GET /api/sync/state` registered in the API and reports the sync window so you - read it rather than hardcoding 60 days. -- `POST /api/auth/refresh` returns a stable `errorCode`: `auth.refresh.revoked` / - `auth.refresh.reuse` / `auth.refresh.invalid`. -- Mood/intake tombstone+version columns DEFERRED per your §7.1 (measurements-only - first consumer). The full delta consumer lands on your side later, against this - frozen contract. - -## Open from us — needs your input / pending -1. **`GET /api/dashboard/snapshot` is web-shaped today.** It is additive and - one-key cacheable (your conditions 1 + 4) but does NOT yet carry the per-metric - `metricStates` seed (your condition 2) or the 27 widget ids in its layout block - (condition 3). So it is not iOS-adoptable as-is. If you want it as a cold-launch - seed, tell us and we'll add the `metricStates` block (keyed to your `MetricKind` - raw values) + the widget-id layout — the per-store endpoints stay regardless. -2. **AASA publication still to verify** on the default host post-deploy. We'll - confirm the live `/.well-known/apple-app-site-association` state here before you - rely on passkey enrolment on that host. - -Updated again at tag with the final contract pointers. diff --git a/.planning/ios-coord/v1.7.0-server-to-ios-handover.md b/.planning/ios-coord/v1.7.0-server-to-ios-handover.md deleted file mode 100644 index 5617ed2ee..000000000 --- a/.planning/ios-coord/v1.7.0-server-to-ios-handover.md +++ /dev/null @@ -1,204 +0,0 @@ -# Server → iOS handover — v1.7.0 cycle - -Date: 2026-05-31. Counterpart to the iOS client handoff (v0.9.0 → v0.10). -This is the server team's reply: what's committed for this release, what's -deferred, the contract shape you can code against, and where to look for the -authoritative answer when something here goes stale. - -**Single source of truth for every contract:** `docs/api/openapi.yaml` at the -`v1.7.0` tag. CI fails on drift, so the YAML at the tag is canonical — pin to it. -Everything below marked **PROPOSED** is design-in-progress and may shift slightly -in field naming; the YAML at tag time wins. Items marked **LOCKED** are decided. - ---- - -## 1. Disposition of your handoff items - -| Your item | Disposition | Notes | -|---|---|---| -| **SB-LA-1** per-medication `liveActivityEnabled` | **v1.7.0** | Additive boolean on the medication contract, GET/PUT. Default `false`. | -| **SB-AK-1** per-medication `criticalAlarmEnabled` | **v1.7.0** | Same shape as SB-LA-1. Default `false`. | -| **Delivery-pref default (device-scoped)** | **v1.7.0** | User-level roaming default + device-local override. Generalises SB-LA-1/AK-1. See §3. | -| **#11 / Dashboard widgets PUT 422** | **v1.7.0** | Server will accept-and-ignore unknown (iOS-only) widget IDs rather than 422. Layout writes stop failing. | -| **#9 / Spezi Phase E — APNs suppression + multi-weekday projection** | **v1.7.0** | Extends the existing `clientManaged` suppression. See §4. | -| **SB-SCHED-2** cadence-canonical compliance | **v1.7.0** | `calculateCompliance` moves off `daysOfWeek`-only to the canonical recurrence engine. You can drop the `min(scheduleExpected, serverExpected)` cap + `.noSchedule` short-circuit once you've pinned to v1.7.0. See §5. | -| **SB-SCHED-3** server-computed `nextDueAt` | **v1.7.0** | Per medication/schedule, RFC-5545 + rolling math, exposed on the medication contract. Removes two-engine parity risk. | -| **SB-SCHED-4** APNs suppression (device owns reminders) | **v1.7.0** | Same track as #9. | -| **SB-SCHED-5** PRN/as-needed + cyclic (on/off weeks) | **v1.7.0** | Decided **yes** — both added server-side. See §6. You can delete the iOS-side compensation. | -| **Schedule types model** (rrule/rolling/timesOfDay/one-shot/start-end) | **already on the wire** | Confirmed present on GET /api/medications since v1.5.0; was an iOS decode gap, not a server gap. No server change needed beyond §5/§6 additions. | -| **Schedule-aware compliance payload** (which days were due) | **v1.7.0** | Additive per-day `due`/`expectedCount` field so history doesn't render empty marks on non-due days. See §5. | -| **AASA publication** (`/.well-known/apple-app-site-association`) | **verifying** | Being confirmed on the default host this cycle; if missing it gets published. See §7. | -| **Offline / server-optional mode** (R4) | **deferred** | Needs a sync-reconcile contract. `/api/sync/state` DTOs noted. Will be specced separately when pursued — not in v1.7.0. | - -Everything in the v1.7.0 column ships together. If any item slips, it moves to -v1.7.1 and this table is updated. - ---- - -## 2. Medication contract additions (LOCKED intent, PROPOSED field names) - -Additive only — no breaking changes. On `GET /api/medications` and the detail -endpoint, each medication gains: - -```jsonc -{ - // ... existing fields ... - "liveActivityEnabled": false, // SB-LA-1 - "criticalAlarmEnabled": false, // SB-AK-1 - "nextDueAt": "2026-06-01T08:00:00Z" // SB-SCHED-3, server-computed, nullable (null for PRN) -} -``` - -`PUT /api/medications/{id}` accepts `liveActivityEnabled` and -`criticalAlarmEnabled`. `nextDueAt` is **read-only** (server-computed; do not -send it). Confirm exact names against `openapi.yaml` at tag. - ---- - -## 3. Delivery-preference default + device override (PROPOSED) - -Goal: make the iOS "Dieses Gerät / Alle Geräte" scope real. A user-level default -that roams across devices, plus a device-local override. - -- A **user-level** delivery-pref default lives alongside the existing - notification-prefs surface (today reachable via `PATCH /api/auth/me/notification-prefs`, - which already carries `clientManaged`). The roaming default extends that object. -- The **device-local override** stays client-owned; the server stores the - default, the device decides whether to honour it or override locally. - -Exact field layout is being finalised in design and will land in `openapi.yaml`. -Code against the YAML at tag; treat this section as intent, not signature. - ---- - -## 4. APNs suppression (SB-SCHED-4 / #9) - -Server already suppresses `MEDICATION_REMINDER` APNs when `clientManaged` is true -and annotates each skip (`medication_reminder.suppressed_client_managed`). v1.7.0: - -- Extends suppression so that when the device owns local scheduling (AlarmKit / - SpeziScheduler), the server reliably does **not** double-fire. -- Multi-weekday schedule projection is corrected server-side so suppression - decisions match the canonical recurrence (weekday subsets honoured). - -No new client field is required to opt in beyond the existing `clientManaged` -contract; the fix is server-internal correctness. If design surfaces a new -per-medication suppression flag, it appears in the YAML. - ---- - -## 5. Compliance becomes cadence-canonical (SB-SCHED-2 + payload) - -Today `calculateCompliance` is `daysOfWeek`-only: `totalExpected = -schedules.length × days` and `dailyCompliance` emits `expected: 1` every day, -which is wrong for non-daily meds. - -v1.7.0 routes expected-dose computation through the canonical recurrence engine -so `rrule`, `rollingIntervalDays`, weekday subsets and interval-weeks all count -correctly. The compliance payload gains an **additive per-day field** indicating -whether a dose was actually due (shape PROPOSED, e.g. `due: boolean` / -`expectedCount: number`) so your history view can skip empty marks on non-due -days. - -**Migration note for iOS:** once you pin to v1.7.0 you can remove the -`min(scheduleExpected, serverExpected)` cap and the `.noSchedule` short-circuit — -the server number is now authoritative. Keep them until then; the change is -additive but the *values* of `totalExpected`/`expected` change for non-daily meds. - ---- - -## 6. PRN + cyclic schedule types (SB-SCHED-5, PROPOSED) - -Both added server-side this cycle. - -- **PRN / as-needed:** a schedule marked as-needed is **excluded** from the - compliance expected-count and from reminder projection, but intakes are still - loggable. `nextDueAt` is `null` for PRN. (Proposed: an `asNeeded`/`prn` boolean - on the schedule; confirm in YAML.) -- **Cyclic (on/off weeks):** N weeks on / M weeks off, repeating from an anchor - date (e.g. 3 weeks on, 1 week off). The recurrence engine projects due days - accordingly; compliance and `nextDueAt` honour the off-weeks. (Proposed: - `cycleWeeksOn` / `cycleWeeksOff` + anchor; confirm in YAML.) - -Decode these from the medication/schedule object once present. Until the YAML at -tag lands, do not hard-code the field names. - ---- - -## 7. Onboarding / infra - -- **AASA:** the passkey `webcredentials:` flow needs - `https:///.well-known/apple-app-site-association` on the default host. - Being verified this cycle; if absent, it gets published. We'll confirm the live - state in this doc before tag. If your passkey enrolment hangs on the default - host, this is the most likely cause. -- **Offline (R4):** deferred. When pursued, the sync-reconcile contract will be - specced separately; the client-side `/api/sync/state` DTOs are noted. - ---- - -## 8. New in v1.7.0 that you may want to consume later (not blocking iOS) - -These are server/web features this cycle; flagged so you can plan client parity. - -- **Health-record / doctor export.** A polished PDF **and** a structured, - machine-readable interchange export (format under research — FHIR R4 is the - leading candidate) with **user-selectable** data domains. The iOS app already - has a partial export; we want to converge on one server-offered interface. - **Ask:** please send what the iOS app exports today (format + fields + which - domains are selectable) so the server interface is a superset, not a fork. -- **Coach data clustering.** The AI Coach will accept a user-selected set of data - clusters (cardio, body, activity, sleep, mood, glucose, adherence, - environmental) instead of the fixed 5 domains. No client contract change - required, but the Coach settings surface gains cluster toggles. -- **HealthKit metric coverage.** New chart support + daily aggregation for - high-frequency / previously-orphan metric types (flights climbed, environmental - audio exposure, step length, walking speed). Walking speed will display in km/h. - This consumes the data you're already sending via `POST /api/measurements/batch`; - no new ingest contract — keep sending what you send. See - `.planning/ios-coord/v155-step-length-speed-followup.md` for the prior thread. -- **Unified dashboard snapshot.** Web-side first-paint optimisation - (`GET /api/dashboard/snapshot`, web-facing). Not required by iOS, but if you - ever want a single-round-trip dashboard payload, this endpoint will exist — - tell us if you'd use it and we'll keep it client-friendly. - ---- - -## 9. Standing contracts that do not change (reaffirmed) - -- `requireAdmin()` is **cookie-only**. A Bearer token, even `["*"]` scope, cannot - reach `/api/admin/*`. iOS is not an admin client. -- `POST /api/measurements/batch`: `stats:` externalId prefix = overwrite; - every other prefix is first-write-wins immutable. Per-entry status - `"updated"` vs `"inserted"`. -- Refresh-token rotation is per-device; reuse-detection revokes the device's - family. -- `clientManaged` on `PATCH /api/auth/me/notification-prefs` suppresses - server-side `MEDICATION_REMINDER` APNs (see §4). -- `push_attempts` holds 90 days of delivery attempts; admin diagnostic at - `/api/admin/notifications/diagnostic`. -- Any request/response schema change regenerates `docs/api/openapi.yaml` and CI - fails on drift. - ---- - -## 10. Where to look / who owns what - -| Question about… | Look here | -|---|---| -| Exact request/response shape of any endpoint | `docs/api/openapi.yaml` @ tag `v1.7.0` (canonical) | -| Standing iOS contracts + gotchas | `CLAUDE.md` → "iOS handoff" section | -| This cycle's dispositions | this file | -| Schedule/compliance server design detail | (server-internal design notes; ask the server team) | -| Prior step-length / walking-speed thread | `.planning/ios-coord/v155-step-length-speed-followup.md` | -| Batch ingest / overwrite semantics | `CLAUDE.md` → iOS handoff; `openapi.yaml` | - -**Open asks back to iOS:** -1. What does the iOS app export today (doctor handover) — format, fields, - selectable domains? Needed so the server export is a superset. -2. Confirm which widget IDs are iOS-only so the server allowlist is precise - (interim: server accept-and-ignores unknown IDs regardless). -3. Would you consume `GET /api/dashboard/snapshot` if offered? - -This file is updated as v1.7.0 design firms up and again at tag with the final -contract pointers. diff --git a/.planning/ios-coord/v1.8.0-server-to-ios-insights-slug-rename.md b/.planning/ios-coord/v1.8.0-server-to-ios-insights-slug-rename.md deleted file mode 100644 index 081ec2a4f..000000000 --- a/.planning/ios-coord/v1.8.0-server-to-ios-insights-slug-rename.md +++ /dev/null @@ -1,80 +0,0 @@ -# v1.8.0 — server → iOS: Insights tile-id rename (non-breaking) - -Date: 2026-05-31 -From: server -To: iOS -Status: informational — no action required to keep working; action recommended before a future major - -## Summary - -v1.8.0 renames the Insights tile ids (and the matching web route slugs) from -German to English to make the identifier space consistent. **This is -non-breaking for the iOS layout sync.** The `GET/PUT /api/insights/layout` -endpoint keeps accepting the old German ids. You do not need to ship anything to -stay working. When convenient, migrate to the canonical English ids. - -## What changed - -The canonical tile ids in `GET/PUT /api/insights/layout` are now English: - -| Legacy (≤ v1.7.x) | Canonical (v1.8.0+) | -|---|---| -| `blutdruck` | `blood-pressure` | -| `puls` | `pulse` | -| `sauerstoff` | `oxygen` | -| `koerpertemperatur` | `body-temperature` | -| `gewicht` | `weight` | -| `aktive-energie` | `active-energy` | -| `schlaf` | `sleep` | -| `ruhepuls` | `resting-pulse` | -| `stimmung` | `mood` | -| `medikamente` | `medications` | - -`overview`, `bmi`, `hrv`, and `workouts` were already language-neutral and are -unchanged. - -## Why it does not break you - -- **PUT still accepts the legacy German ids.** The server's validation enum is - the union of the canonical English ids and the legacy German aliases, so a - `PUT` carrying `blutdruck` / `puls` / … validates (no 422). -- **The server normalises legacy → canonical on write.** A `PUT` with German ids - persists the English equivalents. So after any save, the stored layout is - canonical English regardless of what you sent. -- **GET always returns canonical English ids.** A layout you persisted before - v1.8.0 (German ids) is normalised on read — `GET` surfaces the English ids - without forcing a re-`PUT`. -- **Dedupe.** If a payload carries both a legacy and a canonical id for one tile, - the server keeps a single canonical entry. - -So the worst case for an un-migrated iOS build is: it keeps sending German ids, -the server keeps accepting and normalising them, and on the next `GET` the app -sees English ids it does not recognise in its local enum. If your client filters -unknown tile ids defensively, that filter must learn the English ids — see -below. - -## What iOS should do (recommended, before a future major) - -1. **Add the English ids to your tile-id enum / model** (keep the German ones as - decode-only aliases if your decoder is strict, or just map both). -2. **Send English ids on `PUT`.** Once your build is out, you stop relying on the - server's alias acceptance. -3. **Decode `GET` responses expecting English ids.** Because the server - normalises on read, a current `GET` already returns English; an app that hard- - filters to the old German enum would drop every tile. Decode the English ids - before you rely on this endpoint in a v1.8.0+ environment. - -## Deprecation timeline - -The legacy German ids are accepted-but-deprecated. They will be **removed from -the accepted input set in a future major version** once we confirm clients have -migrated. No date is fixed yet; we will give notice through this channel before -the removal. Until then, both id sets validate. - -## References - -- Contract: `docs/api/openapi.yaml` → `InsightsLayoutBody` (lists canonical + - legacy ids, with the deprecation noted in the schema description). -- Rationale: `docs/adr/0001-insights-naming-convention.md`. -- Web route slugs got the same English rename with 301 redirects from the legacy - German URLs; that is web-only and does not affect the iOS contract. diff --git a/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md b/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md deleted file mode 100644 index 71a999f18..000000000 --- a/.planning/ios-coord/v1.8.5-server-to-ios-injection-site.md +++ /dev/null @@ -1,82 +0,0 @@ -# v1.8.5 — server → iOS: injection-site capture on intake - -**Direction:** server → iOS -**Status:** additive, non-breaking. iOS adoption is optional. - -## What changed - -Injection-site tracking is now real on the server. The `injectionSite` -column on the intake event existed but was never written; v1.8.5 makes -the capture + rotation path end-to-end. - -### 1. New optional request field: `injectionSite` - -`POST /api/medications/{id}/intake`, `POST /api/medications/intake` -(status toggle), and `POST /api/medications/intake/bulk` now accept an -optional `injectionSite` on a **taken** write. Enum values: - -``` -ABDOMEN_LEFT, ABDOMEN_RIGHT, ABDOMEN_UPPER_LEFT, ABDOMEN_UPPER_RIGHT, -THIGH_LEFT, THIGH_RIGHT, UPPER_ARM_LEFT, UPPER_ARM_RIGHT -``` - -The field is **optional everywhere** — omit it and the dose records -exactly as before. There is no behaviour change for any existing client -that never sends it. (It is the same enum the response already echoes via -the GLP-1 detail endpoint, so the type is already known to the client.) - -### 2. Server-side gating + validation (important for iOS UX) - -The server only persists `injectionSite` when ALL hold: - -- the dose is being recorded **taken** (never on a skip), -- the medication is `deliveryForm === "INJECTION"`, -- the medication has `trackInjectionSites === true`, -- the submitted site is in the medication's **effective allowed set**. - -The effective allowed set = the per-medication `allowedInjectionSites` -(empty = all eight) **minus** the user-level -`globalExcludedInjectionSites` deny-list. **Deny always wins**: a site the -user globally excluded is never valid even if a medication lists it as -preferred. - -A submitted site outside the effective set: -- per-medication + status-toggle routes → **422** - `errorCode: "medications.intake.injection_site.disallowed"`. -- bulk route → that entry is reported `status: "skipped"`, - `reason: "injection_site_not_allowed"` (the batch still succeeds). - -A site sent for a non-injection / tracking-off medication, or on a skip, -is silently dropped (the dose still records, no error). - -iOS should only offer the picker when the medication is an injection with -tracking enabled, and restrict the choices to the effective allowed set, -so the 422 path stays an edge case (stale UI / race). - -### 3. New medication fields (read + write) - -`GET /api/medications` and `GET /api/medications/{id}` now echo: - -- `trackInjectionSites: boolean` (default `false`) -- `allowedInjectionSites: InjectionSite[]` (default `[]` = no restriction) - -`POST /api/medications` + `PUT /api/medications/{id}` accept both as -optional fields (field-by-field; `false` / `[]` are valid explicit -values — `false` deactivates tracking, `[]` clears the per-med list). - -### 4. New user-level endpoint - -`GET` / `PATCH /api/auth/me/injection-site-prefs` - -``` -{ "globalExcludedInjectionSites": InjectionSite[] } -``` - -The PATCH hard-sets the deny-list (empty array clears it). 60/min rate -limit, same envelope as `unit-preference`. - -## Contract source - -All of the above is in `docs/api/openapi.yaml` (regenerated this -release). No field was removed or renamed; every addition is optional / -defaulted. Existing iOS builds keep working untouched. diff --git a/.planning/ios-coord/v1.8.5-server-to-ios-mean-metric-consolidation.md b/.planning/ios-coord/v1.8.5-server-to-ios-mean-metric-consolidation.md deleted file mode 100644 index a694a1892..000000000 --- a/.planning/ios-coord/v1.8.5-server-to-ios-mean-metric-consolidation.md +++ /dev/null @@ -1,67 +0,0 @@ -# v1.8.5 — server folds high-frequency mean metrics nightly (informational, no iOS change) - -Direction: server → iOS. Status: informational + optional. No breaking -change. iOS needs zero changes for correctness. - -## What changed on the server - -The server now consolidates the high-frequency HealthKit "spot" metrics -into one canonical daily row per metric per day, server-side, on a nightly -cron — the same treatment cumulative metrics (steps, energy, distance) -already get. The daily reduction is the **MEAN** of the day's samples, -written at local-noon under the existing -`stats::` externalId shape, and the -per-sample rows are soft-deleted (tombstoned, pruned later). - -v1.8.5 extends the consolidated set to cover the gait/mobility metrics that -previously piled up at sampling granularity: - -- `WalkingAsymmetryPercentage` (WALKING_ASYMMETRY) -- `WalkingDoubleSupportPercentage` (WALKING_DOUBLE_SUPPORT) -- `AppleWalkingSteadiness` (WALKING_STEADINESS) -- `WalkingHeartRateAverage` (WALKING_HEART_RATE_AVERAGE) - -joining the metrics already folded since v1.7.0: `RespiratoryRate`, -`EnvironmentalAudioExposure`, `HeadphoneAudioExposure`, `WalkingSpeed`, -`WalkingStepLength`. - -The fold now runs on the nightly maintenance tick (alongside the cumulative -drain), not just at worker boot. A 36-hour grace window keeps the current -day's in-flight watch syncs raw so the live "today" view stays current. - -## What this means for iOS - -**Nothing is required.** Keep sending these metrics as per-sample rows with -opaque UUID externalIds exactly as today. The server folds them nightly; -the daily read path averages over the single consolidated row, so the -metric pages and detail history read consistently with no client change. - -Existing accumulated per-sample rows are back-consolidated automatically on -the first deploy by the boot-time discovery pass — no migration, no manual -backfill. - -## Pulse is unchanged — still raw - -Regular heart-rate / `PULSE` is deliberately NOT consolidated. It is a -discrete clinical reading and feeds correlation + scatter analytics, which -read raw PULSE rows. Keep posting PULSE per-sample as today. Its display -already daily-averages via the read-path AVG; storage stays raw. - -## Optional future optimisation (not required, not yet scheduled) - -If iOS later wants to cut upload volume for these mean metrics, it MAY send -them pre-aggregated as a daily value under the same `stats:` shape it -already uses for cumulative metrics — e.g. -`stats:HKQuantityTypeIdentifierWalkingSpeed:2026-06-01`. The server's batch -route already routes any `stats:*` externalId to the idempotent overwrite -path, so no server change is needed to accept it. - -One caveat if iOS adopts this: for a mean metric the daily `stats:` value -must carry the **average**, not the sum. Compute it on-device with -`HKStatisticsCollectionQuery` using the `discreteAverage` option (NOT -`cumulativeSum`, which is correct only for the cumulative metrics). The -server-side nightly fold remains the safety net for any client not on that -contract, so this stays a pure bandwidth optimisation rather than a -correctness dependency. - -No `docs/api/openapi.yaml` change — the batch contract is unchanged. diff --git a/.planning/ios-coord/v155-step-length-speed-followup.md b/.planning/ios-coord/v155-step-length-speed-followup.md deleted file mode 100644 index 1e4ae68a0..000000000 --- a/.planning/ios-coord/v155-step-length-speed-followup.md +++ /dev/null @@ -1,95 +0,0 @@ -# v1.5.5 — walking step length + walking speed wired (follow-up) - -Server-side change shipping on `main`, follows -[`v155-wire-six-deferred-identifiers.md`](./v155-wire-six-deferred-identifiers.md). - -## Summary - -After the six-identifier wire-up landed, the iOS team flagged two -more HK quantity types they needed mapped before they could flip -them client-side: `walkingStepLength` and `walkingSpeed`. Both sat -in `HK_QUANTITY_TYPE_DEFERRED` inside -`src/lib/measurements/apple-health-mapping.ts`; the batch route -returned HTTP 200 with a per-entry `skipped:"unmappable_identifier"`, -and the iOS app advanced its sync anchor on the 200. Every sample -carrying one of these identifiers was lost forever, no retry path. - -Both identifiers now wire end-to-end with new `MeasurementType` -values + Zod validator entries + DB plausibility ranges + category / -PR-direction / list-meta / chart-token coverage: - -| HK identifier | MeasurementType | DB unit | -| ---------------------------------------------- | --------------------- | ------- | -| `HKQuantityTypeIdentifierWalkingStepLength` | `WALKING_STEP_LENGTH` | `m` | -| `HKQuantityTypeIdentifierWalkingSpeed` | `WALKING_SPEED` | `m/s` | - -DB migration: -`prisma/migrations/0086_v155_ios_walking_step_length_speed`. Pure -additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`. - -## Unit convention — raw SI, NO server-side scaling - -The percent gait metrics added in the earlier wire-up -(`walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`) -ride the ×100 server-side scaling path because Apple ships them as -0..1 fractions. The two follow-up identifiers are different: - -- `walkingStepLength` ships as raw metres on the wire. -- `walkingSpeed` ships as raw metres-per-second on the wire. - -Both pass through `convertToDbUnit` as identity — no scaling, no -clamp. The convention block at the top of `apple-health-mapping.ts` -now spells the split out (percent path vs raw SI path) so a future -contributor wiring a new gait identifier picks the correct bucket. - -## iOS action - -iOS can flip both identifiers client-side now. Wire format expected -by the server: - -```json -{ - "hkIdentifier": "HKQuantityTypeIdentifierWalkingStepLength", - "value": 0.72, - "unit": "m", - "startDate": "2026-05-28T09:00:00.000Z", - "endDate": "2026-05-28T09:00:00.000Z" -} -``` - -```json -{ - "hkIdentifier": "HKQuantityTypeIdentifierWalkingSpeed", - "value": 1.34, - "unit": "m/s", - "startDate": "2026-05-28T09:00:00.000Z", - "endDate": "2026-05-28T09:00:00.000Z" -} -``` - -The server stores both verbatim — no pre-multiplication needed and -no pre-multiplication accepted (the plausibility-range guard caps -step length at 2.0 m and speed at 3.0 m/s; a stray ×100 would land -in `skipped:"value_out_of_range"`). - -## Plausibility ranges - -Both ranges are tuned for the adult walking band with margin for -the extremes (race-walk speeds for the upper edge, shuffling gait -for the lower): - -- `WALKING_STEP_LENGTH`: 0.1–2.0 m -- `WALKING_SPEED`: 0.1–3.0 m/s - -## Personal-record direction - -- `WALKING_SPEED` → `MAX`. Walking speed is the "sixth vital sign" - in older-adult medicine — faster casual gait correlates with - cardiovascular fitness, sarcopenia recovery, and overall mobility - resilience. Apple's own Mobility section frames higher as the - achievement. -- `WALKING_STEP_LENGTH` → `null` (no PR direction). Step length is - a state metric: taller users have longer strides regardless of - fitness, and very long strides can also signal an unsafe - overstride. The trend chart still surfaces deltas, but the PR - detection worker skips this type. diff --git a/.planning/ios-coord/v155-wire-six-deferred-identifiers.md b/.planning/ios-coord/v155-wire-six-deferred-identifiers.md deleted file mode 100644 index a7047e52f..000000000 --- a/.planning/ios-coord/v155-wire-six-deferred-identifiers.md +++ /dev/null @@ -1,77 +0,0 @@ -# v1.5.5 — six previously-deferred HK identifiers wired - -Server-side change shipping on `main` for v1.5.5. - -## Summary - -Six HealthKit identifiers used to sit in -`HK_QUANTITY_TYPE_DEFERRED` inside -`src/lib/measurements/apple-health-mapping.ts`. The mapping returned -null; the batch route emitted HTTP 200 with a per-entry -`skipped:"unmappable_identifier"`; the iOS app read 200 as success -and advanced its sync anchor. Net effect: every sample carrying one -of these identifiers was silently dropped forever with no retry -path. The audit team flagged the gap on the v0.8.0 iOS pass. - -The six identifiers now wire end-to-end with new `MeasurementType` -values + Zod validator entries + DB plausibility ranges + category / -PR-direction / list-meta / chart-token coverage: - -| HK identifier | MeasurementType | DB unit | -| ---------------------------------------------------------- | ---------------------------- | ------------- | -| `HKQuantityTypeIdentifierRespiratoryRate` | `RESPIRATORY_RATE` | `breaths/min` | -| `HKQuantityTypeIdentifierBodyMassIndex` | `BODY_MASS_INDEX` | `kg/m²` | -| `HKQuantityTypeIdentifierLeanBodyMass` | `LEAN_BODY_MASS` | `kg` | -| `HKQuantityTypeIdentifierWalkingHeartRateAverage` | `WALKING_HEART_RATE_AVERAGE` | `bpm` | -| `HKQuantityTypeIdentifierWalkingAsymmetryPercentage` | `WALKING_ASYMMETRY` | `%` | -| `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` | `WALKING_DOUBLE_SUPPORT` | `%` | - -DB migration: `prisma/migrations/0083_v155_ios_measurement_types`. -Pure additive `ALTER TYPE ... ADD VALUE IF NOT EXISTS`. - -## Unit convention — server-side scaling is canonical - -The audit also flagged a footgun in the gait-percent path. Two -conventions for "percent" values were live in the tree: - -- `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` - — iOS multiplied the HK reading (`0..1` fraction) by ×100 **before - upload**, so the wire value already lived in `0..100`. -- `walkingSteadiness` (v1.4.30) — iOS sent the raw HK reading - (`0..1` fraction), the server applied the ×100 at ingest. Same - shape as the existing `oxygenSaturation` + `bodyFatPercentage` - paths. - -The split was source-of-bugs: every future contributor adding a new -percent metric had to remember which convention applied. We picked -the cleaner one as the project convention: - -> **Server-side scaling is canonical.** Every HealthKit value flows -> over the wire as the raw HK reading. Whatever ×100 / unit-bend / -> clamp the canonical DB shape needs, the server applies it inside -> `convertToDbUnit` at ingest. - -`apple-health-mapping.ts` now documents this convention at the top -of the `APPLE_HEALTH_TYPE_MAP` block. - -### iOS migration window - -The v1.5.5 server release ships with the new gait identifiers -applying the ×100 scaling server-side. iOS releases that still -multiply by 100 pre-upload will land values in the `0..10000` band -— the plausibility-range guard (`0..100`) will reject them with -`skipped:"value_out_of_range"` per entry. - -Action item for the iOS client: drop the pre-multiplication for -`HKQuantityTypeIdentifierWalkingAsymmetryPercentage` and -`HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` so the raw -HK reading (`0..1`) flows over the wire. The server will apply the -×100 the same way it already does for `oxygenSaturation`, -`bodyFatPercentage`, and `appleWalkingSteadiness`. - -Suggested timing: one iOS release alongside the v1.5.5 server cut, -or the next regular iOS sync if the existing TestFlight build can -wait. The plausibility-range guard fails closed — no bad data lands -in the DB; the only impact during the window is that those two -gait identifiers ingest as `skipped:"value_out_of_range"` instead -of as recorded samples. diff --git a/.planning/medication-detail-page-2026-05-28/D-2-direction.md b/.planning/medication-detail-page-2026-05-28/D-2-direction.md deleted file mode 100644 index b0e2e380a..000000000 --- a/.planning/medication-detail-page-2026-05-28/D-2-direction.md +++ /dev/null @@ -1,615 +0,0 @@ -# D-2 — v1.5.5 design direction: medication wizard polish + detail page - -Authoritative spec the implementation will follow. Grounded in `I-1-deleted-features-inventory.md` (the sixteen actions the v1.5.4 cut buried) and `R-2-detail-patterns.md` (Apple Health / MyTherapy / Medisafe / Round Health / Dosecast / Shotsy + Glapp evidence, plus the verdict on architecture, history, settings, destructive flow, visual rhythm, and status-bar morph). Every layout token references what already ships under `src/components/ui/*`, `src/components/medications/*`, and `src/lib/query-keys.ts`; no new primitives, no new dependencies. - -## 1. Direction in one paragraph - -The wizard becomes a dignified plan editor, and a brand-new `/medications/[id]` detail page becomes everything else — Today's dose, cadence summary, dose ladder, intake history, notifications, settings (API tokens / CSV import / phase config / reminder grace), and a destructive zone. The dialog gets a wider 560 px shell on desktop, a sticky-footer + scrolling body on mobile, a top-right close X, a 40×40 step icon plate with a left-aligned step header below the progress bar, and a single 6/8 spacing rhythm so the asymmetry Marc reported can never recur. A horizontal progress band morphs across three discrete shapes (circle → rounded square → capsule) over the eight-step path, falling back to an instant snap under `prefers-reduced-motion`. The detail page reads top-down as a clinical document: drug + status pill, today's tap-to-log card, plain-language cadence line with a Bearbeiten affordance that opens the wizard, the existing dose-ladder for GLP-1, a preview of the last 7–14 intakes grouped Heute / Diese Woche / Älter with row-level Bearbeiten + Löschen, notifications, settings rows, and a three-tier destructive footer (Pausieren reversible, Beenden archival, Löschen purges). Patient feel: every action Marc lost in v1.5.4 is reachable within one scroll on the device that prompted the rebuild. - -## 2. Wizard polish (`MedicationWizardDialog`) - -`src/components/medications/wizard/MedicationWizardDialog.tsx` stays the entry point. The shell still nests `` for the dialog/sheet split; the polish is geometry, header structure, spacing tokens, the status-bar morph, and the step transition. - -### 2.1 Geometry - -Desktop (`md+`) — switch from the inherited `sm:max-w-md` (≈448 px) to `sm:max-w-[560px]`. R-2's evidence: the asymmetry Marc reported reads cramped at 448 px because the cadence-summary line, the schedule list on Step 8, and the icon-plate + step subline all fight for the same horizontal band. 560 px lets the summary breathe one full line and matches the iOS 26 sheet preference for left-aligned readability. - -Mobile (`< md`) — keep `` capped at `max-h-[90dvh]`, sticky-pinned footer (already wired in `ResponsiveSheet`'s Sheet branch), body scroll on overflow. Add `min-h-[60dvh]` so short steps (Step 1 — single text input) don't collapse the sheet to a thin band that looks broken. The 60/90 floor + ceiling is the Marc-reported "too short" fix. - -Concrete `` change: - -```tsx - -``` - -The Dialog branch of `ResponsiveSheet` already applies `max-h-[calc(100dvh-2rem)]` and `overflow-y-auto` through the shared `` primitive (`src/components/ui/dialog.tsx:72`). No new primitive needed; the className override drives both widths simultaneously. - -### 2.2 Close affordance - -Already exists in `` and `` at top-right. The wizard currently passes `showCloseButton` and `` forwards it. The audit failure was that Marc couldn't see it because the icon is `text-muted-foreground` at low contrast against the header progress band. Two fixes: - -1. The shell's first child is the progress + counter band on a `border-b` strip; the X sits absolutely positioned at `top-3 right-4` (per `dialog.tsx:84-86`). The progress strip is `p-4`, so the X overlaps it visually. Move the progress band's right-padding to `pr-12` so the X has its own gutter, mirroring how `responsive-sheet.tsx` already pads the Sheet header (`pr-12`). -2. The `` close button already carries `min-h-11 min-w-11` on mobile (44 px tap target) and shrinks to 36 px on `sm+` per `dialog.tsx:86`. Leave the floor; just raise contrast by adding `text-foreground/70 hover:text-foreground` to the existing class chain in the wizard-specific override. (No edit to the shared primitive — wrap the override at the call site if contrast tests fail.) - -Aria — the primitive already injects `{t("common.close")}`. Verify the DE key resolves; it ships as "Schließen" in `messages/de.json`. No new i18n keys. - -### 2.3 Header structure - -Vertical rhythm inside the dialog body, top to bottom: - -``` -┌───────────────────────────────────────────────────────────┐ -│ ▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ [X] │ ← progress strip + close gutter -│ Schritt 3 von 8 [✨ Aus Text] │ -├───────────────────────────────────────────────────────────┤ -│ │ -│ ┌────┐ │ -│ │ 🧪 │ Dosis & Einheit │ -│ └────┘ Gib die Dosis pro Einnahme an. │ -│ │ -│ [ field row ] │ -│ [ field row ] │ -│ │ -└───────────────────────────────────────────────────────────┘ -[ Zurück Weiter ] ← sticky footer -``` - -Concrete numbers: - -- Progress strip: `border-b border-border/70 p-4 pr-12 space-y-1.5` (existing) — the `pr-12` is the close-X gutter fix. -- Step body: `space-y-6 p-6` on desktop (was `space-y-4 p-4`), `space-y-5 p-5` on mobile. Use `space-y-6 p-5 sm:p-6` so one class chain drives both. -- Icon plate: `grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-border/60 bg-card` (already in `MedicationWizardDialog.tsx:417`) — keep. -- Header row: `gap-3` between plate and title block; title block uses `space-y-1`. -- Step caption (multi-schedule context): `text-muted-foreground text-xs` — keep. -- Step title: bump from `text-base font-medium leading-tight` to `text-lg font-semibold leading-tight tracking-tight` so the title outweighs the subline by the type cascade R-2 endorsed. -- Step subline: `text-muted-foreground text-sm` — keep. -- Field surface: `space-y-4`. - -### 2.4 Spacing tokens — the symmetry contract - -Every internal gap maps to one of three Tailwind classes; reviewers reject any PR that introduces a fourth. - -| Concern | Token | -|---|---| -| Progress strip padding | `p-4 pr-12` (X-gutter on the right) | -| Step body padding | `p-5 sm:p-6` | -| Step body vertical rhythm | `space-y-5 sm:space-y-6` | -| Icon plate → title block gap | `gap-3` | -| Within title block | `space-y-1` | -| Top-level wizard sections (Step 8 only) | `gap-6 sm:gap-8` | -| Footer button group gap | `gap-2` | -| Field rows internally | `space-y-3` | - -R-2's concentric 24/20 + 32 px maps to `p-6 / p-5` and `gap-8 / gap-6` cleanly. 20 px mobile floor, 32 px desktop ceiling. Nothing in between. - -### 2.5 Symmetry invariants - -Hard-code these into the code-review checklist for the v1.5.5 PR: - -1. The step icon plate AND the primary footer CTA share `rounded-xl` (not `rounded-md`, not `rounded-lg`). The plate is `rounded-xl` today; the Button primitive ships `rounded-md`. Override the wizard-Next button to `rounded-xl` so the curvature reads as one family. -2. Every progress strip element (bar, counter text, NL button) lives at `text-xs`. The counter is already `text-xs`; do not promote. -3. The step subline (`text-sm`) and the footer secondary "Back" button text (`text-sm` via Button defaults) share weight. No bolding the subline. -4. Every section gap on Step 8 (the only multi-section step) is `gap-6 sm:gap-8`. The schedule cards inside ride `space-y-3`. -5. The 40×40 plate is the only square chrome. No nested square iconography in the body — Lucide icons render at `h-4 w-4` or `h-5 w-5` inline with text. -6. Loader animations everywhere use `animate-spin motion-reduce:animate-none`. No exceptions; this is the project precedent (e.g. `intake-history-list-v2.tsx:184`). - -### 2.6 Status-bar morph animation - -The dialog's progress is currently ``. R-2's verdict: morph the progress *shape* across the eight-step path so the patient feels the rhythm of the form rather than watching a fill bar inch. Three discrete shapes — circle at the head (step 1), rounded square at midpoint (step 4–5), elongated capsule at the tail (step 8). Implemented as a CSS-only `clip-path` interpolation over the existing `` indicator; no Framer Motion, no `motion` library, no new dependency. - -The fill bar itself stays linear; the *leading edge* morphs. The 280 ms total uses `cubic-bezier(0.32, 0.72, 0, 1)` — the same curve Apple ships for spring-like attack on system UI, available in plain CSS. R-2's "Material 3 spring damping 25–30" maps to this curve once translated from spring physics to a Bézier approximation. - -Add to `src/app/globals.css` under a `@layer components` block (the project already keeps custom utilities there): - -```css -@layer components { - .wizard-progress-bar { - /* Underlying primitive paints the fill; we override the - indicator's clip-path + transition so the leading edge - morphs across the three shapes as `--wizard-step` advances. */ - --wizard-step: 0; - transition: - clip-path 280ms cubic-bezier(0.32, 0.72, 0, 1), - width 280ms cubic-bezier(0.32, 0.72, 0, 1); - } - - /* Step 1 — leading edge is a half-circle (full radius). */ - .wizard-progress-bar[data-step="1"] [data-slot="progress-indicator"] { - clip-path: inset(0 0 0 0 round 0 999px 999px 0); - } - - /* Steps 2–4 — leading edge tapers to a rounded square. */ - .wizard-progress-bar[data-step="2"] [data-slot="progress-indicator"], - .wizard-progress-bar[data-step="3"] [data-slot="progress-indicator"], - .wizard-progress-bar[data-step="4"] [data-slot="progress-indicator"] { - clip-path: inset(0 0 0 0 round 0 6px 6px 0); - } - - /* Steps 5–7 — leading edge stretches into a capsule. */ - .wizard-progress-bar[data-step="5"] [data-slot="progress-indicator"], - .wizard-progress-bar[data-step="6"] [data-slot="progress-indicator"], - .wizard-progress-bar[data-step="7"] [data-slot="progress-indicator"] { - clip-path: inset(0 0 0 0 round 0 999px 999px 999px); - } - - /* Step 8 — fully filled capsule (the entire bar reads as one pill). */ - .wizard-progress-bar[data-step="8"] [data-slot="progress-indicator"] { - clip-path: inset(0 0 0 0 round 999px); - } - - @media (prefers-reduced-motion: reduce) { - .wizard-progress-bar, - .wizard-progress-bar [data-slot="progress-indicator"] { - transition: none; - } - } -} -``` - -Wire it in `MedicationWizardDialog.tsx`: - -```tsx - -``` - -Three shapes, not seven (R-2's upper bound) — HealthLog's discrete state space is small. The h-1.5 (was h-1) is the minimum stroke that lets the clip-path morph register at 1× pixel density without aliasing. - -Reduced-motion fallback: the transitions collapse to `none`; the clip-path still applies but snaps instantly. Patients on `prefers-reduced-motion: reduce` still see the shape rhythm — just without the easing — which preserves the navigational hint while honouring the system preference. - -### 2.7 Step transition animation - -R-2 hinted "fade + 8 px slide-from-right on goNext". Confirmed, but the fade carries the load; the 8 px slide should only ride on goNext, NOT goBack (sliding from the right on backward navigation reads as a forward gesture and confuses the spatial model). - -```css -@layer components { - .wizard-step-body { - animation: wizard-step-in 220ms cubic-bezier(0.32, 0.72, 0, 1); - } - .wizard-step-body[data-direction="back"] { - animation-name: wizard-step-back-in; - } - - @keyframes wizard-step-in { - from { - opacity: 0; - transform: translateX(8px); - } - to { - opacity: 1; - transform: translateX(0); - } - } - - @keyframes wizard-step-back-in { - from { opacity: 0; } - to { opacity: 1; } - } - - @media (prefers-reduced-motion: reduce) { - .wizard-step-body, - .wizard-step-body[data-direction="back"] { - animation: none; - } - } -} -``` - -Wire the direction on the body wrapper: - -```tsx -const [direction, setDirection] = useState<"next" | "back">("next"); -// goNext / goBack already exist; set the direction before setStep(...). - -
-``` - -The `key={step}` already in place re-mounts the body on every step change, which triggers the CSS animation deterministically without needing any state machine. - -## 3. Detail page — `src/app/medications/[id]/page.tsx` (NEW) - -`/medications/[id]/history/page.tsx` exists today; `/medications/[id]/page.tsx` does not. v1.5.5 creates the route and the existing history page stays for the dedicated bulk-history surface (§6). The route is a client component (`"use client"`) because every section subscribes through TanStack Query. - -Vertical rhythm at the route level: outer wrapper `space-y-6 sm:space-y-8` so sections breathe at 24/32 px — R-2's concentric token. Inner sections use the existing `` wrapper at `src/components/medications/medication-detail-section.tsx` for chrome symmetry with the GLP-1 sub-pages. - -Mobile-first: every wireframe below describes the 360 px baseline. On `md+` the page widens to `max-w-3xl mx-auto` so the cadence-summary line + intake-history table stop reading edge-to-edge on tablet portrait. - -### Section order (locked) - -``` -1. Header band — drug name + dose + status pill + edit affordance -2. Today's dose card — Genommen / Übersprungen / Verschoben one-tap -3. Cadence summary — plain-language line + Bearbeiten → wizard -4. Dose ladder / Phasen — only for GLP-1 + course window -5. Intake history preview — last 7–14 grouped Heute / Diese Woche / Älter -6. Notifications — switch + helper line -7. Settings — API tokens, CSV import, Phasen, grace -8. Destructive zone — Pausieren / Beenden / Löschen -``` - -Daily actions at the top (log today's dose, see what's coming), rare ones at the bottom — Apple Health's pattern with the labelled destructive zone Apple's surface lacks. - -### 3.1 Header band - -``` -┌───────────────────────────────────────────────────┐ -│ Mounjaro [✏ Bearbeiten] │ -│ 7,5 mg │ -│ ● Aktiv · Wöchentlich · seit 12.03. │ -└───────────────────────────────────────────────────┘ -``` - -- Component: inline JSX (no new wrapper). The header is light enough not to warrant a custom component. -- Type cascade: `text-2xl font-bold tracking-tight` for the drug name (matches `/medications/[id]/history/page.tsx:82`); `text-muted-foreground text-sm` for the dose; `text-xs` for the status row. -- Status pill: `` with one of three shapes (Aktiv / Pausiert / Beendet). The accent dot is a `bg-emerald-500` / `bg-amber-500` / `bg-zinc-500` square `h-2 w-2 rounded-full`. -- Edit affordance: outline `
`. Cadence row: section `

` first, `

` next, edit last. Acceptance: screen-reader walk announces name → dose → status → "Bearbeiten button". - -**C-E4-4 — Modal stack ≤ 2; row-edit Sheet hosts no destructive action** (E-4 C-4; D-2 §6.3). Sheet → peer `` mid-close returns focus to `document.body`. Fix: row-edit Sheet hosts only the edit form. Single-row Löschen lives on the row kebab (kebab → `` directly). Sheet → AlertDialog stack forbidden. Acceptance: Sheet tree contains no ``; focus returns to kebab. - -**C-E4-5 — Status-pill text never hides; dot uses Dracula tokens** (E-4 C-5 + H-9; D-2 §3.1). Raw Tailwind palette (`bg-emerald-500` / `bg-amber-500` / `bg-zinc-500`) bypasses theme; D-2 did not pin that text never hides. Fix: `` with `Aktiv` / `Pausiert` / `Beendet` text always rendered. Dot `aria-hidden="true"`, Dracula tokens via `bg-[hsl(var(--success))]` / `bg-[hsl(var(--warning))]` / `bg-muted-foreground`. If `--success` / `--warning` not yet exposed, pre-work adds them next to `--destructive` with Dracula `#50fa7b` / `#ffb86c`. Acceptance: axe contrast passes; snapshot at 320 px shows label. - -### UX wire correctness + lifecycle (E-2 Criticals) - -**C-E2-1 — Two-button Today's-dose card + locked wire shape + `compliance-chart-inline` in bundle** (E-2 C-1; co-resolves E-3 H-1/H-2/H-3; D-2 §3.2; `medicationDependentKeys`). D-2 invented a `DEFERRED`/`Verschoben` status absent from route + schema + i18n; the wire shape mismatched the actual route (`{ scheduledFor, takenAt, skipped, idempotencyKey }`); `compliance-chart-inline` prefix was not in the bundle. Fix: two buttons `[Genommen]` + `[Übersprungen]` (Verschoben dropped, §12); POST `{ scheduledFor: , takenAt: , skipped: boolean }`; card derives "due today" from `medication.cadence` + `summariseCadence`; empty `Heute keine Einnahme geplant.`. Pre-work adds `["compliance-chart-inline"]` to `medicationDependentKeys`; every detail mutation routes through `invalidateKeys(queryClient, medicationDependentKeys)` only. Acceptance: integration test asserts wire shape; cache test asserts inline-compliance evicts in one tick. - -**C-E2-2 — `landingStepForEdit` gains `intent`; step title receives focus on landing ≠ 1** (E-2 C-2; co-resolves E-4 H-1/H-2 + E-2 L-4; `wizard-payload.ts:870`; `MedicationWizardDialog.tsx:213-224`). D-2's cadence-row edit promised "the cadence step" but the helper only returns 1 or 8. `key={step}` re-mount drops one frame of focus; `onEditSchedule` 8→5 used `direction="next"`. Fix: `landingStepForEdit(payload, intent?: "cadence" | "summary" | "name"): number` — `"cadence"`→5, `"name"`→1, omitted→current 1/8. Header edit passes `"name"`; cadence row passes `"cadence"`. Title `

` carries `tabIndex={-1}`; focus effect focuses the title first when landing ≠ 1. Slide stripped (C-E1-1) → `direction` state removed. Acceptance: unit test asserts `(payload, "cadence") === 5`; component test asserts title focus on edit-mount. - -**C-E2-3 — One-shot variant of the detail page; suppress sections 4/6/7 once logged** (E-2 C-3; D-2 §3 section order). v1.5.4 auto-deactivate (commit `39430e16`) kills notifications post-log; sections 5 / 6 / 7 read as bureaucratic theatre for a single dose. Fix: page branches on `kind = medication.oneShot ? "oneShot" : "recurring"`. One-shot renders 5 sections — Header (Einmalig genommen pill when logged) / Today's-dose-or-logged-state / Cadence static line / Intake row (no grouping) / Verwaltung & Gefahrenzone. Notifications + Settings not rendered. Cadence row hides edit affordance (wizard handles via header edit). Acceptance: snapshot asserts 5 sections; wizard-edit from header lands correctly for one-shot. - -## 4. High-priority items that ship in v1.5.5 - -Bundled by area. `[absorbed]` = a Critical already covers it. - -**H-cluster-D — Type cascade + spacing tokens + padding alignment** (E-1 H-1/H-2/H-3/H-4). Wizard title at `text-lg` (sole outlier); "three tokens" framing with eight; page rhythm `space-y-6 sm:space-y-8` while siblings ship one class; body `p-5 sm:p-6` vs strip `p-4`. Fix: wizard title stays `text-base font-semibold leading-tight tracking-tight`. Five buckets — outer `p-4 pr-12 sm:p-6 sm:pr-14`, section `gap-6 sm:gap-8`, row `gap-3`, tight `space-y-1`, footer `gap-2`. Page rhythm `space-y-6`. Strip + body share padding. Tests: component asserts both titles at `text-base`; snapshot asserts shared left edge. - -**H-cluster-E — Loading/error/empty contract per section** `[absorbed by C-E4-2]`. Pinned in §9. - -**H-cluster-F — Pausieren copy rewrite + two-card destructive split** (E-2 H-1/H-2). Old helper reads as "skip today"; mixing scope+severity in one card hides escalation. Fix: helper → `Erinnerungen anhalten, bis du sie wieder aktivierst. Verlauf bleibt erhalten.`. Split destructive into two cards under heading `Verwaltung & Gefahrenzone`. Card A hosts Pausieren + Beenden (reversible); Card B (`border-destructive/40`) hosts Verlauf + Medikament löschen (irreversible). Tests: i18n key `medications.pause.helper` ships; snapshot shows split. - -**H-cluster-G — Bundle includes `compliance-chart-inline`** `[absorbed by C-E2-1]`. Tier 3b orders `await invalidateKeys(...); router.push("/medications");`. Per-row edit/delete also routes through the bundle. Inline comment on bundle explains prefix-match coverage. Tests: cache test asserts inline-compliance evicts on every mutation; Tier 3b lands on fresh cache. - -**H-cluster-H — Switch in `