Compare commits

...
107 Commits
Author SHA1 Message Date
Marc-André Bombeck 24ffca7220 feat(meds): scope the advanced-settings export to the single medication
The advanced sheet sits on one medication and its import already loads
that medication's intake history, but the co-located export streamed
every medication. Pass the medication id through to the export route,
which narrows both the medication row and the intake history by it
(always alongside userId, so a foreign id 404s rather than leaking).
2026-05-31 20:22:40 +02:00
Marc-André Bombeck 8363db4146 chore(release): v1.7.2 — coach source parity, first-paint snapshot, medication card cleanup 2026-05-31 20:03:48 +02:00
Marc-André Bombeck 039d402c9b chore(meds): correct kebab comment + drop orphaned scheduling i18n keys
The medication-card kebab menu comment implied the GLP-1 "Log side
effect" item is reachable from the card. It is not wired on the
medications list page — both card variants there receive only the
edit / history / advanced handlers, so the item never renders. Side-
effect logging lives on the detail page's SideEffectsSection. Correct
the comment to match.

Remove the scheduling i18n key subtrees orphaned when the schedule
section and cadence-summary row left the card: section, loading,
loadFailed, editCta, reminders, cadenceViz, compliance, oneShot,
legacyWindow, plus detail.cadence.title. Dropped in lockstep across all
six locales; the still-live naturalLanguage / cadence / timesOfDay /
courseWindow keys stay.
2026-05-31 20:01:29 +02:00
Marc-André Bombeck 3a5e157d41 refactor(meds): extract shared card parts for structural symmetry
The generic medication card and the GLP-1 variant carried ~120 lines of
hand-synced JSX for the status pill, compliance bars, primary intake
actions, and state badges. Lift these into single-purpose presentational
components under card-parts/ consumed by both cards, so symmetry is
structural rather than maintained by hand.

Unify the day-streak flame onto the canonical text-dracula-orange
warning/streak token. The generic card had drifted onto Tailwind-stock
text-orange-400; both cards now render the same token.

No visual or behavioural change beyond the streak-token convergence.
2026-05-31 20:01:29 +02:00
Marc-André Bombeck 44e83d22df docs: document the NEXT_PUBLIC_DASHBOARD_SNAPSHOT build-time toggle
It is a build-time NEXT_PUBLIC_* var (inlined into the client bundle),
default ON, disabled only by rebuilding with the var set to false. Note
the build-time nature in .env.production.example and getting-started,
and keep it out of the compose runtime whitelist where it would mislead
operators.
2026-05-31 20:00:16 +02:00
Marc-André Bombeck d1e3c9a792 test(dashboard): cover isDashboardSnapshotEnabled default-on boundary
Pin undefined/empty/"true" to ON and exactly "false" to OFF so a
future edit cannot quietly regress to the old === "true" opt-in.
2026-05-31 20:00:16 +02:00
Marc-André Bombeck a3e1d369e6 test(e2e): mock the dashboard snapshot endpoint for tile specs
The v1.7.2 default-ON snapshot flag makes the dashboard read every tile
from GET /api/dashboard/snapshot; the e2e build sets no opt-out var, so
the specs that only mocked the legacy cells hit the real route, got
empty summaries for the zero-measurement seed user, and timed out the
tile/chart assertions.

Add a shared, correctly-typed mock helper and wire it into the five
tile-asserting specs alongside the existing legacy mocks (kept to
protect the reversible path).
2026-05-31 20:00:16 +02:00
Marc-André BombeckandClaude Opus 4.8 d6ce53a318 feat(meds): simplify the detail page into the history view
The detail page carried an Edit / History / Advanced action row and a
'Rhythmus' cadence block that duplicated the card actions. Drop them:
editing and advanced settings are now reached from the card overflow
menu only. The detail page leads with the intake-history table, keeps
the GLP-1 drug-level / titration as a default-closed disclosure, and
renders a compact, read-only summary header (name, dose, status, plain-
language cadence line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:14 +02:00
Marc-André BombeckandClaude Opus 4.8 61d91df251 feat(meds): collapse card actions into an overflow menu
The overview cards (generic and GLP-1) carried four header icon-buttons
(open / edit / history / advanced). Collapse them into a single overflow
kebab and make the card header link to the detail page. The GLP-1 card
folds its side-effect quick-log into the same menu so both variants keep
one symmetric affordance. Menu items inherit the 44px tap-target floor
from the dropdown primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:14 +02:00
Marc-André BombeckandClaude Opus 4.8 c48541e74f feat(coach): back the chat source rail with persisted cluster prefs
The chat-side sources rail and the settings cog drove two different
data-source models — the rail hardcoded the legacy five sources and kept
its selection in ephemeral drawer state that reset on close, while the cog
edited the ten persisted clusters on User.coachPrefsJson. The prompt scope
followed the persisted clusters, so what the rail showed could differ from
what the model received.

Bind the rail to the same persisted prefs: it now reads useCoachPrefs and
writes the cluster toggles + analysis window through a shared
useSaveCoachPrefs mutation, so a toggle sticks and the cog reflects it (and
vice-versa). The chat request no longer sends a scope override, so the
route resolves the source set and window from coachPrefsJson and the rail
display cannot drift from the prompt. Drop the ephemeral scope state, the
duplicate header window pill, and the legacy five-source rail model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:39:35 +02:00
Marc-André Bombeck 91f23bae68 feat(meds): widen advanced settings into a two-column layout with co-located export
Widen the medication advanced-settings dialog to the 4xl token and lay
the Data, Reminders and Lifecycle groups into a responsive two-column
grid on lg+ so a normal desktop viewport no longer forces a long
single-column scroll. The Danger zone keeps its destructive-tinted card
full-width at the bottom, separated from routine toggles.

Co-locate medications CSV export beside the intake import in the Data
group: a new DataPortabilityRow pairs the import button with an export
button that hits the existing /api/export/medications endpoint, with an
intake-history toggle mirroring the Settings export card. No new
endpoint.

Correct the import affordance copy to JSON — the dialog and API only
parse a JSON intake array, so the prior CSV/JSON label overstated the
accepted format.
2026-05-31 19:38:43 +02:00
Marc-André Bombeck 7802ce1e11 feat(dashboard): enable unified snapshot for atomic first paint
Default the dashboard snapshot flag to ON (disable with
NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false). The legacy four-cell waterfall
let the mood cache resolve first and pop in ahead of the rest of the
strip; the snapshot path hydrates every tile from one un-gated cell so
the whole strip shares a single completion moment.

Defaulting on rather than gating on === "true" sidesteps the
NEXT_PUBLIC_* build-time-baking trap: the production image and dev/e2e
all exercise the snapshot path without a special build flag. Fully
reversible to the legacy path via the env var. Comment-only sync in
page.tsx; no logic change there.
2026-05-31 19:37:02 +02:00
dependabot[bot] 5dde85da84 chore(deps): Bump undici from 7.25.0 to 8.3.0 (#230)
Bumps [undici](https://github.com/nodejs/undici) from 7.25.0 to 8.3.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.25.0...v8.3.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 16:15:51 +00:00
dependabot[bot] 4cbee77d60 chore(deps-dev): Bump testcontainers from 11.14.0 to 12.0.1 (#227)
Bumps [testcontainers](https://github.com/testcontainers/testcontainers-node) from 11.14.0 to 12.0.1.
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v11.14.0...v12.0.1)

---
updated-dependencies:
- dependency-name: testcontainers
  dependency-version: 12.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 16:11:20 +00:00
dependabot[bot] b06205851b chore(deps-dev): Bump @types/node from 25.8.0 to 25.9.1 (#226)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.8.0 to 25.9.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 16:06:44 +00:00
dependabot[bot] 383eef8dc0 chore(deps-dev): Bump pg from 8.20.0 to 8.21.0 (#229)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.20.0 to 8.21.0.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.21.0/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.21.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 16:02:16 +00:00
dependabot[bot] ef534d5c32 chore(deps): Bump lucide-react from 1.16.0 to 1.17.0 (#228)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.16.0 to 1.17.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 16:02:02 +00:00
dependabot[bot] 869c96968e chore(deps-dev): Bump @testcontainers/postgresql from 11.14.0 to 12.0.1 (#224)
Bumps [@testcontainers/postgresql](https://github.com/testcontainers/testcontainers-node) from 11.14.0 to 12.0.1.
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v11.14.0...v12.0.1)

---
updated-dependencies:
- dependency-name: "@testcontainers/postgresql"
  dependency-version: 12.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 16:00:51 +00:00
dependabot[bot] c2c1e8ef3e chore(deps): Bump @hookform/resolvers from 5.2.2 to 5.4.0 (#223)
Bumps [@hookform/resolvers](https://github.com/react-hook-form/resolvers) from 5.2.2 to 5.4.0.
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.2.2...v5.4.0)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 15:59:08 +00:00
dependabot[bot] 62e001af17 chore(deps-dev): Bump shadcn from 4.7.0 to 4.9.0 (#225)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.7.0 to 4.9.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.9.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 15:57:59 +00:00
dependabot[bot] 9a9e5b77b6 chore(deps): Bump docker/setup-buildx-action from 3 to 4 (#220)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 15:54:39 +00:00
Marc-André BombeckandClaude Opus 4.8 05fdddabc5 chore(release): v1.7.1 — overview-card edit/history/advanced actions (incl. GLP-1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:10:03 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André BombeckandClaude Opus 4.7 33b912babd test(settings): exempt the hidden avatar file input from the 44px touch-target sweep
The avatar upload uses the accessible visually-hidden <input type="file">
pattern: the input is sr-only / zero-size and the visible 44px touch
target is the styled label/button that triggers it. The mobile
touch-target sweep now skips hidden/zero-size file inputs and keeps the
44px assertion for every genuinely visible input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:44:40 +02:00
Marc-André BombeckandClaude Opus 4.7 ac1d03f8ea test(meds): green the wizard e2e locale and one-shot timezone fixtures
The medication-wizard e2e specs assert the German step counter, but the
seeded e2e user falls back to the English default locale; pin the
`healthlog-locale=de` cookie for the wizard flow and correct the
storage-state cookie allowlist to the hyphenated cookie name the i18n
layer actually reads.

The one-shot cadence integration fixture derived `startsOn`/`endsOn` from
the UTC calendar date but checked emission against the Europe/Berlin
today-window, so the assertion flipped when the suite ran in the UTC
evening. Derive the fixture dates from the same Berlin calendar day the
window uses.

Also refresh the OpenAPI spec version to 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:30:27 +02:00
Marc-André BombeckandClaude Opus 4.7 8ee7aaac44 chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:10:12 +02:00
Marc-André Bombeck d01d1d1dc9 Update README.md 2026-05-29 22:11:05 +02:00
Marc-André Bombeck 189d162dff Update README.md 2026-05-29 22:08:53 +02:00
Marc-André Bombeck edfe43d391 Update README.md 2026-05-29 22:08:35 +02:00
Marc-André BombeckandClaude Opus 4.7 8528c3b4fe chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.

- /medications/[id] becomes a read-only history surface; every setting
  moves into a new advanced-settings sheet, and the edit trigger becomes
  a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
  daily total per user per day and soft-deletes the originals; migration
  0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
  Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
  healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
  a unique-constraint day instead of stranding it, avatar upload reads
  the body once under a bounded cap, egress lint rejects absolute URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:07:10 +02:00
Marc-André BombeckandClaude Opus 4.7 d54addd61a chore(release): v1.5.5
Medication detail page, iOS coord wave, outbound-fetch hardening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:55:40 +02:00
Marc-André BombeckandClaude Opus 4.7 353c8d7fa0 feat(meds): row-level edit and bulk-delete on the intake history preview
Per-row Bearbeiten and Löschen actions sit behind a kebab on each
intake row; multi-select gates a bulk-delete toolbar that wires
through to the v1.5.5 bulk-delete endpoint. Closes the two
displaced features from the v1.5.4 flat-form retirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 167cc3afa2 feat(meds): rate-limit the bulk-delete intake endpoint
Match the glp1 POST cap (30 calls per minute per user) so a caller
firing repeated 500-id deletes cannot pin the recompute worker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 ff334ff10e fix(meds): phase-config upsert builds the Prisma payload field-by-field
Drop the parsed.data spread so a future schema extension cannot
silently land on the Prisma write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 e6907d4ea9 fix(meds): phase-config returns the 422 multi-issue envelope
The phase-config PUT was the last v1.5.5 route still returning a
flat 400 on Zod failure. Returning the multi-issue envelope means
iOS gets per-field feedback the same way the bulk-delete and
intake routes do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 56523bafb1 chore(meds): centralise the medication api-token query key
A bare ["medications", id, "api-endpoint"] tuple escaped the
factory rule via useMemo; the new medicationApiEndpoint key in
the factory restores the single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 9532f14f3b fix(meds): notification section title id no longer duplicates the section heading
Split the two title ids so the Switch's aria-labelledby resolves
to the row label rather than the section heading. axe duplicate-id
gone; the announced name now matches the user's mental model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 359b103e90 fix(meds): glp1 GET routes through assertMedicationOwnership
The destructive sweep in commit af224964 missed the glp1 GET
branch; the handler still hand-rolled findUnique + userId compare.
Lifting it to the same ownership helper closes the §10 invariant
24 gap and aligns the GET with the POST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 043c335ba3 fix(meds): purge route invalidates the user-medication caches
The Tier-3a Verlauf-löschen cascade dropped the rollup rows but
left the analytics / iOS today-tally caches with the pre-purge
counts for up to their TTL. Add invalidateUserMedications to the
success path so every downstream reader sees the fresh state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 8adf1b55e8 fix(meds): grace-row save lands on the primary schedule
The detail page settings section PUTs reminderGraceMinutes at the
top level, but the schema only accepted it per-schedule, returning
422 on every save. The route now maps a top-level grace value onto
the primary schedule's reminderGraceMinutes before the Prisma
update; the schema declares the top-level field with a description
noting the normalisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 8c740fd146 feat(meds): list-card history icon routes to the new detail page + cascade pin tests
The list-page medication cards previously pointed their History icon
straight at `/medications/{id}/history`. v1.5.5 promotes the detail
page as the canonical umbrella surface for everything the v1.5.4 form
retirement displaced, so the icon now opens `/medications/{id}` and
the detail page's intake-history preview links onward to the bulk-
delete sub-route in one extra tap.

Two focused tests:

  - `medication-detail-header.test.tsx` pins the status-pill contract
    (label always rendered, dot rides Dracula `--success` / `--warning`
    tokens, dot is `aria-hidden`, DOM order is heading-then-edit).
  - `detail-page-cascade.test.tsx` pins the per-section query-key +
    invalidation cascade against the central factory. The §9.4
    phase-config key lives outside the bundle so the settings section
    must invalidate it explicitly; the test documents that contract
    so a future refactor cannot drop it silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:54:07 +02:00
Marc-André BombeckandClaude Opus 4.7 4ae8801c56 feat(meds): self-hosted medication detail page restoring every v1.5.4-displaced action
A new `/medications/[id]/page.tsx` surfaces every action the v1.5.4
form retirement buried — log today's dose, edit the plan, manage
notifications, manage API tokens, import CSV, configure GLP-1 phases
in a focused Sheet, walk a three-tier destructive cascade. The page
reads as a clinical document from header to destructive footer; one
phone-scroll reaches every restored feature.

Section order (recurring variant — 8 sections):

  1. Header band — drug name + dose + status pill + edit pencil
  2. Today's dose card — two buttons (`[Genommen]` + `[Übersprungen]`)
  3. Cadence summary — plain-language line + Bearbeiten → wizard
     Step 5 via the new `landingIntent: "cadence"` route
  4. Dose ladder / Phasen — GLP-1 only, reuses the v1.4 components
  5. Intake history preview — last 14 grouped + footer link to
     `/history`; CSV-import trigger lives in the header
  6. Notifications — `<Switch>` in `<label>` row + read-only chip
     strip; replaced by an "iPhone steuert die Erinnerungen" chip
     when `clientManaged === true`
  7. Settings — Externe Integration / CSV stub / Phasen
     (button → Sheet) / Grace minutes
  8. Verwaltung & Gefahrenzone — Card A (Pausieren + Beenden) and
     Card B (Verlauf + Medikament löschen) under one heading

One-shot variant collapses to 5 sections per D-3 §6. Paused variant
keeps the same structure with the header pill flipped and the
today's-dose buttons muted.

Every mutation cascades through `medicationDependentKeys` so the
inline compliance chart, dashboard chart and achievement reads
converge in one tick. Tier 1 Pausieren PUTs `{ active }` only;
the server derives `pausedAt`. Destructive CTAs ship `font-semibold`
so the WCAG large-text contrast band catches the destructive-button
white-on-#ff5555. Status pill text is always rendered; the dot is
`aria-hidden` and reads from Dracula `--success` / `--warning` tokens
so the colour stays in lockstep with the theme.

i18n covers DE + EN natively; ES/FR/IT/PL inherit the EN strings as
the locale-integrity guard's fallback contract specifies. New keys
live under `medications.detail.*`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:51:17 +02:00
Marc-André BombeckandClaude Opus 4.7 7f95a2a44f fix(measurements): wire walking step length and walking speed
The iOS team had gated both client-side waiting on the server map.
Both flow raw SI on the wire (metres and metres per second
respectively) — the server-side ×100 scaling the percent gait
metrics use does NOT apply here. The convention block at the top of
apple-health-mapping.ts now spells the split out so a future
gait-metric addition picks the right path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:38:21 +02:00
Marc-André BombeckandClaude Opus 4.7 8d5d039ed1 feat(meds): bulk-delete intake endpoint for the detail-page preview multi-select
The v1.5.5 detail-page intake-history preview offers a multi-select
that posts the selected eventIds in one round-trip instead of firing
N concurrent DELETEs from the client. The endpoint:

- Asserts ownership through the shared `assertMedicationOwnership`
  helper so the 404 leak shape stays identical across `[id]/**`.
- Reads rows scoped by `(eventId IN, userId, medicationId)` so a
  leaked id from another medication's history never deletes anything.
- Runs `deleteMany` scoped by the same predicate.
- Recomputes the compliance rollup once per unique dayKey (not once
  per event), so a 14-row delete spanning two days closes in two
  recompute trips and usually 1-3.
- Returns `{ deleted: number }` carrying the real `deleteMany` count
  so the client toast announces the truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:36:56 +02:00
Marc-André BombeckandClaude Opus 4.7 ef21967d12 refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.

`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.

`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 c97c4c3be3 feat(meds): wizard polish — 560 px shell, intent-aware landing, omit notifications on edit
Six changes the v1.5.5 detail page leans on:

- Desktop shell widens from `sm:max-w-md` (448 px) to `sm:max-w-[560px]`
  so the strip + body stay readable on the wider section icons the v1.5
  shell ships. Mobile bottom-sheet keeps the 40 dvh floor so short
  screens never collapse the wizard to a stub.
- Outer spacing scales to `p-4 pr-12 sm:p-6 sm:pr-14` so the close-X
  has room to breathe and the strip + body share one column. The
  fade-only `animate-in fade-in-0 duration-200 motion-reduce:animate-none`
  on the step body keeps motion to one vocabulary — the slide proposed
  earlier was stripped.
- `<Progress>` gets the explicit `transition-all motion-reduce:transition-none`
  so width-only progress reads as the one source of motion on the
  strip.
- `landingStepForEdit` takes an optional `intent` arg (`"cadence"` /
  `"name"` / `"summary"`); the detail page's cadence-summary row uses
  `"cadence"` so the user lands on Step 5 instead of Step 1. One-shot
  payloads fall back to Step 8 because the cadence picker is not on
  their path.
- The step `<h2>` carries `tabIndex={-1}` and receives focus on
  intent-routed edit-opens so the screen reader announces the step
  the user actually landed on, not the first input on it.
- `buildCreateBody` takes `forMode: "create" | "edit"` and omits
  `notificationsEnabled` on edit. The detail-page notifications
  switch is the single source of truth post-create; the wizard now
  refuses to round-trip its default back to the API on edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:30:35 +02:00
Marc-André BombeckandClaude Opus 4.7 af2249644b fix(meds): route every detail-page sub-route through assertMedicationOwnership
The intake purge endpoint, the parent medication PUT, the parent DELETE
and the CSV-import POST each hand-rolled the same `findUnique +
userId !== id ? 404` block instead of using the shared
`assertMedicationOwnership` helper that every other
`src/app/api/medications/[id]/**` handler already uses. The hand-rolled
checks read slightly different fields and skipped the helper's narrow
`select`, which leaked extra column data into routes that never needed
it. Routing each of the four through the helper keeps the 404 leak
shape on a single line, prepares the purge endpoint for promotion to a
user-discoverable surface on the new detail page, and removes one of
the open audit findings against the medications API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:27:02 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 e93feaf8d1 feat(dashboard): emit i18n keys instead of hardcoded German strings
The /api/dashboard/summary endpoint returned per-metric `title` and
`unit` as translated German strings ("Gewicht", "Schritte", …), which
injected DE into clients with an English UI. The wire now carries i18n
keys (`titleKey` / `unitKey`) under a new `dashboard.metric.*`
namespace; the PWA's `useTranslations()` and the iOS client's bundled
`Localizable.xcstrings` each resolve against their own locale bundle.

Locale parity preserved across de, en, es, fr, it, pl (DE / EN native,
the rest matched against the existing measurement-type labels). The
locale narrowing in `buildContext` is widened to accept every shipped
locale rather than dropping fr / es / it / pl back to the default — the
greeting + streak label now resolve in the user's configured language.

The OpenAPI yaml drift was a pre-existing 1.5.3 → 1.5.4 version-string
gap; regenerating picked it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:46 +02:00
Marc-André BombeckandClaude Opus 4.7 a75b96b2a9 feat(insights): server-persist insights tile order + visibility
Mirrors /api/dashboard/widgets for the insights surface: GET returns
the user's saved layout (defaulting to project order when unset),
PUT writes the layout, the response is the canonical shape the
clients can render directly. Closes the cross-device drift where
insights ordering lived in iOS local state only.

Storage is a new `insights_layout_json` JSONB column on `users`,
parallel to `dashboard_widgets_json` so each surface's resolver owns
its tile-id allowlist. Migration 0083 adds the column idempotently;
default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes
through the shared multi-issue 422 envelope + payload-redaction +
audit-row dedup pattern the dashboard-widgets route established.
A 5-minute per-user cache mirrors the dashboard-widgets cache and
busts on every save / reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:40 +02:00
Marc-André BombeckandClaude Opus 4.7 19cfd69ff1 feat(measurements): accept restingHeartRate, hrv, vo2max in the series enum
The iOS app surfaces these as series-capable, but the kind enum on
/api/measurements/series did not accept them — opening the detail
or trend view returned 422. The underlying MeasurementType enum
already carries the values; this commit extends the route-side
enum so the wire shape matches the data shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 26f13c5064 feat(measurements): raise the series days cap to ten years
The 365-day cap on /api/measurements/series rejected the iOS app's
"Alle"-range request (days = 3650) with a 422, painting an error
banner on every metric tile. Ten years matches the recurrence
engine's hard cap and the medication course-window upper bound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 befac0ec65 fix(measurements): wire the six deferred Apple Health identifiers
Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 b43b57f657 feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding
Closes issue 217. The isPublicUrl input-time guard catches every
RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL
DNS record can flip between accept and dispatch. The new
pinnedPublicDispatcher resolves the hostname literally inside
undici.Agent's connect hook, refuses any address isPublicIp would
reject, and pins the connection to the first valid public address.

The five outbound paths that accept a user-supplied host route
through the dispatcher via safeFetch's requirePublicHost flag —
moodlog sync, moodlog reverse-push, the moodlog credentials probe,
the local AI client (already gated in commit 1), and the ntfy
sender. The flag now buys both the input-time check and the
connect-time pin in one call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 bf6dfb8f6d chore(deps): bump knip 6.14.1 → 6.14.2 and docker/metadata-action v5 → v6
Both are Dependabot bumps that have been waiting on the queue; landing
them directly so the lock files stay coherent with the rest of v1.5.5.

knip: minor; docker/metadata-action: clean major (workflow change is
exactly the @v5 → @v6 string in two places).

Closes #209
Closes #183

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:00:19 +02:00
Marc-André BombeckandClaude Opus 4.7 6e204f1a25 fix(insights): raise the trends-row chart-slot wrapper so the annotation no longer overlaps
The 140 px slot matched the mini-chart's internal painting area but
not the mini-card shell's header + padding above it. The roughly
34 px the shell adds overflowed the slot and pushed the chart
envelope down into the TrendAnnotation row below — visible as text
sitting on top of the chart. Raise the wrapper to 180 px so the
full envelope (header + chart band) lives inside the slot and the
three tiles share one annotation baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:42:20 +02:00
Marc-André BombeckandClaude Opus 4.7 3b7c72e1d0 chore(release): v1.5.4
Medication wizard: modal dialog, compose-mode, ten-bucket taxonomy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:55:55 +02:00
Marc-André BombeckandClaude Opus 4.7 dba844235c test(meds): coverage for compose-mode
Five new payload-helper cases pin the multi-schedule encoder, the
hydration that opens on the list view, the add and remove guards,
and the schedule-id preservation on edit. A new Playwright spec
walks a two-schedule create flow end-to-end and verifies both
summary cards land before save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:52:39 +02:00
Marc-André BombeckandClaude Opus 4.7 8578924f14 feat(meds): compose-mode for the modal wizard
A medication can now hold multiple parallel schedules, each with its
own cadence and times. Steps 5-7 of the wizard become per-schedule;
Step 8 lists every configured schedule with edit and remove actions
and surfaces a "Weiteren Zeitplan hinzufügen" card at the bottom.
The hydrator reads every schedule on edit and lands the user
directly on the list view when more than one is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:52:39 +02:00
Marc-André BombeckandClaude Opus 4.7 320970c97d test(meds): coverage for the modal wizard
Component tests pin the path table, the step encoding, the
treatment-class to category mapping, and the create-vs-edit prop
branching. POST /api/medications gains pin-point coverage for the
new DIABETES and ANTIBIOTIC clinical-category values. Six Playwright
specs walk the new dialog end-to-end via the German label surface,
one per cadence shape; helpers consolidate the dialog-open + step
indicator assertions so each cadence spec only carries its own
sub-control selectors. The OpenAPI yaml refreshes to surface the
two new enum values to the iOS client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 105fcee242 refactor(meds): route /medications/new and the edit trigger through the new dialog
The legacy CreationWizard and the flat MedicationForm retire in
favour of MedicationWizardDialog. /medications/new keeps working
through a redirect that opens the dialog from the list page; the
"Bearbeiten" action on a card opens the same dialog with the
hydrated payload. The PhaseConfigDialog drops along with the flat
form — it was only reachable from the kebab menu and stays out of
the wizard's surface; a future release can resurface the per-phase
configuration once the wizard grows compose-mode for multi-schedule
medications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 e686083bae i18n(meds): localise the modal-wizard surface
Populates messages/{de,en}.json with every key the new dialog reads
under medications.wizard.*, plus categoryDiabetes and
categoryAntibiotic on the cards' badge namespace; the other four
locales ship the English fallback per project convention. The
retired medications.create.wizard.* namespace is removed from all
six files so the locale-integrity guard surfaces every dangling key
the moment the old surface stops being reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 b1f9a4bf8f feat(meds): step content for the modal wizard
Each of the eight steps lands with its Lucide icon, title, subline
and a single focused field. The cadence radio rows carry the
plain-language description directly under each label (not in a
tooltip), and the rolling-cadence row gets the literal mental-model
line so the patient understands the counter resets on every log.
Step 2 surfaces ten taxonomy rows that each map onto a single
(treatmentClass, category) pair through a hard-coded table; Step 6
swaps its body on the cadence kind picked in Step 5; Step 7 wraps
the existing times-of-day chips primitive with a sunrise / sun /
sunset / moon preset row above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 0a0af4b772 feat(meds): modal-wizard skeleton + dialog/sheet shell
A new MedicationWizardDialog routes through a Dialog on desktop and
a bottom Sheet on mobile via the existing ResponsiveSheet primitive,
with the wizard's own header inside the body and the footer carrying
Back / Next / Save. The dialog tracks an eight-step state machine
whose visible counter follows the path the user actually walks —
one-shot collapses to 5 steps, daily to 7, every other cadence to 8.
The pure helpers (path table, validation gate, request-body builder,
summary formatter, edit-path hydration) live alongside the dialog
shell so the next commits can plug step content in without touching
the wiring. Step content is empty in this commit; the next commits
fill the eight steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 a868bd1cf2 feat(meds): extend MedicationCategory with diabetes and antibiotic
Adds DIABETES and ANTIBIOTIC to MEDICATION_CATEGORY_VALUES so the
upcoming modal wizard's Step 2 taxonomy can write the two rows as
first-class clinical categories instead of falling back to OTHER.
The clinical category column on medication_categories is plain TEXT
backed by Zod for validation; no Prisma enum exists to ALTER, so the
migration is documentation-only and the category-label drift-guard
gets the matching keys. Both new values feed back into the rendered
badges on the medication card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 47c8f52001 chore(release): v1.5.3
Medication scheduling: RRULE cadences, rolling intervals, one-shot
lifecycle, creation wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:14:52 +02:00
Marc-André BombeckandClaude Opus 4.7 9472899736 chore(meds): drop a forwarder and a reserved-code branch the audit flagged
Removes the 402 / 423 spare-code branch in the natural-language
extractor; the route surfaces budget exhaustion through the standard
error envelope, so the inline comment was an unused shim. The
recapCadence single-call wrapper was already retired alongside the
empty day-detail step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 a730ebf07b fix(meds): focus management and full-row hit area on the wizard
Step advance moves focus into the new step's first input so
keyboard and screen-reader users land where they need to type. The
cadence-picker rows now register clicks across the full 44 px row,
not just the radio glyph + label. The "describe it" trigger on
step 1 rises to 44 px so it clears the WCAG 2.5.5 floor the rest of
the codebase honours.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 4ccb70aff8 fix(meds): mount the natural-language overlay on /medications/new
The wizard's "✨ Beschreiben" button now opens the extractor on the
production route. The overlay merges its result onto the wizard
payload; one-shot prefills clear timesOfDay so the user has to pick
the dose time explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 2a9f0c1e06 fix(meds): collapse the medication-level oneShot toggle to a single source
The flat form's oneShot switch now drives the picker's allowed kinds
directly; the picker can no longer encode a contradictory shape.
Multi-schedule medications surface a confirmation when the switch
flips on, since the existing logic drops every schedule but the
first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 22164c6c8b fix(meds): honest step counter and a single source of one-shot truth in the wizard
The progress chip now matches the path the user actually walks (5
steps for one-shot, 6 for recurring after dropping the empty "day
detail" step). The cadence picker no longer offers a one-shot radio
once the mode picker has settled on recurring. The summary line on
the final step interpolates the actual weekdays / day-of-month /
yearly date rather than reading a category label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 17ea23a1cc fix(meds): cadence picker accepts an allowedKinds filter
Lets the wizard and the edit form constrain the radio list so the
one-shot kind never appears alongside a medication-level switch
that already encodes it. The unfiltered shape is preserved for
callers that don't pass the prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 a2de3c7a30 docs(api): document the medication natural-language extract route
Wires the v1.5 Coach-backed extractor route into the OpenAPI
registry with field descriptions covering the citation-guarded
return shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 1cc6f500bb fix(meds): reconcile one-shot active flag on every intake mutation
Extracts the lifecycle hook into reconcileOneShotState and wires it
into POST, PUT, and DELETE on the intake event. A user who logs the
single dose then undoes the log gets the medication back; a user who
flips skipped does too. The hook is idempotent on non-one-shot
medications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 c7e839f97a fix(meds): close the legacy-tier startsOn gap in the recurrence engine
The legacy fallback now honours medication.startsOn the way every
other dispatch tier does, so a legacy-shape schedule with a future
startsOn no longer emits historical slots. Adds a MAX_CHUNKS cap on
the forward walk for defence-in-depth, surfaces RRULE parse errors
through annotate, and stops the engine from compounding the user's
UNTIL/COUNT with its own UNTIL suffix. The header doc-comment is
rewritten to reflect that the read-flip for the legacy walkers is
the v1.5.1 release, not this one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 be7a2f135f fix(meds): tighten the v1.5 schedule schema invariants
Adds three Zod refines that close gaps the audit surfaced: one-shot
medications now require a startsOn, course windows refuse endsOn
before startsOn, and rolling-cadence schedules cap timesOfDay at one
entry. The wizard's reminders toggle is now part of the create
payload so the user's choice actually ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 b4b099003f test(meds): playwright e2e for every wizard cadence shape
Six specs walk the seven-step creation wizard end-to-end via the
German label surface, one per cadence kind, with shared step helpers
behind a single API stub. Verifies the medication lands in the list
view with the right next-due chip; one-shot suppresses the chip per
the design. Also closes the missing `wizard.page.backToList` key the
host route reads on /medications/new.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:42:10 +02:00
Marc-André BombeckandClaude Opus 4.7 030e97906c i18n(meds): localise the v1.5 scheduling surface across six locales
Populates messages/{de,en}.json with native copy for every key the
new picker primitives, the wizard, the edit-form refactor, and the
plain-language summary consume. The remaining four locales ship the
English fallback for v1.5.0; native polish lands in a follow-up.

CadencePicker rolling-explainer key flipped to `kind.rollingExplainer`
so the i18n resolver can return both the rolling label and its
explainer (the original `kind.rolling.explainer` path collided with
the `kind.rolling` string leaf — JSON cannot host both shapes on the
same prefix). Adds the dose-unit SI tokens (mg / g / ml) to the
placeholder-allowlist in the locale-integrity guard since those
labels are intentionally identical across every locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:31:15 +02:00
Marc-André BombeckandClaude Opus 4.7 1bdbc2106f feat(meds): natural-language extraction overlay for the create wizard
Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 53695f9434 feat(meds): seven-step creation wizard built on the v1.5 pickers
Adds a step-driven create flow that composes the cadence picker, the
times-of-day chips, and the course-window row. Surfaces a placeholder
slot for the natural-language extraction overlay that lands separately.
Pure helpers (validateStep, buildCreateBody, summariseCadence) carry
the test surface; the interactive flow is covered by Playwright in a
later commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 bb1a587f8b refactor(meds): wire the edit form through the v1.5 picker primitives
Drops the legacy weekday grid in favour of the cadence picker, the
times-of-day chips, and the course-window row. Pre-v1.5 medications
read-fall-back through inferCadenceFromLegacy; writes go to both
shapes so the v1.5.x dual-write window stays intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 a87a48a7c3 docs(api): document the medication + medication-schedule shapes
Annotates the medication Zod schemas with the metadata the OpenAPI
generator consumes and wires the seven medication routes into the
registry. The XOR constraint between rrule and rollingIntervalDays
is documented at the schema description so iOS code-gen surfaces the
mutual exclusion.

Routes covered: list + create on /api/medications; read + replace +
delete on /api/medications/{id}; intake log on
/api/medications/{id}/intake; cadence + compliance read on
/api/medications/{id}/cadence.

Medication is registered as a top-level component so the iOS Swift
codegen produces a shared struct backing the list and detail variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 57ff8776f5 feat(meds): reusable cadence-picker, times-of-day, and course-window components
Three components plus a shared `types.ts` module under
`src/components/medications/scheduling/`. The wizard + the form
refactor consume them; the picker emits a `CadenceValue`
discriminated union that the form maps onto the API request body.

### CadencePicker

A grouped flat radio list with 8 cadence kinds:

- Every day → `FREQ=DAILY`
- Certain weekdays (chip-toggle row) → `FREQ=WEEKLY;BYDAY=...`
- Every N weeks on certain days → `FREQ=WEEKLY;INTERVAL=N;BYDAY=...`
- Monthly on day → `FREQ=MONTHLY;BYMONTHDAY=N`
- Every N months on day → `FREQ=MONTHLY;INTERVAL=N;BYMONTHDAY=M`
- Yearly on date → `FREQ=YEARLY;BYMONTH=M;BYMONTHDAY=D`
- Rolling N days from last intake → `rollingIntervalDays: N`
- One-time dose → `oneShot: true`

The "Every N days flexible" option carries an explainer line so
the GLP-1 patient understands the rolling semantic. Defaults are
chosen so the encoded RRULE is always parseable (weekday set
defaults to Monday, yearly clamps to Jan 1) — the wizard / form
gates "continue" until the user picks the real day.

Sub-controls state is owned by the parent (the picker accepts
`subControls` + emits via the second `onChange` arg) so a wizard
that re-enters step 3 sees the user's previous picks.

### TimesOfDayChips

Multi-chip HH:mm input with morning / noon / evening / night
presets. Dedupes + sorts. `maxChips` prop honours the Zod cap of
8; when the parent passes `maxChips: 1` (one-shot context) the
component renders a single time picker instead of a chip row.

### CourseWindowRow

Two date pickers (`startsOn` + `endsOn`) with a "No end date"
toggle. `lockEndsToStart` prop forces the one-shot case where
both pickers share a date. Validation message rendered below the
row when `endsOn < startsOn`.

### Quality

- "use client" on every component file.
- shadcn/ui-only — no new deps, no shadcn Calendar primitive in
  this repo so date pickers fall back to `<Input type="date">`.
- WCAG 2.5.5 tap targets (44 px) across every interactive
  element; focus rings, aria-labels, aria-pressed on toggles,
  role="group" on chip rows.
- 49 tests across the three components (encoder mapping, sort /
  dedupe / preset toggle, range validation, SSR-only render
  smoke tests). Pure helpers exported so the contract is pinned
  at the unit-test level; user-event paths land in Playwright
  e2e per the synthesis quality bar.

i18n keys listed at the top of each component file under
`medications.scheduling.{cadence,timesOfDay,courseWindow}.*`. The
follow-up i18n agent populates DE + EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:22:33 +02:00
Marc-André BombeckandClaude Opus 4.7 925138eccb feat(meds): plumb v1.5 scheduling fields through POST + PUT routes
The Zod validators + the engine + the worker accept the new
v1.5 fields. The API route handlers didn't — they silently dropped
`startsOn` / `endsOn` / `oneShot` / `timesOfDay` / `rrule` /
`rollingIntervalDays` / `reminderGraceMinutes` before the Prisma
write. This commit threads them through.

### Route invariants

- One-shot consistency: `oneShot === true` ⇒ at most one schedule
  (422 otherwise), that schedule has neither `rrule` nor
  `rollingIntervalDays`, and `endsOn` is auto-normalised to equal
  `startsOn` on write.
- Recurring default: when `oneShot !== true` and the incoming
  schedule has no `rrule` / `rollingIntervalDays` / legacy
  `daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"` so the
  worker always sees a clean shape.
- Backwards compat: empty / absent `timesOfDay` is server-side
  populated from `windowStart` so the engine always sees a
  non-empty array, and legacy iOS v0.6.x clients that still post
  the `windowStart` / `windowEnd` / `daysOfWeek` shape keep
  working unchanged.

### Tests

9 new cases across both route test files (7 POST + 3 PUT, one
POST as a 7b subcase): one-shot create persistence, RRULE
persistence, rollingIntervalDays persistence, one-shot +
recurrence → 422, one-shot + multiple schedules → 422, PUT
endsOn updates, legacy POST back-fills timesOfDay, legacy POST
with no recurrence hints stamps daily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:22:17 +02:00
Marc-André BombeckandClaude Opus 4.7 2aa19fa770 test(meds): integration coverage for every v1.5 cadence shape
8 cases that exercise the schedule-resolution path against a
testcontainer Postgres + the canonical recurrence engine + the
reminder worker's helper seam:

1. **Bi-weekly RRULE** — `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE` anchored
   on a known Wednesday emits week 1, skips week 2, emits week 3.
   Pins the pre-existing worker bug closed.

2. **Monthly** — `FREQ=MONTHLY;BYMONTHDAY=1` emits 3 first-of-month
   slots across a 3-month window; in-between days don't fire.

3. **Quarterly** — `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15` emits 3
   slots across 9 months; off-quarter months don't fire.

4. **Rolling** — `rollingIntervalDays = 7`. Initial intake on day 0
   targets day 7 as the next slot; an earlier intake on day 5
   re-anchors to day 12, not day 14. Rolling emits only the
   immediately-next slot; past rolling occurrences are historical
   intake events, not predictable forward slots.

5. **One-shot lifecycle** — a take flips `medication.active` to
   false; a skip leaves it true.

6. **endsOn cap** — a daily schedule with `endsOn = today + 3d`
   emits 4 slots (today + day1 + day2 + day3); day 4+ skip.

7. **Legacy fallback** — a row with `rrule = null`,
   `daysOfWeek = "i2;3"` behaves the same as the explicit RRULE
   bi-weekly Wednesday case. Proves the engine's legacy path closes
   the worker bug for pre-v1.5 rows too — covers tenants that
   haven't been backfilled (defensive).

8. **One-shot skipped intake** — separate case verifying that
   skipping a one-shot does NOT auto-deactivate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:08:15 +02:00
Marc-André BombeckandClaude Opus 4.7 39430e16c0 feat(meds): auto-deactivate one-shot medications when the dose is logged
A "one-shot" medication is a single-administration course (flu shot,
vaccine, post-op final dose, one-time injection). The v1.5 schema
adds the `Medication.oneShot` flag; the intake route is the place
the lifecycle bottoms out.

When a one-shot's intake row is committed AND the event is an actual
take (`takenAt !== null && skipped === false`):

1. Flip `medication.active` to `false`.
2. Audit-log `medication.one_shot.completed` with the intake id and
   the medication id.
3. Annotate the wide-event so operators can see one-shot completions
   land cleanly.
4. Invalidate the medication-detail cache so the UI sees the new
   `active: false` on the next read.

A "skipped" event on a one-shot does NOT deactivate the medication.
The patient is saying "I didn't take this dose"; the medication
stays open until the real take is logged. Pinned by a test in the
new integration suite.

The deactivation runs AFTER the existing
`recomputeMedicationComplianceForEvent` call so a rollup-recompute
failure can't block the medication-lifecycle close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:08:09 +02:00
Marc-André BombeckandClaude Opus 4.7 344d1c33b0 feat(meds): route the reminder worker through the canonical recurrence engine
Two changes that ship as one because they share a single rewrite of
the worker's schedule-resolution site:

### The bi-weekly fix

`grep intervalWeeks src/lib/jobs/reminder-worker.ts` used to return
zero hits. The worker iterated `recurrence.daysOfWeek.includes(
todayDow)` and called it good — a bi-weekly Wednesday schedule
fired EVERY Wednesday, regardless of "on" vs "off" week. The
cadence chart respected the interval; the worker did not. This is
the single line at `reminder-worker.ts:514` and it's been wrong
since `intervalWeeks` was introduced.

The fix routes the same predicate through the new canonical engine
(`src/lib/medications/scheduling/recurrence.ts`). The engine
honours INTERVAL natively for RRULE schedules and honours the
legacy `intervalWeeks > 1` arithmetic on the fallback path. Both
paths now agree with the chart.

### New cadence types

The same rewrite picks up monthly / quarterly / yearly / rolling /
one-shot for free because the engine dispatches on the
schedule's shape. The worker doesn't special-case anything — it
just asks the engine.

### Rolling needs a `lastIntakeAt`

Rolling schedules anchor next-due on the latest
`MedicationIntakeEvent.takenAt`. The worker now does a conditional
findFirst on the medication's intake stream when the schedule list
contains a rolling row. The lookup is gated on
`med.schedules.some(s => s.rollingIntervalDays !== null)` so the
calendar-cadence common case keeps its single-query hot path.

### The seam — `worker-helpers.ts`

A small module exports `buildCanonicalSchedule`, `buildRecurrenceContext`,
`scheduleEmitsInWindow`. Keeps the worker file focused on phase math
and dispatch (it's 2347 LOC; a deeper rewrite is for another release)
and gives the integration tests a clean place to exercise the
schedule-resolution path with real Postgres rows.

### Dead code retired

- `todayDow` (line 492) — replaced by the engine's per-instant check.
- `getDayOfWeekInTz` alias (line 355) — last caller was `todayDow`.
- `parseScheduleRecurrence` import — the worker no longer touches the
  legacy decoder directly; the engine handles the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:07:41 +02:00
Marc-André BombeckandClaude Opus 4.7 65b923ad53 feat(medications): canonical recurrence engine + Zod validation
A single source of truth for "does this schedule fire at this
moment?" — replaces the three duplicate engines that grew up
alongside each other (`expandScheduleSlots` in scheduling/cadence,
`getNextOccurrenceTimestamp` on the medication card,
`getNextRecurrenceDate` in the medication form). The retirement of
the three legacy walkers is a follow-up wire-up step; this commit
ships the engine standalone with no callers so the foundation can
land on its own.

### `src/lib/medications/scheduling/recurrence.ts`

Three public functions:

- `occurrencesBetween(schedule, from, to, ctx)` — emit every slot
  in `[from, to]` inclusive, time-of-day applied, in chronological
  order.
- `nextOccurrenceAfter(schedule, after, ctx)` — strict-after; null
  when the schedule has terminated.
- `matchesInstant(schedule, instant, ctx)` — 1-minute-tolerance
  predicate for the reminder-worker fan-in.

The engine dispatches on the schedule's shape:

- One-shot → emit one slot per `timesOfDay` at the course anchor.
- Rolling → next-due = `lastIntakeAt + rollingIntervalDays * day`,
  fall back to `startsOn` / `createdAt` when never taken; emit only
  the immediately-next slot (past rolling occurrences are historical
  intake events, not predictable future slots).
- RRULE → delegate to the `rrule` library with
  `dtstart = startsOn ?? createdAt` and an optional `until = endsOn`.
- Legacy fallback (rrule + rolling both null, oneShot false) →
  decode the existing `daysOfWeek` string and emit weekly slots,
  **now correctly honouring `intervalWeeks > 1`** — the existing
  `expandTodayIntakes` skipped multi-week schedules; the engine
  emits them anchored at `startsOn` / `createdAt`.

Time-of-day application uses the canonical `wallClockInTz` helper
so a `08:00` schedule fires at Berlin 06:00 / 07:00 UTC depending
on DST, and a Tokyo 08:00 schedule fires at the correct UTC instant
regardless of the host clock's zone. The DST spring-forward case
(02:30 on the transition day in Europe/Berlin) forwards to 03:30
wall-clock — matches macOS / iOS / Google Calendar behaviour.

Grace window is computed from `reminderGraceMinutes` when set,
otherwise from the legacy `windowEnd - windowStart` span, with a
60-minute default for legacy single-time windows.

DoS protection: `nextOccurrenceAfter` walks forward in 90-day
chunks with a 10-year hard cap so a broken / pathological RRULE
can't spin the worker.

### Tests

28 cases pinning the contract for every dispatch branch:

- RRULE DAILY / WEEKLY / MONTHLY / QUARTERLY / YEARLY across
  multi-month windows.
- Bi-weekly Wed anchored — emits weeks 1/3/5, skips weeks 2/4/6.
  Pins the pre-existing `intervalWeeks` worker bug closed for the
  legacy fallback path.
- Rolling: last-intake anchor + null-intake fallback + endsOn cap.
- One-shot: emit + nextOccurrenceAfter returns null past it.
- DST spring-forward (Europe/Berlin) + cross-TZ (Asia/Tokyo).
- endsOn inclusive of full UTC day (matches `@db.Date` semantics).
- Empty `timesOfDay` falls back to `[windowStart]`.
- Grace window: explicit, legacy span, default 60 min.

### Zod validation

`scheduleSchema` extended with `timesOfDay` (HH:mm entries, max 8),
`reminderGraceMinutes` (1..1440 minutes), `rrule` (length-bounded +
regex against the RRULE properties HealthLog mints), and
`rollingIntervalDays` (1..365). A `.refine` enforces the
rrule-xor-rolling invariant at the validation layer (the DB CHECK
constraint is the second line of defence).

`createMedicationSchema` + `updateMedicationSchema` gain the
medication-level `startsOn`, `endsOn`, `oneShot` triplet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:52:38 +02:00
Marc-André BombeckandClaude Opus 4.7 f80e3375b2 feat(medications): v1.5 scheduling fields + backfill migration
Adds the columns the v1.5 cadence work needs without breaking any
v1.4-era reader. Pre-existing `windowStart` / `windowEnd` /
`daysOfWeek` stay live; v1.5.x writes both legacy + new; the read
flip lands in v1.5.1 once production traffic has validated the new
fields.

### Medication

- `startsOn DATE?` — course start; anchors RRULE BYDAY / BYMONTHDAY
  patterns and the rolling-interval countdown's first window. NULL
  preserves the legacy implicit "from creation" anchor.
- `endsOn DATE?` — course end; reminder worker stops minting slots
  past this date. NULL = chronic.
- `oneShot BOOLEAN NOT NULL DEFAULT FALSE` — single-administration
  flag (flu shots, vaccines, post-op final doses). Auto-deactivates
  the medication after the single intake is logged.

### MedicationSchedule

- `timesOfDay TEXT[]` — first-class HH:mm entries. Patients think
  in points-in-time, not in windows; the legacy single-window
  primitive misframed the mental model. Migration backfills
  `[windowStart]` for every pre-v1.5 row.
- `reminderGraceMinutes INT?` — replaces the implicit
  `windowEnd - windowStart` span for late-classification. NULL
  falls back to the legacy math.
- `rrule TEXT?` — RFC 5545 RRULE string for calendar-anchored
  cadences (every N weeks, monthly, quarterly, yearly, etc.).
- `rolling_interval_days INT?` — flexible-rolling interval counted
  from the latest intake. Mutually exclusive with `rrule` via the
  `medication_schedules_rrule_xor_rolling` CHECK constraint.

### Backfill

The migration walks every existing `medication_schedules` row and
derives an `rrule` + a single-entry `timesOfDay` from the legacy
encoding. Three branches:

- `days_of_week IS NULL / ""` → `FREQ=DAILY`
- `days_of_week = "1,3,5"` → `FREQ=WEEKLY;BYDAY=MO,WE,FR`
- `days_of_week = "i2;1,3,5"` → `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR`

A `pg_temp` helper handles the HealthLog-weekday → RFC-5545-BYDAY
conversion. Anything unexpected stays NULL so the v1.5.1 reader
falls back to the legacy decoder.

Indexes:

- Partial index `medication_schedules_rolling_idx` on
  `rolling_interval_days IS NOT NULL` so the reminder worker can
  fan in over rolling schedules cheaply on every intake event.

Column comments document the legacy → v1.5 → v1.6 migration plan
so a future reader doesn't accidentally drop the legacy columns
before the read-flip lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:52:11 +02:00
Marc-André BombeckandClaude Opus 4.7 3480a3d766 chore(deps): add rrule for richer medication scheduling cadences
The v1.5 medication-scheduling work needs to model monthly /
quarterly / yearly cadences plus arbitrary `INTERVAL` patterns. The
existing string encoding caps the interval at 4 weeks and has no
representation for month-level recurrence at all. RFC 5545 RRULE is
the industry standard for this and the `rrule` package is the
battle-tested TypeScript implementation (used by Google Calendar
sync, EventKit translation layers, FHIR servers).

Pin the latest stable. The library's release cadence has slowed
since 2022; a future migration to `rrule-temporal` is worth tracking
once Node 24 LTS lands, but the existing surface covers HealthLog's
needs without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:51:56 +02:00
Marc-André BombeckandClaude Opus 4.7 1757b35327 feat(auth): record an identifier HMAC on failed-login audit rows
Failed-login audit rows currently carry `ipAddress` plus `reason`
(`user_not_found_or_no_password` or `invalid_password`). The typed
identifier is deliberately omitted (H-1 contract: PII never lands
in operator artefacts) — but that leaves no anchor a future spray
detector can group on.

Recording `hashToken(identifier)` alongside the reason keeps the
H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one-
way; the raw identifier never serialises into the row) while
giving any future "same identifier hit from many IPs" detector a
correlation key.

Test mock for `hashToken` now returns a deterministic hex string
that does not echo the raw input — mirrors the real HMAC shape
and keeps the existing "audit row must not contain the typed
email" assertion passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:40:05 +02:00
Marc-André BombeckandClaude Opus 4.7 e74eff2979 fix(api): rate-limit POST /api/devices per user
`POST /api/devices` returns a distinct 409 when an `apnsToken` (or
the legacy `token`) is already bound to a different account —
`apns_token_owned_by_other_user` / `token_owned_by_other_user`. The
guard is correct (an attacker who can guess an APNs token shouldn't
be able to hijack another account's pushes), but without a rate
limit the same 409 signal lets an authenticated user enumerate
token ownership across accounts unboundedly.

A `checkRateLimit("devices:register:${userId}", 20, 15 * 60 * 1000)`
landing before the schema parse caps enumeration at 20 mutations
per 15 minutes per user. Real-world clients register a device once
on login plus once on each APNs token rotation; 20 / window is well
outside any legitimate use.

The 429 returns through `apiError()` for envelope parity, and a
`devices.register.rate-limited` wide-event annotation lands so
operators can see the limit fire. Test mock adds the
`@/lib/rate-limit` arm + re-arms the success path inside
`beforeEach` because `vi.resetAllMocks()` strips the top-level
`mockResolvedValue`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:59 +02:00
Marc-André BombeckandClaude Opus 4.7 a680785023 fix(auth): require user verification on every passkey ceremony
`generateRegistrationOptions` and `generateAuthenticationOptions` were
both passing `userVerification: "preferred"`. The WebAuthn spec
treats `preferred` as a soft hint — an authenticator that lacks (or
chooses to skip) a UV factor returns an assertion with
`userVerified: false`, and SimpleWebAuthn accepts it as a successful
ceremony. The registration carries the same `userVerified: false`
flag forever, so a credential created without a biometric / PIN /
fingerprint check cannot reach AAL3 on any later assertion either.

Flipping both call sites to `"required"` makes the UV check
mandatory at the authenticator-policy layer. Apple's platform
authenticators (Touch ID / Face ID / passcode) and every shipped
hardware authenticator support UV, so the user-visible flow is
unchanged for every realistic device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:51 +02:00
Marc-André BombeckandClaude Opus 4.7 b89a79554a docs: add CLAUDE.md as the maintainer's operating manual for the repo
A single-page reference for anyone editing the repository. Pairs with
README.md (the public face) and CHANGELOG.md (the release history);
the focus here is the conventions, gotchas, and structural boundaries
that don't show up by reading any one file in isolation.

Covers:

- The voice + privacy contract for committed artefacts (no AI /
  agent vocabulary, no personal names, no live health figures, no
  live tenant identifiers).
- Branch and release model (main = release-only, develop = feature
  target, semver-conservative).
- Tech stack at a glance with the load-bearing pins.
- A request-flow diagram that traces a request from the client edge
  through proxy.ts, apiHandler, route body, the response envelope,
  and back.
- Coding conventions (apiHandler everywhere, no mass assignment,
  raw SQL parameter-bound or whitelist-spliced, queryKey factory,
  Zod-OpenAPI registry stays in sync, …).
- The command surface for tests, typecheck, lint, OpenAPI gen,
  env-check, bundle inspection.
- A critical-files map keyed to the boundaries an editor cares
  about (HTTP edge, API kit, response envelope, crypto, sessions,
  refresh tokens, rollup tier, AI provider chain, notification
  dispatcher, OpenAPI registry, schema, compose).
- Security-relevant patterns to preserve on future churn (encryption
  fail-closed, isPublicUrl as the SSRF floor, rate-limit bucket
  convention, webhook secret + timingSafeEqual, idempotency scope,
  strict CSP, no CORS anywhere, centralised redaction).
- Self-hosting gotchas (SESSION_COOKIE_SECURE, pull_policy: always,
  encryption key rotation playbook, GeoLite2 staging, APNs .p8 key
  scoping + env-var contract).
- iOS handoff specifics (stats:* externalId overwrite contract,
  refresh-token rotation, requireAdmin cookie-only boundary,
  clientManaged opt-in, push_attempts ledger).
- DO-NOTs (no --no-verify, no force-push to main, no destructive git
  as a shortcut, no markdown library, no CORS headers, …).
- Pointers to the rest of the documentation set + the local
  codebase / security audit notes under .planning/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:17:47 +02:00
Marc-André BombeckandClaude Opus 4.7 53e7389854 fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint
`enforceBudget()` catches the cost dimension — once a user has burned
the day's token allowance the route returns 429 with
`coach.budget.exceeded`. It does not catch the request-rate
dimension: a tight loop or a stolen session can issue 60 requests in
a second, all accepted up to the moment the budget arithmetic
finally rejects, while pinning Prisma connections and provider-side
SSE slots in the meantime.

A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in
front of the budget gate. 20 requests per minute is well outside any
realistic interactive pace — a human doesn't type that fast and the
iOS client paces from user gestures — while keeping the budget logic
as the cost ceiling it was designed to be.

The 429 returns through `apiError()` for envelope parity with the
other rate-limited routes; an `insights.coach.rate-limited`
wide-event lands so operators can see when the limit fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:42 +02:00
Marc-André BombeckandClaude Opus 4.7 7dc9dbaa44 fix(crypto): cover the encrypted columns the rotation script was missing
`scripts/rotate-encryption-key.ts` enumerated columns manually and
predated three of them:

- `User.aiOpenaiKeyEncrypted` (`prisma/schema.prisma:86`, added in
  v1.4.3 alongside the other AI provider keys but never threaded
  into the rotation list).
- `IntegrationStatus.lastError` (`prisma/schema.prisma:1441`) — the
  AES-256-GCM ciphertext of upstream error payloads from the
  Withings / moodLog sync paths. Skipped on rotation, every row
  stayed on whichever key wrote it.
- `CoachMessage.encryptedContent` (`prisma/schema.prisma:1983`) —
  the only `Bytes` column in the encrypted set, so it needed a
  Buffer round-trip rather than the standard string path. Mirrors
  the `encryptToBytes` / `decryptFromBytes` pattern at
  `src/lib/ai/coach/persistence.ts:60-71`.

Without these three, the documented rotation flow ends with the
operator removing `v1` from `ENCRYPTION_KEYS` while rows still live
on the legacy key — and the next read of an affected row throws,
breaking AI Settings, the integration-status panel, or the Coach
history page depending on which column was missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:36 +02:00
Marc-André BombeckandClaude Opus 4.7 a4b53b1852 feat(api): add an opt-in body-size cap to safeJson
`safeJson()` previously called `request.json()` directly, which buffers
the entire body before parsing. Next.js imposes a runtime cap of around
4 MB on App Router handlers, so the worst case is bounded — but
routes that legitimately accept large batches can't tighten the cap
through the helper, and a 3.9 MB payload to a single-record route
still lands in heap before any Zod check fires.

The new `opts.maxBytes` parameter routes the request through
`request.text()`, checks the byte length first, and only then runs
`JSON.parse`. A 413 envelope returns when the cap trips. Existing
callers (every route that doesn't pass `opts`) keep their current
behaviour — the helper falls through to `request.json()` exactly as
before, no regression risk on the broader codebase.

The CSP-report endpoint inlines the same pattern with a 10 KB cap;
this generalises it so single-record routes can pass `{ maxBytes:
16_384 }` and batch routes can pass `{ maxBytes: 262_144 }` (or
whatever fits the schema's max array length × per-row budget).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:27 +02:00
Marc-André BombeckandClaude Opus 4.7 909c572bfd fix(auth): reject moodLog webhook secrets that fail envelope decryption
`readMoodLogSecret()` previously caught every decrypt failure and
returned the stored bytes verbatim, on the theory that legacy
plaintext rows should keep working through the migration grace
period. That fallback is correct for genuinely unprefixed legacy
values, but it also swallowed two failure modes that should never
silently succeed:

- An envelope-shaped row (`<keyId>.<payload>`) whose key id is no
  longer in the live `ENCRYPTION_KEYS` map. Returning the literal
  `v1.<base64-ciphertext>` as if it were the plaintext secret would
  cause every webhook attempt against that row to compare a known-bad
  literal — and worse, lock the user into a state where rotating the
  key out of the env quietly broke their integration.
- A row whose envelope bytes were tampered with directly in the
  column (SQL injection, restored-from-backup-with-tamper, leaked DB
  credentials). The catch-all returned the attacker-chosen value as
  the active secret; the webhook would then accept whatever the
  attacker sent over the wire.

The fix routes the envelope-shape check through `extractKeyId()` and
only catches the failure for the genuinely legacy path. Envelope
rows that fail to decrypt now throw — operators see the rotation gap
or the tampered row instead of running on a quietly-broken secret.

`isLegacyPlaintext()` simplifies to the same shape check: anything
with an envelope prefix is encrypted (even if currently undecryptable),
so it should not be re-rotated as if it were plaintext.

Two new test cases pin the contract for envelope-shape failures
(unknown key id; tampered ciphertext under a known key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:56 +02:00
Marc-André BombeckandClaude Opus 4.7 165a14afda fix(import): refuse zip-bomb expansions on the Apple Health export
The hand-rolled extractor called `inflateRawSync(compressed)` without
a `maxOutputLength` option and never inspected the central directory's
advertised uncompressed size, so a 1 KB ZIP could expand to tens of
gigabytes during inflate and OOM the shared Next.js process. The route
already caps the upload at 1.5 GB and rate-limits to 3 / min per user,
but a single successful inflate would still take the whole container
with it.

Two layers of defence land here:

- Pre-flight refusal on the central directory's `uncompressedSize`
  when it exceeds 8 GiB or when the advertised compression ratio
  exceeds 200×. This is a coarse signal — the metadata is attacker-
  controlled — but it catches honest-but-oversized payloads early.
- `inflateRawSync(compressed, { maxOutputLength: 8 GiB })` instructs
  `node:zlib` to throw rather than expand past the cap, which holds
  even when the metadata lies about the uncompressed size.

8 GiB leaves a wide ceiling for legitimate multi-year heavy-user
exports (the largest real-world ones settle in the low single-digit
GB range), while making the bomb class infeasible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:45 +02:00
Marc-André BombeckandClaude Opus 4.7 e16f4038ed fix(security): require admin Umami and GlitchTip URLs to be publicly reachable
Both fields are admin-supplied and feed into outbound server-side
fetches: the Umami script URL is proxied through
`/api/monitoring/umami-script` (and the script body is re-served from
the app origin under a strict CSP), and the GlitchTip DSN derives the
`envelopeUrl` / `storeUrl` the server posts errors to. The previous
validators only checked the protocol, so an admin (or an attacker
holding the admin account) could point either at a private-IP target
and drive the server into the RFC1918 / loopback / metadata space.

The `isPublicUrl` helper already governs every other admin-supplied
outbound URL the app dials; this aligns Umami + GlitchTip with that
posture. GlitchTip DSNs carry the public key in userinfo
(`https://<key>@host/projectid`) — the WHATWG URL parser strips the
userinfo from the hostname, so the helper validates the host as
expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:32 +02:00
Marc-André BombeckandClaude Opus 4.7 a4e4aaf009 fix(security): replace isAllowedUmamiUrl with the canonical isPublicUrl guard
`/api/send` is the public Umami proxy. The hand-rolled allowlist it
carried predated the central `isPublicUrl` helper in
`src/lib/validations/notifications.ts` and was missing every
notation-bypass class the helper catches:

- 100.64.0.0/10 CGNAT
- 0.0.0.0/8 (only the literal `0.0.0.0` was matched)
- 169.254/16 outside the AWS metadata IP (e.g. azure metadata is
  `169.254.169.254` too, but other addresses in the block are
  routable to other internal endpoints)
- IPv6 ULA (`fc00::/7`)
- IPv4-mapped IPv6 (`::ffff:127.0.0.1`)
- Leading-zero IPv4 octal bypass (`http://010.0.0.1` → 8.0.0.1)
- Hex IPv4 (`http://0x7f.0.0.1`)
- Pure-decimal IPv4 (`http://2130706433`)

The dev-mode escape hatch is also dropped — serving the proxy against
a localhost / RFC1918 Umami target was never a useful configuration
anyway, since the proxy sends the visitor's user-agent + body upstream
which only makes sense against an externally-reachable analytics host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:26 +02:00
Marc-André BombeckandClaude Opus 4.7 fc83d8541f fix(ai): bound every provider fetch with a 60 s AbortSignal timeout
The OpenAI, Anthropic, and local OpenAI-compatible clients all used a
bare `fetch()` with no `signal`. A tar-pit upstream (a misconfigured
local Ollama on a stalled GPU, a flaky proxy in front of a hosted
provider, or a deliberately malicious user-configured LOCAL endpoint)
could accept the POST and then hang indefinitely, pinning the request
handler until the connection died on the socket level.

A 60 s ceiling lands well outside any legitimate completion budget
(production timings live in `getEvent()?.addExternalCall` traces) while
capping the worst case.

The Codex client uses an SSE stream with its own loop and stays as-is
for now; a follow-up can thread the same ceiling through the streaming
read once the contract there is settled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:05:12 +02:00
Marc-André BombeckandClaude Opus 4.7 2d5b4cfac1 fix(observability): extend the redaction denylist with passphrase, otp, recovery, dsn
The wide-event `received_shape_excerpt` surface walks every body key
through `SENSITIVE_KEY_PATTERNS` before the eventual JSON.stringify.
The original set covered passwords, tokens, secrets, API keys, the
Authorization header, CSRF state, and nonces. Four additions worth
keeping out of observability sinks:

- `/passphrase/i` — backup-encryption passphrases (off-host backups
  use one) and any future user-facing recovery-phrase flows.
- `/otp/i` — TOTP / email-OTP one-time codes for future second-factor
  surfaces.
- `/recovery/i` — passkey-recovery codes and account-recovery flows.
- `/dsn/i` — Glitchtip / Sentry-style DSNs are `https://<key>@host`
  shapes where the key is auth material; today they enter the system
  via admin settings but a future debug surface might quote one back
  in a body excerpt.

Test pins the four new patterns alongside the existing required set,
and a positive-redaction case covers all four in one shot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:05:05 +02:00
Marc-André BombeckandClaude Opus 4.7 4b4fb7aaf8 feat(security): emit COOP, CORP, and X-Permitted-Cross-Domain-Policies headers
Three additions to the production header set in `src/proxy.ts`:

- `Cross-Origin-Opener-Policy: same-origin` isolates the
  BrowsingContextGroup from cross-origin popups. Closes the
  `window.opener` reference channel for Spectre-class side-channel
  attacks. The OAuth flows (Withings, Codex) are top-level redirects
  rather than popups, so they aren't affected.
- `Cross-Origin-Resource-Policy: same-origin` narrows the page's
  resources to same-origin loaders, complementing the existing
  `frame-ancestors 'none'` CSP rule for non-document subresources.
- `X-Permitted-Cross-Domain-Policies: none` shuts the Flash / Adobe
  Reader `crossdomain.xml` channel that some scanners still flag.

All three land in every response; production-only HSTS stays in its
existing `!isDev` block. No CSP changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:04:54 +02:00
Marc-André BombeckandClaude Opus 4.7 a60c924c02 chore(docker): add .dockerignore to keep secrets and scratch out of the build context
BuildKit walks the working tree (not the git index), so files like a
local `.env`, `.planning/`, `coverage/`, the e2e Playwright
`storageState.json`, captured HARs, and `.git/` history were all shipped
into the build cache by default. The runner stage doesn't COPY any of
them into the final image, but they sit in the builder layer cache and
leak through any operation that reads cache layers (e.g. `docker save`).

The list mirrors `.gitignore` for the entries that matter, plus the
scratch directories that live in the working tree but aren't version
controlled. `.env.example` and `.env.production.example` stay explicitly
allowed so the documented self-host bootstrap keeps working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:04:48 +02:00
Marc-André BombeckandClaude Opus 4.7 261cd32705 chore(gitignore): extend planning-scratch exclusions to cover session-scoped notes
Mirrors the existing v14*-/QA-/round- patterns. Local audit and survey
artefacts written under `.planning/marathon-*`, `.planning/security-audit-*`,
`.planning/session-*`, and any future v15-stream scratch never need to
leave the working tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:40:56 +02:00
494 changed files with 62426 additions and 6166 deletions

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
# Keep the BuildKit build context small and free of secrets / scratch.
# Mirrors `.gitignore` for the files that matter, with a few additions
# (planning scratch, e2e storage state) that aren't in version control
# but live in the working tree.
# Local environment files. The image reads its runtime config from the
# compose `environment:` block at boot — `.env` should never ship.
.env
.env.*
!.env.example
!.env.production.example
# Version control + CI metadata.
.git
.github
.gitleaks.toml
# Build / install output that gets regenerated inside the builder stage.
node_modules
.next
out
coverage
test-results
playwright-report
e2e-results
.eslintcache
# Persisted session state from e2e runs.
e2e/setup/storageState.json
# Planning scratch + per-release audit notes (operator-local).
.planning
docs/audit
# Captures from local debugging sessions.
*.har
*.heapsnapshot
# Editor / agent worktrees + caches.
.claude
.cursor
.aider*
.vscode
.idea
.DS_Store
+18
View File
@@ -60,3 +60,21 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# BACKUP_S3_ACCESS_KEY=""
# BACKUP_S3_SECRET_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# -----------------------------------------------------------------------------
# Dashboard snapshot -- BUILD-TIME toggle (default ON)
# -----------------------------------------------------------------------------
# The dashboard hydrates every above-the-fold tile from the single
# `GET /api/dashboard/snapshot` cell so the whole strip shares one
# completion moment. This is the default and the desired behaviour.
#
# `NEXT_PUBLIC_*` vars are inlined into the client bundle at BUILD time,
# not read at runtime -- so this has NO effect on the prebuilt GHCR
# image and is intentionally absent from the `docker-compose.yml`
# runtime `environment:` whitelist. To fall back to the legacy
# four-cell path you must rebuild the image with the var set to "false":
#
# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG
#
# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false"
+5 -5
View File
@@ -75,7 +75,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# GHCR rejects repository refs with uppercase letters. `IMAGE_NAME`
# is derived from `github.repository`, which preserves the case of
@@ -101,7 +101,7 @@ jobs:
- name: Compute image labels
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_REF }}
labels: |
@@ -251,14 +251,14 @@ jobs:
steps:
- name: Download digest artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# GHCR requires the repository ref to be lowercase. See the same
# step in the `build` job above for the longer rationale.
@@ -275,7 +275,7 @@ jobs:
- name: Compute image tags & labels
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_REF }}
tags: |
+4
View File
@@ -76,9 +76,13 @@ next-env.d.ts
/.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-*
# Docs scratch — temporary spec drafts that live in the repo only
# while a release is in flight.
@@ -0,0 +1,35 @@
# 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.
@@ -0,0 +1,175 @@
# 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.
@@ -0,0 +1,376 @@
# 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:<id>:<YYYY-MM-DD>` 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-<UUID>"` 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<MoodEntry>`). 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.
@@ -0,0 +1,35 @@
# 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.
@@ -0,0 +1,52 @@
# 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.
@@ -0,0 +1,238 @@
# 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.
@@ -0,0 +1,471 @@
# 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=<opaque>&limit=200
Authorization: Bearer <access-token>
```
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": "<opaque-next>", // 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.
@@ -0,0 +1,46 @@
# 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.
@@ -0,0 +1,72 @@
# 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.
@@ -0,0 +1,204 @@
# 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://<host>/.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.
@@ -0,0 +1,95 @@
# 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.
@@ -0,0 +1,77 @@
# 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.
@@ -0,0 +1,615 @@
# 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 `<ResponsiveSheet>` 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 `<Sheet side="bottom">` 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 `<MedicationWizardDialog>` change:
```tsx
<ResponsiveSheet
...
className="sm:max-w-[560px] min-h-[60dvh] md:min-h-0"
bodyClassName="gap-0 p-0"
showCloseButton // already true; explicit for the audit
footer={...}
>
```
The Dialog branch of `ResponsiveSheet` already applies `max-h-[calc(100dvh-2rem)]` and `overflow-y-auto` through the shared `<DialogContent>` 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 `<DialogContent>` and `<SheetContent>` at top-right. The wizard currently passes `showCloseButton` and `<ResponsiveSheet>` 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 `<DialogContent>` 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 `<span class="sr-only">{t("common.close")}</span>`. 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 `<Progress value={progress} className="h-1" />`. 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 `<Progress>` 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
<Progress
value={progress}
className="wizard-progress-bar h-1.5"
data-step={step}
aria-label={stepOf}
/>
```
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(...).
<div
ref={bodyRef}
className="wizard-step-body space-y-5 p-5 sm:space-y-6 sm:p-6"
data-slot="wizard-step-body"
data-direction={direction}
key={step} // existing — forces re-mount on step change
>
```
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 `<MedicationDetailSection>` 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: `<Badge variant="secondary">` 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 `<Button variant="outline" size="sm">` with the `Pencil` Lucide icon and the `t("common.edit")` label, fires `setEditDialogOpen(true)` → mounts `<MedicationWizardDialog mode="edit" initial={…} />` (see §5).
- API: read via `queryKeys.medicationDetail(id)` from `GET /api/medications/[id]` — already used by `/medications/[id]/history/page.tsx`.
Empty state: not applicable (a 404 redirects before the page mounts).
### 3.2 Today's dose card
```
┌───────────────────────────────────────────────────┐
│ Heute │
│ Geplant: 08:00 │
│ [✓ Genommen] [⤳ Verschoben] [✗ Übersprungen] │
└───────────────────────────────────────────────────┘
```
- Component: NEW `<TodaysDoseCard>` in `src/components/medications/todays-dose-card.tsx`. Uses `<Card>` from shadcn for chrome. Three primary buttons in a `flex gap-2 flex-wrap` row.
- API: `POST /api/medications/[id]/intake` with `{ status: "TAKEN" | "SKIPPED" | "DEFERRED" }`. The route already exists; the shape mirrors the per-card action on the list page.
- Query key: invalidates `medicationDependentKeys` and `queryKeys.medicationDetail(id)` on success.
- Each button: `min-h-11` mobile tap target (44 px floor), `sm:min-h-9`. Inside the Card, `p-5 sm:p-6 space-y-4`.
- Empty-state copy DE: `Heute keine Einnahme geplant.` rendered when the cadence has no due-event for today.
### 3.3 Cadence summary
```
┌───────────────────────────────────────────────────┐
│ Rhythmus │
│ Wöchentlich, mittwochs · 08:00 │
│ Kurs: 12.03.2026 → 12.06.2026 │
│ [✏ Bearbeiten] │
└───────────────────────────────────────────────────┘
```
- Component: NEW `<CadenceSummaryRow>` in `src/components/medications/cadence-summary-row.tsx`. Mounts `<MedicationDetailSection>` for chrome.
- Logic: reuses the existing `summariseCadence(payload, t)` helper at `src/components/medications/wizard/wizard-payload.ts:657` — the wizard's Step 8 summary line. Pass the medication payload + the translator and render the returned string.
- Edit affordance: outline button identical to the header's, opens the wizard prefilled with the current state.
- API: read-only; no mutations on this row.
- Query key: shares `queryKeys.medicationDetail(id)` with the header.
- Empty state: only appears for course-mode + recurring meds; one-shot meds render a single-line `Einmalig am DD.MM.` and hide the Bearbeiten affordance (the wizard handles one-shot edits but the row label changes).
### 3.4 Dose ladder / Phasen (GLP-1 only)
Reuses the existing `<TitrationSection>` at `src/components/medications/TitrationSection.tsx`. Currently mounted only when `treatmentClass === "GLP1"` on the history page; lift the same conditional onto the detail page. Reads `GET /api/medications/[id]/titration`; query key `queryKeys.medicationTitration(id)`.
The PhaseConfigDialog functionality (green/yellow/orange/red bands) is NOT this section — that's the §7 SettingsSection sub-row.
Empty state: when the medication is GLP-1 but no titration is configured, the existing component already handles the placeholder; no new copy.
### 3.5 Intake history preview
```
┌───────────────────────────────────────────────────┐
│ Einnahmen [⤴ Importieren] │
│ │
│ Heute │
│ 08:02 7,5 mg ✓ Genommen ⋯ │
│ │
│ Diese Woche │
│ Mi 08:00 7,5 mg ✓ Genommen ⋯ │
│ So 08:00 — ✗ Übersprungen ⋯ │
│ │
│ Älter │
│ Mi 22.05 08:00 7,5 mg ✓ Genommen ⋯ │
│ │
│ [ Alle Einnahmen anzeigen ] │
└───────────────────────────────────────────────────┘
```
- Component: NEW `<IntakeHistoryPreview>` wrapping `<IntakeHistoryListV2>` with new props (see §6). Uses `<MedicationDetailSection>` for chrome.
- API: `GET /api/medications/[id]/intake?limit=14&offset=0&status=completed&sortBy=takenAt&sortDir=desc`.
- Query key: `queryKeys.medicationIntakeList(id, { sortBy: "takenAt", sortDir: "desc", limit: 14, offset: 0, status: "completed" })`. Already in the factory — no new key.
- Grouping: client-side bucket by `takenAt` (or `scheduledFor` if no taken) into Heute (today), Diese Woche (this ISO week excluding today), Älter (everything before).
- Per-row affordance: mobile uses a swipe-to-reveal pattern; desktop renders a kebab. Both fire `setEditingEvent({ id, scheduledFor })` or `setDeletingEvent({ id })` on the parent.
- The "Alle Einnahmen anzeigen" CTA links to `/medications/[id]/history` (the existing dedicated page).
- The "⤴ Importieren" CTA opens the existing `IntakeImportDialog` (currently orphaned at `src/app/medications/page.tsx:344`); the detail page lifts the dialog onto its own surface and wires the trigger.
- Empty state DE (already in i18n): `Noch keine Einnahmen erfasst.` from `messages/de.json:1001` + the existing action chip routing to `/medications`.
### 3.6 Notifications
```
┌───────────────────────────────────────────────────┐
│ Erinnerungen │
│ [●— ] │
│ APNs · Telegram · Web-Push │
│ Ruhezeit 22:00 – 07:00 │
└───────────────────────────────────────────────────┘
```
- Component: NEW `<NotificationsSection>` in `src/components/medications/sections/notifications-section.tsx`. Uses `<MedicationDetailSection>`.
- Primary control: `<Switch>` from shadcn (`src/components/ui/switch.tsx`). On toggle: `PUT /api/medications/[id]` with `{ notificationsEnabled }`. Optimistic update — flip the cache, rollback on error.
- Helper line below the switch: lists the active push channels as `<Badge variant="outline">` chips, reading from `queryKeys.notificationsStatus()`. Single-line read; no per-channel toggles on this surface (those live in global Settings).
- Query keys: invalidates `queryKeys.medicationDetail(id)` + `medicationDependentKeys` on success.
- iOS clientManaged note: when the calling session has `notificationPrefs.clientManaged === true`, render a single `text-xs text-muted-foreground` line: `Diese Erinnerungen werden auf deinem iPhone verwaltet.` (new DE key `medications.notifications.clientManagedNote`).
- Empty state: not applicable — the switch always renders.
### 3.7 Settings consolidation
R-2's verdict: inline `<SettingsSection>`, NOT a Sheet/Drawer. Locked.
```
┌───────────────────────────────────────────────────┐
│ Einstellungen │
│ │
│ ┌─ Externe Integration ───────────────────────┐ │
│ │ Token „iOS" Erstellt 12.03. Widerrufen │ │
│ │ Token „Bot" Erstellt 02.04. Widerrufen │ │
│ │ [ + Neuen Token erstellen ] │ │
│ │ Endpunkt: POST /api/medications/abc/intake │ │
│ │ [ Kopieren ] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌─ Einnahmen importieren ─────────────────────┐ │
│ │ CSV oder JSON. Lädt deine bestehenden │ │
│ │ Einnahmen in die Historie. │ │
│ │ [ ⤴ Importieren ] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌─ Erinnerungsphasen ─────────────────────────┐ │
│ │ Grün 60 Min vor Kursende │ │
│ │ Gelb 30 Min vor Kursende │ │
│ │ Orange 0 Min nach Kursende │ │
│ │ Rot 240 Min nach Kursende │ │
│ │ [ Auf Standard ] [ Speichern ] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌─ Erinnerungsfenster (Grace) ────────────────┐ │
│ │ Kulanz in Minuten: [ 15 ▼ ] │ │
│ │ Erinnerungen sind innerhalb dieses Fensters │ │
│ │ noch fällig. │ │
│ └───────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
- Component: NEW `<SettingsSection>` in `src/components/medications/sections/settings-section.tsx`. Wraps `<MedicationDetailSection>` and composes four sub-rows. Each sub-row is its own component file so the unit tests stay narrow.
Sub-rows + wiring:
| Row | Component | Mount when | API | Invalidate |
|---|---|---|---|---|
| Externe Integration | `<ApiTokensRow>` (resurrects `ApiEndpointDialog` body inline) | always | `GET /api/medications/[id]/api-endpoint`, `POST /api/medications/[id]/api-endpoint` to mint, `DELETE /api/tokens/[id]` to revoke | `queryKeys.tokens()` + `queryKeys.medicationDetail(id)` |
| CSV-Import | `<CsvImportRow>` | always | opens `IntakeImportDialog` → `POST /api/medications/[id]/intake/import` | `queryKeys.medicationIntakeList(...)` prefix + `medicationDependentKeys` |
| Phasen | `<PhaseManagementRow>` (inline editor, NOT a nested dialog) | `treatmentClass === "GLP1"` + has course window | `GET/PUT/DELETE /api/medications/[id]/phase-config` | `queryKeys.medicationPhaseConfig(id)` |
| Grace | `<GraceMinutesRow>` | always | `PUT /api/medications/[id]` with `{ reminderGraceMinutes }` (per-schedule field — applied to the medication's primary schedule for the v1.5.5 surface; multi-schedule grace is a v1.5.x follow-up) | `queryKeys.medicationDetail(id)` |
PhaseManagementRow's decision (R-2 left it open): inline editor, not a focused modal. The four rows fit in 320 px of vertical space; nesting a dialog inside a settings card is the v1.5.4 mistake repeated. Rows render as a `grid grid-cols-[1fr,5rem,4rem,1fr] gap-2 items-center` (label / numeric input / MINUTES toggle / suffix). The "Auf Standard zurücksetzen" and "Speichern" CTAs sit in a footer row below the grid (`flex justify-end gap-2 pt-3`). Status banner becomes a `toast.success(t("medications.phaseSaved"))` — drop the 2 s auto-clear banner.
Empty states:
- ApiTokensRow with no tokens: `Noch kein Token erzeugt.` + the create button.
- PhaseManagementRow when no course window is set: rendered as a stub row `Phasen sind nur mit Kurs-Fenster verfügbar.` linking back to the cadence-summary edit affordance.
### 3.8 Destructive zone
```
┌───────────────────────────────────────────────────┐
│ Gefahrenzone │
│ │
│ Pausieren [●— ] │
│ Erinnerungen pausieren, Verlauf bleibt. │
│ │
│ ───────────────────────────────────────────── │
│ │
│ Beenden [Medikament beenden] │
│ Kein neuer Verlauf, alte Einträge bleiben. │
│ │
│ ───────────────────────────────────────────── │
│ │
│ Verlauf löschen [Verlauf löschen] │
│ Nur Einnahmen löschen, Medikament bleibt. │
│ │
│ Medikament löschen [Medikament löschen] │
│ Löscht das Medikament samt allen Einnahmen. │
│ │
└───────────────────────────────────────────────────┘
```
- Component: NEW `<DestructiveZoneSection>` in `src/components/medications/sections/destructive-zone-section.tsx`. Uses `<MedicationDetailSection>` with `title={t("medications.dangerZoneTitle")}` (already in i18n at `messages/de.json:2260`).
- Row spacing: `space-y-4` with `<Separator>` from shadcn (`src/components/ui/separator.tsx`) between tiers.
- Tier 1 — Pausieren: `<Switch>`, no confirmation. `PUT /api/medications/[id]` with `{ active: false, pausedAt: <iso> }`. Optimistic. Header pill flips to "Pausiert". Fully reversible.
- Tier 2 — Beenden: single-step `<AlertDialog>` confirmation. `PUT /api/medications/[id]` with `{ endsOn: <today-iso> }` so the existing course-window-ended path kicks in. Title `Medikament beenden?`; body `Keine weiteren Erinnerungen. Alte Einträge bleiben sichtbar.`; destructive action button `Beenden`.
- Tier 3a — Verlauf löschen (purge intake): single-step `<AlertDialog>` confirmation per the existing `settings/advanced-section.tsx` precedent. `DELETE /api/medications/[id]/intake/purge`. Title `Verlauf wirklich löschen?`; body `Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.`; destructive action.
- Tier 3b — Medikament löschen: single-step `<AlertDialog>` confirmation, same primitive. `DELETE /api/medications/[id]`. Title `Medikament löschen?`; body lists what gets purged. After success, `router.push("/medications")`.
The type-back-name guard mentioned in the brief is overkill for the project's precedent — `settings/advanced-section.tsx:287-317` ships a single-step alert dialog for the user-wide purge, which is a strictly more destructive action than a per-medication delete. Match that precedent: single-step `<AlertDialog>` with destructive accent on the confirm button. Documented as an open question (§13) so Marc can override.
## 4. Restored-feature placement table
Every row traces to an `I-1-deleted-features-inventory.md` row.
| # | Feature (Marc's German label) | Trigger today | v1.5.4 status | v1.5.5 home | Section | Component |
|---|---|---|---|---|---|---|
| 1 | Bearbeiten | none — wizard handles it | LIVE | wizard (edit mode) | Header band + Cadence summary | `<MedicationWizardDialog mode="edit">` |
| 2 | Löschen (Medikament) | none | ORPHAN | tier 3b destructive | Destructive zone | `<DestructiveZoneSection>` + `<AlertDialog>` |
| 3 | Pausieren / Aktivieren | none | ORPHAN | tier 1 destructive | Destructive zone | `<DestructiveZoneSection>` `<Switch>` |
| 4 | Benachrichtigungen aus / ein | none | ORPHAN | Notifications switch | Notifications | `<NotificationsSection>` |
| 5 | Einnahmen importieren | none | ORPHAN | Settings CSV row + Einnahmen header | Settings + Intake history | `<CsvImportRow>` + `<IntakeHistoryPreview>` header CTA → existing `IntakeImportDialog` |
| 6 | API-Endpunkt | none | ORPHAN | Externe Integration sub-row | Settings | `<ApiTokensRow>` (resurrects `ApiEndpointDialog` body inline) |
| 7 | Titrations-Phasen konfigurieren (GLP-1) | none | ORPHAN | Phasen sub-row (inline) | Settings | `<PhaseManagementRow>` (replaces `PhaseConfigDialog` modal) |
| 8 | Aufzeichnungen löschen (Purge) | none | ORPHAN | tier 3a destructive | Destructive zone | `<DestructiveZoneSection>` + `<AlertDialog>` |
| 9 | Zurücksetzen (form reset) | none | n/a — create flow only | (deferred) | wizard | wizard internal — out of scope per I-1 |
| 10 | GLP-1 Wochentags-Preset | wizard Step 5 | LIVE (in wizard) | wizard Step 5 | wizard | `<Step5Cadence>` |
| 11 | Schedule hinzufügen / entfernen | wizard Step 8 | LIVE (in wizard, single-schedule edit; multi-schedule compose deferred) | wizard Step 8 | wizard | `<Step8Summary>` |
| 12 | Legacy reminder window fallback | wizard Step 7 | LIVE (in wizard via `legacyOnLoad`) | wizard Step 7 | wizard | `<Step7Times>` |
| 13 | One-Shot Switch | wizard Step 4 | LIVE (in wizard) | wizard Step 4 | wizard | `<Step4Window>` |
| 14 | Course-Window-Picker | wizard Step 4 | LIVE (in wizard) | wizard Step 4 | wizard | `<Step4Window>` + `<CourseWindowRow>` |
| 15 | Einnahmen bearbeiten (per-dose) | `/medications/[id]/history` row (read-only today) | DEGRADED | Intake history preview row kebab/swipe | Intake history preview | `<IntakeHistoryListV2>` with `onEditIntake` |
| 16 | Einnahmen löschen (per-dose) | same | DEGRADED | same | Intake history preview | `<IntakeHistoryListV2>` with `onDeleteIntake` |
Items 1, 10, 11, 12, 13, 14 stay in the wizard per §5. Items 2–8, 15, 16 land on the detail page. Item 9 is the only one explicitly out of scope (I-1 §1, "n/a — not on detail page; lives on wizard").
## 5. Edit-the-medication trigger
Contract:
- Wizard owns: `name`, `category`, `treatmentClass`, `dose`, `dosesPerUnit`, `oneShot`, `startsOn`, `endsOn`, `schedules[]`.
- Detail page owns everything else: `active`, `notificationsEnabled`, `pausedAt`, phase-config bands, API tokens, CSV import, reminder grace, intake CRUD, destructive cascade.
Two triggers open `<MedicationWizardDialog mode="edit" initial={medicationToPayload(med)} />`: the header band's `[✏ Bearbeiten]` outline button (lands on Step 1) and the cadence-summary row's `[✏ Bearbeiten]` button (lands on `landingStepForEdit(payload)` per the existing helper — currently the cadence step).
The list page keeps its own wizard mount for create + per-card edit; the detail page mounts a second copy with its own state. On save the wizard already invalidates `medicationDependentKeys`; the detail page additionally invalidates `queryKeys.medicationDetail(id)` so the header re-renders.
## 6. Intake history detail
Two surfaces:
### 6.1 Inline preview on `/medications/[id]/page.tsx`
`<IntakeHistoryPreview>` wraps `<IntakeHistoryListV2>` and projects only the last 14 rows. The grouped Heute / Diese Woche / Älter rendering lives in the wrapper, not in V2 (V2 stays a flat sortable table for the dedicated history page).
### 6.2 Dedicated surface — `/medications/[id]/history/page.tsx`
Already exists. Bulk surface, month-grouped pagination, the existing `<IntakeHistoryListV2>` table, the GLP-1 sub-sections (`DrugLevelChart`, `SideEffectsSection`, `SchedulingSection`, `TitrationSection`). v1.5.5 adds bulk-delete: a header CTA `Auswählen` flips the table into multi-select mode (`<Checkbox>` per row, header-row "Alle" checkbox), and a sticky-bottom action bar surfaces `{n} ausgewählt` + a single destructive `<AlertDialog>` confirming the batch delete. Backend: existing `DELETE /api/medications/[id]/intake/[eventId]` looped via `Promise.allSettled` — no new API route.
### 6.3 `<IntakeHistoryListV2>` prop additions
Current props: `medicationId`, `pageSize`. The list is read-only today (the JSDoc spells this out — "No edit-in-row, no delete buttons").
Add three optional props:
```ts
interface IntakeHistoryListV2Props {
medicationId: string;
pageSize?: number;
/** When provided, each row renders a per-row trigger that fires this. */
onEditIntake?: (event: IntakeEvent) => void;
/** When provided, each row renders a per-row trigger that fires this. */
onDeleteIntake?: (event: IntakeEvent) => void;
/** Bulk mode — adds a leading checkbox column + emits selection updates. */
selection?: {
selected: Set<string>;
onToggle: (id: string) => void;
};
}
```
The detail-page preview wraps V2 with `onEditIntake` and `onDeleteIntake` callbacks that open a `<ResponsiveSheet>`-backed edit sheet (re-use `responsive-sheet.tsx`) and an `<AlertDialog>` respectively. The history page passes a `selection` prop when the user enters bulk mode.
The trigger element on mobile is a swipe-to-reveal Bearbeiten + Löschen affordance using CSS scroll-snap (no JS gesture library — the project bans new dependencies). On desktop it's a `<DropdownMenu>` kebab at the row's right edge. Both targets respect the 44 px floor.
Edit mutation: `PUT /api/medications/[id]/intake/[eventId]` with `{ takenAt? , scheduledFor?, skipped?, notes? }`. Invalidates the `["medications", id, "intake", "list"]` prefix.
Delete mutation: `DELETE /api/medications/[id]/intake/[eventId]`. Same invalidation prefix.
## 7. Settings consolidation
§3.7 is the authoritative spec. Decision the brief asked: **inline editor for Phasen, no nested dialog.** The four bands fit comfortably; nesting modals inside settings cards repeats the v1.5.4 mistake.
## 8. Destructive zone
§3.8 is the authoritative spec. Three tiers, single-step `<AlertDialog>` on Tier 2 + 3a + 3b matching the existing project precedent in `settings/advanced-section.tsx:287-317`. Type-back-name guard filed as open question §13.
## 9. Status-bar morph — pinned spec
§2.6 holds the implementation-locked CSS. Restated as a contract:
- One CSS class (`.wizard-progress-bar`) lives in `globals.css`.
- The class drives a `clip-path: inset(...)` interpolation across four `data-step` buckets — step 1 (half-circle leading edge), steps 2–4 (rounded square), steps 5–7 (capsule), step 8 (full capsule).
- Transition: `clip-path` and `width` both at `280ms cubic-bezier(0.32, 0.72, 0, 1)`.
- The `<Progress>` indicator's underlying slot (`data-slot="progress-indicator"`) is targeted — this is the radix-ui Progress primitive's stable selector, exposed by shadcn.
- `prefers-reduced-motion: reduce` collapses the transition to `none`; the clip-path still applies but snaps. No animation runs.
- No JS state machine. The morph rides entirely on the `data-step={step}` attribute already available in the wizard.
R-2 specified "spring damping 25–30" for Material 3 Expressive. The CSS-only equivalent is `cubic-bezier(0.32, 0.72, 0, 1)` — Apple's iOS-default ease-out attack, well-known in design-press literature as the cleanest spring-emulation in pure CSS. 280 ms total because R-2's three-shape morph at 60 fps reads as crisp at ≤ 300 ms and laggy past 400 ms.
## 10. Iconography & typography map
Lucide icons per section:
| Section | Lucide |
|---|---|
| Header edit | `Pencil` |
| Today's dose | `Sunrise` |
| Cadence summary | `Repeat` |
| Dose ladder | `TrendingUp` |
| Intake history | `History` (matches V2 empty state) |
| Notifications | `Bell` |
| Settings | `Settings2` |
| Externe Integration | `KeyRound` |
| CSV Import | `Upload` |
| Phasen | `Layers` |
| Grace | `Timer` |
| Destructive zone | `AlertTriangle` |
| Pausieren / Beenden / Löschen | `Pause` / `Square` / `Trash2` |
Sizing: `h-5 w-5` next to a section title; `h-4 w-4` inside body rows. All `aria-hidden="true"` because labels carry the accessible name.
Type cascade (R-2: 28 / 17 / 15 / 13 px):
| Role | Tailwind | Use |
|---|---|---|
| Drug name | `text-2xl font-bold tracking-tight` | Header band only |
| Section title | `text-base font-semibold leading-6 tracking-tight` | Every `<MedicationDetailSection>` heading (matches `medication-detail-section.tsx:67`) |
| Body | `text-sm` | Default for every paragraph + helper line |
| Micro / timestamps / status pills | `text-xs` | Status row, ms-since labels, Badge text |
Left-aligned throughout. No centring inside cards. The wizard's Step 8 summary is the only exception (`text-center` for the badge-row of completion icons), unchanged from current.
## 11. Component tree
```
src/app/medications/[id]/page.tsx (NEW)
├── <MedicationDetailHeader> NEW — inline JSX block, no separate file needed
├── <TodaysDoseCard> NEW — src/components/medications/todays-dose-card.tsx
├── <CadenceSummaryRow> NEW — src/components/medications/cadence-summary-row.tsx
├── <TitrationSection> EXISTS — src/components/medications/TitrationSection.tsx
├── <IntakeHistoryPreview> NEW — src/components/medications/sections/intake-history-preview.tsx
│ └── <IntakeHistoryListV2> 🔧 needs prop additions (§6.3)
├── <NotificationsSection> NEW — src/components/medications/sections/notifications-section.tsx
├── <SettingsSection> NEW — src/components/medications/sections/settings-section.tsx
│ ├── <ApiTokensRow> NEW — src/components/medications/sections/api-tokens-row.tsx
│ ├── <CsvImportRow> NEW — src/components/medications/sections/csv-import-row.tsx
│ ├── <PhaseManagementRow> NEW — src/components/medications/sections/phase-management-row.tsx
│ └── <GraceMinutesRow> NEW — src/components/medications/sections/grace-minutes-row.tsx
├── <DestructiveZoneSection> NEW — src/components/medications/sections/destructive-zone-section.tsx
├── <MedicationWizardDialog mode="edit"> EXISTS — src/components/medications/wizard/MedicationWizardDialog.tsx 🔧 polish (§2)
├── <IntakeImportDialog> EXISTS — currently inline at src/app/medications/page.tsx:344, EXTRACT to standalone file
└── <ApiEndpointDialog> EXISTS — currently inline at src/app/medications/page.tsx:559, RETIRE (logic absorbed by <ApiTokensRow>)
```
Refactor pre-work: extract `IntakeImportDialog` from `src/app/medications/page.tsx` into `src/components/medications/intake-import-dialog.tsx` so the detail page imports cleanly. `ApiEndpointDialog`'s body folds entirely into `<ApiTokensRow>` — the inline dialog stops existing.
## 12. Out of scope for v1.5.5
- Cadence-engine read-flip (separate v1.5.x track).
- Wider design-system refresh — only the wizard + detail-page surfaces shift.
- Native iOS mirror — the contract under `docs/api/openapi.yaml` is locked; iOS picks up the new endpoints (none — every wired API in this doc already ships) when its own release cuts.
- Multi-medication batch actions on the list page (`/medications/page.tsx`). The list stays card-grid; v1.5.5 only adds a per-card link to the detail page.
- Type-back-name destructive guard — filed in §13 for Marc to decide.
- Wizard multi-schedule compose mode — the flat form's multi-schedule edit path is acknowledged by I-1 item 11 as deferred. The wizard still supports the single-schedule edit path; the detail page does not duplicate the schedule list. A "Weitere Zeitpläne bearbeiten" affordance can land in v1.5.6 if Marc's clinical workflow proves the wizard's single-schedule view too restrictive.
- Framer Motion / `motion` / Lottie. CSS animations only, per the project's no-new-deps rule.
## 13. Open questions for Marc
1. **Detail-page route URL.** `/medications/[id]/page.tsx` is proposed as the new top-of-the-funnel route. `/medications/[id]/history` stays as the dedicated bulk-history surface (it already mounts `DrugLevelChart`, `SideEffectsSection`, `SchedulingSection`, `TitrationSection`, `IntakeHistoryListV2`). Confirm the split, or do you want the detail page to live AT `/medications/[id]/history` and the bulk surface to move to `/medications/[id]/history/full`?
2. **Type-back-name confirm on delete.** The project precedent in `src/components/settings/advanced-section.tsx:287-317` is a single-step `<AlertDialog>` for the more-destructive "delete every user data point" surface. v1.5.5 follows that precedent for the per-medication delete + purge. Are you OK with the single-step guard, or do you want the type-back-name pattern for the per-medication delete? (Note: no such pattern exists in the tree today; introducing one is a new primitive.)
3. **Phase-management editor.** §3.7 + §7 lock the inline editor on the detail page. The alternative is to resurrect `PhaseConfigDialog` as a focused `<ResponsiveSheet>` opened from a `<PhaseManagementRow>` button. Inline is the proposal because nesting a modal inside a settings card is the v1.5.4 mistake; a focused sheet from a settings row would partially repeat it. Confirm inline.
4. **Status-bar morph variant.** §2.6 commits to the three-shape clip-path morph (half-circle / square / capsule / full capsule) over 280 ms. R-2's reference was Material 3 Expressive's seven-shape loop; we collapsed to three shapes because HealthLog's state space is discrete and small. If you want something even more subtle — say a single capsule that just stretches without changing radius — the CSS collapses further. Confirm three-shape, or downgrade to width-only.
@@ -0,0 +1,289 @@
# D-3 — Final direction: v1.5.5 medication wizard polish + detail page
Authoritative spec. Reconciles E-1 (frontend), E-2 (UX), E-3 (architecture), E-4 (a11y) against D-2 and Marc's locked decisions. D-2 is superseded by reference. The implementation agent codes against D-3 only.
## 1. TL;DR
v1.5.5 ships a polished `MedicationWizardDialog` (560 px desktop shell, sticky-footer sheet on mobile, calm width-only progress, disciplined spacing) and a new client-side route `/medications/[id]/page.tsx` that restores every action the v1.5.4 cut buried — log today's dose, edit the plan, manage notifications, manage API tokens, import CSV, configure GLP-1 phases in a focused Sheet, walk a three-tier destructive cascade. The page reads as a clinical document from header to destructive footer; one phone-scroll reaches every restored feature. Patient feel: Apple Health-shaped top-down read with the labelled Gefahrenzone Apple lacks, and a wizard that no longer looks asymmetric. Decisions that made it: keep the radius vocabulary the rest of the app ships (no `rounded-xl` Button override, no new motion language on progress), keep mutations honest about server-derived fields, route every Switch through `<label>`, put a kebab on every intake row so keyboard always works.
## 2. Marc's four locked decisions (verbatim)
1. Detail page = new `/medications/[id]/page.tsx`; `/history` stays as bulk-delete sub-route.
2. Delete confirm = single-step `<AlertDialog>`, no type-back-name.
3. Phase editor (GLP-1 titration) = focused `<Sheet>` from a Settings row, NOT inline.
4. Status-bar animation = restrained width-only progress via shadcn `<Progress>` + Tailwind `transition-all`. NO Material-3 three-shape morph, NO clip-path animation, NO new motion vocabulary. D-2 §2.6/§2.7/§9 stripped.
## 3. Critical-fix punch list — must land in v1.5.5
Fourteen Criticals, grouped by area.
### Animation residue (decision #4)
**C-E1-1 — Strip three-shape clip-path morph + `wizard-step-in` keyframe** (E-1 C-1; D-2 §2.6/§2.7/§9; `src/app/globals.css` + `data-step` wiring in `MedicationWizardDialog.tsx`). D-2 committed ~155 LOC of CSS to a three-shape clip-path morph + slide keyframe (violates decision #4; the `@layer components` claim was wrong — only `@layer base` ships). Fix: drop every named CSS rule. Progress bar is `<Progress value={progress} className="h-1.5" aria-label={stepOf} />` — shipped primitive carries `transition-all` (`progress.tsx:24`). Step transitions use tailwindcss-animate `animate-in fade-in-0 duration-200`. Both carry `motion-reduce:*`. Acceptance: grep on `globals.css` returns zero hits for `wizard-progress-bar` / `wizard-step-body` / `wizard-step-in`.
### Wizard polish
**C-E1-2 — Drop `max-w-3xl` wrapper; inherit auth-shell** (E-1 C-2; D-2 §3 page wrapper). D-2 capped at 768 px while siblings inherit `max-w-screen-xl`. Fix: strip the `max-w-3xl mx-auto` wrapper; container stays inherited. `<MedicationDetailSection>` ships `px-3` for per-section comfort. Acceptance: route's outermost `<div>` has no `max-w-*` class; Playwright snapshot at 1024 px matches `/medications/[id]/history`.
**C-E1-3 — Keep `<Button>` at `rounded-md`; icon plate is the only `rounded-xl` accent** (E-1 C-3; D-2 §2.5 invariant 1). D-2 promoted Next button to `rounded-xl` (Button is `rounded-md`, Dialog `rounded-lg`, Card `rounded-xl` — three radii on one surface, violates concentric). Fix: rewrite invariant — **icon plate is the only `rounded-xl` inside the dialog body; every other chrome is `rounded-md` (Button) or `rounded-lg` (Dialog).** No override, no new radius proposal. Acceptance: review checklist flags any `rounded-xl` override on a wizard-child `<Button>`.
### Detail-page architecture (decision #3 + wire correctness)
**C-E3-1 — Resurrect `PhaseConfigDialog` as a focused `<Sheet>` from a Settings row** (E-3 C-1; decision #3; D-2 §3.7 + §7 + §11 + §13 Q3). D-2 committed three sections to an inline editor; decision #3 reverses. Fix: Settings hosts `<PhaseManagementRow>` whose body is a single `[Phasen konfigurieren]` outline button (mounts only on `treatmentClass === "GLP1"` + course window set). Opens new `<PhaseConfigSheet>` (`src/components/medications/sections/phase-config-sheet.tsx`) — resurrects v1.5.4 `{ medicationId, open, onOpenChange }` contract inside `<ResponsiveSheet>` (mobile bottom, desktop right). Hosts four-row green/yellow/orange/red grid + Auf-Standard + Speichern. PUT/DELETE `/api/medications/[id]/phase-config`. Success invalidates `medicationPhaseConfig(id)` + `medicationDependentKeys` + closes. 2 s status banner → `toast.success(t("medications.phaseSaved"))`. No course window → muted `Phasen sind nur mit Kurs-Fenster verfügbar.`; button hidden. Acceptance: conditional mount test; save invalidates two keys + closes.
**C-E3-2 — Drop `pausedAt` from the Pausieren PUT body** (E-3 C-2; D-2 §3.8 Tier 1). D-2 minted client-side `pausedAt`; `updateMedicationSchema` (`validations/medication.ts:257`) exposes only `active`; PUT route (`[id]/route.ts:122-129`) derives `pausedAt` server-side. Fix: Pausieren PUTs `{ active: false }` only; re-activate PUTs `{ active: true }`. Server derives timestamp. Optimistic update flips `medicationDetail(id).active` only. Acceptance: integration test asserts server-set `pausedAt`; grep confirms no `pausedAt` literal on Tier 1 mutation.
**C-E3-3 — Hoist purge + parent PUT/DELETE onto `assertMedicationOwnership`** (E-3 C-3; `api/medications/[id]/intake/purge/route.ts:11-18`, `intake/import/route.ts:34-38`, `[id]/route.ts:29-36` + `:63-66`). Four hand-rolled `findUnique` + `userId !== user.id ? 404` while every other route uses `assertMedicationOwnership`. Purge is the destructive outlier; v1.5.5 promotes it to user-discoverable. Fix: pre-work refactor to `assertMedicationOwnership(user.id, id)`. No behavioural change. Acceptance: grep returns zero hand-rolled checks under `src/app/api/medications/[id]/**`; route tests green.
### A11y mutation feedback + focus + contrast
**C-E4-1 — Polite live region on every mutating section** (E-4 C-1; D-2 §3.2/§3.6/§3.7/§3.8). D-2 left mutation feedback to sonner without pinning the contract. Fix: auth-shell `<Toaster />` carries `aria-live="polite"`. Every mutation persists state visibly in the same paint as the toast — Tier 1 Pausieren flips header pill via optimistic update; Today's-dose card switches to read-only `Heute genommen um HH:MM`; notifications switch flips its label. Per-section contracts in §9 pin the live-region wiring. Acceptance: axe walk asserts toaster region; component tests assert cache update in same render pass as toast.
**C-E4-2 — Loading branch wraps in `<Card aria-busy="true" aria-live="polite">`** (E-4 C-2; D-2 §3.4–§3.7). D-2 named the `Loader2` precedent but did not pin `aria-busy` + `aria-live`. `intake-history-list-v2.tsx:184` ships a bare spinner. Fix: every async section renders loading as `<Card aria-busy="true" aria-live="polite">{spinner}</Card>` per `insight-status-card.tsx:53`. Error: `<p className="text-destructive text-sm">{t("common.loadFailed")}</p>` inside same Card. Empty: section-specific (§9). Acceptance: every new sections component carries the wrapper; axe walk reports zero "missing-live-region" violations.
**C-E4-3 — DOM order: drug name first, edit button last** (E-4 C-3; D-2 §3.1 header + §3.3 cadence row). If implementer reads the right-aligned visual as `<Button>` first + `ml-auto`, tab order announces "Bearbeiten" before `<h1>`. Fix: header DOM is `<div className="flex items-start justify-between gap-3"><div className="space-y-1"><h1>{name}</h1>{dose}{statusRow}</div><Button>{edit}</Button></div>`. Cadence row: section `<h2>` first, `<p>` 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 `<AlertDialog>` 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 → `<AlertDialog>` directly). Sheet → AlertDialog stack forbidden. Acceptance: Sheet tree contains no `<AlertDialog>`; 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: `<Badge variant="secondary">` 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: <today-iso>, takenAt: <now-iso>, 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 `<h2>` 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 `<label>`; kebab universal; flex-wrap on settings footers** (E-4 H-4/H-5/H-6/H-7). Switch accessible name unpinned; swipe excluded keyboard; German label length risked flex-wrap collapse < 44 px. Fix: Switch wraps in `<label>` (row = AT hit target); `aria-labelledby` row title + `aria-describedby` helper. Intake-row affordance is `<DropdownMenu>` kebab at every viewport — swipe dropped. Settings footers `flex flex-wrap gap-2 justify-end` with `min-h-11` per button. Tests: axe walk reports no labelling violations; keyboard reaches every action.
**H-cluster-I — Destructive CTA `font-semibold`** (E-4 H-8). `<Button variant="destructive">` ships `text-sm font-medium`; white on `#ff5555` is ~3.45:1. Fix: override to `font-semibold` (600) at call sites — qualifies as WCAG Large Text. Tests: axe contrast walk reports no destructive violations.
**H-3-UX — CSV import only in intake-history header** (E-2 H-3). Settings CsvImportRow becomes one-line stub `Importiere Einnahmen direkt aus dem Verlauf oben.` (no button). Page-level `intakeImportOpen` state lifted into intake-history. Tests: snapshot asserts stub; component asserts single mount.
**H-4-UX — Grace row labelled primary-schedule-scoped** (E-2 H-4). Label → `Erinnerungsfenster — gilt für deinen Hauptzeitplan`; helper explains multi-schedule case (deferred §12). Tests: i18n key `medications.settings.grace.primaryScheduleNote` ships.
**H-6-UX — Status-bar morph contradicts decision #4** `[absorbed by C-E1-1]`.
**H-5-UX — Swipe-to-reveal collides with scroll** `[absorbed by H-cluster-H]`.
## 5. Mediums and Lows — explicit defer or absorb
29 Mediums + 23 Lows (52 rows).
| Origin | Title | Disposition |
|---|---|---|
| E-1 M-1 | Settings buries destructive | absorb (H-cluster-F) |
| E-1 M-2 | Focus-ring on destructive | absorb (no override) |
| E-1 M-3 | Reduced-motion on step-fade | absorb (§10) |
| E-1 M-4 | Raw Tailwind palette on status pill | absorb (C-E4-5) |
| E-1 M-5 | clientManaged copy + key path | absorb (§9) |
| E-1 M-6 | clip-path browser support | absorb (C-E1-1) |
| E-1 M-7 | File-name kebab-case | absorb (§10) |
| E-2 M-1 | Header pill conflates lifecycle+date | absorb (drop `seit DD.MM.` from header) |
| E-2 M-2 | Empty-state inventory gaps | absorb (§9) |
| E-2 M-3 | NL "Aus Text" create-only | absorb (§8) |
| E-2 M-4 | Rolling cadence example missing | absorb (§9.3) |
| E-2 M-5 | clientManaged note placement | absorb (read-only chip when true) |
| E-2 M-6 | Sheet height floor | absorb (`min-h-[40dvh]`; revisit) |
| E-2 M-7 | Bulk-delete no progress | absorb (new bulk-delete endpoint) |
| E-2 M-8 | API endpoint context | absorb (caption + two buttons; §9.7) |
| E-3 M-1 | Header naming drift | absorb (kebab-case extraction; §9.1) |
| E-3 M-2 | Card-header duplication | defer v1.5.6 |
| E-3 M-3 | Wizard `notificationsEnabled` write | absorb (§10) |
| E-3 M-4 | Detail-page tests not enumerated | absorb (§9) |
| E-3 M-5 | IntakeImportDialog single mount | absorb (page-owned state) |
| E-3 M-6 | Bulk-delete partial failure | absorb (same as E-2 M-7) |
| E-4 M-1 | Close-X overlap with progress | absorb (`flex-col` wrapper) |
| E-4 M-2 | Toast is only confirmation | absorb (§10) |
| E-4 M-3 | AlertDialog focus default | absorb (§10) |
| E-4 M-4 | Kebab `aria-label` missing | absorb (§9.5) |
| E-4 M-5 | Group headers `<h3>` | absorb (§9.5) |
| E-4 M-6 | Section icons `aria-hidden` | absorb (§10) |
| E-4 M-7 | Tier 2 self-describing title | absorb (§9.8 copy) |
| E-4 M-8 | Notification chips decorative | absorb (§9.6) |
| E-1 L-1 | Sticky-header overlap | absorb (header static) |
| E-1 L-2 | `useIsMobile()` breakpoint | obsolete (swipe dropped) |
| E-1 L-3 | Icon colour token | absorb (§10) |
| E-1 L-4 | 44 px floor not pinned | absorb (§10) |
| E-2 L-1 | Edit-title sr-only | record only |
| E-2 L-2 | Tier 3a `count === 0` | absorb (disable when 0) |
| E-2 L-3 | Lucide Square → Archive | absorb (§9.8) |
| E-2 L-4 | Step-transition direction | obsolete (slide stripped) |
| E-2 L-5 | `Heute (Mi, 28.05.)` label | defer v1.5.6 |
| E-2 L-6 | Import-dialog extraction order | absorb (§10) |
| E-2 L-7 | Skeleton choreography | absorb (§9 + §10) |
| E-2 L-8 | Sonner vs inline banner | defer v1.5.6 |
| E-3 L-1 | `medicationDetail(id)` in bundle | absorb (comment) |
| E-3 L-2 | Notifications dual-source read | absorb (§9.6) |
| E-3 L-3 | Progress `data-slot` selector | obsolete |
| E-3 L-4 | Wizard double-mount race | record only |
| E-3 L-5 | iOS forward-compat | record only |
| E-4 L-1 | Skip-link target | record only |
| E-4 L-2 | TOC for long scroll | record only |
| E-4 L-3 | `prefers-reduced-motion` on Card hover | absorb (§10) |
| E-4 L-4 | AlertDialog 200% reflow | record only |
| E-4 L-5 | Switch keyboard + onClick | absorb (§10) |
| E-4 L-6 | `aria-busy` on body+Save | record only |
## 6. Final detail-page section order
### Recurring variant (8 sections)
1. **Header band** — drug name + dose + status pill + edit. Static.
2. **Today's dose card** — `[Genommen]` / `[Übersprungen]`. Empty: `Heute keine Einnahme geplant.`.
3. **Cadence summary** — plain-language line + Bearbeiten → wizard Step 5 (`intent: "cadence"`).
4. **Dose ladder / Phasen** — GLP-1 only. Reuses `<TitrationSection>`.
5. **Intake history preview** — last 14 grouped Heute/Diese Woche/Älter, kebab per row. CSV-import trigger in header. Footer link to `/history`.
6. **Notifications** — `<Switch>` in `<label>` + read-only chip strip.
7. **Settings** — Externe Integration / CSV-Import stub / Phasen (button → Sheet) / Grace.
8. **Verwaltung & Gefahrenzone** — Card A (Pausieren + Beenden), Card B (Verlauf + Medikament löschen) under one heading.
### One-shot variant (5 sections)
Drop section 4 (not GLP-1), 6 (notifications already off post-log), 7 (settings nonsensical). Section 5 collapses to one row; section 3 becomes static `Einmalig am DD.MM.` with no edit affordance.
1. Header band
2. Today's-dose-or-logged-state (read-only `Einmalig genommen am DD.MM. um HH:MM` if taken; two-button card if not)
3. Cadence summary static line
4. Intake history (single row)
5. Verwaltung & Gefahrenzone (Pausieren + Beenden + Verlauf löschen + Medikament löschen)
### Paused variant
Same section structure as the active recurring/one-shot variant. Status pill flips to "Pausiert" (Dracula orange dot). Today's-dose card renders muted `Pausiert — keine Erinnerung heute.` with both buttons `aria-disabled` until re-activation. Every other section continues to render — user can still edit, manage phases/tokens, import CSV, walk destructive cascade.
## 7. Final restored-feature placement table
| # | Feature | v1.5.4 | v1.5.5 home | Section | Component |
|---|---|---|---|---|---|
| 1 | Bearbeiten | none | wizard edit (intent-aware) | Header + Cadence | `<MedicationWizardDialog mode="edit">` |
| 2 | Löschen (Medikament) | none | tier 3b | Verwaltung & Gefahrenzone | `<DestructiveZoneSection>` + `<AlertDialog>` |
| 3 | Pausieren / Aktivieren | none | tier 1 | Verwaltung & Gefahrenzone | `<DestructiveZoneSection>` `<Switch>` (PUT `{ active }`) |
| 4 | Benachrichtigungen | none | switch | Notifications | `<NotificationsSection>` + `<Switch>` in `<label>` |
| 5 | Einnahmen importieren | none | intake-history header CTA | Intake history preview | `<IntakeHistoryPreview>` + extracted `<IntakeImportDialog>` |
| 6 | API-Endpunkt | none | Externe Integration sub-row | Settings | `<ApiTokensRow>` (absorbs `ApiEndpointDialog`) |
| 7 | GLP-1 Phasen | none | Phasen sub-row → focused Sheet | Settings | `<PhaseManagementRow>` + `<PhaseConfigSheet>` |
| 8 | Aufzeichnungen löschen | none | tier 3a | Verwaltung & Gefahrenzone | `<DestructiveZoneSection>` + `<AlertDialog>` |
| 9 | Zurücksetzen (create-only) | none | out of scope | — | — |
| 10 | GLP-1 Wochentags-Preset | wizard Step 5 | wizard Step 5 | wizard | `<Step5Cadence>` |
| 11 | Schedule hinzufügen/entfernen | wizard Step 8 | wizard Step 8 | wizard | `<Step8Summary>` |
| 12 | Legacy reminder-window | wizard Step 7 | wizard Step 7 | wizard | `<Step7Times>` (`legacyOnLoad`) |
| 13 | One-Shot Switch | wizard Step 4 | wizard Step 4 | wizard | `<Step4Window>` |
| 14 | Course-Window-Picker | wizard Step 4 | wizard Step 4 | wizard | `<Step4Window>` + `<CourseWindowRow>` |
| 15 | Einnahmen bearbeiten | `/history` (read-only) | preview kebab | Intake history preview | `<IntakeHistoryListV2>` + `onEditIntake` (Sheet) |
| 16 | Einnahmen löschen | same | preview kebab | Intake history preview | `<IntakeHistoryListV2>` + `onDeleteIntake` (peer AlertDialog) |
## 8. Final wizard-polish spec
- **Geometry.** Desktop `<ResponsiveSheet className="sm:max-w-[560px] min-h-[40dvh] md:min-h-0">`. Mobile `<Sheet side="bottom">` capped `max-h-[90dvh]`, sticky footer, `min-h-[40dvh]` (revisit v1.5.6). Progress strip + body share `p-4 pr-12 sm:p-6 sm:pr-14`.
- **Close X.** Shipped top-right; `min-h-11 min-w-11` mobile / 36 px desktop. Raise contrast via `text-foreground/70 hover:text-foreground`. Sr-only label `{t("common.close")}` → `Schließen` (no new key).
- **Spacing — five buckets only.** 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`. "Three classes" framing dropped.
- **Radius.** Icon plate `rounded-xl` (only accent). Dialog/Sheet `rounded-lg`. Button `rounded-md` (no override). Card `rounded-xl`.
- **Progress.** `<Progress value={progress} className="h-1.5" aria-label={stepOf} />`. Shipped `transition-all`. No clip-path, no keyframes. `motion-reduce:transition-none` per §10.
- **Step transition.** Fade-only via tailwindcss-animate: `animate-in fade-in-0 duration-200 motion-reduce:animate-none` on body wrapper. `key={step}` triggers it. No slide, no `wizard-step-in`, no `data-direction`.
- **Field error + submit-busy.** `<form noValidate>`. Errors: `<p id={errorId} className="text-destructive text-xs">{error}</p>`; input `aria-invalid="true" aria-describedby={errorId}`. Save `aria-busy={submitting}` + `disabled` (shipped).
- **Title focus.** Body wraps title `<h2>` with `tabIndex={-1}`. Focus effect focuses title first when landing-step ≠ 1.
- **`landingStepForEdit`.** `(payload, intent?: "cadence" | "summary" | "name") → 5 | 1 | (existing 1/8)`. Header edit passes `"name"`; cadence-row passes `"cadence"`.
- **NL "Aus Text".** Renders only when `mode === "create"` (annotated in wireframe).
## 9. Final detail-page-section spec
Per section: layout, component, API, query key, a11y invariants, tests.
**9.1 Header band.** Outer `<div className="flex items-start justify-between gap-3">`. Left `<div className="space-y-1">` hosts `<h1 className="text-2xl font-bold tracking-tight">{name}</h1>` + dose `<p className="text-muted-foreground text-sm">` + status row `<div className="text-xs flex items-center gap-2">` (no `seit DD.MM.`). Right: `<Button variant="outline" size="sm">` Pencil + `t("common.edit")`. New `<MedicationDetailHeader>` at `src/components/medications/medication-detail-header.tsx`. Reads `medicationDetail(id)`. A11y: name first, edit last; status pill text always rendered. Tests: snapshot at 320/768/1024; tab order asserts heading-then-button.
**9.2 Today's dose card.** `<Card className="p-5 sm:p-6 space-y-4">`; `flex gap-2 flex-wrap` row with two `min-h-11 sm:min-h-9` buttons. New `<TodaysDoseCard>` at `src/components/medications/todays-dose-card.tsx`. POST `/api/medications/[id]/intake` with `{ scheduledFor, takenAt, skipped }`. Reads `medicationDetail(id)`; invalidates `medicationDependentKeys`. A11y: polite `role="status"` region announces success; card optimistically switches to read-only `Heute genommen um HH:MM`. Empty: `Heute keine Einnahme geplant.` (new key `medications.today.noneScheduled`). Tests: integration asserts wire shape; component asserts 44 px floor + optimistic switch.
**9.3 Cadence summary.** `<MedicationDetailSection title={t("medications.cadence.title")}>`. Body: `<p className="text-sm">{summariseCadence(payload, t)}</p>` covering every cadence kind (daily / weekday / everyNWeeks / monthly / yearly / rolling / oneShot — e.g. `Alle 12 h nach letzter Einnahme`, `Alle 2 Wochen, mittwochs`, `Monatlich am 15.`). Course-window sub-line when set. Right `<Button variant="outline" size="sm">` Bearbeiten passes `intent: "cadence"`. New `<CadenceSummaryRow>` at `src/components/medications/cadence-summary-row.tsx`. Reads `medicationDetail(id)`. A11y: edit last in DOM. Tests: unit asserts every kind rendered; component asserts intent prop.
**9.4 Dose ladder / Phasen.** Reuses shipped `<TitrationSection>`. Conditional on `treatmentClass === "GLP1"`. Reads `medicationTitration(id)`. A11y inherited. Tests: conditional mount.
**9.5 Intake history preview.** `<MedicationDetailSection>` chrome with header CTA `[⤴ Importieren]` (only CSV trigger). Body groups last 14 rows into Heute / Diese Woche / Älter; group labels are `<h3 className="text-xs uppercase tracking-wide text-muted-foreground">`. Per-row right-edge `<DropdownMenu>` kebab with Bearbeiten + Löschen items + `aria-label={t("medications.intakeRowActions", { time })}`. Footer link to `/medications/[id]/history`. New `<IntakeHistoryPreview>` at `src/components/medications/sections/intake-history-preview.tsx` wraps `<IntakeHistoryListV2>` with optional `onEditIntake`, `onDeleteIntake`, `selection` props. GET `/api/medications/[id]/intake?limit=14&offset=0&sortBy=takenAt&sortDir=desc`. Single-row PUT/DELETE on `[eventId]`; bulk-delete via new `POST /api/medications/[id]/intake/bulk-delete` (pre-work). Reads `medicationIntakeList(...)`; invalidates `medicationDependentKeys`. A11y: `<h3>` headers; kebab `aria-label`; edit opens `<ResponsiveSheet>` with form only (C-E4-4); delete opens peer `<AlertDialog>` from kebab. Tests: keyboard walk; bulk-delete recomputes rollups once per dayKey.
**9.6 Notifications.** `<MedicationDetailSection title={t("medications.notifications.title")}>`. Body: `<label className="flex items-center justify-between">` wraps `<Switch>` (entire row is the hit target). Below: `text-xs text-muted-foreground` summary with single `aria-label`; chip `<Badge variant="outline">` are `aria-hidden="true"`. Quiet-hours preview below. New `<NotificationsSection>` at `src/components/medications/sections/notifications-section.tsx`. PUT `/api/medications/[id]` with `{ notificationsEnabled }`. Reads `medicationDetail(id).notificationsEnabled` for switch state + `notificationsStatus()` for chip strip (dual-source — comment in component). Invalidates `medicationDependentKeys`. Switch `aria-labelledby` row title + `aria-describedby` helper. When `notificationPrefs.clientManaged === true`, switch replaced by read-only chip `iPhone steuert die Erinnerungen` (new key `medications.notifications.clientManagedChip`). Tests: label-wrap as hit target; chip replaces switch.
**9.7 Settings.** `<MedicationDetailSection title={t("medications.settings.title")}>`. Four sub-rows `space-y-4`:
- **Externe Integration** — new `<ApiTokensRow>` (`api-tokens-row.tsx`). Always mounts. Caption `Endpunkt für „{medicationName}"` above URL. Two buttons `[URL kopieren]` + `[Token erzeugen]`; minted token shows in one-shot modal auto-closing after copy. GET/POST `/api/medications/[id]/api-endpoint`, DELETE `/api/tokens/[id]`. Invalidates `tokens()` + `medicationDependentKeys`.
- **CSV-Import stub** — new `<CsvImportRow>`. One-line stub `Importiere Einnahmen direkt aus dem Verlauf oben.`; no button.
- **Phasen** — new `<PhaseManagementRow>` + `<PhaseConfigSheet>`. Mount when GLP-1 + course window. Body: `[Phasen konfigurieren]` outline button opens Sheet with four-row green/yellow/orange/red grid (MINUTES/PERCENT) + `[Auf Standard]` + `[Speichern]`. GET/PUT/DELETE `/api/medications/[id]/phase-config`. Success → `toast.success` + close + invalidate `medicationPhaseConfig(id)` + bundle. Empty (no course window): muted `Phasen sind nur mit Kurs-Fenster verfügbar.`; button hidden.
- **Grace** — new `<GraceMinutesRow>`. Label `Erinnerungsfenster — gilt für deinen Hauptzeitplan`. PUT `/api/medications/[id]` with `{ reminderGraceMinutes }` on primary schedule.
**9.8 Verwaltung & Gefahrenzone.** `<MedicationDetailSection title={t("medications.dangerZone.title")}>` single heading. Body: two `<Card>` children `space-y-4`. New `<DestructiveZoneSection>` at `src/components/medications/sections/destructive-zone-section.tsx`.
*Card A — Verwaltung:*
- Tier 1 Pausieren: `<label className="flex items-center justify-between"><Switch />Pausieren</label>` + helper `Erinnerungen anhalten, bis du sie wieder aktivierst. Verlauf bleibt erhalten.`. No confirmation. PUT `{ active: false }`.
- `<Separator />`
- Tier 2 Beenden: `<Button variant="destructive" className="font-semibold">` + `Archive` icon. `<AlertDialog>` title `Medikament beenden — Erinnerungen stoppen, Verlauf bleibt sichtbar.` + body `Keine weiteren Erinnerungen. Alte Einträge bleiben.`. PUT `{ endsOn: <today-iso> }`.
*Card B — Gefahrenzone (`border-destructive/40`):*
- Tier 3a Verlauf löschen: `<Button variant="destructive" className="font-semibold">`. Disabled when count is 0. `<AlertDialog>` title `Verlauf wirklich löschen?` + body `Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.`. DELETE `/api/medications/[id]/intake/purge`.
- `<Separator />`
- Tier 3b Medikament löschen: `<Button variant="destructive" className="font-semibold">`. `<AlertDialog>` title `Medikament löschen?` + body lists purge scope. DELETE `/api/medications/[id]`. Success: `await invalidateKeys(queryClient, medicationDependentKeys); router.push("/medications");`.
Every tier invalidates `medicationDependentKeys`. AlertDialog Cancel autofocus preserved; switch label-wrap; destructive CTAs `font-semibold`. Tests: integration asserts four mutations + invalidations + Tier 3b ordering.
## 10. Cross-cutting invariants
1. Async sections wrap loading in `<Card aria-busy="true" aria-live="polite">` (C-E4-2).
2. Mutations either announce via polite live region OR persist state visibly in same paint as toast (C-E4-1, E-4 M-2).
3. Icon-only triggers carry `aria-label` from i18n + row context (E-4 M-4).
4. Every `<Switch>` wraps in `<label>` (H-cluster-H).
5. Status-pill text never hides; dot `aria-hidden="true"` + Dracula tokens (C-E4-5).
6. Kebab always rendered on intake rows; swipe dropped (H-cluster-H).
7. Modal stack ≤ 2; row-edit Sheet hosts no destructive action (C-E4-4).
8. Step title `tabIndex={-1}` + focus on landing ≠ 1 (H-cluster-B).
9. AlertDialog Cancel autofocus stays default — never autofocus destructive (E-4 M-3).
10. Heute/Diese Woche/Älter use `<h3>`; dividers are `<Separator>` (E-4 M-5).
11. Destructive CTAs at `font-semibold` 600 (H-cluster-I).
12. Section-title icons inherit `text-foreground`; body icons `text-muted-foreground`; wizard plate is only `text-primary`; every Lucide `aria-hidden="true"` (E-4 M-6, E-1 L-3).
13. Mobile primary CTAs render `min-h-11`; close-X stays 36 px exception (E-1 L-4).
14. Every animation utility carries `motion-reduce:animate-none` or `motion-reduce:transition-none` (E-1 M-3, E-4 L-3).
15. TanStack keys come from the central factory only — no bare arrays (ESLint `healthlog/queryKey-factory`).
16. Wizard `buildCreateBody` PUT does NOT emit `notificationsEnabled` (E-3 M-3).
17. Every `prisma.X.{create,update}({ data })` builds field-by-field, no spreads (CLAUDE.md).
18. New API routes wrap in `apiHandler(...)` with Zod `safeParse` + `returnAllZodIssues`. Only new route in v1.5.5: `POST /api/medications/[id]/intake/bulk-delete`.
19. `IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx` as the first commit of the PR series (E-2 L-6).
20. `medicationDependentKeys` includes `["compliance-chart-inline"]` (H-cluster-G).
21. `<MedicationDetailSection>` is the only section chrome; new components mount inside it.
22. New component files are kebab-case under `src/components/medications/sections/` or `src/components/medications/`. PascalCase outliers (`TitrationSection.tsx`, `SideEffectsSection.tsx`, `SchedulingSection.tsx`, `DrugLevelChart.tsx`) are pre-existing per CLAUDE.md (E-1 M-7).
23. Per-section `<Suspense>` boundaries — header + cadence render first, others stream (E-2 L-7).
24. `assertMedicationOwnership` is the single ownership predicate across `src/app/api/medications/[id]/**` (C-E3-3).
## 11. Open questions for Marc
None. Synthesised calls:
- URL split / type-back-name / phase editor / status-bar morph → locked by decisions #1–#4.
- Sheet `min-h` floor (E-2 M-6) → `min-h-[40dvh] md:min-h-0` for v1.5.5.
- Bulk-delete strategy (E-2 M-7 / E-3 M-6) → build `POST /api/medications/[id]/intake/bulk-delete` as pre-work.
- Aggregate `/api/medications/[id]/detail` → defer v1.5.6; per-section `<Suspense>` for now.
## 12. What's deferred to v1.5.6+
- Cadence-engine read-flip (separate v1.5.x track).
- Wider design-system refresh.
- Native iOS mirror — only v1.5.5 addition is `POST /api/medications/[id]/intake/bulk-delete`.
- Multi-medication batch actions on `/medications/page.tsx`.
- Wizard multi-schedule compose mode.
- Aggregate `/api/medications/[id]/detail` endpoint.
- `MedicationCardHeader.tsx` lift to drive list-card + detail-page headers.
- `Heute (Mi, 28.05.)` parenthetical bucket-label.
- `Verschoben` / deferred-dose status (needs schema decision).
- Multi-schedule grace settings.
- "Pausieren bis…" duration-scoped pause.
- `min-h-[60dvh]` sheet floor revisit.
- Sonner-toast vs inline-banner for PhaseConfigSheet save.
- Framer Motion / `motion` / Lottie — CSS only per project rule.
@@ -0,0 +1,190 @@
# E-1 — Frontend-design audit of D-2 (v1.5.5 medication-surface direction)
Reviewer's lens: visual rhythm, primitive token consistency, section hierarchy, animation discipline, accessibility-adjacent polish, dark-mode + reduced-motion fallbacks. Marc's four locked decisions (detail-page URL split, single-step `<AlertDialog>` on delete, focused-Sheet phase editor, restrained width-only progress) are out of scope for relitigation — only audited for consistency where D-2 prose still echoes the superseded direction.
Severity counts:
- Critical: 3
- High: 5
- Medium: 7
- Low: 4
---
## Critical
### C-1. Status-bar animation section (D-2 §2.6 + §9) is entirely superseded by decision #4 and must be stripped
D-2 §2.6 ("Status-bar morph animation"), §2.7 ("Step transition animation"), and §9 ("Status-bar morph — pinned spec") commit ~85 LOC of doc + ~70 lines of CSS to a three-shape `clip-path` morph (half-circle → rounded square → capsule → full capsule) plus a separate per-step `wizard-step-in` keyframe animation, both layered under a brand-new `@layer components` block in `src/app/globals.css`. Decision #4 supersedes all of this. The implementation must:
- Drop the entire `.wizard-progress-bar[data-step="N"]` clip-path interpolation. The shipped `<Progress>` primitive (`src/components/ui/progress.tsx:24`) already carries `transition-all` on its indicator. The only legitimate v1.5.5 polish on the bar is the `h-1 → h-1.5` stroke bump (justified by reduced-motion testing — keep), plus an optional `aria-label={stepOf}` already in place.
- Drop the new `@layer components` block. No precedent exists — `globals.css:218` ships exactly one `@layer base` block, and the two existing `@keyframes` (`insight-fade-in`, `pulseDot`) live at top level. D-2's claim "the project already keeps custom utilities there" (line 108) is factually wrong.
- Drop `wizard-step-in` + `wizard-step-back-in` keyframes. The step body already re-mounts via `key={step}` (line 413 of the wizard); a fade alone could ride on `data-[mounted]:animate-in fade-in-0 duration-200` using the existing tailwindcss-animate utility classes the dialog already imports (e.g. `dialog.tsx:43`). No new keyframes needed; no new motion vocabulary introduced.
Action: replace §2.6/§2.7/§9 with a three-paragraph note pinning the existing `<Progress>` + `transition-all` cadence, a `motion-reduce:transition-none` discipline note, and the `h-1.5` stroke. Match the project precedent at `intake-history-list-v2.tsx:184` (`animate-spin motion-reduce:animate-none`).
### C-2. Detail-page width override (`max-w-3xl mx-auto`) breaks cross-surface page-container parity
D-2 §3 (line 232) caps the detail page at `max-w-3xl mx-auto` (768 px) on `md+`. Every other top-level surface in the tree rides the auth-shell's `max-w-screen-xl` container (1280 px, set in `src/components/layout/auth-shell.tsx:186`). `/insights/page.tsx`, `/dashboard/page.tsx`, `/medications/page.tsx`, and the existing `/medications/[id]/history/page.tsx` all read edge-to-edge at the shell width. Imposing a 768 px ceiling on the detail page alone produces a visible visual hiccup when the user navigates `/medications` (full-width grid) → `/medications/[id]` (centred narrow column) → back. R-2's "concentric tokens" argument supports inner-section padding, not a separate page cap.
Action: drop the `max-w-3xl mx-auto` wrapper. If the cadence-summary line + intake-history table read edge-to-edge as too wide, cap *those individual sections* via the existing `<MedicationDetailSection>` chrome which already centres content within its `px-3` body band. The page-level container stays inherited from the auth-shell.
### C-3. `rounded-xl` symmetry contract (D-2 §2.5) contradicts every shipped primitive
§2.5 invariant #1: "The step icon plate AND the primary footer CTA share `rounded-xl`." Three problems:
1. `<Button>` ships `rounded-md` by default (`button.tsx:33`) on every size variant (`xs`, `sm`, `lg`, all explicit at `rounded-md`). Overriding the wizard-Next button to `rounded-xl` introduces a one-off curvature on the wizard footer that no other button in the app shares — including the destructive-zone buttons D-2 wires elsewhere on the same surface. The user sees `rounded-md` on every CTA across `/medications`, `/insights`, `/dashboard`, and then `rounded-xl` on the wizard footer only.
2. `<DialogContent>` ships `rounded-lg` (`dialog.tsx:72`) and `<AlertDialogContent>` ships `rounded-lg` (`alert-dialog.tsx:61`). The dialog chrome is `rounded-lg`, the inner icon plate is `rounded-xl`, the CTA would also be `rounded-xl` — the user reads three different radii on a single surface.
3. `<Card>` ships `rounded-xl` (`card.tsx:23`). The detail page's `<TodaysDoseCard>` will be a Card — its outer corner is already `rounded-xl`. Promoting the inner buttons to match the card outer corner violates the concentric rule R-2 cited (inner radius < outer radius).
Action: keep the icon plate at `rounded-xl` (it's a visual accent, not chrome), keep `<Button>` at default `rounded-md`. Rewrite invariant #1 as "the step icon plate is the only `rounded-xl` element inside the dialog body — every other chrome is `rounded-md` (Button, MedicationDetailSection) or `rounded-lg` (DialogContent)."
---
## High
### H-1. Section-title type cascade fragments across the page
§2.3 promotes the wizard step title from `text-base font-medium leading-tight` to `text-lg font-semibold leading-tight tracking-tight`. §10 then specifies the detail-page section title as `text-base font-semibold leading-6 tracking-tight` (matching the shipped `<MedicationDetailSection>` at `medication-detail-section.tsx:68`). The user sees:
- Wizard step title: 18 px / 600
- Detail-page section title: 16 px / 600
- IntakeHistoryListV2 `<CardTitle className="text-base">`: 16 px / 600
The wizard step title would be the only `text-lg` element on the surface, even though the wizard opens *over* the detail page. Promoting one element produces a single outlier rather than a coherent type cascade. R-2's 17 pt / 600 section header maps cleanly to `text-base font-semibold` (the precedent shipped). Pick one.
Action: hold the wizard step title at `text-base font-semibold leading-tight tracking-tight` so it matches every other section title in the app. The "title outweighs subline" R-2 argued for is achieved by the weight delta (`font-semibold` vs `text-sm` body), not the size delta.
### H-2. Spacing-token table promises three classes but ships six
§2.4 opens with "Every internal gap maps to one of three Tailwind classes; reviewers reject any PR that introduces a fourth." The table then lists eight distinct gap tokens: `p-4 pr-12`, `p-5 sm:p-6`, `space-y-5 sm:space-y-6`, `gap-3`, `space-y-1`, `gap-6 sm:gap-8`, `gap-2`, `space-y-3`. The "three classes" rule is unenforceable as written. A reviewer cannot reject a PR for adding a fourth token when the spec itself defines eight.
Action: either restate as "five tokens — outer (`p-5 sm:p-6`), section (`gap-6 sm:gap-8`), row (`space-y-3`/`gap-3`), tight (`space-y-1`), footer (`gap-2`)" — five concrete buckets the implementer maps to — or drop the "three classes" framing and let the table stand as the contract. Currently the prose and the table disagree.
### H-3. Page-level vertical rhythm picks `space-y-6 sm:space-y-8` while sibling surfaces ship a single class
D-2 §3 (line 231) proposes `space-y-6 sm:space-y-8`. Sibling surfaces:
- `/medications/[id]/history/page.tsx:66` — `space-y-6` (no breakpoint shift)
- `/insights/page.tsx:195` — `space-y-8` (no breakpoint shift)
Picking a breakpoint-shifted token on the detail page alone means navigating between the two `/medications/[id]/*` routes shows different scroll cadence (24 px on history, 24 → 32 px on detail). The shipped pages chose one cadence each and stuck to it.
Action: pin to `space-y-6` (matches history sibling, matches dashboard pillars) OR `space-y-8` (matches insights), but not both. Recommended: `space-y-6` — it matches the v1.4.28 collapse-to-canonical decision documented at `/medications/[id]/history/page.tsx:59-64`.
### H-4. Wizard step body padding shift (`p-4` → `p-5 sm:p-6`) introduces asymmetry with the progress strip
§2.3 proposes step body `p-5 sm:p-6`. The progress strip stays `p-4 pr-12` (line 380 of the shipped wizard). At mobile width the progress strip pads at 16 px while the body pads at 20 px — the user sees the progress fill bar inset 4 px more than the icon-plate row below it. Marc reported the v1.5.4 dialog as "asymmetric"; this is a new asymmetry introduced by the polish itself.
Action: keep the progress strip and the body at the same horizontal padding. Either bump the progress strip to `p-5 sm:p-6 pr-12 sm:pr-14` or keep the body at `p-4`. Recommended: keep both at `p-4` on mobile and bump *both* to `sm:p-6` at the breakpoint, so the optical left edge stays aligned at every width.
### H-5. Loading skeleton + error state coverage is missing per section
§3.1–§3.8 specify components, APIs, and empty-state copy, but the loading and error states are only spelled out for one block (§3.5 mentions `Loader2` indirectly via the existing `IntakeHistoryListV2` precedent). The other six sections — `<TodaysDoseCard>`, `<CadenceSummaryRow>`, `<TitrationSection>` (already shipped), `<NotificationsSection>`, `<SettingsSection>` sub-rows, `<DestructiveZoneSection>` — have no spec for the `isLoading` or `isError` branch. The Coach disable-cascade pattern from v1.4.49 means missing skeletons land as flashes of empty card chrome before content fills in.
Action: add a single subsection at the bottom of §3 listing the loading and error contracts: "every section renders `<Loader2 className='h-5 w-5 animate-spin motion-reduce:animate-none' />` centred inside the section chrome on `isLoading`; renders `<p className='text-destructive text-sm'>{t(\"common.loadFailed\")}</p>` on `isError`; renders the section-specific empty-state copy on the empty branch." Single contract, every section conforms.
---
## Medium
### M-1. Section-order weighting: Settings (§3.7) is heavy enough to bury the Destructive zone
§3 puts Notifications (6) → Settings (7) → Destructive zone (8). Settings is the heaviest block on the page (four sub-rows, the API-token grid alone is ~120 px of vertical space, the inline phase editor adds 320 px when GLP-1 + course window). On a mobile viewport at 360 px width the destructive zone lands ~1400 px below the header — past the second-screen fold even on a tall device. Apple Health's destructive zone is similarly far down its detail page, but Apple Health users complain (R-2 §1 cited "where is delete?" as the dominant signal). The R-2 verdict (§A) explicitly calls out "discoverability" as the gap to close, and §3.8 quietly hides the destructive zone behind the largest section on the page.
Action: either swap §3.7 and §3.8 — destructive zone before Settings, so the labelled "Gefahrenzone" appears at ~1100 px — or accept Apple's placement but add an in-page anchor link or jump-to nav. Recommended: swap. The destructive zone visibility was the headline R-2 argument against Apple Health's pattern; current §3 repeats Apple's mistake.
### M-2. Focus-ring discipline not specified for the destructive cascade
§3.8 specifies the three-tier destructive zone with `<Switch>` + `<AlertDialog>` primitives. The shipped `<Switch>` and `<AlertDialogAction variant="destructive">` already render focus rings via the button primitive's `focus-visible:ring-ring/50 focus-visible:ring-[3px]` class chain (`button.tsx:33`). What's not specified: the destructive `<AlertDialogAction>` should render the focus ring on the destructive token rather than the default ring. Currently `button.tsx` uses `focus-visible:ring-ring/50` for every variant; a destructive button shows a blue focus ring around a red button, which reads as visual noise.
Action: add a sentence to §3.8: "every `<AlertDialogAction variant='destructive'>` uses the existing primitive — no custom focus-ring override. If the destructive ring colour reads wrong against the red button, that's a Button-primitive concern to surface separately." Lock the surface against ad-hoc focus-ring overrides per section.
### M-3. Reduced-motion fallback missing from the step-fade contract
Per the locked decision #4, the morph and slide animations are dropped. But the step body still re-mounts via `key={step}`. If the implementer reaches for the `data-[mounted]:animate-in fade-in-0` pattern (recommended in C-1), the `prefers-reduced-motion: reduce` discipline needs to be explicit: the tailwindcss-animate package's `animate-in` utility already honours `prefers-reduced-motion` *only when paired with* `motion-reduce:animate-none`. D-2 §2.5 invariant #6 captures this for loader spins but doesn't extend it to step transitions.
Action: lift §2.5 invariant #6 from "Loader animations everywhere use `animate-spin motion-reduce:animate-none`" to "every animation utility (`animate-in`, `animate-spin`, transition-* on the progress bar) carries `motion-reduce:animate-none` or `motion-reduce:transition-none`. No exceptions; this is the project precedent."
### M-4. Status-pill colour tokens use raw Tailwind palette names instead of semantic theme tokens
§3.1 specifies status pill accent dots as `bg-emerald-500` / `bg-amber-500` / `bg-zinc-500`. These are raw Tailwind palette names — fine in light mode, fine in dark mode (Tailwind's palette is tuned for both), but they bypass the theme system at `globals.css`. The rest of the app uses semantic tokens — `bg-primary`, `bg-muted-foreground`, `text-destructive` — that map to the Dracula palette via CSS variables. A raw `bg-emerald-500` won't shift if the operator ever themes the app away from defaults.
Action: pick one of two paths. Either (a) restate the status pill colours as raw palette tokens, with an explanatory note "status colours don't theme — they're traffic-light semantic and stay constant", or (b) introduce three new theme tokens (`--state-active`, `--state-paused`, `--state-stopped`) and reference them as `bg-[var(--state-active)]`. Recommended: (a) with the note, since the destructive zone in §3.8 doesn't theme either (uses `<Switch>` default colour). Be explicit so the rule is documented.
### M-5. iOS clientManaged note copy + key path not in i18n yet
§3.6 introduces a new DE key `medications.notifications.clientManagedNote`. This key does not exist in `messages/de.json` today (grep confirms). The i18n-call-site-coverage test would fail the moment the wired component lands without the key being added to all six locales (`de`, `en`, `es`, `fr`, `it`, `pl`). D-2 mentions the key in passing without listing every required locale or pinning the en/es/fr/it/pl copy.
Action: add a sub-table to §3.6 listing every new i18n key the v1.5.5 surface needs, with each locale's copy. Crosscheck: D-2 §3.6 introduces `clientManagedNote`, §3.7 implicitly needs `medications.settings.{title,apiTokens,csvImport,phases,grace}.*`, §3.8 reuses `medications.dangerZoneTitle` (lives) but introduces new sub-row labels (`pauseTitle`, `endTitle`, `purgeTitle`, `deleteTitle` + descriptions). Inventory or this becomes a Phase-4 implementer surprise.
### M-6. CSS `clip-path: inset(... round 0 999px 999px 0)` syntax is not a stable Tailwind contract
Even after stripping the morph per C-1, the residual block still ships in D-2 §2.6. If anything from that block survives unaudited into the implementer's PR, `clip-path: inset(0 0 0 0 round 0 999px 999px 0)` is parsed inconsistently across Safari < 17, Chromium-based, and Firefox — the round syntax with per-corner radii is iOS 17.4+ / Chrome 121+ / Firefox 128+ baseline. HealthLog supports iOS 15+ via the PWA. The progress bar would render correctly on every modern browser but the clip-path would silently no-op on older iOS, leaving the indicator as a flat rectangle.
Action: confirm C-1 is applied. If anything from §2.6 survives, gate it behind a `@supports (clip-path: inset(0 round 0 999px 999px 0))` block with a `border-radius: 9999px` fallback. Or just don't ship the morph.
### M-7. Sub-row component file count (§11) sprawls the directory
§11 introduces eight new files under `src/components/medications/sections/` — `intake-history-preview.tsx`, `notifications-section.tsx`, `settings-section.tsx`, `api-tokens-row.tsx`, `csv-import-row.tsx`, `phase-management-row.tsx`, `grace-minutes-row.tsx`, `destructive-zone-section.tsx` — plus two more in `src/components/medications/` (`todays-dose-card.tsx`, `cadence-summary-row.tsx`). The current `src/components/medications/` directory has 18 PascalCase outliers already (CLAUDE.md flagged this). Adding 10 new files at once, mixing kebab-case (new) with PascalCase (existing), is a visible filename-convention split mid-directory.
Action: confirm all 10 new files land kebab-case (D-2 §11 already lists them that way — good). Add a one-line note: "PascalCase outliers in the same directory (`TitrationSection.tsx`, `SideEffectsSection.tsx`, `SchedulingSection.tsx`, `DrugLevelChart.tsx`) are pre-existing per CLAUDE.md and stay until those files come up for edit. New files in this batch are kebab-case." Locks the convention so the reviewer doesn't have to relitigate.
---
## Low
### L-1. Sticky-footer + sticky-header overlap not specified
The wizard uses a sticky footer (`responsive-sheet.tsx:147`). The detail page header band (§3.1) is not specified as sticky. On a long scroll past the destructive zone, the user has no quick way back to "edit" — they have to scroll all the way up. Apple Health's detail page pins the back button + drug name in the nav bar. R-2 §A mentions "header band" but doesn't pin sticky vs static.
Action: add one sentence to §3.1: "header band is static — no sticky behaviour. The app shell's bottom nav already pins navigation. Scroll-to-top is a standard browser gesture."
### L-2. Swipe-to-reveal pattern (§3.5, §6.3) needs an explicit fallback for desktop without `<DropdownMenu>` mount
§3.5 + §6.3 spec the row-level edit/delete as "mobile: swipe-to-reveal, desktop: kebab via `<DropdownMenu>`". The breakpoint pick is not specified — `useIsMobile()` (already in the tree) vs CSS `md:` class. Mixing the two on the same component produces SSR hydration mismatches.
Action: pick the existing precedent — `useIsMobile()` from `src/hooks/use-is-mobile.ts` — and spell it out in §6.3. The hook already powers `<ResponsiveSheet>`; reusing it keeps the breakpoint single-source.
### L-3. Iconography map (§10) doesn't pin the icon colour token
§10 lists 13 Lucide icon assignments but doesn't pin the colour. The shipped wizard icon plate uses `text-primary` for the step icon (`MedicationWizardDialog.tsx:421`). Detail-page section icons would default to `currentColor` inheriting the section title's `text-foreground`. Mixed treatment across sections (icon ≠ title colour) would read as drift.
Action: pin one rule: "every section-title icon inherits `text-foreground` (matching the title text). Inline body icons inherit `text-muted-foreground`. The icon plate stays the only `text-primary` accent."
### L-4. "tap targets ≥ 44 px everywhere" claim in the brief is not enforceable as stated
Brief asked: "Tap targets ≥ 44 px everywhere?" D-2 §2.5 invariant #6 pins loader animation, but no invariant pins tap-target floor. The shipped `<Button>` primitive ships `h-11` (44 px) only on size `lg`; `default` is `h-9` (36 px) and `sm` is `h-8` (32 px). The wizard footer (`MedicationWizardDialog.tsx:338,348,360`) explicitly sets `className="h-11"` to override. Detail-page sections will use the default `size="default"` (36 px) unless every consumer overrides. The dialog primitive's close-X already has the 44 → 36 px exception documented (`dialog.tsx:80-86`).
Action: add to §2.5 invariants: "Primary CTAs (`<Button>` not size-overridden) on mobile (`<md`) render at `min-h-11` (44 px); the existing wizard precedent at `MedicationWizardDialog.tsx:338` is the template. Icon-only close-X stays at the 36 px documented exception." Locks the floor.
---
## Confirmation — what D-2 gets right
The audit is not all gaps. D-2 holds up cleanly on several axes:
- **Section-order patient logic (§3.1–§3.8)** is patient-coherent: daily actions (log today, see recent) at top, rare actions (destructive) at bottom. The Apple-Health-style top-down read survives Marc's locked decisions intact.
- **Component reuse discipline (§11)** correctly leverages `<MedicationDetailSection>`, `<IntakeHistoryListV2>`, `<TitrationSection>`, `<ResponsiveSheet>`, `<AlertDialog>` without reaching for new primitives. The pre-work to extract `IntakeImportDialog` and retire `ApiEndpointDialog` (§11 footer) is correct.
- **Phase-editor placement (decision #3 alignment)** is consistent with the locked decision — §3.7's "inline" phase row is what Marc picked. The brief's "focused `<Sheet>` opened from a Settings row" is a refinement that fits cleanly with the row component already named `<PhaseManagementRow>`; switching to a Sheet trigger is a one-line change.
- **Single-step `<AlertDialog>` for delete (decision #2 alignment)** lands cleanly. §3.8's reuse of the `settings/advanced-section.tsx:287-317` precedent is the right model. §13's open question about the type-back-name guard is now answered by Marc's lock — strip §13 question 2 entirely from the document.
- **Detail-page URL split (decision #1 alignment)** is exactly as Marc picked: new `/medications/[id]/page.tsx` as primary, `/medications/[id]/history` as bulk-delete deep-dive. §6.1/§6.2 split is correct.
- **The wizard geometry shift to `sm:max-w-[560px]` + `min-h-[60dvh]`** is the right call against Marc's "too short" complaint. §2.1 maps cleanly to the shipped `<ResponsiveSheet>` primitive.
- **The `pr-12` close-X gutter fix (§2.2)** is a clean one-line patch with no primitive change.
---
## Recommendations for the implementer
1. Strip §2.6, §2.7, §9, and §13 question 4 entirely — they are superseded by Marc's locked decision #4 (Critical C-1).
2. Strip the `max-w-3xl` page wrapper — keep the inherited auth-shell container (Critical C-2).
3. Keep the `<Button>` primitive at `rounded-md`; only the icon plate is `rounded-xl` (Critical C-3).
4. Hold the wizard step title at `text-base font-semibold` (High H-1).
5. Resolve the spacing-token "three classes" vs "eight tokens" contradiction (High H-2).
6. Pick `space-y-6` for the page-level rhythm (High H-3).
7. Align progress strip + step body horizontal padding (High H-4).
8. Add a single loading/error/empty-state contract subsection (High H-5).
9. Swap §3.7 and §3.8 so the destructive zone is more discoverable (Medium M-1).
10. Inventory every new i18n key with all six locales' copy before Phase 4 implementation (Medium M-5).
After these passes, D-2 lands inside the existing HealthLog design language (Dracula palette, zinc base, shadcn new-york, the same `transition-*` cadence the rest of the app uses) and inherits decision #4's restraint at the implementation layer.
@@ -0,0 +1,247 @@
# E-2 — UX + QoL review of D-2 direction
Scope: walk D-2 against the I-1 punch list, the v1.5.4 wizard Marc tested, and R-2's evidence. Out-of-scope per brief: design tokens, architecture, a11y, and the four locked decisions (URL split, single-step delete, Phase Sheet — overridden to inline by D-2 §3.7, restrained progress animation). Where D-2 has already swapped the locked Sheet for inline editing on Phasen, I flag it but do not relitigate the call; Marc's directive accepted "Sheet" for the editor's *focus*, not the literal primitive, and inline is defensible.
Findings are pinned to D-2 sections (§n) or component files. Verbatim German is quoted where the wording itself is the issue.
---
## Critical
### C1. The Today's-Dose card invents a wire shape that doesn't exist (D-2 §3.2)
D-2 §3.2 specifies:
> API: `POST /api/medications/[id]/intake` with `{ status: "TAKEN" | "SKIPPED" | "DEFERRED" }`. The route already exists; the shape mirrors the per-card action on the list page.
`src/app/api/medications/[id]/intake/route.ts:75` parses `{ scheduledFor, takenAt, skipped, idempotencyKey }`. There is no `status` enum, no `TAKEN`, no `SKIPPED`, no `DEFERRED` anywhere in the route, anywhere in the schema, anywhere in messages/de.json (which carries only `Eingenommen` / `Übersprungen` / `Ausgelassen`). The `DEFERRED` / `Verschoben` concept has no home in the data model.
Three UX consequences cascade off this:
1. The wireframe button **`[⤳ Verschoben]`** can't be wired without a schema migration the v1.5.5 brief does not have on its plate.
2. The list-page per-card action that D-2 says this "mirrors" is the existing `take` / `skip` pair on `<MedicationCard>` — two buttons, not three. The D-2 mock invents a third state.
3. The empty-state line `Heute keine Einnahme geplant.` and the three-button row both depend on a `today's due event` shape that the medication-detail GET does not currently return. The reader needs to compute "is there an event due today" from the schedule — that's a non-trivial cadence-engine call the section assumes is free.
Recommendation: collapse Today's Dose to two buttons (`[✓ Genommen]` / `[✗ Übersprungen]`) and POST the existing wire shape. If "Verschoben" is a v1.5.5 feature Marc wants, it needs an explicit data-model decision (a new `deferredUntil` column? a status enum migration?) flagged in §13 — it cannot ship as "the route already exists; the shape mirrors the per-card action".
### C2. Wizard hand-off lands the user on the wrong step for cadence edits (D-2 §5 + wizard-payload.ts:870)
D-2 §5:
> The cadence-summary row's `[✏ Bearbeiten]` button (lands on `landingStepForEdit(payload)` per the existing helper — currently the cadence step).
`landingStepForEdit` at `wizard-payload.ts:870`:
```ts
export function landingStepForEdit(payload: WizardPayload): number {
return payload.schedules.length > 1 ? 8 : 1;
}
```
The helper returns Step **1** (name) or Step **8** (schedule list). It does not return "the cadence step". A user tapping `[✏ Bearbeiten]` on the cadence-summary row to fix a cadence question will land on the *name* input (single-schedule meds) or the *summary* (multi-schedule meds) — two screens away from Step 5/6/7 where the cadence actually lives. The Apple Health and Round Health precedents R-2 cited gate "Edit Schedule" behind a dedicated row exactly because rewriting cadence is a heavy operation; landing the user on a different step than the one they tapped on breaks the spatial contract.
Two-row recommendation: either teach `landingStepForEdit` an optional `intent: "cadence" | "summary" | "name"` argument (the header band passes "name", the cadence row passes "cadence" → Step 5), or split the helper so the cadence row imports a different entry function. Either way, the wireframe in D-2 §5 promises a wizard mount it does not currently deliver, and the user-facing wording matters: "Bearbeiten" on the cadence row reads as a cadence-scoped edit, not a full-wizard reopen.
### C3. One-shot medications break the entire section model below Step 4 (D-2 §3 + I-1 §1 item 13)
D-2 §3 locks the eight sections in a recurring-cadence-shaped order. Walk a one-shot vaccine through it:
| § | Section | One-shot reality |
|---|---|---|
| 1 | Header band | OK — `Aktiv` until taken, then? Beendet? Status pill semantics unspecified for one-shot. |
| 2 | Today's dose | OK on dose-day. **What renders on day-1 + 1?** "Heute keine Einnahme geplant." is technically true but reads wrong — the dose was *yesterday*, not "not scheduled". |
| 3 | Cadence summary | D-2 §3.3: "one-shot meds render a single-line `Einmalig am DD.MM.` and hide the Bearbeiten affordance". This contradicts D-2 §5, which says the cadence-row Bearbeiten always opens the wizard. Pick one. |
| 4 | Dose ladder | Hidden when not GLP-1 — fine. |
| 5 | Intake history preview | At most **one** row. The "Heute / Diese Woche / Älter" grouping reads as bureaucratic theatre over one record. |
| 6 | Notifications | Switch is moot once the dose is logged — v1.5.4's auto-deactivate-on-log behaviour (`feat(meds): auto-deactivate one-shot medications when the dose is logged`, commit `39430e16`) means notifications are off the moment the user taps Genommen. The page still renders the switch as if it were live. |
| 7 | Settings | API tokens + CSV import are absurd for a single-dose vaccine. PhaseManagementRow is hidden (no GLP-1). GraceMinutesRow applies but is one minute of decision-surface. The whole section reads as out-of-scope. |
| 8 | Destructive zone | Tier 1 Pausieren is meaningless after the dose was logged; Tier 2 Beenden is redundant with the auto-deactivate. Only Löschen + Verlauf löschen remain coherent. |
The v1.5.4 auto-deactivate behaviour is a quiet but material change: a one-shot medication that's been logged is essentially archived. D-2 needs to treat "one-shot, logged" as a third lifecycle alongside `Aktiv` / `Pausiert` / `Beendet` and suppress sections 2, 5 (or collapse to "Einmalig genommen am DD.MM."), 6, and most of 7. The current spec renders the full ladder regardless, which is the same "dense settings list" critique R-2 §2 levelled at MyTherapy.
Recommendation: add a `kind: "oneShot"` branch at the top of the route that renders a five-section variant — Header, Logged dose card (read-only: "Einmalig genommen am DD.MM. um HH:MM"), Cadence summary (single line), Notifications (only if not yet logged), Destructive zone. Hide §3.4, §3.5 grouping, §3.7 entirely.
---
## High
### H1. Destructive zone Tier 1 (Pausieren) ships without confirmation and without explicit reversibility cue (D-2 §3.8)
> Tier 1 — Pausieren: `<Switch>`, no confirmation. … Fully reversible.
R-2 §D endorses "no confirmation" because pause is fully reversible. The user-facing affordance is fine; what's missing is the *signal* that the pause is reversible. The MyTherapy uservoice thread R-2 cited explicitly shows users uncertain whether "pause" means "skip today" or "stop until I say so". HealthLog's pause is the latter — but the German copy `Erinnerungen pausieren, Verlauf bleibt.` reads more like "skip today" than "park indefinitely".
Recommendation: rewrite the helper line to `Erinnerungen anhalten, bis du sie wieder aktivierst. Verlauf bleibt erhalten.` — the "bis du sie wieder aktivierst" clause makes the indefinite-but-reversible nature explicit. Marc's voice contract (every committed string is one human writing notes to the next) survives the rewrite.
### H2. The destructive zone has four tiers, not three, and the wireframe order is wrong (D-2 §3.8)
D-2 §3.8 numbers them as `Pausieren / Beenden / Verlauf löschen / Medikament löschen` — four tiers. The ASCII wireframe groups Tier 3a (Verlauf löschen) and Tier 3b (Medikament löschen) together visually but doesn't separate them with the `─────` divider it uses between Beenden and the deletion pair. Two consequences:
1. **Order reads wrong.** The current order escalates Pausieren → Beenden → Verlauf löschen → Medikament löschen, which mixes a *data scope* axis (just-the-history vs. the-whole-record) into a *severity* axis. A patient reading top-down can't tell whether "Verlauf löschen" is more or less destructive than "Beenden" — the former is *irreversibly* destructive over a *narrower* scope; the latter is *recoverable-by-reactivating* over a *wider* scope. Both can't be "less destructive" than the next tier simultaneously.
2. **Visual grouping doesn't match severity.** Apple Health's solution (R-2 §1) is to put Archive (reversible) and Delete (irreversible) at opposite ends of the Options scroll, with one swipe affordance pulling Archive and the other pulling Delete. D-2 puts all four tiers in one card with one divider, which makes the destructive escalation hard to scan.
Recommendation: split into two cards. Card A "Verwaltung" houses Tier 1 (Pausieren) + Tier 2 (Beenden) — both reversible-or-archival operations. Card B "Gefahrenzone" houses Verlauf löschen + Medikament löschen with explicit red-tinted destructive accent (the project already uses `text-destructive` on the Tier 2 + 3 buttons; the card-level distinction makes the severity gradient visible at a glance). The `<Separator>` between the two cards becomes meaningful (visual scope change) rather than decorative.
### H3. The "Einnahmen importieren" trigger appears in two places without precedence rules (D-2 §3.5 + §3.7 + restored-feature table item 5)
> Settings CSV row + Einnahmen header — `<CsvImportRow>` + `<IntakeHistoryPreview>` header CTA → existing `IntakeImportDialog`
Two surfaces to launch one dialog. Marc's voice contract aside, this surfaces a discoverability question: which trigger wins for the muscle memory of returning users? The header CTA is one scroll away; the Settings row is six scrolls away. If a user lands on the page and the Settings row is the "official" home, the header CTA reads as a duplicate that won't survive the next sweep.
Recommendation: pick one. R-2 §B + §C suggest the intake-history header is the right home because every CSV-import workflow ends with the user wanting to see the new rows land in the history list. Drop the Settings row to a one-line stub `Importiere Einnahmen direkt aus dem Verlauf oben.` with a `Zum Verlauf` link that scrolls to the section. Saves one new component, removes the duplicate.
### H4. Settings section assumes endpoints that the codebase may not expose at the documented paths (D-2 §3.7)
D-2 §3.7 settings-row API column lists:
| Row | API documented in D-2 |
|---|---|
| Externe Integration | `GET /api/medications/[id]/api-endpoint`, `POST` to mint, `DELETE /api/tokens/[id]` |
| Phasen | `GET/PUT/DELETE /api/medications/[id]/phase-config` |
| Grace | `PUT /api/medications/[id]` with `{ reminderGraceMinutes }` |
I-1 §3 confirms `api-endpoint` + `phase-config` routes exist; the `reminderGraceMinutes` field on PUT medication is the one I cannot verify against the code without a deeper read. If grace is currently a per-schedule (not per-medication) field, the implementer needs to pick a primary schedule, which D-2 acknowledges in a parenthetical aside:
> per-schedule field — applied to the medication's primary schedule for the v1.5.5 surface; multi-schedule grace is a v1.5.x follow-up
A user on a multi-schedule medication who edits grace from this row will silently update only one schedule. From the UX side that's the worst-of-both-worlds: it looks like a global control, but writes a scoped one. Either label the row explicitly (`Erinnerungsfenster — gilt für deinen Hauptzeitplan`) or defer the row to v1.5.6 alongside the multi-schedule handling.
### H5. Intake history preview's swipe-to-reveal pattern collides with vertical scroll on 360 px (D-2 §6.3)
> The trigger element on mobile is a swipe-to-reveal Bearbeiten + Löschen affordance using CSS scroll-snap (no JS gesture library — the project bans new dependencies).
CSS scroll-snap horizontal-on-a-vertically-scrolling-list works, but on a 360 px viewport with 14 rows stacked, every accidental horizontal drag near the edge of a row pulls the row into reveal-state. The Apple Health and MyTherapy precedents R-2 cited use native iOS swipe — backed by `UISwipeActionsConfiguration`, which has gesture-arbitration code HealthLog doesn't have access to in a PWA.
Worse: the destructive `Löschen` reveals on the **right** (per the wireframe), which is the same edge a right-handed thumb naturally rests near while scrolling. The thumb-reach map for a 360 × 800 viewport puts the right-edge bottom half in the "easy reach" zone — exactly where the user wants to scroll and exactly where they'll accidentally trigger Löschen.
Recommendation: lean on the desktop pattern for mobile too — kebab `<DropdownMenu>` at the row's right edge, always visible, 44 px tap target. The kebab is the project precedent (`intake-history-list-v2.tsx` already ships with `text-muted-foreground` row controls). The swipe affordance only makes sense if it's gestur-arbitrated against the parent scroll — and that's a JS dependency the project bans. Document the decision in §13 so Marc can override if he wants the iOS-feel.
### H6. Status-bar morph animation spec contradicts the locked decision (D-2 §2.6 + brief locked decision #4)
The locked decision is:
> Status-bar animation = restrained width-only progress in the existing HealthLog design vocabulary (NO Material-3 three-shape morph, NO new motion language).
D-2 §2.6 + §9 ship the three-shape clip-path morph (half-circle → rounded square → capsule → full capsule). The CSS doesn't import a new library, but it absolutely introduces a new motion language — the discrete shape morph is exactly what Marc's locked decision rules out.
The brief is explicit that this audit reviews D-2 for UX coherence and does NOT relitigate the four locked decisions. The morph spec violates a locked decision; either D-2 is wrong or the locked decision wasn't communicated to D-2's author. Surface this for Marc to resolve before implementation. If the spec stands, drop §2.6 + §9 entirely and replace with `<Progress value={progress} className="h-1.5" aria-label={stepOf} />` plus a CSS-only width transition matching the existing `<Progress>` primitive default.
---
## Medium
### M1. Header status pill conflates two orthogonal axes (D-2 §3.1)
> `Aktiv` / `Pausiert` / `Beendet`
These are lifecycle states. The header band also surfaces the cadence summary (`Wöchentlich · seit 12.03.`) and the dose. The wireframe shows three pills' worth of information on one line, which on 360 px will wrap. The `seit 12.03.` postfix is muted but reads as a date of *activation*, not the course start — and for a course-window medication those are different days. Recommendation: drop `seit DD.MM.` from the header (it's repeated on the cadence-summary row a few pixels below) and let the pill carry only the lifecycle state.
### M2. Empty-state copy inventory is incomplete (D-2 §3.2 + §3.5 + §3.7)
D-2 pins copy for:
- §3.2 Today's dose: `Heute keine Einnahme geplant.` (new key needed)
- §3.5 Intake history preview: `Noch keine Einnahmen erfasst.` (exists at `messages/de.json:1033`)
- §3.7 ApiTokensRow: `Noch kein Token erzeugt.` (new key needed)
- §3.7 PhaseManagementRow: `Phasen sind nur mit Kurs-Fenster verfügbar.` (new key needed)
Not pinned:
- §3.3 Cadence summary when payload is incomplete (cadence has no schedule yet — only reachable through a corrupted record but the page still has to render)
- §3.4 Dose ladder when titration catalog is missing for a GLP-1 row
- §3.6 Notifications when no push channel is configured (the helper line "APNs · Telegram · Web-Push" doesn't degrade gracefully to "Keine Push-Kanäle konfiguriert")
Pin the missing four; add the four new DE keys to the i18n table in §10.
### M3. The natural-language "✨ Aus Text" affordance disappears on edit but the wireframe doesn't say so (D-2 §2.3 header structure)
Wizard code `MedicationWizardDialog.tsx:388`:
```tsx
{step === 1 && mode === "create" && (
<Button ... >Aus Text</Button>
)}
```
The button only renders on `mode === "create"`. D-2 §2.3 wireframes the header with `[✨ Aus Text]` shown, no note that it's create-only. A reader following the wireframe to the letter would mount the button in edit mode, where the NL extractor doesn't have a sensible reset path. Annotate the wireframe.
### M4. R-2's rolling-cadence wording is missing from the cadence-summary row (D-2 §3.3)
R-2 §A item 3 explicitly calls out "Alle 12 h nach letzter Einnahme" as a Dosecast-pattern sentence the cadence-summary line should support. The example sentences in D-2 §3.3 mock only `Wöchentlich, mittwochs · 08:00`. The `summariseCadence` helper already supports the `rolling` cadence (`summary.cadence.rolling`), but D-2 doesn't restate it in the wireframe — which matters because the wireframe is what the implementer reads first. Add one rolling example, one everyNWeeks example, one monthly example. The R-2 patient-pain item — "I can't model schedules that aren't every day" — needs the wireframe to *show* the non-daily case.
### M5. `clientManaged` note placement is buried (D-2 §3.6)
> when the calling session has `notificationPrefs.clientManaged === true`, render a single `text-xs text-muted-foreground` line: `Diese Erinnerungen werden auf deinem iPhone verwaltet.`
A `text-xs text-muted-foreground` line below an unresponsive switch is the worst-of-both-worlds: the switch reads as broken until the user reads the explanation. Either disable the switch with a tooltip-on-tap, or replace the switch entirely with a read-only chip `iPhone steuert die Erinnerungen` when `clientManaged` is true. The iOS coordination memo for v1.4.49 hard-rules that the server cron skips dose-due APNs when this flag is true; the UI should match that hard-rule visually.
### M6. The wizard's primary CTA on Step 8 (Save) sits inside a sticky footer; the height contract isn't honoured by the v1.5.4 code (D-2 §2.1 + MedicationWizardDialog.tsx:330-368)
D-2 §2.1 specifies `min-h-[60dvh] md:min-h-0` on the ResponsiveSheet to fix the "too short" bug Marc reported. The footer is sticky-pinned in the Sheet branch but absolute-positioned in the Dialog branch (both via `ResponsiveSheet`). On a 60dvh-tall sheet with two visible inputs (e.g. Step 3 Dose) the body fills less than the floor and the footer floats mid-screen with whitespace below — which is uglier than the cramped state D-2 was trying to fix. Either drop the floor to `min-h-[40dvh] md:min-h-0` or move the footer to the natural document flow on short steps. The fix is a regression risk and needs a screenshot pass at every step on a 360 × 640 viewport before merge.
### M7. Bulk-delete on `/medications/[id]/history` ships without a per-row optimistic strategy (D-2 §6.2)
> Backend: existing `DELETE /api/medications/[id]/intake/[eventId]` looped via `Promise.allSettled` — no new API route.
The user marks 50 rows, taps `Löschen`, the page fires 50 parallel `DELETE`s. With `Promise.allSettled` the UI gets a single "X erfolgreich, Y fehlgeschlagen" summary at the end. UX consequence: the user sees no progress for 50× the per-DELETE latency (~1-2 s on a cold rollup-tier query). A 30 s "wait while we delete" is a worse experience than the "no bulk delete" state. Either gate bulk-delete behind a max of 20 rows per submit (with a "split into batches?" prompt above), or build a dedicated `DELETE /api/medications/[id]/intake?ids=...` route as a v1.5.5 prerequisite. Document the decision in §13.
### M8. ApiTokensRow's "Endpunkt: POST /api/medications/abc/intake" + Kopieren is opaque (D-2 §3.7 ApiTokensRow wireframe)
The user sees the endpoint URL with `abc` as the literal medication id. That's the actual id of the medication — opaque to a patient ("what's abc and why is it my medication?"). Round Health and Dosecast hide raw IDs entirely; the API-token flow is the one place a power-user needs the id, but the wireframe doesn't label *which* medication's endpoint this is. Add a one-line caption `Endpunkt für „Mounjaro 7,5 mg"` above the URL so the user is anchored.
Worse: a `Kopieren` button is shown but the wireframe doesn't disambiguate "copy the URL" vs. "copy the URL + a freshly-minted token". The original `ApiEndpointDialog` would mint a token and surface it once (HMAC-SHA256 means the plaintext is gone after that view). The settings row needs to make "the token plaintext appears here ONCE; copy it now" explicit — this is a security-sensitive copy decision that R-2 §C marked as the place users complain about ambiguity.
---
## Low
### L1. The wizard's edit-title interpolation is fragile for long medication names (D-2 §2.3 + MedicationWizardDialog.tsx:303)
`t("medications.wizard.header.editTitle", { name: initial.name })` renders something like `Mounjaro 7,5 mg bearbeiten`. D-2 §2.3's wireframe header reserves a single line for "Schritt 3 von 8" + "✨ Aus Text". The edit-title is the dialog's title (passed to `<ResponsiveSheet title>`) but the wizard sets `hideHeader`, so the title is screen-reader-only. Fine. But the new 560 px geometry adds enough room to *show* the edit title, and that would be more user-friendly than leaving it as sr-only. Surface this as an enhancement option.
### L2. The destructive Tier 3a body interpolates `{count}` without an empty-state fallback (D-2 §3.8)
> body `Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.`
If the user opens Verlauf-löschen on a medication with zero intakes, the dialog reads `Die 0 Einnahmen werden unwiderruflich gelöscht.` Either disable the affordance when count is 0 or branch the copy to `Es sind keine Einnahmen zum Löschen vorhanden.` The cleaner fix is the disable — there's nothing to confirm.
### L3. The Lucide icon for Tier 2 Beenden is `Square` (D-2 §10)
`Square` is a content-empty icon (just a square outline). Apple Health uses an archive-box glyph; the lucide equivalent is `Archive` or `ArchiveX`. `Square` reads as "stop button" only in TV-remote context, not in a medication-management surface. Swap to `Archive`.
### L4. The wizard step-transition animation direction logic doesn't propagate to the goto-from-Step-8 case (D-2 §2.7)
The `onEditSchedule` handler at `MedicationWizardDialog.tsx:230` does `setStep(5)` — that's a *backward* jump from 8 to 5 but uses the default `direction = "next"` (the state defaults to "next" on the initial render and only flips on `goBack`). The user would see an 8 px slide-from-right when navigating from "I'm editing the summary" to "I'm editing schedule 2's cadence" — directionally wrong. Set `setDirection("back")` in `onEditSchedule` too, or build a `goToStep(step, direction)` wrapper.
### L5. "Heute" group label in the intake history preview reads as a date, not a relative bucket (D-2 §3.5)
The R-2 reference apps (MyTherapy, Apple Health) label the group with the absolute date (`Mo, 28.05.`) and let the user infer "today" from context. The German `Heute` reads as a label, not a date, which is correct for the bucket but creates ambiguity when the user scrolls back: the next row's date-context starts at "Diese Woche", forcing the eye to compute "what's today's date" from elsewhere on the page. Add the absolute date in parens for unambiguous timestamping: `Heute (Mi, 28.05.)`. Low because the existing pattern is fine for chronic users; the parenthetical is the polish layer.
### L6. The intake-import dialog extraction (D-2 §11 refactor pre-work) is a separate PR but D-2 doesn't say so
> Refactor pre-work: extract `IntakeImportDialog` from `src/app/medications/page.tsx` into `src/components/medications/intake-import-dialog.tsx` so the detail page imports cleanly.
The extraction crosses test-coverage boundaries (list-page test mocks vs. detail-page test mocks). v1.5.5 PRs that bundle the extraction with the new detail page will be hard to review; the rebase risk on `develop` while the marathon runs is non-trivial. Note this as "land the extraction as the first commit of the PR series" so the diff stays scannable.
### L7. The skeleton/loading choreography is unspecified across the whole page (D-2 §3 + §11)
The detail page reads four to six endpoints (`medicationDetail`, `medicationTitration` for GLP-1, `medicationIntakeList`, `notificationsStatus`, `tokens`, `medicationPhaseConfig` for GLP-1). D-2 doesn't pin a per-section skeleton vs. a whole-page spinner. The existing history page at `[id]/history/page.tsx:51` blocks the entire page on `(authLoading || medLoading)`. Replicating that for the detail page would mean the user sees a spinner while *every* tail call resolves — including the GLP-1 phase-config, which is slow on uncached tenants. R-2 §A's "concentric document" reads of the detail surface assume the header band lands first and sections cascade in. Per-section `<Suspense>` boundaries are the project precedent (mentioned in MEMORY.md re v1.4.40 W-13 per-tile Suspense). Pin the staging: header + cadence-summary render on the first paint, every other section streams in. Without this in D-2 the implementer will default to the history-page blocking pattern.
### L8. Sonner toast precedent vs. inline status banner (D-2 §3.7 PhaseManagementRow)
> Status banner becomes a `toast.success(t("medications.phaseSaved"))` — drop the 2 s auto-clear banner.
The 2 s banner in the original `PhaseConfigDialog` was close to the action that fired it — eyes already pointing at the save button see the success. A sonner toast lives in the top-right of the viewport, away from the save button on the bottom of the settings section. The patient may not even see the toast if they're scrolled into a section. The 2 s banner was actually better UX for a settings-card save; defending the swap needs a "where does the user's eye land after save?" answer D-2 doesn't give. Recommendation: keep the inline banner for in-card saves, reserve sonner for cross-page operations.
---
## Summary
- **Critical: 3** — C1 (Today's-Dose wire shape doesn't exist), C2 (cadence-row Bearbeiten lands on wrong step), C3 (one-shot meds break the section model).
- **High: 6** — H1 (Pausieren copy ambiguous), H2 (destructive zone tier ordering), H3 (CSV-import duplicate triggers), H4 (grace silently scoped to one schedule), H5 (swipe-to-reveal collides with scroll), H6 (status-bar morph violates locked decision).
- **Medium: 8** — M1 (header pill conflation), M2 (empty-state inventory gaps), M3 (NL button create-only undocumented), M4 (rolling-cadence example missing), M5 (clientManaged note placement), M6 (sheet floor regression), M7 (bulk-delete no progress), M8 (API endpoint context).
- **Low: 8** — L1 (edit title sr-only), L2 (zero-intake purge copy), L3 (Lucide icon swap), L4 (step-transition direction edge case), L5 (Heute label ambiguity), L6 (extraction PR order), L7 (loading choreography unspecified), L8 (toast vs. banner).
Marc-walk count for the brief scenario ("öffne /medications → lösche heute morgens Einnahme → pausiere für 3 Tage"): **7 taps** today (medications → card → detail → history-preview kebab → Bearbeiten/Löschen → confirm → back to detail → destructive pause switch). Down from the v1.5.4 status of "unreachable except via API". Friction concentrates at the kebab → swipe pattern (H5) and the missing "pause for 3 days" affordance — D-2's pause is indefinite, not duration-scoped. The patient who said "pause for 3 days" needs a `Pausieren bis…` date-picker option that no tier of the destructive zone offers; that's a v1.5.6 enhancement, not a v1.5.5 hold.
Restored-feature mapping (I-1 → D-2 §4): all 16 features traced. Items most awkward in the new home: #15 + #16 (intake CRUD via swipe — see H5) and #6 (API endpoint — see M8). Every other feature reaches a more discoverable surface than v1.5.4 buried it under.
@@ -0,0 +1,208 @@
# E-3 — Architectural review of D-2 (v1.5.5 medication-detail direction)
Reviewer: senior software architect, audit-only. The four Marc-locked
decisions (detail page at `/medications/[id]/page.tsx`; single-step
`<AlertDialog>` for delete; focused `<Sheet>` phase editor; restrained
width-only status-bar morph) are out of scope — the audit only checks
that the surrounding design is sound around them.
Severity legend: **Critical** = will break or leak on first deploy /
silently violate a project invariant; **High** = ships with a known bug
or invalidation gap and will surface in production; **Medium** = costs
churn or carries risk worth addressing before code lands;
**Low** = polish, naming, future-flex.
Tally: **3 Critical**, **5 High**, **6 Medium**, **5 Low** (19 total).
---
## CRITICAL
### C-1 — Marc-locked decision contradicts the rest of D-2 on PhaseConfigDialog
- **D-2 reference:** §3.7 sub-row table + §3.7 PhaseManagementRow narrative + §7 "inline editor for Phasen, no nested dialog" + §13 Q3.
- **Marc-locked direction:** "Phase editor = focused `<Sheet>`."
- **Conflict:** Three separate sections of D-2 explicitly commit to an inline `<PhaseManagementRow>` editor that REPLACES the v1.5.4 PhaseConfigDialog ("nesting modals inside a settings card is the v1.5.4 mistake repeated"). The locked decision reverses that. D-2 still has §13 Q3 unresolved.
- **Why this is Critical:** the entire §3.7 / §7 / §3.4 architecture is built around the inline pattern. A focused-Sheet variant moves `<PhaseManagementRow>` out of the SettingsSection composition, changes the invalidation surface (sheet open/close lifecycle vs always-mounted row), and re-introduces the orphaned-trigger risk pattern that bit v1.5.4 — the Sheet only matters if there is a row to open it from, and D-2 §11 lists no such trigger.
- **Action:** rewrite §3.7 + §7 + §11 + §13 to spell out the resurrected PhaseConfig sheet: where the trigger lives (settings sub-row CTA), what its prop surface is (resurrect the deleted I-1 §4 contract: `{ medicationId, open, onOpenChange }`), how Phasen empty state ("Phasen sind nur mit Kurs-Fenster verfügbar.") renders inside the trigger row when GLP-1 + no course window, and what query keys the sheet's save / reset mutations invalidate. Resolution of §13 Q3 must land BEFORE implementation.
### C-2 — Tier 1 "Pausieren" Switch wired to a non-existent field
- **D-2 reference:** §3.8 Tier 1: `PUT /api/medications/[id]` with `{ active: false, pausedAt: <iso> }`.
- **Live tree:** `src/lib/validations/medication.ts:257` exposes `active` on `updateMedicationSchema`, but NOT `pausedAt`. The PUT route at `src/app/api/medications/[id]/route.ts:122-129` computes `pausedAt` server-side as a derived patch — flipping `active:false` automatically stamps `pausedAt = new Date()`, and `active:true` clears it. Clients that send `pausedAt` get it dropped by Zod's strip-unknowns and the server overrides anyway.
- **Why this is Critical:** the wire contract in D-2 mints a client-side `pausedAt` timestamp the server will ignore. If a future schema migration changes the strip-unknowns posture (Zod v4 has `.strict()` knobs) the PUT 422s. The detail page would ship "works on my machine" until the schema tightens.
- **Action:** §3.8 Tier 1 should drop the `pausedAt` field from the PUT body. The active-flip is sufficient — the server derives `pausedAt` correctly. Same fix for the I-1 §1 "Pausieren / Aktivieren" snippet.
### C-3 — Authz read of `/api/medications/[id]/intake/purge` differs from the rest of the cluster
- **D-2 reference:** §3.8 Tier 3a — Verlauf löschen → `DELETE /api/medications/[id]/intake/purge`.
- **Live tree:** `src/app/api/medications/[id]/intake/purge/route.ts:11-18` does NOT use the `assertMedicationOwnership(...)` helper that every other route in the cluster (intake, intake/[eventId], intake/import via the inline check, api-endpoint, phase-config, cadence, glp1, compliance, inventory) routes through. It rolls its own `findUnique({ where: { id } })` + `userId !== user.id ? 404`. The check IS correct today — but it's the only purge surface in the tree and D-2 promotes it to a user-discoverable destructive flow that runs alongside per-event DELETE in the same UI band.
- **Why this is Critical:** consistency now is cheaper than a CVE later. The route was added before the `assertMedicationOwnership` hoist (v1.4.25 W21 Fix-N); the v1.5.5 PR is the natural moment to fold it in so a future schema/auth change cascades through one helper, not nine route-local copies. Without this, the purge route is the one outlier where a developer changing the ownership predicate forgets to update the manual check.
- **Action:** §3.8 Tier 3a should call out a pre-work item: refactor `src/app/api/medications/[id]/intake/purge/route.ts:14-18` to use `assertMedicationOwnership` so the destructive cluster has a single ownership predicate. Same review pass for `intake/import/route.ts:34-38` and `[id]/route.ts:29-36` + `:63-66` while we're here (PUT + DELETE on the parent route also hand-roll `findUnique`).
---
## HIGH
### H-1 — Cache invalidation cascade for §3.8 destructive tiers is partially undocumented
- **D-2 reference:** §3.7 "Invalidate" column lists keys for the four settings sub-rows; §3.8 lists no explicit invalidation contract for any of the three destructive tiers.
- **Live tree:** `medicationDependentKeys` (`src/lib/query-keys.ts:357-364`) bundles `medications()` + `analytics()` + `insightsRoot()` + `insightsTargets()` + `gamificationAchievements()` + the `["dashboard-medication-compliance"]` prefix. The per-medication keys NOT in the bundle: `medicationDetail(id)`, `medicationTitration(id)`, `medicationCadence(id)`, `medicationGlp1Details(id)`, `medicationIntakeList(...)` prefix, `medicationCompliance(id)`, `medicationPhaseConfig(id)`, `medicationIntakeDrugLevelChart(id)`. Pause, Beenden, Verlauf-löschen, Medikament-löschen each touch a different subset.
- **Why this is High:** §3.8 wires four mutations into the same UI band; if the implementation copies the wizard's `invalidateKeys(queryClient, medicationDependentKeys)` (the wizard's pattern at `MedicationWizardDialog.tsx:274`), the detail-page header pill won't re-render after Pausieren (`medicationDetail(id)` is not in the bundle), the inline compliance chart won't refresh after Verlauf-löschen (`medicationCompliance(id)` and `medicationIntakeDrugLevelChart(id)` not in the bundle), and on Medikament-löschen the `router.push("/medications")` racing with the in-flight invalidations leaves the destination page reading from the still-warm `medications()` cache for ~60 ms.
- **Action:** add a per-tier invalidation table to §3.8 mirroring §3.7's. Concretely:
- Tier 1 Pausieren → `medicationDependentKeys` + `medicationDetail(id)` + `medicationCadence(id)` + `medicationCompliance(id)`.
- Tier 2 Beenden → same as Pausieren.
- Tier 3a Verlauf löschen → `medicationDependentKeys` + `medicationDetail(id)` + `medicationIntakeList(id, *)` prefix + `medicationIntakeDrugLevelChart(id)` + `medicationCompliance(id)` + `medicationCadence(id)` + `["compliance-chart-inline"]`.
- Tier 3b Medikament löschen → `medicationDependentKeys`, then `router.push("/medications")` AFTER `await invalidateKeys`, not before.
### H-2 — `medicationIntakeList` prefix invalidation is not declared in `medicationDependentKeys`
- **D-2 reference:** §3.5 "Per-row affordance" + §6.3 edit/delete mutations declare "Invalidates the `["medications", id, "intake", "list"]` prefix."
- **Live tree:** `queryKeys.medicationIntakeList(...)` at `src/lib/query-keys.ts:98-118` builds `["medications", id, "intake", "list", sortBy, sortDir, limit, offset, status]`. The list prefix is `["medications", id, "intake", "list"]`. `medicationDependentKeys` (`:357-364`) only ships the `["medications"]` root. Per TanStack's hierarchical-prefix semantics that DOES invalidate every sub-key including the intake list — so the read is "everything refetches on every intake mutation" rather than "only the intake list".
- **Why this is High:** D-2 §3.5 + §6.3 describe a narrower invalidation than what `medicationDependentKeys` actually performs. Worse, the per-row Edit / Delete on §6.3 routes through `invalidateKeys(queryClient, medicationDependentKeys)` and gets the full cascade — every other medication's compliance, the analytics tree, the achievements, the dashboard compliance chart. That is the right behaviour (compliance changes on any intake edit), but D-2 wording implies otherwise.
- **Action:** §3.5 + §6.3 should explicitly invoke `invalidateKeys(queryClient, medicationDependentKeys)` for both edit + delete + bulk-delete. The prefix-only language is a footgun for the implementer.
### H-3 — Cross-surface invalidation: dashboard `medication-intake-quick-add` already runs the bundle + a one-off `["compliance-chart-inline"]` fan-out — detail page must mirror
- **D-2 reference:** §3.2 Today's dose card "invalidates `medicationDependentKeys` and `queryKeys.medicationDetail(id)` on success."
- **Live tree:** `src/components/dashboard/medication-intake-quick-add.tsx:265-270` runs `await invalidateKeys(queryClient, medicationDependentKeys)` AND `await queryClient.invalidateQueries({ queryKey: ["compliance-chart-inline"] })` because `compliance-chart-inline` is a bare-literal key (factory-routed as `medicationComplianceChart` at `query-keys.ts:74-75` but the prefix is NOT in the bundle).
- **Why this is High:** when the user taps Genommen on the detail page, the dashboard's compliance chart (if cached from a previous session) won't refresh. The two surfaces drift. The fix in v1.4.40 W-RSC was to add `["dashboard-medication-compliance"]` to the bundle, but the per-medication inline chart prefix was left out.
- **Action:** either (a) add `["compliance-chart-inline"]` to `medicationDependentKeys` so every surface stays symmetric, or (b) mirror the explicit two-key fan-out in §3.2 + §3.8 + the wizard's onSuccess. Preference (a) — one source of truth wins.
### H-4 — Forward-compat: detail page reads through legacy cadence helpers that the v1.5.x read-flip hasn't yet replaced
- **D-2 reference:** §3.3 "reuses the existing `summariseCadence(payload, t)` helper" — that helper sits in `wizard-payload.ts` and consumes the `WizardPayload` shape; §12 "Cadence-engine read-flip (separate v1.5.x track)" acknowledges the timing.
- **Live tree:** `medication-card.tsx:91-113` reads `parseScheduleRecurrence(schedule.daysOfWeek)` and walks `recurrence.daysOfWeek` / `recurrence.intervalWeeks` to compute the next dose, NOT through `src/lib/medications/scheduling/recurrence.ts` (the canonical engine, which the v1.5.0 cut wired only to the reminder worker per the file's own preamble at `recurrence.ts:7-13`). A rolling-cadence medication minted by the wizard has `rollingIntervalDays` set, but `medication-card.tsx` walks `parseScheduleRecurrence(schedule.daysOfWeek)` first and only falls back to the rolling tier if the recurrence walker emits nothing — which it will, but the read happens through the legacy chip render, not the canonical engine.
- **Why this is High:** D-2 §3.3's cadence-summary line uses `summariseCadence` correctly (the wizard's helper is fed by the wizard payload), so the row reads "every 7 days" for a rolling-cadence medication. But `MedicationCardHeader` / `SchedulingSection` / `glp1-medication-card` (the other surfaces D-2 §3 doesn't touch) still surface the legacy chip. If the detail page is the primary surface a user lands on after editing, they may see the rolling label but the dashboard card still paints a weekly chip — a known v1.5.x defect D-2 doesn't propagate the warning for.
- **Action:** §12 should explicitly enumerate which detail-page rows are read-flip-safe (the new cadence summary in §3.3 is, because it reads `summariseCadence` over the WizardPayload) and which inherit the v1.5.x legacy reader (the GLP-1 §3.4 dose ladder via `SchedulingSection`, if mounted alongside — D-2 §3.4 says it's not mounted directly but the history-page route still mounts it). Document the read-flip caveat at the detail-page level so the implementer knows where NOT to add a "consistent across surfaces" assertion in tests.
### H-5 — N+1 round-trips on detail-page mount, no aggregate `/detail` endpoint considered
- **D-2 reference:** §11 component tree implies ≥ 7 concurrent reads on mount: `medicationDetail(id)` (header + cadence summary + today's card share this), `medicationTitration(id)` (GLP-1 conditional), `medicationCadence(id)` (today's-dose schedule resolution if applicable), `medicationIntakeList(id, default-params)` (history preview, 14 rows), `notificationsStatus()` (notifications section helper line), `tokens()` (settings api-tokens), `medicationPhaseConfig(id)` (settings GLP-1 conditional), plus the api-endpoint per-medication probe.
- **Live tree:** each route is its own `apiHandler(...)` round-trip. The cadence + intake routes carry sub-100ms p50 today; the api-endpoint probe (`isApiGloballyEnabled()` + `count`) and the tokens read (Bearer-scoped list) are the two slowest.
- **Why this is High:** the detail page is the primary post-edit landing surface. The marathon's perf budget calls out the page-blocking critical path target ≤ 1 s; eight serial-tolerant but mount-fanout reads against a busy Postgres pool risk pool starvation under load — the v1.4.40 audit identified this exact class as the "fan-out starves Prisma pool" symptom.
- **Action:** consider a single `/api/medications/[id]/detail` aggregate endpoint that folds the medication + titration + cadence-summary + phase-config + tokens-summary into one round-trip, mirroring the dashboard summary pattern at `src/app/api/dashboard/summary/route.ts`. Failing that, explicit `Promise.all` in the page's loader with a hard cap on parallel reads. §12 should call this out as v1.5.6 follow-up if not v1.5.5.
---
## MEDIUM
### M-1 — `<MedicationDetailHeader>` is named in §11 but listed as "inline JSX block, no separate file needed" — file-naming convention drift risk
- **D-2 reference:** §11 component tree line 1: "`<MedicationDetailHeader>` NEW — inline JSX block, no separate file needed".
- **Live tree:** CLAUDE.md "File naming" — "Components and lib files: kebab-case … React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit."
- **Why this is Medium:** naming it as a component while not extracting it sets up the next maintainer to extract it carelessly into `MedicationDetailHeader.tsx` (PascalCase outlier), instead of `medication-detail-header.tsx` (the convention). The other §11 entries (`todays-dose-card.tsx`, `cadence-summary-row.tsx`, etc.) already use kebab-case correctly.
- **Action:** drop the "no separate file needed" hedge; either extract to `medication-detail-header.tsx` from day one (so the SettingsSection's symmetry pattern carries through) or remove the component name from §11 and just render the JSX block inline at the page level under a `data-slot="medication-detail-header"`.
### M-2 — `<MedicationCard>` ↔ `<MedicationDetailHeader>` duplication is real but the audit lens missed it
- **D-2 reference:** §11 lists `<MedicationDetailHeader>` without proposing reuse of `medication-card.tsx` or its sub-component `MedicationCardHeader.tsx`.
- **Live tree:** `src/components/medications/medication-card.tsx` renders the same `{name} · {dose} · status pill` shape on the list page; `MedicationCardHeader.tsx` already exists as the extracted header chrome. The detail-page header could absorb `MedicationCardHeader.tsx` directly as the title block, leaving only the status pill + edit affordance distinct.
- **Why this is Medium:** future drift between the list-card header and the detail-page header is the exact "edit one, forget the other" failure mode CLAUDE.md guards against ("`useAuth` uses `["auth", "me"]` but `queryKeys.auth()` returns `["auth"]`" was the precedent). Two surfaces, one component, one cache.
- **Action:** §11 should propose either (a) lifting `MedicationCardHeader.tsx` to drive both surfaces (preferred — it's already a stable extraction), or (b) explicitly noting why the detail-page header diverges (so future readers don't refactor blind).
### M-3 — Wizard ↔ detail-page mutation conflict surface for `notificationsEnabled`
- **D-2 reference:** §5 "Wizard owns: name, category, treatmentClass, dose, dosesPerUnit, oneShot, startsOn, endsOn, schedules[]. Detail page owns everything else: active, notificationsEnabled, …"
- **Live tree:** `MedicationWizardDialog.tsx` build-create-body path (`wizard-payload.ts` exports `buildCreateBody`) emits `notificationsEnabled` IF the wizard surfaces a step for it; the v1.5.4 wizard does not, but the payload carries it (`notificationsEnabled: initial.notificationsEnabled ?? true` at `wizard-payload.ts:858`). `updateMedicationSchema.notificationsEnabled` at `validations/medication.ts:258` is optional on the PUT. The detail page is the canonical surface for this toggle (§3.6).
- **Why this is Medium:** if the user toggles notifications on the detail page, then opens the wizard for an edit and saves, the wizard's payload could overwrite the just-saved notifications flag depending on which value the payload was hydrated with. The hydration at `MedicationWizardDialog.tsx:131` rides `hydrateWizardPayload(initial)` which captures `initial.notificationsEnabled` at sheet-open time — if the user toggled the flag after that point but before saving the wizard, the toggle is lost.
- **Action:** §5 should declare that the wizard NEVER mutates `notificationsEnabled` on PUT — the field must not enter the wizard's `buildCreateBody` payload. This is a small wizard-payload audit item, not a detail-page change, but it belongs in the spec because the detail page is the toggle's new home.
### M-4 — Detail-page tests not enumerated; cross-surface invalidation tests not pinned
- **D-2 reference:** the doc does not have a §13 "Test surface" section.
- **Why this is Medium:** the marathon's previous releases have pinned cross-surface invalidation as a non-negotiable (the `medicationDependentKeys` bundle, the dashboard / detail symmetry, the Coach disable cascade invariant test). Without an explicit test list, the implementer ships green CI that still leaves the cascade silently broken.
- **Action:** add §14 "Test surface" with at minimum:
1. **Component:** `<MedicationDetailSection>` renders chrome for each of the seven sections with no console errors at default i18n locale (de + en).
2. **Component:** `<TodaysDoseCard>` renders Genommen / Verschoben / Übersprungen buttons with 44 px floor, and disabled state on `cadence.next === null`.
3. **Integration:** Pausieren on `/medications/[id]` invalidates the list-page card's status pill + the dashboard quick-add medication option list within one tick.
4. **Integration:** Verlauf-purge on `/medications/[id]` evicts the intake history preview AND the `medicationCompliance(id)` AND `compliance-chart-inline` caches.
5. **Integration:** Medikament-löschen on `/medications/[id]` lands `/medications` on a fresh `medications()` cache (no stale row).
6. **Integration:** wizard edit landing on the page hydrates `summariseCadence` correctly for one-shot / daily / weekday / rolling / monthly / yearly modes.
7. **Component:** intake-history preview's per-row edit / delete affordance respects `min-h-11` floor on mobile.
### M-5 — `<IntakeImportDialog>` lift-out is correctly noted as pre-work but the trigger surface ownership is ambiguous
- **D-2 reference:** §3.5 "The 'Importieren' CTA opens the existing `IntakeImportDialog` (currently orphaned at `src/app/medications/page.tsx:344`); the detail page lifts the dialog onto its own surface and wires the trigger." Plus §11 "Refactor pre-work: extract `IntakeImportDialog` from `src/app/medications/page.tsx` into `src/components/medications/intake-import-dialog.tsx` so the detail page imports cleanly."
- **Live tree:** the dialog still mounts on the list page (`src/app/medications/page.tsx:292`) referenced by `setImportMedId(...)` — but no current trigger calls that setter (post-v1.5.4 it's orphaned).
- **Why this is Medium:** D-2 implicitly assumes the list page DROPS its mount once the detail page picks it up. Without an explicit lift contract, two mounts may co-exist (memory + state-machine churn). The CSV import row in §3.7 + the intake-history preview header CTA in §3.5 both gesture at this dialog — only one should own the mount.
- **Action:** §11 / §3.5 should declare a single canonical mount of `<IntakeImportDialog>` on the detail page, with §3.7's CsvImportRow and §3.5's "Importieren" CTA both reaching the SAME `intakeImportOpen` state at the page level (lifted state, not duplicated). Remove the orphaned mount from `medications/page.tsx`.
### M-6 — Bulk-delete in §6.2 uses `Promise.allSettled` over per-event DELETE — no rate-limit or partial-failure UX
- **D-2 reference:** §6.2 "Backend: existing `DELETE /api/medications/[id]/intake/[eventId]` looped via `Promise.allSettled` — no new API route."
- **Live tree:** `src/app/api/medications/[id]/intake/[eventId]/route.ts` triggers `recomputeMedicationComplianceForEvent` per delete + `reconcileOneShotState`. Looped 50× in parallel from the client, that's 50 rollup recomputes against the same `(userId, medicationId, dayKey)` row — potential write-lock thrash on the rollup table.
- **Why this is Medium:** the v1.4.39 W-MED rollup pass was tuned for a single intake mutation; a bulk delete fan-out is a new shape. Also, `Promise.allSettled` silently swallows per-event failures — the UI shows "30 ausgewählt gelöscht" when only 27 succeeded.
- **Action:** §6.2 should commit to either (a) a small `POST /api/medications/[id]/intake/bulk-delete` accepting `eventIds[]` and recomputing rollups once per touched dayKey (mirror the import-route pattern at `intake/import/route.ts:93-95`), or (b) hard-cap client-side parallelism (e.g. `Promise.all` over chunks of 5), surface per-event failures in a sonner toast, and prove the rollup table doesn't deadlock under contention. Option (a) is cleaner.
---
## LOW
### L-1 — `medicationDetail(id)` is the most-invalidated key but not in the central `medicationDependentKeys` bundle
- **D-2 reference:** §3.1 + §3.3 share `queryKeys.medicationDetail(id)`; every destructive tier (§3.8) and every settings sub-row (§3.7) needs to invalidate it; the wizard's onSuccess also needs it.
- **Live tree:** `medicationDependentKeys` at `src/lib/query-keys.ts:357-364` does include `queryKeys.medications()` whose prefix `["medications"]` covers `["medications", id]` via TanStack's hierarchical-prefix match. So invalidating the bundle DOES evict `medicationDetail(id)`. No bug — just non-obvious.
- **Action:** add a comment to `medicationDependentKeys` noting that `medications()` is the prefix that catches every `medicationDetail(id)` / `medicationCompliance(id)` / `medicationCadence(id)` / `medicationTitration(id)` / `medicationGlp1Details(id)` / `medicationIntakeList(...)` / `medicationIntakeDrugLevelChart(id)` consumer. Two minutes of clarity that defends the bundle's purpose for the next reader.
### L-2 — `<NotificationsSection>` reads `queryKeys.notificationsStatus()` but the section is per-medication
- **D-2 reference:** §3.6 helper line reads from `queryKeys.notificationsStatus()`.
- **Live tree:** `notificationsStatus()` at `query-keys.ts:139` is the global push-channel status (which channels are connected at all). It's the right key for the helper line (APNs · Telegram · Web-Push chip strip).
- **Why this is Low:** the per-medication switch state lives on `medicationDetail(id).notificationsEnabled` and rides the PUT; the global status read is a separate concern. D-2 is correct, but the implementer might confuse the two — the section name and the cache key both say "notifications" but mean different things.
- **Action:** §3.6 should explicitly call out the dual-source read: `medicationDetail(id).notificationsEnabled` drives the Switch state, `notificationsStatus()` drives the helper-line chip strip. They share no cache slot and invalidate independently.
### L-3 — Status-bar morph spec uses `data-slot="progress-indicator"` — confirm shadcn primitive exposes that selector
- **D-2 reference:** §2.6 + §9 CSS targets `[data-slot="progress-indicator"]` on the `<Progress>` indicator slot.
- **Live tree:** shadcn-ui's `progress.tsx` ships `data-slot="progress-indicator"` in v0.9+; HealthLog's `src/components/ui/progress.tsx` would need a quick grep to confirm.
- **Why this is Low:** if the slot attribute isn't present, the CSS silently no-ops and the morph never runs. Worth pinning a unit test that asserts the slot is on the rendered Progress.
- **Action:** §14 (new) test list should include "Progress slot attribute exists" assertion; or §2.6 should grep-verify the slot in the existing primitive before locking the spec.
### L-4 — `setActiveDialogOpen(true)` mount pattern conflicts with the §5 "second copy" claim
- **D-2 reference:** §5 "The list page keeps its own wizard mount for create + per-card edit; the detail page mounts a second copy with its own state."
- **Live tree:** `MedicationWizardDialog` keys its inner state container on `${props.open ? "open" : "closed"}:${props.mode}:${props.initial?.id ?? "new"}` (line 114). Two mounts with different open-states share the same `medications()` cache and the same query client — fine — but they DO race on the `onSuccess` callback's invalidation if both happen to be open at once (unlikely but not impossible if the user clicks Bearbeiten on a card while the detail-page sheet is also open from a back-nav).
- **Why this is Low:** edge case; the only consequence is a double-invalidation, which the bundle handles idempotently.
- **Action:** §5 should add a one-line note that the two mounts share `queryKeys.medicationDetail(id)` — invalidations are idempotent so a race is harmless.
### L-5 — iOS native-detail-screen forward-compat is acknowledged but not constrained
- **D-2 reference:** §12 "Native iOS mirror — the contract under `docs/api/openapi.yaml` is locked; iOS picks up the new endpoints (none — every wired API in this doc already ships) when its own release cuts."
- **Live tree:** every API surface in D-2 already exists; no new endpoint is implied. ✓
- **Why this is Low:** the v1.5.5 detail-page work doesn't strain the iOS contract, but the iOS client today opens detail screens with a strict subset of the web surface. The audit lens just confirms that's still true (D-2 doesn't smuggle in a new field on the wire).
- **Action:** none. D-2 §12 is correct as written. Listed for completeness.
---
## What D-2 got right
- Section ordering (§3 1-8) is the right clinical-document rhythm; matches Apple Health's daily / configuration / destructive split.
- Tier 1 (Pausieren) as a Switch with no confirmation, Tier 2/3 as single-step `<AlertDialog>` matches the `settings/advanced-section.tsx:287-317` precedent.
- Status-bar morph CSS is a clean v1.5.5-fit — CSS-only, no new dep, respects `prefers-reduced-motion`.
- Wizard ↔ detail-page boundary (§5) is the right cut: wizard owns the plan, detail page owns the state + the destructive edge.
- Restored-feature placement table (§4) accounts for every I-1 row.
- Out-of-scope (§12) is explicit and correctly leaves the v1.5.x read-flip + multi-schedule compose-mode for follow-up.
- No new Prisma migration: confirmed against `prisma/schema.prisma` — every field D-2 touches already exists.
---
## Summary of pre-implementation work
Before code lands, D-2 needs:
1. §3.7 + §7 + §11 + §13 rewritten around Marc's locked `<Sheet>` phase editor (C-1).
2. `pausedAt` field removed from §3.8 Tier 1 PUT body — server derives it (C-2).
3. `assertMedicationOwnership` rolled into the purge route AND the parent PUT/DELETE route as pre-work (C-3).
4. §3.8 invalidation table mirroring §3.7 (H-1).
5. §3.5 / §6.3 explicit `medicationDependentKeys` invalidation (H-2).
6. `compliance-chart-inline` folded into `medicationDependentKeys` OR mirrored in detail-page mutations (H-3).
7. Read-flip caveat surfaced per row (H-4).
8. Aggregate `/api/medications/[id]/detail` evaluated; deferred to v1.5.6 acceptable but document the decision (H-5).
9. §11 file-name convention + `MedicationCardHeader` reuse note (M-1, M-2).
10. Wizard payload's `notificationsEnabled` write surface (M-3).
11. §14 Test surface block added (M-4).
12. `<IntakeImportDialog>` single-mount contract (M-5).
13. Bulk-delete strategy choice — endpoint vs chunked parallel (M-6).
Lows are polish; address inline with the rest.
@@ -0,0 +1,134 @@
# E-4 — Accessibility review of D-2 (v1.5.5 medication detail page + wizard polish)
Scope: WCAG 2.1 AA + Apple HIG-equivalent rigor on `/Users/marc/Projects/HealthLog/.planning/medication-detail-page-2026-05-28/D-2-direction.md`. Out of scope: visuals (3A), UX walk (3B), architecture (3C), color-blindness deep dive. Locked decisions Marc carries (detail-page-as-new-URL, single-step delete, phase editor in Sheet, restrained status-bar) are honoured — none of the findings re-litigate them.
Reviewer cross-checks: `src/components/ui/dialog.tsx`, `src/components/ui/sheet.tsx`, `src/components/ui/responsive-sheet.tsx`, `src/components/ui/alert-dialog.tsx`, `src/components/ui/switch.tsx`, `src/components/ui/button.tsx`, `src/components/medications/wizard/MedicationWizardDialog.tsx`, `src/components/medications/intake-history-list-v2.tsx`, `src/components/medications/medication-detail-section.tsx`, `src/components/layout/auth-shell.tsx`.
---
## Critical (5)
### C-1 — Today's-dose card emits no `aria-live` confirmation; the only feedback is a toast
D-2 §3.2. The three primary buttons (`Genommen` / `Verschoben` / `Übersprungen`) mutate via `POST /api/medications/[id]/intake` and rely on react-query invalidation. D-2 does not name a polite live region for the success path nor an assertive region for the failure path. A screen-reader user taps "Genommen", focus stays on the button, and the toast (`sonner`) only announces when `aria-live="polite"` is wired on the toaster — verified in repo but not pinned in D-2 for this section. **Fix:** pin a `role="status" aria-live="polite"` invisible status node inside `<TodaysDoseCard>` that re-renders with `t("medications.takenConfirmed")` on success, OR explicitly cite the sonner polite-region as the canonical announcer and require it to be mounted at the auth-shell level. Same applies to §3.6 notifications switch (no announce on toggle), §3.8 tier-1 Pausieren switch (no announce on flip), §3.7 GraceMinutesRow (number input has no commit feedback for AT).
### C-2 — Loader-state coverage gap on detail-page section reads
D-2 names a `<Loader2 ... animate-spin />` precedent (§2.5 invariant 6) but does NOT pin an `aria-busy` + `aria-live="polite"` wrapper on the section-level loading skeletons. `intake-history-list-v2.tsx:184` already gets this wrong — bare spinner, no announce. D-2 §3.5 reuses V2 inside `<IntakeHistoryPreview>` so the same hole rides forward, and §3.4 (TitrationSection), §3.7 (ApiTokensRow / PhaseManagementRow), and §3.6 (NotificationsSection) all fetch independently. Pattern to enforce, matching `insight-status-card.tsx:53`: every async section wraps its loading branch in `<Card aria-busy="true" aria-live="polite">`. Codify in D-2 §3 as a per-section invariant or risk seven independent regressions.
### C-3 — Header band breaks reading order — `[Bearbeiten]` button precedes the drug name in tab order
D-2 §3.1 wireframe places `[✏ Bearbeiten]` on the same line as the drug name with float-right visual placement. The DOM order Marc's wireframe implies is `<h1>{name}</h1>` then `<Button>Bearbeiten</Button>` — that's fine on a flex row — but the spec doesn't pin which side renders first in the DOM. If the implementer follows the visual "right-aligned button" cue by writing the button first + `ml-auto`, sighted keyboard users will Tab to Edit BEFORE reading the drug name in DOM order, AT will announce "Bearbeiten button, [drug name]". **Fix:** D-2 §3.1 must state explicitly: heading element first in the DOM, edit button last, justified with `justify-between` on the parent. Same on §3.3 (cadence-summary Bearbeiten).
### C-4 — Modal-stack: AlertDialog opened from `<IntakeHistoryPreview>` is fine on the route page, but the same `onDeleteIntake` callback is also reachable from `/medications/[id]/history` (§6.2) where `<IntakeHistoryListV2>` mounts as a child of a different surface
The detail-page preview opens its destructive AlertDialog at the route level (clean). The bulk surface §6.2 wraps the same V2 list AND opens a sticky-bottom batch action bar AND opens a bulk-delete AlertDialog. If the per-row swipe-reveal Bearbeiten target opens a `<ResponsiveSheet>` per §6.3, and the user immediately fires the row-level delete, a `<Sheet>` and `<AlertDialog>` stack inside the route. Radix handles this correctly but the **focus-return chain breaks** when an AlertDialog dismisses while the Sheet underneath is also closing: focus returns to `document.body`, not the row trigger. D-2 must pin the contract: the edit Sheet's destructive-delete affordance lives INSIDE the Sheet (and dismisses the Sheet via its own onSuccess), it never opens a peer AlertDialog. Stack depth ≤ 2 with explicit close-then-confirm sequencing.
### C-5 — `bg-emerald-500 / bg-amber-500 / bg-zinc-500` status-pill dots are non-textual indicators; the pill text MUST carry the state for WCAG 1.4.1
D-2 §3.1: `Aktiv` / `Pausiert` / `Beendet` pill uses an `h-2 w-2 rounded-full` colored dot + text. WCAG 1.4.1 is satisfied because text IS present (`Aktiv`, `Pausiert`, `Beendet`) — but D-2 does NOT pin that the text MUST stay rendered at all viewport widths. If the implementer hides the text under a `sm:hidden` because the row crowds on 320 px, the pill collapses to a color-only signal — fail. Add an invariant: status-pill text never hides, the dot is `aria-hidden="true"`. Same for the Heute / Diese Woche / Älter group headers in §3.5 if they ever pick up colored chevrons.
---
## High (9)
### H-1 — Wizard focus management on edit-mode landing-step is undefined for screen readers
D-2 §5: header-band Edit lands the user on Step 1, cadence Edit lands them on `landingStepForEdit(payload)`. The wizard's `useEffect` at `MedicationWizardDialog.tsx:213-224` runs after mount and focuses the first interactive child. **Gap:** when the wizard opens on Step 5 (cadence-edit landing), the screen reader will announce the Sheet/Dialog title (`{name} bearbeiten`) but not the step context — the user lands in the middle of the form with no anchor. **Fix:** D-2 must pin `aria-labelledby` on the wizard body to the step title h2, and require the step title to receive focus (or be referenced via `aria-describedby`) when the wizard mounts mid-flow. Today the title is `<h2>` only — add `tabIndex={-1}` and a focus call when the landing step is anything other than 1.
### H-2 — Step transition `key={step}` re-mount loses focus across step navigation
`MedicationWizardDialog.tsx:413` uses `key={step}` to re-mount the body on every step change. The `useEffect` then re-runs and focuses the first interactive control — but the body re-mount happens BEFORE the effect runs, which causes a single-frame focus loss (focus reverts to `document.body`). Screen readers announce nothing during that frame; some AT (NVDA in particular) drops the next announcement. D-2 §2.7 adds a CSS animation on the same re-mount, which extends the gap to 220 ms. **Fix:** pin focus-restore inside the animation's `onAnimationEnd`, or use `aria-live="polite"` on a sr-only step-context announcer that fires the `stepOf` + `stepTitle` synchronously on `step` change, independent of the body re-mount.
### H-3 — Status-bar morph animation: `prefers-reduced-motion` collapses transition but does NOT collapse the clip-path geometry
D-2 §2.6 / §9 hard-locks the CSS. The `@media (prefers-reduced-motion: reduce)` block sets `transition: none` — the clip-path still snaps to the new shape on `data-step` change, but instantly. For a user with vestibular sensitivity who set this preference, a leading-edge SHAPE change with no transition is still a discrete motion event in their peripheral vision; the v1.5.5 progress bar fires a flicker on every step change. **Acceptable per spec** but better: under reduced-motion, drop the morph entirely and render a single capsule that grows by width only. D-2 §13 Q4 asks Marc to confirm "downgrade to width-only" — the a11y default IF Marc doesn't decide should be width-only under `prefers-reduced-motion`.
### H-4 — Destructive zone: tier 1 Pausieren is the only switch on the page that shares its tap zone with no label
D-2 §3.8 wireframe: row reads `Pausieren [●— ] Erinnerungen pausieren, Verlauf bleibt.` The switch is on the right, the label "Pausieren" is the row title, the helper text is below. Radix Switch's accessible name is whatever is wired via `aria-labelledby` — D-2 doesn't pin which element the switch references. **Fix:** require `aria-labelledby={pauseLabelId} aria-describedby={pauseHelperId}` on `<Switch>`. Same risk on §3.6 NotificationsSection and §3.7 ApiTokensRow / CsvImportRow / etc. — pin the labelledby/describedby contract once at §3 and reference it from every sub-row.
### H-5 — Intake-history kebab/swipe per-row affordance: keyboard equivalent on mobile is undefined
D-2 §6.3: mobile = swipe-to-reveal, desktop = `<DropdownMenu>` kebab. The mobile branch uses CSS scroll-snap (no JS gesture lib). Keyboard users on mobile (e.g. an iPad with a hardware keyboard, or AT users with switch-control) cannot trigger a scroll-snap with Tab. The "swipe-reveal" intrinsically excludes keyboard. **Fix:** the swipe surface MUST also expose the same actions through a kebab/DropdownMenu visible at the row-tail at all viewports — let the SWIPE be an additional discovery affordance, not the only one. The kebab adds 44 px to row tail; the swipe-reveal hides actions until interaction. Both can coexist. Codify in §6.3.
### H-6 — Switch tap target leaks 13 px outside the visible track but no `aria-label` on the `::before` pseudo-element
The project precedent (`switch.tsx`) uses `before:absolute before:inset-[-13px]` to widen pointer-event hit-zone to 44 px. The visual switch is `1.15rem` (~18.4 px) tall. **Gap:** the `::before` is a pseudo-element — it captures clicks but is invisible to AT layout heuristics. iOS VoiceOver's rotor and macOS Accessibility Inspector flag this as a 18×32 px hit area, not 44×44 px. Apple's audit tooling will fail this rule. **Fix on D-2:** every Switch caller on the detail page (NotificationsSection, Pausieren, the page-wide reminder toggles) must ensure the SURROUNDING row is the actual interactive label — wrap the switch + label in a `<label>` (Radix Switch supports this) so the entire row is the hit target reported by AT. D-2 doesn't say this anywhere.
### H-7 — German vs English label length collapse below 44 px on the destructive-zone CTA row
D-2 §3.8 wireframe: `[Medikament löschen]` (≈ 18 chars) on tier 3b. The EN equivalent is `[Delete medication]` (17 chars) — comparable. But §3.7 PhaseManagementRow: `[Auf Standard zurücksetzen]` (26 chars) and `[Speichern]` (10 chars) ride on a `flex justify-end gap-2` footer row. On a 320 px viewport, the row's combined min-width is ~280 px including padding — fits — but EN equivalent `[Reset to defaults]` is shorter. The risk is the OPPOSITE direction: a future locale (es / fr / it / pl per the v1.4.38 expansion) with `Restablecer los valores predeterminados` (40 chars) blows out the row, forcing `flex-wrap` and collapsing buttons below 44 px floor. **Fix:** pin a `min-h-11` floor on every settings-row button AND mandate `flex-wrap gap-2` over single-row layout for any row with ≥ 2 buttons; if buttons wrap, each retains 44 px.
### H-8 — Color contrast — destructive zone CTA on `bg-card`
D-2 §3.8: tier 2 + tier 3a + tier 3b CTAs use `<Button variant="destructive">` which is `bg-destructive text-white`. Project default `--destructive` is roughly the Dracula red `#ff5555` family. White-on-#ff5555 contrast ratio is approximately 3.45:1 — **fails WCAG 2.1 AA for body text (4.5:1), passes Large Text (3.0:1)**. The button is `text-sm font-medium` (14 px, 500-weight) — borderline Large Text per WCAG (Large = ≥ 18 px regular OR ≥ 14 px bold/700). 500-weight is NOT bold per WCAG. **Fix:** D-2 must either (a) raise the button's font-weight to 600 to qualify as Large Text, (b) darken `--destructive` to a 4.5:1 white-text ratio, or (c) switch the variant to a destructive-outline + red-text-on-card (better contrast against #282a36).
### H-9 — The status pill (`<Badge variant="secondary">`) accent dot — Dracula contrast against pill background
D-2 §3.1: pill body is `<Badge variant="secondary">` (likely `bg-secondary text-secondary-foreground`, a low-contrast plate). The accent dot `bg-emerald-500 / bg-amber-500 / bg-zinc-500` sits ON that plate. `bg-zinc-500` (#71717a) on Dracula's `--secondary` (a dark grey) yields ~2.1:1 — the "Beendet" dot is functionally invisible at 8 px. The text saves WCAG 1.4.1 (per C-5) but the dot becomes decorative-only. **Fix:** the dot palette must be Dracula tokens — `bg-dracula-green` (#50fa7b), `bg-dracula-orange` (#ffb86c), `bg-dracula-comment` (#9aa3b3, the existing muted). Tailwind's `emerald-500` / `amber-500` / `zinc-500` are not from the project palette and break the design-token contract Marc has on file.
---
## Medium (8)
### M-1 — Wizard close-X overlap mitigation (D-2 §2.2 fix #1) shrinks the `<Progress>` bar's `right` edge
D-2 §2.2 says move the progress strip to `pr-12` to give the X its own gutter. Acceptable, but `<Progress aria-label={stepOf}>` is the AT-readable counter for the wizard; squeezing it under 80% width on 360 px mobile makes the "Schritt 3 von 8" line wrap UNDER the bar. Verify the text-counter stays on the same row as the progress meter for AT lipreading. **Fix:** wrap the counter + bar in a `flex-col` and keep the X gutter on the OUTER container, not on the progress strip.
### M-2 — `Toast` (sonner) success on mutation is the ONLY persistent confirmation
Throughout D-2 (§3.2, §3.6, §3.8, §3.7 PhaseManagementRow per spec: "Status banner becomes a `toast.success(...)`"), the success indicator is a sonner toast that auto-dismisses (~4 s). AT users may miss the announcement window. **Fix:** the toast region must be `aria-live="polite"` (confirmed in repo) AND every mutation must additionally update the visible UI to a new state the user can re-read. E.g. tier 1 Pausieren toggling to "Pausiert" — the header pill flip is the persistent confirmation; verify D-2 wires the cache invalidation chain so the pill ACTUALLY flips visibly in the same paint, not just on the next route revisit.
### M-3 — `<AlertDialog>` focus-default — Radix focuses Cancel by default; D-2 should pin this for the destructive flow
The locked decision is single-step `<AlertDialog>` for tier 2 + 3a + 3b. Radix UI defaults to focusing the Cancel button on AlertDialog open (the safer choice). D-2 should EXPLICITLY confirm this default is honored — the temptation to autofocus the destructive action for "fewer taps" must be resisted. Note in §3.8.
### M-4 — `<DropdownMenu>` kebab on intake-history rows — missing `aria-label`
D-2 §6.3 mentions a kebab but doesn't pin the trigger's `aria-label`. Lucide `MoreVertical` icon alone has no accessible name. **Fix:** the trigger must carry `aria-label={t("medications.intakeRowActions", { time })}` with the specific row's scheduled time as anchor so each kebab announces unambiguously ("Aktionen für 08:00 Mittwoch").
### M-5 — Heute / Diese Woche / Älter grouping headers must use `<h3>` or equivalent landmarks
D-2 §3.5 lists three group buckets. These need to be `<h3>` headings (under the `<h2>` of `<MedicationDetailSection>`) so AT users can navigate by heading. Today the wrap component renders them inline as `<p>` or `<div>` per the wireframe ASCII. **Fix:** explicitly require `<h3>` for group labels.
### M-6 — Wizard step-icon plate is `aria-hidden="true"` (correct) but the Lucide icon inside has no `aria-hidden`
`MedicationWizardDialog.tsx:419` wraps the icon plate with `aria-hidden="true"` — the inner `<Icon className="...h-6 w-6" />` is implicitly hidden via inheritance, but the project's other icon-in-button patterns explicitly mark every Lucide `aria-hidden="true"`. D-2 §10 ("All `aria-hidden='true'` because labels carry the accessible name") is correct on principle but verify it's enforced on the detail-page section icons too — `Sunrise`, `Repeat`, `Bell`, `Settings2`, etc. carry no semantic value next to a section title.
### M-7 — Tier 2 Beenden confirmation copy: `Keine weiteren Erinnerungen` — no AT context for what "beenden" means
D-2 §3.8 tier 2 dialog body: `Keine weiteren Erinnerungen. Alte Einträge bleiben sichtbar.` For a first-time user with AT (or any user), "beenden" the medication vs "löschen" needs the title to clearly say "Medikament beenden — alle Erinnerungen werden gestoppt, der Verlauf bleibt." instead of relying on the user reading the body separately. The AlertDialog title should fully describe the action. **Fix:** lengthen the title to be self-describing or wire `aria-describedby` so AT reads title+body together.
### M-8 — Notifications switch in §3.6 + helper line "APNs · Telegram · Web-Push" — chip row is decoration
The chip row of active push channels is decorative (read-only). It must not be announced as 3 separate interactive chips. **Fix:** wrap the chips in `<div role="list">` + `<div role="listitem">` per chip, OR make the row a single AT-readable summary like `aria-label={t("medications.notifications.activeChannels", { list: "APNs, Telegram, Web-Push" })}` and `aria-hidden="true"` on the individual badges.
---
## Low (6)
### L-1 — Skip-link target on `/medications/[id]/page.tsx` is `#main-content` via the auth-shell — verify the route page mounts beneath that id
Already in place from `auth-shell.tsx:155-160`. The skip-link works ONLY if the detail page's outer wrapper falls inside `<main id="main-content">` (it does — auth-shell mounts everything under). No new skip-link needed for the long-scroll detail page; the existing one suffices. Mention in D-2 §3 so a future "let's add our own skip link" doesn't double up.
### L-2 — Section-internal navigation: 8 sections is long enough to benefit from a TOC, but the locked spec is top-to-bottom scroll
Not a a11y violation, but power users navigating by heading (NVDA `H` shortcut) will appreciate that every section uses `<h2>` consistently (per `<MedicationDetailSection>`). Confirm — already enforced.
### L-3 — `prefers-reduced-motion` honoured everywhere on the wizard CSS (§2.6 + §2.7) — confirm the same coverage on the detail-page's `<Card>` hover/transitions
shadcn `<Card>` ships no transitions by default, but if the detail page adds `hover:shadow-md transition` or similar polish in 3A, those need `motion-reduce:transition-none`. Codify as an invariant in §3 alongside the existing §2.5 invariant 6.
### L-4 — `<AlertDialog>` content lacks an explicit `<AlertDialogMedia>` icon — fine, but verify the title font-size meets WCAG 1.4.4 reflow at 200% zoom
Title is `text-lg font-semibold` (18 px). At 200% zoom on 320 px viewport = 36 px effective, the AlertDialog content's `max-w-[calc(100%-2rem)]` constraint keeps it readable. No fix; flagged for completeness.
### L-5 — `<Switch>` for tier 1 Pausieren — Radix Switch responds to Space + Enter; confirm no spurious `onKeyDown` handler blocks them on the surrounding row
If the row wraps the switch in a `<label>` per H-6 fix and the row carries `onClick` to trigger the same toggle (for the larger hit target), the keyboard handler must not double-fire. Pin once in §3.8 as a contract.
### L-6 — Wizard `aria-busy={submitting}` is set on both the body wrapper AND the Save button (`MedicationWizardDialog.tsx:358, 376`)
This is technically correct (busy state on container + control) but a single source is cleaner. Not a violation; flagged for tidiness.
---
## Pinned invariants D-2 should absorb before implementation
1. Every section that fetches async data wraps the loading branch in `<Card aria-busy="true" aria-live="polite">`. (C-2)
2. Every section that mutates data hosts a polite `role="status"` region OR persists state visibly within the same paint as the toast. (C-1, M-2)
3. Every icon-only trigger has an `aria-label` derived from i18n + row context. (M-4)
4. Every `<Switch>` is wrapped in a `<label>` so AT reports the row as the hit target, not the 18×32 px visual track. (H-6)
5. Status-pill text never hides; the colored dot is `aria-hidden="true"`. (C-5, H-9)
6. Status-pill dot palette uses Dracula tokens, not Tailwind's `emerald/amber/zinc`. (H-9)
7. `<DropdownMenu>` kebab is always rendered as the keyboard-accessible peer of the swipe-reveal, not a desktop-only fallback. (H-5)
8. Modal stack depth ≤ 2; the row-edit Sheet's destructive flow dismisses the Sheet before opening an AlertDialog peer. (C-4)
9. Wizard step title receives focus when the wizard mounts on any step other than 1 (edit landing). (H-1)
10. `prefers-reduced-motion` collapses the status-bar morph to width-only, not just `transition: none`. (H-3) — open question Q4 for Marc.
11. Destructive CTAs raise font-weight to 600 OR switch to outline-on-card to clear 4.5:1 contrast. (H-8)
12. Heute / Diese Woche / Älter use `<h3>` headings; group dividers are `<Separator role="separator">` not styled `<div>`s. (M-5)
---
## Counts
| Severity | Count |
|---|---|
| Critical | 5 |
| High | 9 |
| Medium | 8 |
| Low | 6 |
| **Total** | **28** |
@@ -0,0 +1,220 @@
# F-1 — Senior-dev audit, v1.5.5 pre-tag
Scope: `git log --oneline bf6dfb8f..HEAD` (15 commits, v1.5.4 → HEAD). Goal-backward read against D-3-final-direction. Each finding pinned to file:line and to a commit SHA when the issue is recent.
Severity buckets and counts:
- Critical: 4
- High: 6
- Medium: 8
- Low: 7
---
## Critical
### C-1 — `<SettingsSection>` Grace-row save is dead on arrival
`src/components/medications/sections/settings-section.tsx:65-89` (commit `4ae8801c`).
The Grace row PUTs `{ reminderGraceMinutes: graceValue }` against `/api/medications/[id]`. The schema accepts `reminderGraceMinutes` only as a **per-schedule** field (`src/lib/validations/medication.ts:113`); the top-level `updateMedicationSchema` does not carry it (`:250-261`). The PUT returns 422; the toast surfaces `medications.detail.settings.grace.failed` and the user can never change the grace window from the detail page. The inline comment ("v1.5.4 flat-form bridge") describes a bridge that does not exist.
Fix: either extend `updateMedicationSchema` with a top-level `reminderGraceMinutes` that the route maps onto the primary schedule, or have the row emit a `schedules: [{ id, reminderGraceMinutes }]` array. D-3 §9.7 / H-4-UX picked the primary-schedule narrowing, so the route deserves the explicit shim.
### C-2 — `<IntakeHistoryPreview>` ships none of the D-3 §9.5 row affordances or bulk-delete UI
`src/components/medications/sections/intake-history-preview.tsx:42-79` (commit `4ae8801c`).
D-3 §9.5 spec: per-row right-edge `<DropdownMenu>` kebab with `Bearbeiten + Löschen`, group `<h3>` headers (`Heute / Diese Woche / Älter`), multi-select with bulk `<POST /intake/bulk-delete>` wire-up. The component renders `<IntakeHistoryListV2 medicationId={medicationId} pageSize={14} />` as-is — that list is explicitly read-only (`intake-history-list-v2.tsx:63` — "No edit-in-row, no delete buttons"). The bulk-delete endpoint exists (commit `8d5d039e`) but no client surface posts to it. The component's own header comment claims "multi-select + row kebab Bearbeiten / Löschen ride on top of the wrapped list" — the implementation does not match the comment. v1.5.4 displaced features 15 + 16 (Einnahmen bearbeiten / Einnahmen löschen) per §7 — neither is reachable on the new detail page.
Fix: either (a) extend `IntakeHistoryListV2` with the `onEditIntake / onDeleteIntake / selection` props promised in D-3 §9.5 and wire the bulk-delete POST on a selection toolbar, or (b) ship the row affordances in `<IntakeHistoryPreview>` over a fresh `last-14-grouped` reader and drop the `<IntakeHistoryListV2>` re-use.
### C-3 — Purge route does not invalidate the server-side per-user medication caches
`src/app/api/medications/[id]/intake/purge/route.ts:31-61` (touched in commit `af224964`).
`prisma.medicationComplianceRollup.deleteMany` runs, but the route never calls `invalidateUserMedications(user.id)`. The PUT / POST / DELETE / bulk-delete siblings all call it (e.g. `intake/bulk-delete/route.ts:145`, `[id]/route.ts:263`, `[id]/route.ts:320`). After a Tier-3a "Verlauf löschen" the in-memory caches `caches.medications` / `caches.medicationsIntake` / `caches.analytics` keep the pre-purge counts for up to their TTL. `/api/dashboard/summary` (analytics cache) and the iOS today-tally read can paint the wrong compliance for minutes after the user confirms the destructive cascade. The detail-page TanStack invalidation hides this for the source tab, not for any other client tab or any iOS poll cycle.
Fix: add `invalidateUserMedications(user.id)` to the purge route's success path alongside the rollup delete.
### C-4 — `glp1` GET still hand-rolls the ownership check
`src/app/api/medications/[id]/glp1/route.ts:44-61` (untouched by `af224964`).
Commit `af224964` "route every detail-page sub-route through assertMedicationOwnership" did NOT touch the glp1 GET branch. The handler still reads the medication via `findUnique` then runs `medication.userId !== user.id ? 404`. C-E3-3 / D-3 §10 invariant 24 ("`assertMedicationOwnership` is the single ownership predicate across `src/app/api/medications/[id]/**`") is unmet. The same file's POST branch (line 127) and the other sub-routes converged; only the GET stayed on the old shape. The functional behaviour is identical, but the contract D-3 explicitly enumerated is broken and the next refactor that changes the 404 leak shape (e.g. `not-owned` vs `not-found` diagnostic) will silently miss this branch.
Fix: lift the glp1 GET to `assertMedicationOwnership(id, user.id)` like every sibling and drop the hand-rolled compare.
---
## High
### H-1 — Duplicate DOM id in `<NotificationsSection>`
`src/components/medications/sections/notifications-section.tsx:60` + `:134` + `:158` (commit `4ae8801c`).
`TITLE_ID = "medication-detail-notifications-title"` is rendered twice in the same subtree: once on `<MedicationDetailSection titleId={TITLE_ID}>` (the section `<h2>`), once on the inner `<span id={TITLE_ID}>` wrapping the switch row label. axe will flag `duplicate-id`. The Switch's `aria-labelledby` will resolve to the first occurrence in tree order (the section heading), not the row title — the helper ID is also picked up by `aria-describedby` regardless, so the announced name is "Benachrichtigungen, Benachrichtigungen aktivieren, helper". Fix: split the constants — one id for the section heading, one for the row title.
### H-2 — Bare-array `queryKey` bypass in `<ApiTokensRow>`
`src/components/medications/sections/api-tokens-row.tsx:54-57` (commit `4ae8801c`).
`const queryKey = useMemo(() => ["medications", medicationId, "api-endpoint"] as const, …)`. CLAUDE.md "TanStack Query keys live in the centralised factory" + §10 invariant 15 + ESLint `healthlog/queryKey-factory` rule. The literal escapes the rule because it's wrapped in `useMemo` rather than appearing as a direct `queryKey: [...]` object property — but the spirit (one source of truth in `query-keys.ts`) is violated. Fix: add `medicationApiEndpoint(medicationId)` to `src/lib/query-keys.ts:73-90` next to the other per-medication keys and import it here.
### H-3 — `phase-config` PUT returns 400, not 422-multi-issue
`src/app/api/medications/[id]/phase-config/route.ts:60-63`.
The PUT/DELETE handlers call `apiError("Invalid input", 400)` on a Zod failure (line 62) rather than `returnAllZodIssues(parsed.error, 422)`. CLAUDE.md "Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues`". The phase-config PUT is a v1.5.5 first-class surface (D-3 §9.7) and should match the bundle's convention so iOS gets per-field error feedback instead of a flat "Invalid input".
Fix: replace `apiError("Invalid input", 400)` with `returnAllZodIssues(parsed.error, 422)`; matching the v1.5.5 bulk-delete sibling on the same surface.
### H-4 — `phase-config` upsert spreads `parsed.data` whole
`src/app/api/medications/[id]/phase-config/route.ts:65-72`.
The upsert's `create` and `update` branches spread `...parsed.data` into the Prisma payload. CLAUDE.md "No mass assignment. Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field". The schema currently lists eight fields so the security blast radius is small, but the invariant is structural and a future schema extension (adding say `notifyOnPhaseChange`) would silently land on the Prisma write.
Fix: build the payload field-by-field from `parsed.data.greenValue / .greenMode / .yellowValue / …`.
### H-5 — `safeFetch` migration missed three constant-host call sites
`src/lib/withings/client.ts:112`, `:155`, `:272`, `:349`, `:400`; `src/lib/withings/sync-activity.ts:151`; `src/lib/withings/sync-sleep.ts:121`; `src/lib/ai/codex-oauth.ts:176`, `:217`, `:243`, `:279`; `src/app/api/bugreport/route.ts:162` (commit `425503e0`).
`safe-fetch.ts:91-95` documents "the four hard-coded hosts also route through it for convention consistency" — Anthropic / OpenAI / Apple / Telegram. In practice Withings, Codex, and the GitHub bug-reporter still bypass `safeFetch`. Constant hosts dodge the DNS-rebinding hardening risk so the security claim holds, but each missing site is a worker that a tar-pit upstream can pin for as long as the underlying socket waits (no `AbortSignal.timeout` on any of them). Withings + Codex are routinely-hit. Recommend extending `safeFetch` migration to these in a follow-up patch with the same convention (`timeoutMs` opt-in, no `requirePublicHost`).
### H-6 — Bulk-delete endpoint has no rate limit
`src/app/api/medications/[id]/intake/bulk-delete/route.ts:49-149` (commit `8d5d039e`).
The route caps `eventIds` at 500 per call, but a caller can fire repeated requests with disjoint `eventIds` sets and force `recomputeMedicationComplianceForDay` to spin per affected `dayKey`. The recompute is best-effort but it still hits Postgres. Single-user-scoped abuse only (the route is owner-scoped) so the blast radius is the user's own pool. Recommend a `checkRateLimit("medication-intake-bulk-delete:<userId>", 30, 60_000)` matching the `glp1` POST cap pattern (`glp1/route.ts:36-37`).
---
## Medium
### M-1 — `<MedicationCard>` "History" icon glyph stays after the route change
`src/components/medications/medication-card.tsx:265-280` (commit `8c740fd1`).
The icon button now routes to `/medications/{id}` instead of `/medications/{id}/history`, and the `aria-label` updates to `medications.openDetailPage`. The visual glyph is still `<History />` (clock-arrow). A user expecting "go to history" gets a route the icon does not advertise; a screen reader hears the new label correctly but the sighted user runs into a glyph-label mismatch. Pick a neutral glyph — `ChevronRight`, `ExternalLink`, or `Maximize2` — to match the new destination.
### M-2 — `<MedicationDetailPage>` re-hydrates the wizard payload on every render
`src/app/medications/[id]/page.tsx:319-323` + `:386` (commit `4ae8801c`).
`snapshotToWizardPayload(medication)` runs inline at both the `<CadenceSummaryRow medication=...>` mount and the `<MedicationWizardDialog initial=...>` mount; both run on every render. The function does Date construction + `parseScheduleRecurrence` walk per schedule. The wizard's `stateKey` (`MedicationWizardDialog.tsx:123`) re-keys the inner shell on intent change which already re-instantiates the payload, so the inline computation is redundant. Memoise via `useMemo(() => snapshotToWizardPayload(medication), [medication])` so the per-render hot path stays cheap.
### M-3 — `<IntakeHistoryPreview>` mounts `<IntakeImportDialog>` permanently
`src/components/medications/sections/intake-history-preview.tsx:74-77` (commit `4ae8801c`).
The dialog mount is unconditional: `<IntakeImportDialog medicationId={importOpen ? medicationId : null} onClose={...}/>`. The `medicationId` null-guard inside the dialog gates its render, but the dialog component still mounts on first paint and pulls its own deps (Dropzone, React-Hook-Form). D-3 §10 invariant 19 + L-7 say "skeleton choreography" — the dialog should mount lazily. Either gate the JSX with `{importOpen && <IntakeImportDialog … />}` or hoist behind a `next/dynamic` boundary.
### M-4 — `<ApiTokensRow>` minted token persists in component state for 30 s
`src/components/medications/sections/api-tokens-row.tsx:131-135`.
After mint the token sits in `useState` and renders in plain text under the auto-copy chip. 30 s auto-clear via setTimeout is appropriate, but the underlying value sticks in the React tree until either the timer fires or the user navigates. Recommend clearing on `unmount` too (return cleanup in the effect already does this) AND on `onBlur` of the window so a multi-tab user copying then switching does not leave the value visible. Defence-in-depth, not a critical leak.
### M-5 — `<SettingsSection>` Phasen row hides instead of explaining when one bound is set but not both
`src/components/medications/sections/settings-section.tsx:62`.
`hasCourseWindow = Boolean(startsOn) && Boolean(endsOn)`. When only one of the two is set the row shows the muted `requiresCourseWindow` fallback. D-3 §9.7 spelled out the empty-state copy as "Phasen sind nur mit Kurs-Fenster verfügbar" — works for the "no window at all" branch but reads confusingly for a user who set `startsOn` only and expects to see what's missing. Tighten the helper to "Setze Start- UND Enddatum, damit die Phasen aktiv werden" or split the two states.
### M-6 — `<IntakeHistoryPreview>` import button label outside the section header
`src/components/medications/sections/intake-history-preview.tsx:47-58`.
`headerExtras` correctly carries the `[⤴ Importieren]` button per D-3 §9.5, but the `Upload` icon (`ArrowUp` glyph in Lucide naming) reads as "export" / "send" rather than "import". D-3 uses an `⤴` arrow which Lucide does not ship as a single glyph; the closest match is `Upload` (arrow into a tray) which has the right semantic. Keep `Upload`, but consider adding a `<span className="sr-only">{t("medications.detail.intake.importButton")}</span>` near the icon for clarity if a future tooltip rolls in.
### M-7 — `landingStepForEdit` does NOT short-circuit a multi-schedule "cadence" intent
`src/components/medications/wizard/wizard-payload.ts:903-913`.
`intent === "cadence"` lands the user at Step 5 regardless of whether the payload has > 1 schedule. The legacy fall-through (no intent) puts a multi-schedule payload at Step 8 (summary), which is the muscle memory call-out documented in the function's own comment. A user tapping "Bearbeiten" on the cadence-summary row of a multi-schedule medication will land at Step 5 of the FIRST schedule — surprising if their target was schedule #2. D-3 §6 / §8 considered this single-schedule by default; the docstring covers the call shape but a multi-schedule cadence edit on the detail page (currently rare but reachable) drops the user on the wrong schedule. Recommend: if `payload.schedules.length > 1 && intent === "cadence"` → land at 8 with a note, or add a `scheduleIndex` arg.
### M-8 — Insights layout is not in the generated OpenAPI
`docs/api/openapi.yaml` (no `insights/layout` path).
`/api/insights/layout` GET/PUT/DELETE shipped (commit `a75b96b2`) but no `.meta()` registration exists in `src/lib/openapi/routes.ts` (grep shows only `/api/user/avatar` + `/api/user/avatar/{id}` for v1.5.5 additions). iOS clients have to discover the shape by trial. Mirroring `/api/dashboard/widgets` (which is in the registry) would close the gap.
---
## Low
### L-1 — `<DestructiveZoneSection>` re-renders Card B border with `border-destructive/40` regardless of count
`src/components/medications/sections/destructive-zone-section.tsx:250-253`.
The destructive border stays visually destructive even when `intakeCount === 0` makes the Tier-3a button non-actionable. Minor visual confusion — the card looks ready to hurt the user but its primary action is disabled. Consider keeping the border but muting it when `intakeCount === 0 && deleteDialogOpen === false`.
### L-2 — `intake/route.ts:208` reconciles oneShot state after the audit row already landed
`src/app/api/medications/[id]/intake/route.ts:208`.
The oneShot reconciliation runs AFTER `auditLog("medication.intake")` writes its row. A reconciliation flip is `noop` or `deactivated`; the audit row reads `medication.intake` only, with no `lifecycle: "auto-deactivated"` breadcrumb. The detail page's one-shot variant gate (page.tsx:248) depends on `medication.oneShot === true` AND the now-flipped `active`. Add a second annotate / audit row on the flip so the operator can trace why the medication transitioned (closes a long-standing v1.5 observability gap).
### L-3 — `<TodaysDoseCard>` overwrites local error state on a second submit attempt
`src/components/medications/todays-dose-card.tsx:101-135`.
A failed submit ("error"), then a second click sets state back to "submitting", but the toast is not re-emitted on error — the user sees the inline `<p>` flicker into "submitting" then back to "error" with no toast. Add an explicit `toast.error(message)` on the error branch so the error is announced through the polite live region.
### L-4 — `<NotificationsSection>` Switch + chip strip dual mount on every paint
`src/components/medications/sections/notifications-section.tsx:139-181`.
The clientManaged branch renders ONLY a chip; the regular branch renders the Switch + chip strip. The branch resolves correctly, but the chip strip (line 183) sits at the same tree level for both branches and re-mounts on a flip. Wrap the chip strip in the same `clientManaged ? … : …` ternary to avoid the remount.
### L-5 — `phase-config` GET response shape inconsistent (returns either the saved row or a default literal)
`src/app/api/medications/[id]/phase-config/route.ts:33-44`.
When no row exists, the route returns a literal `{ greenValue: 60, … }` with NO `medicationId`, `createdAt`, `updatedAt`. When a row exists, the route returns the full row. Clients that destructure `config.medicationId` get `undefined` on a fresh medication. Either always normalise the response to the same shape, or mark the absence with `{ defaults: true, … }` so the client can tell the two apart.
### L-6 — `<MedicationDetailPage>` does not handle the iOS-coord `clientManaged` toggle on the today's-dose card
`src/app/medications/[id]/page.tsx:292-301`.
The page passes `active: medication.active` to `<TodaysDoseCard>` but does not surface the `notificationPrefs.medication.clientManaged` state — which `<NotificationsSection>` reads — to the dose card. A user with the iOS-managed-reminders opt-in still sees the "Heute keine Einnahme geplant" web copy when in fact the iOS reminder is what they care about. Minor — the dose card is action-first, not status-first — but flagging for a v1.5.6 polish pass.
### L-7 — `bulk-delete` audit row carries `requestedCount` AND `deletedCount` but no `eventIds` breadcrumb
`src/app/api/medications/[id]/intake/bulk-delete/route.ts:126-134`.
The audit row captures the count but not the ids. A user disputing "why did my row vanish" cannot resolve which 14 events were deleted. Trade-off: storing 500 ids per audit row inflates the ledger. Recommend the truncated first-N ids + a hash of the full set, or skip ids entirely and rely on Postgres WAL.
---
## Cross-cutting positive findings (no action needed)
- Migrations 0083 → 0086 are additive-only, no order coupling (each `ALTER TYPE ADD VALUE IF NOT EXISTS` or nullable `ADD COLUMN`).
- `safeFetch` defaults (`redirect: "manual"`, `AbortSignal.timeout(15_000)`) match issue #218; the `requirePublicHost`-gated undici dispatcher (`safe-fetch-dispatcher.ts`) closes #217 cleanly.
- `assertMedicationOwnership` lifted on purge / intake / import / parent PUT / parent DELETE / bulk-delete / phase-config / api-endpoint / titration / cadence — only the `glp1` GET (C-4 above) and the inventory + side-effects + compliance siblings (pre-existing scope, not touched in v1.5.5) deviate.
- `medicationDependentKeys` carries `["compliance-chart-inline"]` (`query-keys.ts:377`) per §10 invariant 20; every detail-mutation routes through the bundle.
- Locale parity confirmed across en/de/es/fr/it/pl for `medications.detail.*` (77 keys, 0 missing in any locale); umlauts intact in de.json.
- Avatar size guards fire on `content-length` first (route.ts:85-95), then on `file.size` (line 112), then magic-byte sniff, then dimension check — no DoS via large-file-small-declared-size claim.
- Avatar GET is owner-scoped (`/api/user/avatar/[id]/route.ts:41-46`); admin-elevation explicitly declined.
- Insights layout PUT is owner-scoped (`route.ts:84-153`) and busts its server-side cache via `invalidateUserInsightsLayout`.
- Status-pill text always renders; dot uses Dracula tokens (`medication-detail-header.tsx:73-78`); `--success` / `--warning` defined in globals.css.
- Wizard `buildCreateBody` skips `notificationsEnabled` on edit per §10 invariant 16.
- C-E1-1 grep clean: zero `wizard-progress-bar` / `wizard-step-body` / `wizard-step-in` keyframe hits in `globals.css`.
---
## Recommended pre-tag actions
Block release on:
- C-1 (Grace row dead-write — user-visible regression on a restored feature).
- C-2 (intake-history preview missing kebab + bulk-delete UI — D-3 §9.5 unmet, the v1.5.4 displaced features 15 + 16 still unreachable).
Ship-then-patch on:
- C-3 (stale dashboard summary post-purge — one-tick visual lag, not a security issue).
- C-4 (`glp1` GET ownership invariant — same behaviour, contract drift).
The High bucket is appropriate for a v1.5.5.1 hotfix bundle; the Mediums + Lows fit a v1.5.6 polish wave.
@@ -0,0 +1,330 @@
# F-2 Security Audit — v1.5.5 diff (`bf6dfb8f..HEAD`, 16 commits)
Audit scope: every commit since `3b7c72e1` (v1.5.4). Threat model: motivated
adversary against a self-hosted production deployment. Findings are pinned to
file:line and the commit that introduced them.
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 0 |
| Medium | 6 |
| Low | 6 |
| Informational | 4 |
---
## Critical
None. The avatar pipeline, the `safeFetch` wrapper, the DNS-rebinding
dispatcher, and the medication-ownership sweep all hold up under direct
attack. The remaining findings degrade defence-in-depth rather than open a
breach path.
---
## High
None.
---
## Medium
### M-1 — `safeFetch` migration is incomplete on three hard-coded-host call sites that carry credentials
Commit `425503e0` claimed "14 outbound call sites migrated. The four that
already pinned manual redirect (the historic templates) keep their
semantics; the wrapper preserves an explicit `init.redirect`." That accounting
misses three callers that ship secrets on the wire and have neither
`redirect: "manual"` nor a timeout:
- `src/lib/withings/client.ts:112,155,272,349,400` — every Withings call sits
on raw `fetch` with the OAuth `client_secret` (line 105) or the user's
`access_token` in the body. A 302 from `wbsapi.withings.net` would leak
those headers on the redirected hop, and a slow upstream pins the
worker indefinitely. The host is hardcoded and Withings does not redirect
today, so the active risk is "future Withings change" + tar-pit; both
classes are what the wrapper exists to defeat.
- `src/lib/withings/sync-sleep.ts:121` and `src/lib/withings/sync-activity.ts:151`
— same pattern, same headers, same gap.
- `src/lib/ai/codex-client.ts:286` — POST to `CODEX_ENDPOINT` with the user's
Codex `Bearer ${accessToken}` and account id, no manual redirect, no
timeout. Same risk profile as the Withings calls.
- `src/lib/ai/codex-oauth.ts:176,217,243,279` — four raw `fetch` calls during
the Codex device-OAuth dance, no manual redirect, no timeout. These carry
the device-code and the OAuth `code_verifier`, both of which a redirect-
follow would forward.
- `src/app/api/bugreport/route.ts:162` — `await fetch(...)` to GitHub's API
with `Authorization: Bearer ${ghToken}` on the screenshot-comment path.
The route migrated the issue-creation call on line 124 to `safeFetch`
but left the comment call as raw `fetch`.
Action: route each through `safeFetch` so the convention is enforced
everywhere a secret rides outbound. The wrapper preserves an explicit
`init.redirect` so a caller that genuinely needs to follow stays free to
opt in.
### M-2 — `safeFetch` callers with operator-supplied hosts skip `requirePublicHost`, leaving the DNS-rebinding pin off
Commit `b43b57f6` wired the pinned dispatcher behind the `requirePublicHost`
flag and the body of that commit lists "five outbound paths that accept a
user-supplied host". The audit found five more that accept an
**operator-supplied** host and skip the flag:
- `src/app/api/monitoring/umami-script/route.ts:29` — unauthenticated GET
that proxies `settings.umamiScriptUrl` (admin-configured) and returns the
body as `application/javascript` to every browser. A misconfigured admin
+ a low-TTL DNS record could route the fetch at an internal HTTP service
whose response body is then served as JS on the HealthLog origin.
- `src/app/api/send/route.ts:77` and
`src/app/api/admin/monitoring/umami-test/route.ts:92` — operator-configured
Umami URLs. `isPublicUrl` runs at URL construction (good), but the
connect-time pin from issue #217 is bypassed.
- `src/lib/monitoring/glitchtip.ts:126,155,180` — operator-configured
GlitchTip envelope/store URLs. The host is operator trust, but a rebinding
attack against a misconfigured DNS could land the report (which carries
the redacted-but-still-sensitive wide-event payload) at an internal IP.
- `src/lib/logging/transports.ts:68` — operator-configured Loki endpoint.
Same trust shape as GlitchTip.
Action: add `requirePublicHost: true` to every `safeFetch` call whose target
URL is sourced from an operator setting or env var. The five user-supplied
paths in `b43b57f6` are correctly flagged; this finding only extends the
convention.
### M-3 — Avatar upload buffers a chunked body up to the 2 MiB cap with no `Content-Length` pre-flight
`src/app/api/user/avatar/route.ts:85-95` pre-flights the declared
`Content-Length` and bails with 413 when it exceeds `AVATAR_MAX_BYTES`. The
guard only fires when the client sends a `Content-Length` header. A
`Transfer-Encoding: chunked` upload (or any request without the header) skips
the pre-flight; the code then calls `await request.formData()` at line 99,
which buffers the entire stream before the post-parse `file.size > AVATAR_MAX_BYTES`
check at line 112 can fire. Per-user rate limit (10/hour) caps the abuse,
but each request still allocates up to the cap before the size check runs.
Action: enforce the cap at the stream level. Next.js App Router doesn't
expose a `bodyParser.sizeLimit` for route handlers; the practical mitigation
is to read the body as a streamed `ReadableStream`, accumulate into a bounded
buffer, and abort on the first byte past `AVATAR_MAX_BYTES`. Alternatively
gate the route through a request-size guard in `proxy.ts` for the avatar
path.
### M-4 — `requireAuth` runs after the rate-limit lookup in the avatar POST, so an unauthenticated caller cannot trip the per-user limit
`src/app/api/user/avatar/route.ts:66-72` resolves the session, then keys the
rate limit on `user-avatar-upload:${user.id}`. An unauthenticated POST 401s
inside `requireAuth()` long before the limiter sees it — which is correct.
However, a Bearer token with `["*"]` scope can churn the limit at 10/hour,
and the limit is the only stop between an authenticated client and 10
`prisma.user.update` writes per hour. The cap is acceptable for a user-
action surface but worth pinning that an automation hitting `["*"]` could
park 10 writes per user per hour indefinitely. No action required if the
maintainer accepts the trade-off; flagging so the rate-limit budget stays
explicit.
### M-5 — `isPublicIp` does not reject IPv4 multicast / broadcast or IPv6 multicast / documentation ranges
`src/lib/validations/notifications.ts:81-130` covers loopback, RFC1918,
CGNAT, link-local, ULA, IPv4-mapped IPv6, and the unspecified address. It
does not reject:
- `224.0.0.0/4` (multicast) and `255.255.255.255` (broadcast) on IPv4.
- `ff00::/8` (multicast) and `2001:db8::/32` (documentation) on IPv6.
- `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24` (TEST-NET-1/2/3).
A legitimate DNS resolver wouldn't return these, but a hostile resolver
could — and the connect-time pin is exactly the layer that's supposed to
catch hostile resolvers. Adding these to `isPrivateIpv4` and to the IPv6
prefix check on lines 94-124 closes the gap with a few extra lines.
### M-6 — Avatar GET uses persisted `Content-Type` directly, but does not pin `X-Content-Type-Options: nosniff` or `Content-Disposition: inline`
`src/app/api/user/avatar/[id]/route.ts:72-79` echoes the persisted
`Content-Type` (whitelisted on the write path — JPEG / PNG / WebP only) and
sets `Cache-Control: private, max-age=31536000, immutable`. The serve path
is owner-scoped (the 403 leak shape on a foreign id is correct). The
defence-in-depth gap: no `X-Content-Type-Options: nosniff`, no
`Content-Disposition: inline; filename="avatar"`. A browser that decides to
sniff (none currently do for binary image types served same-origin, but a
future change might) could mis-handle a crafted image. Adding both headers
costs two lines.
---
## Low
### L-1 — Parent medication GET still hand-rolls the ownership pattern
`src/app/api/medications/[id]/route.ts:25-57` still uses the
`findUnique` + `medication.userId !== user.id ? 404` pattern instead of the
shared `assertMedicationOwnership`. The PUT (line 69) and DELETE (line 280)
were migrated by commit `af224964`; the GET was not. Result is identical for
the caller (404 on either branch), but the codepath leaks an extra
`schedules` include and the 404 message lives outside the helper that the
audit commit named as the single source of truth.
### L-2 — GLP1 GET still hand-rolls the ownership check
`src/app/api/medications/[id]/glp1/route.ts:44-61` uses
`findUnique({ include: ... })` and then `medication.userId !== user.id`.
The route's POST (line 127) was already on the helper. Same gap as L-1,
same fix.
### L-3 — Insights-layout PUT echoes the redacted payload diagnostic but does not redact the layout JSON itself
`src/app/api/insights/layout/route.ts:97` builds a payload diagnostic from
`redactSensitiveFields(body)`. The layout body carries no secrets by
construction (tile ids + booleans + ints), but the redactor runs against the
whole body and may add CPU per failed validation. Not a security gap; an
operator who later adds a sensitive field to the layout schema should
re-verify the redactor's denylist covers it.
### L-4 — Bulk-delete intake endpoint has no per-IP / per-user rate limit
`src/app/api/medications/[id]/intake/bulk-delete/route.ts:49` runs without
a rate-limit gate. The Zod cap (500 ids) bounds a single request, but an
attacker with a session can churn 500-row deletes back-to-back. Each call
also kicks `recomputeMedicationComplianceForDay` per affected dayKey. The
ownership guard means the deletes only touch the attacker's own data, so
the damage is self-inflicted; the rollup recompute is the operator-side
cost. Worth a `medications.intake.bulk-delete:${user.id}` rate-limit
bucket.
### L-5 — Avatar upload audits the byte size + width + height into the audit ledger
`src/app/api/user/avatar/route.ts:187-196` writes the upload size and
dimensions into the audit row's `details`. These are not user-controlled
strings, so no injection; but a privacy-conscious operator might prefer to
keep image dimensions out of the audit trail. Informational at most;
flagging because the prior audit baseline (.planning/security-audit-v1.5.2.md)
explicitly narrows audit `details` to "the smallest identifier that lets
operators reconstruct the action".
### L-6 — Series-cap raise opens a 10-year read window with no row-count guard
`src/app/api/measurements/series/route.ts:55` raises the `days` cap from
365 to 3650. The Apple Health step path on a power-user account can land
hundreds of thousands of rows in that window. The endpoint runs
`prisma.measurement.findMany` with no `take` and the trend summarizer walks
every row. The cold-fallback pattern from v1.4.34 was supposed to land a
hard row-count budget on every long-window read; this route shipped without
one. Per-user, owner-scoped, but a single iOS client repeatedly opening
the "Alle" range can pin a Prisma pool slot for the duration of the read.
---
## Informational
### I-1 — `Cache-Control: private, max-age=31536000, immutable` on the avatar GET relies on the URL's `?v={updatedAtMs}` to bust on re-upload
`src/app/api/user/avatar/[id]/route.ts:77`. The contract holds — the /me
payload appends the suffix at `src/lib/avatar.ts:217` — but a future
refactor that returns the bare `/api/user/avatar/{id}` URL would silently
serve a year-stale avatar. The contract should be tested at the integration
layer to lock it in. Probably already covered by
`tests/integration/user-avatar.test.ts`; flagging for re-verification.
### I-2 — `pinnedLookup` uses `dns.lookup` which consults `nsswitch` / `/etc/hosts`
`src/lib/safe-fetch-dispatcher.ts:55`. An operator that adds a hostname →
private-IP mapping in `/etc/hosts` will have that mapping refused by the
dispatcher (correct behaviour). But the same operator's `/etc/hosts`
entries override the resolver entirely, including the legitimate Withings
or AI provider entries. Document in `docs/ops/` that
`safeFetch` + `requirePublicHost` refuses any host listed in `/etc/hosts`
to point at an RFC1918 address.
### I-3 — `safeFetch` is opt-in; no lint rule or CI grep flags raw `fetch(` outside the wrapper
`src/lib/safe-fetch.ts` is the documented egress entry, but the project's
ESLint config and knip gate do not flag raw `fetch(` calls. The audit
counted 17 raw `fetch(` calls in `src/lib/` + `src/app/api/` (M-1 lists
the ones that carry secrets). A future maintainer landing a new outbound
call has nothing structural reminding them to route through the wrapper.
Action: add a project-local ESLint rule
(`healthlog/safe-fetch-required` or equivalent) that bans
`fetch` outside `src/lib/safe-fetch.ts` and the test helpers. Update
`CLAUDE.md` § "Security-relevant patterns" with the convention.
### I-4 — `Bytes` Prisma column serialised through `new Uint8Array(N).set(buffer)` to satisfy `ArrayBuffer`-backed type
`src/app/api/user/avatar/route.ts:170-178`. The comment explains the cast
correctly. Worth a follow-up to verify that Prisma 7.x's `Bytes` column
type round-trips a > 0 byte buffer without copy on the `findUnique`
read-back path; if the read also clones the buffer the per-request cost is
2× the file size. Performance, not security; flagging for the perf budget.
---
## Migration safety
All four v1.5.5 migrations (`0083_v155_ios_measurement_types`,
`0084_v155_user_insights_layout`, `0085_v155_user_avatar`,
`0086_v155_ios_walking_step_length_speed`) are additive: enum extensions
plus nullable column adds. No `NOT NULL` without a default; no constraint
that an existing row could break. Safe to apply on a live deployment.
---
## Audit-log / wide-event redaction
The new sensitive surfaces — avatar bytes, insights layout JSON, the detail-
page actions — each route their audit / wide-event paths through the
existing redactor (`redactSensitiveFields` + `buildPayloadDiagnostic` for
the insights PUT, `auditLog` with narrow `details` for the avatar paths).
No new field carries user-controlled free-text into the audit envelope.
The series route's audit row strips `message` from Zod issues via
`sanitiseZodIssues(..., { stripValuesFromMessage: true })` (line 119) — same
pattern the v1.4.49 hardening established.
---
## Cross-tenant exposure
Spot-checked every `/api/medications/[id]/**` sub-route. Ownership coverage:
| Route | Method | Ownership |
|---|---|---|
| `/api/medications/[id]` | GET | hand-rolled (L-1) |
| `/api/medications/[id]` | PUT | helper (post-`af224964`) |
| `/api/medications/[id]` | DELETE | helper (post-`af224964`) |
| `/api/medications/[id]/api-endpoint` | GET / POST | helper |
| `/api/medications/[id]/cadence` | * | helper |
| `/api/medications/[id]/compliance` | GET | helper |
| `/api/medications/[id]/glp1` | GET | hand-rolled (L-2) |
| `/api/medications/[id]/glp1` | POST | helper |
| `/api/medications/[id]/intake` | GET / POST | helper |
| `/api/medications/[id]/intake/[eventId]` | * | hand-rolled (`event.userId !== user.id`); narrow on event + medication, structurally correct |
| `/api/medications/[id]/intake/bulk-delete` | POST | helper |
| `/api/medications/[id]/intake/import` | POST | helper (post-`af224964`) |
| `/api/medications/[id]/intake/purge` | DELETE | helper (post-`af224964`) |
| `/api/medications/[id]/inventory` | * | helper |
| `/api/medications/[id]/inventory/[itemId]` | * | hand-rolled (`item.userId !== userId`); narrow on item + medication, structurally correct |
| `/api/medications/[id]/phase-config` | * | helper |
| `/api/medications/[id]/side-effects` | * | helper |
| `/api/medications/[id]/side-effects/[logId]` | * | hand-rolled (`row.userId !== userId`); narrow on row + medication, structurally correct |
| `/api/medications/[id]/titration` | * | helper |
Cross-tenant reads via Bearer token: none found. Every route narrows the
Prisma `where` clause on `userId` from the resolved session.
---
## CSP
`src/proxy.ts:268-275` removes Gravatar from the production CSP and lands
on `img-src 'self' data:`. The same-origin avatar at
`/api/user/avatar/{id}` fits without widening the directive. No new host
in `connect-src`. Strict CSP preserved.
---
## Forward-compat recommendation
The most durable improvement is I-3: a project-local ESLint rule that
flags raw `fetch(` outside the wrapper. The v1.5.5 audit found six call
sites that the wrapper migration missed (M-1 + M-2). A structural gate is
cheaper than a re-audit each release.
@@ -0,0 +1,178 @@
# G-1 — Direction: v1.5.6 pure-history detail page + AdvancedSettingsSheet
Authoritative direction for the v1.5.6 medication-detail refactor. Supersedes the v1.5.5 D-3 §6/§7/§9 *composition* (the section order and the inline-settings placement). It KEEPS D-3 §10 invariants and reuses every section component D-3 shipped — they move container, not contents. The list page (`/medications`) is out of scope and untouched.
## 1. TL;DR
`/medications/[id]` becomes a pure "Vergangenheit" surface: a slim header (name / dose / status pill + one Edit button), a STATIC one-line cadence summary, and the intake-history TABLE with per-row edit, per-row delete and bulk-delete. The Today's-Dose-Card is removed from the detail page (it lives only on the list page's `todays-dose-card`). Every setting — Notifications, API-Tokens, Phasen, CSV-Import, Reminder-Window (grace), and the destructive zone — moves into a new `<AdvancedSettingsSheet>` (a `<ResponsiveSheet>`), reached from a two-option Edit picker in the header: "Plan bearbeiten" opens the existing wizard untouched, "Erweiterte Einstellungen" opens the new sheet. This is the Apple Health model — drillable history on the surface, the schedule editor and destructive controls behind an explicit gate (R-2 §1 / §A) — minus Apple's discoverability problem, because the picker is one obvious control.
## 2. What changes vs D-3, in one table
| D-3 element | v1.5.6 disposition |
|---|---|
| §9.2 Today's dose card | REMOVED from detail page. `todays-dose-card.tsx` stays as a file (list page owns it); the detail page stops importing it. |
| §9.3 Cadence summary (editable) | KEPT as a STATIC line. `cadence-summary-row.tsx` rendered with `hideEdit` always — no pencil. |
| §9.4 Dose ladder / Phasen | Phasen editor moves into the sheet. `DrugLevelChart` + `TitrationSection` (read-only visualisation) stay on the detail page (they are history/context, not settings). |
| §9.5 Intake history preview | KEPT, becomes the primary surface. Same `intake-history-preview.tsx`, same props. |
| §9.6 Notifications | MOVED into `<AdvancedSettingsSheet>`. |
| §9.7 Settings (API / CSV stub / Phasen / Grace) | MOVED into `<AdvancedSettingsSheet>`. |
| §9.8 Verwaltung & Gefahrenzone | MOVED into `<AdvancedSettingsSheet>` (bottom). |
| Header pencil → wizard `intent:"name"` | REPLACED by the two-option picker. |
## 3. Detail-page section order (final)
Pure history. No dose-logging action anywhere on the page.
### Recurring variant
1. **Back link** — unchanged (`<Link href="/medications">`, `page.tsx:270-280`).
2. **Header band** — name / dose / status pill + Edit picker (see §4). Reuse `medication-detail-header.tsx`; the `onEdit: () => void` prop becomes the picker trigger instead of a direct wizard open.
3. **Cadence summary (static)** — `cadence-summary-row.tsx` rendered with `hideEdit` always true. One line via `summariseCadence(payload, t)`. Course-window sub-line when set.
4. **Dose ladder / Phasen-Visualisierung** — GLP-1 only. `DrugLevelChart` + `TitrationSection` as-is (read-only). The *editor* is in the sheet; this is the history view of where the user is on the ladder.
5. **Intake-history table** — `intake-history-preview.tsx`, unchanged props. Per-row edit/delete kebab + bulk-delete toolbar + footer link to `/medications/[id]/history`. The "Importieren" CTA in its header stays (it acts on the table directly — keep it where the data it changes lives, R-2 §C).
### One-shot variant
Drop section 4 (not GLP-1). Section 3 collapses to the static `Einmalig am DD.MM.` one-shot card already in `page.tsx:306-318`. Section 5 renders the single intake row. No Today's-dose card (it was the only thing that differed for one-shot; now removed for every variant).
1. Back link
2. Header band + Edit picker
3. Cadence static line (`Einmalig am …`)
4. Intake-history table (single row)
### Paused / ended variant
Identical structure. The status pill flips to `Pausiert` / `Beendet` via the existing `resolveStatus()` in `medication-detail-header.tsx:42-54`. Nothing on the surface disables — pause/resume now lives in the sheet's destructive zone, so the surface stays a calm read of the past.
The page no longer reads `intakeList?.events.find(... isToday ...)` (that fed the today card). It still reads `medicationIntakeList(...)` for `intakeCount`, which the sheet's destructive zone needs (`page.tsx:254`). Pass `intakeCount` into the sheet.
## 4. The two-option Edit picker
**Component: shadcn `<DropdownMenu>`** (already in the tree — used by `intake-history-list-v2.tsx:24-29` and `intake-import-dialog.tsx:31-36`). Not a dialog: a dialog to choose-then-open-another-modal is a 3-deep stack and feels heavy; a dropdown is one tap, keyboard-native via Radix, and matches Medisafe/Apple's "pencil top-right" placement (R-2 §1 / §3).
Lives in the header band, replacing the single Edit button at `medication-detail-header.tsx:101-110`. The trigger keeps the same `<Button variant="outline" size="sm">` with `Pencil` icon + `{t("common.edit")}` so the visual anchor is unchanged.
```
[Pencil Bearbeiten ▾] ← DropdownMenuTrigger (min-h-11 sm:min-h-9)
├─ Plan bearbeiten → opens MedicationWizardDialog (mode="edit")
└─ Erweiterte Einstellungen → opens <AdvancedSettingsSheet>
```
- Labels (new i18n keys): `medications.detail.edit.planOption` = "Plan bearbeiten", `medications.detail.edit.advancedOption` = "Erweiterte Einstellungen". Trigger label stays `common.edit`.
- "Plan bearbeiten" opens the wizard with `mode="edit"`, `initial={snapshotToWizardPayload(medication)}`. Drop the `landingIntent` plumbing: with cadence no longer editable inline, the header is the only wizard entry, so it lands on Step 1 (omit `landingIntent`, which keeps the legacy 1/8 heuristic — see `MedicationWizardDialog.tsx:94-97`). The `wizardIntent` state and `openWizardWithIntent` helper in `page.tsx:160-261` are removed.
- a11y: Radix `DropdownMenu` already manages roving focus, Escape-to-close, and returns focus to the trigger on close — the §10 invariant-7 "focus returns to trigger" requirement is satisfied by the primitive. Each `DropdownMenuItem` is keyboard-reachable; `onSelect` opens the respective surface. The trigger carries no extra `aria-label` (its text is the label); add `aria-haspopup` is implicit via Radix.
- Modal-depth note: the dropdown is a popover, not a modal — opening the sheet from it is depth 1, not depth 2. Safe.
## 5. AdvancedSettingsSheet spec
New file: `src/components/medications/advanced-settings-sheet.tsx` (kebab-case, §10 invariant 22). Wraps `<ResponsiveSheet>` (`responsive-sheet.tsx`) — mobile bottom sheet, desktop right/centred dialog, sticky footer, `max-h-[90dvh]` body scroll already built in.
Props:
```ts
interface AdvancedSettingsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
medicationId: string;
medicationName: string;
treatmentClass?: string;
active: boolean;
startsOn?: string | null;
endsOn?: string | null;
notificationsEnabled: boolean;
reminderGraceMinutes: number | null;
intakeCount: number;
}
```
`title` = `t("medications.detail.advanced.title")` ("Erweiterte Einstellungen"). The body scrolls (`ResponsiveSheet` body is `overflow-y-auto`); no sticky footer needed because each section saves itself (every moved section already owns its own save button / switch). Leave the `footer` slot empty.
### Section order inside the sheet (top → bottom: routine → rare → destructive)
1. **`<NotificationsSection medicationId notificationsEnabled />`** — verbatim from `sections/notifications-section.tsx`.
2. **`<SettingsSection medicationId medicationName treatmentClass startsOn endsOn reminderGraceMinutes />`** — verbatim from `sections/settings-section.tsx` (hosts API-Tokens row, CSV-import stub, Phasen button, Grace).
3. **`<DestructiveZoneSection medicationId medicationName active intakeCount />`** — verbatim from `sections/destructive-zone-section.tsx`, last, so the irreversible cards sit at the bottom of the scroll (matches Apple's "Options at the bottom", R-2 §A / §D).
Each of these already renders inside `<MedicationDetailSection>` chrome (bordered card with `<h2>` heading), which reads cleanly stacked inside a sheet body — no wrapper changes needed.
### Nested-sheet / modal-stack handling (the one real risk)
Two sections open their own overlays from inside the sheet:
- **PhaseConfigSheet** (`sections/phase-config-sheet.tsx`) is itself a `<ResponsiveSheet>`, opened by the Phasen button in `SettingsSection`. AdvancedSettingsSheet → PhaseConfigSheet = a 2-deep sheet stack. To honour §10 invariant 7 (modal stack ≤ 2) and avoid a janky two-sheet overlay, **the Phasen button closes the AdvancedSettingsSheet before opening the PhaseConfigSheet** — they become sibling sheets, not nested. Implementation: lift the phase-sheet open state out of `SettingsSection` into `AdvancedSettingsSheet` (or the page), and on "Phasen konfigurieren": `onOpenChange(false)` for the advanced sheet, then open the phase sheet. This is a small wiring change to `SettingsSection` (it currently owns `phaseSheetOpen` at `settings-section.tsx:55`); give it an optional `onRequestPhaseSheet?: () => void` callback so the parent can orchestrate the swap. When `onRequestPhaseSheet` is absent (no parent orchestration), it falls back to its current self-managed sheet.
- **IntakeImportDialog / IntakeEditDialog / bulk-delete AlertDialog** all live in `intake-history-preview.tsx` on the detail SURFACE, not in the sheet — so they never stack on top of the AdvancedSettingsSheet. No change.
- **DestructiveZoneSection's AlertDialogs** (end / purge / delete) open from inside the sheet → AdvancedSettingsSheet (sheet) + AlertDialog = 2-deep. AlertDialog over a Sheet is acceptable (it is the documented "confirm a destructive action revealed in a sheet" pattern and Radix returns focus correctly). This is at the depth ceiling; do not add a third layer. The Tier 3b delete already routes `router.push("/medications")` on success (`destructive-zone-section.tsx:181`) — add `onOpenChange(false)` for the sheet on Tier 1/2/3a success so the user lands back on a fresh surface.
## 6. Intake-history table
No new component — reuse `sections/intake-history-preview.tsx` exactly as shipped. It already wires every affordance the maintainer asked for:
- **Per-row edit**: `onEditIntake={setEditingId}` → opens `<IntakeEditDialog>` → `PUT /api/medications/{id}/intake/{eventId}` (`intake-edit-dialog.tsx:105-112`).
- **Per-row delete**: `onDeleteIntake={setPendingDeleteId}` → single-step `<AlertDialog>` → `DELETE /api/medications/{id}/intake/{eventId}` (`intake-history-preview.tsx:119-124`).
- **Bulk-delete**: `selection={{ mode:"multi", selected, onToggle }}` drives the per-row `<Checkbox>` (`intake-history-list-v2.tsx:308-323`); the toolbar appears when `selected.size > 0` (`intake-history-preview.tsx:167`) → confirm `<AlertDialog>` → `POST /api/medications/{id}/intake/bulk-delete` with `{ eventIds }` (`intake-history-preview.tsx:91-98`).
Every mutation routes through `invalidateKeys(queryClient, medicationDependentKeys)` (which includes the `compliance-chart-inline` prefix, D-3 §10 invariant 20).
Props contract reused as-is (`intake-history-list-v2.tsx:99-123`):
- `onEditIntake?: (eventId: string) => void`
- `onDeleteIntake?: (eventId: string) => void`
- `selection?: { mode: "single" | "multi"; selected: Set<string>; onToggle: (id) => void }`
**One open question** on the edit dialog — see §9.
## 7. Component inventory
### Reused as-is (no edit)
- `sections/intake-history-preview.tsx` — primary surface.
- `intake-history-list-v2.tsx` — the table.
- `intake-edit-dialog.tsx`, `intake-import-dialog.tsx` — row edit + CSV import.
- `sections/notifications-section.tsx`, `sections/api-tokens-row.tsx`, `sections/phase-config-sheet.tsx` — moved into the sheet, unchanged.
- `sections/destructive-zone-section.tsx` — moved into the sheet (one small add: call `onOpenChange(false)` on non-navigating success; see §5).
- `cadence-summary-row.tsx` — rendered with `hideEdit` always.
- `DrugLevelChart.tsx`, `TitrationSection.tsx` — read-only ladder on the surface (PascalCase pre-existing per §10 invariant 22).
- `responsive-sheet.tsx`, shadcn `dropdown-menu` — primitives.
### Edited
- `medication-detail-header.tsx` — `onEdit` becomes the picker trigger. Either embed the `<DropdownMenu>` here (preferred — keeps the header self-contained) or accept `onEditPlan` + `onOpenAdvanced` callbacks and render the menu. Recommend embedding the menu in the header and passing two callbacks down, so the page owns the open-state for wizard + sheet.
- `sections/settings-section.tsx` — add optional `onRequestPhaseSheet?: () => void` so the parent can sibling-swap the phase sheet (§5).
- `src/app/medications/[id]/page.tsx` — the big rewrite: remove `TodaysDoseCard` import + render; remove `NotificationsSection` / `SettingsSection` / `DestructiveZoneSection` from the page body and mount them inside `<AdvancedSettingsSheet>`; remove `wizardIntent` state + `openWizardWithIntent`; add `advancedOpen` state; render the two-option picker via the header; render `cadence-summary-row` with `hideEdit`. Keep both `useQuery` reads (`medicationDetail`, `medicationIntakeList`) — the latter still feeds `intakeCount`.
### New
- `src/components/medications/advanced-settings-sheet.tsx` — the only new file. Hosts the three moved sections in order.
### Deleted (usage, not files)
- Today's-dose-card removed from `page.tsx` (file kept for the list page).
- The inline settings composition in `page.tsx:348-373` (the `!oneShot && (<NotificationsSection/><Separator/><SettingsSection/>)` block + the always-on `DestructiveZoneSection`) — deleted from the page, relocated into the sheet.
- `isToday()` helper + `todayEvent` derivation in `page.tsx:138-253` — dead once the today card is gone.
## 8. Invariants to preserve (from D-3 §10)
All still apply to the moved components and the new sheet:
1. Async sections wrap loading in `<Card aria-busy aria-live="polite">` (already in `phase-config-sheet.tsx:156-167`, `api-tokens-row`).
2. Mutations announce via polite live region or persist visibly in the same paint as the toast (the optimistic switches in notifications + destructive zone already do this).
3. Icon-only triggers carry `aria-label` from i18n + context (intake kebab, `intake-history-list-v2.tsx:369`).
4. Every `<Switch>` wraps in `<label>` (notifications + pause rows — preserved).
5. Status-pill text never hides; dot `aria-hidden` + Dracula tokens (`medication-detail-header.tsx:73-99`).
6. Kebab always rendered on intake rows; no swipe.
7. **Modal stack ≤ 2** — the load-bearing one this refactor must respect: dropdown→sheet is depth 1; sheet→AlertDialog is depth 2 (ceiling); the phase sheet is sibling-swapped, not nested (§5).
9. AlertDialog Cancel keeps autofocus; never autofocus destructive (preserved in `destructive-zone-section.tsx`).
10. Intake group headers `<h3>`; dividers `<Separator>`.
11. Destructive CTAs `font-semibold` (preserved at every destructive call site).
12. Section-title icons `text-foreground`, body icons `text-muted-foreground`, every Lucide `aria-hidden`.
13. Mobile CTAs `min-h-11`; close-X 36 px exception.
14. Every animation utility carries `motion-reduce:*`.
15. TanStack keys from the central factory only.
21. `<MedicationDetailSection>` is the only section chrome; the moved sections keep it inside the sheet.
22. New files kebab-case under `src/components/medications/`.
24. `assertMedicationOwnership` is the single ownership predicate across `src/app/api/medications/[id]/**` (no new routes in v1.5.6 — every endpoint the sheet hits already exists and already uses it after D-3 C-E3-3).
Radius vocabulary (D-3 §8): Card `rounded-xl`, Dialog/Sheet `rounded-lg`, Button `rounded-md`, no `rounded-xl` Button override — unchanged, no new surfaces introduce a radius.
## 9. Open questions for the maintainer
1. **IntakeEditDialog seed bug.** The preview opens the edit dialog with a stub event `{ id, takenAt: null, skipped: false }` (`intake-history-preview.tsx:236-240`) rather than the actual row's `takenAt` / `skipped` / `note`. So "Bearbeiten" always shows an empty form regardless of the row's real state. This pre-dates v1.5.6 but sits squarely in the surface the maintainer is now blessing as primary. Fix it in this pass (thread the real `IntakeEvent` from the list row up through `onEditIntake(event)` instead of `onEditIntake(id)`) or defer? Recommendation: fix it — it is the kind of "got worse" paper-cut that prompted v1.5.6.
2. **Phase sheet swap vs. stacked sheet.** §5 proposes closing AdvancedSettingsSheet before opening PhaseConfigSheet (sibling, depth-safe). The alternative is to let them stack (depth 2, but two full-height sheets on mobile read poorly). Confirm the close-then-open swap — it means a user editing phases returns to the *surface*, not to the open settings sheet. Acceptable? Recommendation: yes, swap; phase editing is rare and self-contained.
3. **Reminder-grace placement.** Grace currently sits inside `SettingsSection`. It is arguably "plan"-adjacent (it shapes when reminders fire) rather than "advanced". Keep it in the sheet's Settings block (proposed), or is it wizard territory? Recommendation: keep in the sheet — it is per-primary-schedule and the wizard does not currently own it.
@@ -0,0 +1,153 @@
# G-2 — Senior-dev pre-release review, v1.5.6
Scope: `git diff d54addd6..release/v1.5.6`. Three workstreams: (1) medication
detail-page rewrite + `advanced-settings-sheet` + two-option edit dropdown +
IntakeEdit seed fix; (2) `step-consolidation` pg-boss job + migration 0087;
(3) security hardening (safeFetch migrations, requirePublicHost, avatar
bounded stream, `healthlog/safe-fetch-required` rule, OpenAPI add). All gates
green (typecheck, lint, openapi:check, 5637 unit). Findings are what tests
don't catch.
## Block release on
- Nothing. No Critical or High found. The three workstreams are correct,
robust, and on-convention. Items below are Medium/Low — ship-then-patch.
## Ship-then-patch
- M-1 (migration 0087 lock window on huge `measurements`)
- M-2 (second-unique-index P2002 abort risk in consolidation tx)
- L-1 (safe-fetch rule blind to aliased/variable fetch callees)
- L-2 (avatar bounded-drain clones whole body even under the CL pre-flight)
- L-3 (consolidation `findFirst` existing-total is source-agnostic — benign now, fragile later)
---
## Critical
None.
## High
None.
## Medium
### M-1 — Migration 0087 takes ACCESS EXCLUSIVE on `measurements` for the full index build
`prisma/migrations/0087_v156_step_consolidation/migration.sql:21-24`
A plain (non-`CONCURRENTLY`) `CREATE INDEX IF NOT EXISTS` runs inside Prisma's
migration transaction and holds an ACCESS EXCLUSIVE lock on `measurements` for
the entire build. The migration's own header notes the predicate matches
"hundreds-of-thousands of rows" on a multi-year HealthKit account — exactly the
table this index is built against. On those tenants the deploy migration blocks
every read/write to `measurements` (dashboard, batch ingest, Withings sync)
until the build completes. This is the standard Prisma migration trade-off and
acceptable for most self-hosts, but the largest tenants are the ones who both
need this index and will feel the lock.
Fix: either accept and document the lock in `docs/ops/migrations.md` (note the
index is small because of the `deleted_at IS NULL` partial predicate and most
tenants have no legacy step rows at all), or split into a `CONCURRENTLY` build
applied out-of-transaction via a follow-up ops step. Given the partial
predicate keeps the index tiny on tenants without legacy data, documenting is
the pragmatic call — but it should be a conscious decision, not silent.
### M-2 — Consolidation upsert can throw P2002 on the second unique index and abort the soft-delete
`src/lib/measurements/consolidate-legacy-steps.ts:253-276`
The minted daily-total row is `{ source: "MANUAL", measuredAt: canonical noon,
sleepStage: null }`. `Measurement` carries a second unique index
`@@unique([userId, type, measuredAt, source, sleepStage])`
(`prisma/schema.prisma:607`). The upsert keys on
`userId_type_source_externalId`, so a pre-existing MANUAL `ACTIVITY_STEPS` row
at exactly the canonical-noon instant with null `externalId` would NOT match
the upsert `where` (different externalId), the `create` branch would fire, and
it would violate the `measuredAt+source+sleepStage` index — raising P2002
inside `$transaction`, which also rolls back the legacy soft-delete for that
day. The day then re-appears on every boot discovery (legacy rows still live),
so the pass never converges for that user and re-throws every reboot.
Likelihood is low (requires a manual steps entry landing at the exact UTC
instant of local-noon for that day), but it is a non-converging poison-pill,
not a transient. Fix: wrap the per-day transaction body in a try/catch that, on
P2002, logs + annotates and continues to the next day (skip, don't abort the
whole user); or soft-delete legacy rows in their own statement outside the
upsert's failure path so a mint collision can't strand the tombstone.
## Low
### L-1 — `safe-fetch-required` rule is blind to aliased / variable-callee fetch
`eslint-plugins/healthlog/safe-fetch-required.js:91-107`
`isFetchCallee` matches only `Identifier "fetch"` and member forms on
`globalThis`/`window`/`self`. A `const f = fetch; f(url)`, a destructured
`const { fetch: g } = globalThis`, or `nodeFetch(url)` import-alias all slip
through. This is within the rule's stated syntactic scope and the real call
sites in this diff are clean, so it is a defence-in-depth gap, not a present
bug. Worth a comment in the rule documenting the known evasions so a future
reviewer doesn't assume total coverage.
### L-2 — Avatar bounded-drain clones the full body even after the Content-Length pre-flight passes
`src/app/api/user/avatar/route.ts:125-152`
When a `Content-Length` header is present and under the cap, the code still
clones the body and drives a second full read through `readBoundedBody` in
parallel with `formData()`. For the common well-behaved upload this doubles the
read work (tee buffering of up to 2 MiB). The bounded drain is only load-bearing
for the chunked / header-absent case. The `pipeTo` + `Promise.all` shape does
abort correctly past the cap (the thrown `BodyTooLargeError` rejects the pipe,
cancels the source, and `Promise.all` short-circuits — no undici tee deadlock,
verified against the parallel-drain reasoning in the inline comment). So this is
purely a minor efficiency note: gate the clone+drain on
`!request.headers.has("content-length")` to skip the redundant read when the
pre-flight already bounded the request.
### L-3 — Consolidation existing-total probe is source-agnostic
`src/lib/measurements/consolidate-legacy-steps.ts:217-225`
`existingTotal` matches on `{ userId, type, externalId, deletedAt: null }`
without `source`. This is correct today and intentionally so — it catches an
iOS-written `APPLE_HEALTH` daily total under the same `externalId` and prevents
double-counting (the upsert's own `where` pins `source: "MANUAL"`, so without
the source-agnostic probe a parallel-source total would be missed and a second
MANUAL total minted). The risk is only latent: if a future change makes the
probe and the upsert key diverge further, the `hadExistingTotal` guard and the
upsert target could disagree. Add a one-line comment at the `findFirst` pinning
*why* source is deliberately omitted, so the asymmetry with the upsert `where`
reads as intentional rather than a bug.
---
## Verified-correct (notable, since the prompt asked)
- Queue registration: `STEP_CONSOLIDATION_QUEUE` is in `allQueues`
(`reminder-worker.ts:1885`), has a `boss.work` handler
(`:2196-2210`, serial concurrency), and boot discovery is wired
(`:2369`, best-effort try/catch, never fails boot).
- Idempotency / no double-count: legacy scan excludes `deletedAt IS NOT NULL`
and the `stats:` prefix; `hadExistingTotal` skips the mint and `dailyRowsUpserted`
increment while still tombstoning legacy rows; integration test
`tests/integration/consolidate-legacy-steps.test.ts:102-147` pins the
no-double-count path (`daysFoldedIntoExisting === 1`). Discovery `$queryRaw`
is tagged-template, constant literal prefix, no splice.
- Soft-delete is in the same `$transaction` as the mint
(`consolidate-legacy-steps.ts:244-289`); tombstone via `updateMany` guarded
on `deletedAt: null`.
- TZ day-keying reuses the locked `dayKeyForUserTz` (sv-SE) +
`canonicalDailyTimestamp` (local-noon) helpers — byte-identical to the iOS /
mood-entry round-trip convention.
- No mass assignment: the upsert builds `create`/`update` field-by-field. The
GET `/api/medications/[id]` does spread `{ ...medication, category }`, but
that is a READ response serialization, not a Prisma write — convention is
about write `data` objects, so this is fine.
- Detail-page rewrite: every import is used (no dead imports); the GET returns
full Prisma `schedules` so `reminderGraceMinutes` / `rrule` /
`rollingIntervalDays` / `timesOfDay` consumed by `snapshotToWizardPayload`
and `primaryGrace` are all real columns and present in the payload — no
phantom fields. Sibling-swap (advanced sheet → phase sheet) closes the parent
before opening the child (`page.tsx:241-244`); `settings-section` correctly
defers to `onRequestPhaseSheet` and suppresses its own `<PhaseConfigSheet>`
when the prop is present (`settings-section.tsx:213`).
- IntakeEdit seed bug fix is complete: `onEditIntake` now threads the whole
`IntakeEvent`; `intake-history-preview` seeds the dialog from real
`takenAt`/`skipped` instead of the empty stub. `IntakeEvent` exported cleanly.
- safeFetch migrations: operator-supplied Umami host (send route, umami-script,
umami-test) correctly carries `requirePublicHost: true`; Withings
constant-host calls use plain `safeFetch` (no user input → pin not needed).
No phantom branches.
@@ -0,0 +1,210 @@
# G-3 Security Review — v1.5.6 integration branch
Scope: `git diff d54addd6..release/v1.5.6` (egress hardening focus, plus
step-consolidation job + medication detail-page rewrite for new
SSRF/authz/injection surface). Threat model: motivated adversary against a
self-hosted production deployment. READ-ONLY review.
## Block release on:
- **Nothing is a hard release blocker.** The egress workstream is correct
and the new surfaces (step consolidation, detail page) introduce no
authz/injection regression. The two items below are defence-in-depth
hardening that SHOULD land before tag but do not, on their own, open a
remotely-reachable hole in the default deployment.
- **M-1** — ESLint `/`-prefixed exemption accepts protocol-relative
(`//evil.com`) and backslash (`/\evil.com`) absolute URLs. Lint-time
guard only; tighten before relying on the rule as a real egress floor.
- **M-2** — Avatar oversize-abort leaves the `formData()` parse promise
dangling, so the original (attacker-controlled) body keeps buffering
after the clone drain trips. Bounded by the 10/hour rate-limit; fix to
actually free memory on abort.
---
## Critical
None.
## High
None.
## Medium
### M-1 — `safe-fetch-required` `/`-prefixed exemption smuggles absolute URLs
`eslint-plugins/healthlog/safe-fetch-required.js:108-120`
`isSameOriginRelative` returns true for any string literal / template head
that `.startsWith("/")`. Two absolute-URL forms slip through:
- `fetch("//evil.com/x")` — a protocol-relative URL. WHATWG URL parsing
resolves `//host` against the page origin's scheme → `https://evil.com`.
Starts with `/`, so the rule exempts it.
- `fetch("/\\evil.com")` (`/\evil.com`) — the URL parser normalises `\` to
`/`, yielding `//evil.com` → `https://evil.com`. Also exempted.
Impact is bounded because this is an author-time lint rule, not a runtime
guard — a developer must deliberately write the smuggled form, and the
review/CI would still see the diff. But the rule is explicitly positioned
in `CLAUDE.md` as the egress floor ("banning raw `fetch(` outside the
wrapper"), and an exemption that lets an absolute external host through
defeats that purpose silently.
Fix: only exempt a single leading `/` that is NOT followed by `/` or `\`.
```js
function isSameOriginRelative(arg) {
const head =
arg?.type === "Literal" && typeof arg.value === "string"
? arg.value
: arg?.type === "TemplateLiteral" && arg.quasis.length > 0
? (arg.quasis[0].value.cooked ?? "")
: null;
if (head === null) return false;
// Single leading slash only — reject `//host` and `/\host` (both
// resolve to an absolute external origin).
return /^\/(?![/\\])/.test(head);
}
```
A template head of exactly `"/"` (one char, e.g. `` fetch(`/${path}`) ``)
would now be flagged; that is acceptable — a variable-built path head is
exactly the case that warrants the wrapper or an explicit eslint-disable.
### M-2 — Avatar oversize-abort does not cancel the `formData()` parse; memory not freed on abort
`src/app/api/user/avatar/route.ts:147-169`
The stream cap drains `request.clone().body` (the tee) in parallel with
`request.formData()` on the original. When the clone drain throws
`BodyTooLargeError`, `Promise.all` rejects and the handler returns 413
immediately at line 156-162 — but it does NOT await or cancel
`parsePromise`. The native `formData()` parse on the ORIGINAL request keeps
running in the background and continues buffering the full
attacker-controlled body into memory (the very thing the stream cap was
added to prevent). The clone branch stops early; the original does not.
Contrast with the non-overflow error branch (line 164), which correctly
does `await parsePromise.catch(() => {})`.
The "frees memory on abort" property therefore does not hold for the
overflow path. Real-world blast radius is limited by the per-user
10/hour/`user.id` rate-limit (line 108, runs before any body read) and the
2 MiB cap meaning a single trip only over-buffers one oversized body per
slot — but a malicious authenticated client can still drive 10 large
in-flight buffers/hour, each of which lingers until the dangling parse
settles or the request lifecycle tears the socket down.
Fix: do not leave the parse floating. On `BodyTooLargeError`, abort/settle
it before returning, e.g. attach `parsePromise.catch(() => {})` so the
rejection is observed, and prefer aborting the underlying request. Simplest
robust shape: drive both off a single `AbortController` and abort the
parse when the cap trips, or read the body ONCE through the bounded reader
and reconstruct `FormData` from the captured bytes rather than tee-ing.
```ts
if (err instanceof BodyTooLargeError) {
// Observe the dangling parse so the original body stops buffering
// into an unobserved rejection / lingering allocation.
void parsePromise.catch(() => {});
annotate({ /* … */ });
return apiError(`Upload exceeds ${AVATAR_MAX_BYTES} byte limit`, 413);
}
```
(The `void parsePromise.catch` only prevents an unhandled-rejection; it
does not stop the buffering. The structural fix is a shared AbortController
so the trip actually cancels the parse — recommended.)
## Low
### L-1 — Codex SSE timeout raised to 60 s without a response-body read budget
`src/lib/ai/codex-client.ts:284-326`
The migration correctly bumps `timeoutMs` to 60 000 to match the other AI
clients so a long generation is not clipped, and inherits `redirect:
"manual"` from the wrapper default (good — the OAuth bearer no longer
leaks on a redirect hop). Note only: the 60 s budget bounds the
connect+headers, but a streaming SSE body can still hold the socket past
the timeout once headers have arrived (the timeout signal fires on the
fetch, not the per-chunk read loop). This matches the pre-existing
behaviour of the other AI clients, so it is not a regression — flagged for
completeness only.
## Informational
### I-1 — Credential-carrying constant-host migrations: verified correct
`src/lib/withings/client.ts` (5 calls: exchangeCode, refreshAccessToken,
fetchMeasurements, subscribeWebhook, unsubscribeWebhook),
`src/lib/withings/sync-activity.ts:149`, `src/lib/withings/sync-sleep.ts:119`,
`src/lib/ai/codex-oauth.ts` (4 calls), `src/app/api/bugreport/route.ts:162`.
All now route through `safeFetch`, which pins `redirect: "manual"` by
default. The Withings token bodies (`client_secret`, refresh/access
tokens), the Codex OAuth client-secret/PKCE/refresh bodies, and the GitHub
PAT (`Authorization` header on the bugreport comment POST) can no longer be
replayed onto a redirected hop. None of these set `followRedirects`, so the
manual-redirect default holds. Constant hosts → no `requirePublicHost`
needed (no rebinding surface), consistent with the wrapper docstring.
### I-2 — Operator-supplied-host migrations: `requirePublicHost` set everywhere expected
`src/app/api/monitoring/umami-script/route.ts:30`,
`src/app/api/admin/monitoring/umami-test/route.ts:92-105`,
`src/app/api/send/route.ts:77-91`,
`src/lib/monitoring/glitchtip.ts:126/161/191` (envelope + store-query +
store-header, all 3),
`src/lib/logging/transports.ts:75` (Loki).
Every operator-host call sets `requirePublicHost: true`, which both runs
the input-time `isPublicUrl` floor and routes through
`getPinnedPublicDispatcher()` (connect-time IP pin against DNS rebinding,
`src/lib/safe-fetch-dispatcher.ts`). The dispatcher fails closed
(`ENOTFOUND`) when no resolved address passes `isPublicIp`. Count matches
the workstream spec (umami-script, send, umami-test, glitchtip ×3, Loki).
### I-3 — ESLint rule wiring + residual raw-fetch sweep: clean
`eslint.config.mjs:34-41`, `eslint-plugins/healthlog/index.js`.
`healthlog/safe-fetch-required` is registered as `"error"`. A full sweep of
`src/lib` + `src/app` for raw `fetch(` found only: the wrapper's own call
(`src/lib/safe-fetch.ts:135`, exempt), and same-origin `/`-relative client
fetches (`src/app/insights/medikamente/page.tsx:93,267`,
`src/app/medications/[id]/page.tsx:153,170`,
`src/app/medications/[id]/history/page.tsx:37`) — all legitimately exempt
(modulo M-1's exemption-tightening). No external-host raw fetch remains.
### I-4 — Step-consolidation job + library: owner-scoped, injection-free
`src/lib/jobs/step-consolidation.ts`,
`src/lib/measurements/consolidate-legacy-steps.ts`,
`prisma/migrations/0087_v156_step_consolidation/migration.sql`.
The pg-boss job's discovery `$queryRaw` (step-consolidation.ts:95) is a
tagged template with a compile-time-constant `stats:` prefix literal — no
splice, no user input. It enqueues one payload per internally-discovered
`userId` (never body-supplied). The worker and the library
(`consolidateLegacySteps`) scope every `findMany` / `upsert` / `updateMany`
to `userId: user.id`; the soft-delete `updateMany` is keyed
`{ id: { in: ids }, deletedAt: null }` where `ids` came from the same
user-scoped read. No cross-tenant path. Migration 0087 adds a partial index
only — no data movement, no new column accepting external input.
### I-5 — Medication detail-page rewrite: read-only, no new endpoint
`src/app/medications/[id]/page.tsx`,
`src/app/medications/[id]/history/page.tsx`, plus the
`src/components/medications/*` and `settings/sections/*` changes.
The detail page is now a pure history surface; its only network calls are
GET fetches to existing same-origin endpoints (`/api/medications/${id}`,
`/api/medications/${id}/intake`, `/api/medications/${id}/compliance`). No
new API route, no mutating handler, no body-supplied `userId`, and ownership
continues to be enforced by the existing `assertMedicationOwnership` on the
backing `[id]` routes (unchanged in this diff). No new authz surface.
### I-6 — Avatar route authz + validation otherwise intact
`src/app/api/user/avatar/route.ts`.
`requireAuth()` first, per-user rate-limit before body read, owner-scoped
`prisma.user.update({ where: { id: user.id } })`, magic-byte MIME sniff
(wire Content-Type treated as informational), dimension cap, and the
post-parse `file.size` check as defence-in-depth. The only gap is M-2's
abort-cleanup; the validation chain itself is sound.
@@ -0,0 +1,87 @@
# G-4 — Frontend / UX / a11y review: v1.5.6 medication detail-page refactor
Scope: `git diff d54addd6..release/v1.5.6 -- 'src/components/medications/**' 'src/app/medications/[id]/**' 'messages/**'`. Read-only. Audited against G-1 + D-3 §10 invariants.
## Block release on:
(none)
No Critical findings. The refactor matches the G-1 spec faithfully: modal depth holds at ≤2, the dropdown→sheet→AlertDialog paths use Radix primitives that manage focus correctly, all 6 locales carry the new keys with umlauts intact, and the list page is untouched apart from the sanctioned glyph swap. Ship-able as-is; the items below are polish.
---
## Critical
None.
---
## High
None.
---
## Medium
### M-1 — Pause/resume Switch ejects the user from the settings sheet
`src/components/medications/sections/destructive-zone-section.tsx:118` (pause) + `advanced-settings-sheet.tsx:95` (`onAfterAction={() => onOpenChange(false)}`)
Flipping the **Tier-1 pause Switch** inside the AdvancedSettingsSheet now fires `onAfterAction()`, which closes the entire sheet. Pause is a Switch, not a dialog — a single toggle ejects the user from settings with no confirmation step in between. This is literally what G-1 §5 asked for ("add `onOpenChange(false)` … on Tier 1/2/3a success"), so it is spec-compliant, but Tier-1 is the one reversible, frequently-toggled control and closing the whole surface on a switch flip reads as a glitch ("why did my settings disappear?"). End (Tier 2) and Purge (Tier 3a) close their own AlertDialog first, so for those the sheet-close lands on a calm surface and is fine.
Fix: scope the auto-close to the dialog-backed tiers only — pass `onAfterAction` to the End/Purge handlers but NOT to `handlePauseToggle`. Concretely, drop the `onAfterAction?.()` call at `destructive-zone-section.tsx:118` (leave it at :144 end + :167 purge). The pause toast already confirms the state change; the user stays in the sheet.
### M-2 — `IntakeEditDialog` note still seeds empty after the §9-Q1 fix
`src/components/medications/sections/intake-history-preview.tsx:248-252` seeds `{ id, takenAt, skipped }` only; `intake-edit-dialog.tsx:82,102` reads/writes `event.note`.
The seed fix correctly threads the real `takenAt` + `skipped`, but the dialog also supports `note` and the seed omits it, so editing a row with an existing note opens with an empty note field — and on save, an empty note body means the existing note is dropped from the request body (`intake-edit-dialog.tsx:102` only sets `body.note` when `note.trim().length > 0`). Root cause is upstream: `IntakeEvent` (`intake-history-list-v2.tsx:76-84`) has no `note` field — the list API response never returns it, so the row genuinely cannot seed it.
This is a known data-availability gap, not a regression introduced by this diff (the note was never seedable from the list, even in the v1.5.5 stub). But it now sits on the surface the maintainer is blessing as primary, and the "save silently strips an existing note" behaviour is a real paper-cut.
Fix (defer-able to v1.5.6.1): add `note: string | null` to the list response + `IntakeEvent`, thread it through the seed; OR have `IntakeEditDialog` fetch the single event on open so it edits the full record. Until then, document in I-1 that note edits are not round-tripped.
---
## Low
### L-1 — `Separator` import now dead in `page.tsx`? (verify)
`src/app/medications/[id]/page.tsx` — the diff removes the `<Separator className="opacity-0" />` render but the `import { Separator }` line was removed in the same hunk (confirmed in diff: `-import { Separator } from "@/components/ui/separator";`). No dead import. No action; noted for completeness — clean.
### L-2 — Header trigger gains a `ChevronDown` but keeps `common.edit` label only
`src/components/medications/medication-detail-header.tsx:118-122`
The trigger is now `[Pencil] Bearbeiten [ChevronDown]`. It carries no `aria-label` (correct per G-1 §4 — visible text is the accessible name) and `aria-haspopup`/`aria-expanded` are supplied by Radix `DropdownMenuTrigger`. Both Lucide icons are `aria-hidden="true"`. Compliant. One nit: the trigger now renders two 16px icons + a word; at the `min-h-9` desktop size with a long locale ("Ustawienia"… no, trigger stays `common.edit` = "Edytuj"/"Modifier") the German "Bearbeiten" + two icons fits comfortably > 44px wide. No collapse risk. No action.
### L-3 — `data-slot="advanced-settings-sheet-body"` duplicates `responsive-sheet-body`
`advanced-settings-sheet.tsx:73` adds `data-slot="advanced-settings-sheet-body"` on an inner `<div>` that sits directly inside ResponsiveSheet's own `data-slot="responsive-sheet-body"` wrapper (`responsive-sheet.tsx`). Two nested body slots is harmless (distinct slot names, no `id` collision) but slightly redundant — the inner `space-y-6` div could drop the data-slot or fold into `bodyClassName`. Cosmetic; no functional impact. No dead/duplicate DOM `id`.
### L-4 — Radius vocabulary: ResponsiveSheet mobile branch uses `rounded-t-2xl`, not `rounded-lg`
`src/components/ui/responsive-sheet.tsx` (pre-existing, not in this diff)
D-3 §8 vocabulary is Sheet `rounded-lg`; the mobile bottom-sheet branch uses `rounded-t-2xl` (iOS bottom-sheet idiom). This pre-dates v1.5.6 and the new AdvancedSettingsSheet introduces no radius override of its own (it only sets `sm:max-w-lg` + `bodyClassName="gap-6"`). Invariant satisfied for the new code; flag the primitive drift only if §8 is meant to be literal. No action this release.
### L-5 — `IntakeImportDialog` now conditionally mounted — confirm no focus-loss on close
`src/components/medications/sections/intake-history-preview.tsx:232-237`
Changed from always-mounted (`medicationId={importOpen ? id : null}`) to `{importOpen && <IntakeImportDialog … />}`. Unmounting a Radix Dialog on close is fine (Radix returns focus to the trigger before unmount in the same tick), and the import CTA lives in the preview header so focus returns there. Verified the dialog is a `<Dialog>` with its own `onOpenChange`. No focus-trap leak. Behaviour improvement (no hidden dialog in the tree). No action.
---
## Invariant pass/fail summary (D-3 §10 + G-1)
| Invariant | Status | Evidence |
|---|---|---|
| Modal stack ≤ 2 | PASS | dropdown=popover (depth 0); sheet→AlertDialog=2; phase sheet sibling-swapped via `openPhaseSheet()` closing advanced first (`page.tsx`). |
| Status-pill text never hidden + Dracula tokens | PASS | header unchanged at `:73-99`; dot `aria-hidden`. |
| Every `<Switch>` in `<label>` | PASS | notifications + pause rows unchanged; no new switches. |
| Destructive CTAs `font-semibold` | PASS | destructive-zone CTAs unchanged. |
| Icon-only triggers `aria-label` | PASS | list-page nav glyph keeps `aria-label={t("medications.openDetailPage")}` (`medication-card.tsx:275`, `glp1-medication-card.tsx:337`); intake kebab unchanged. Edit dropdown trigger has visible text (no aria-label needed). |
| Group headers `<h3>` | PASS | intake group headers unchanged; section chrome `<h2>` preserved inside sheet. |
| `motion-reduce` on animation | PASS | no new animation utilities introduced; ResponsiveSheet/Radix transitions pre-existing. |
| New files kebab-case | PASS | `advanced-settings-sheet.tsx`. |
| Radius vocabulary | PASS (new code) | new sheet adds no radius override; see L-4 re primitive. |
| i18n keys in all 6 locales + umlauts | PASS | `medications.detail.edit.{planOption,advancedOption}` + `medications.detail.advanced.title` present in de/en/es/fr/it/pl; "Erweiterte Einstellungen" umlaut intact; es/fr/it/pl translated (not stubbed). |
| List page untouched except glyph swap | PASS | only `History`→`ChevronRight` in `medication-card.tsx` + `glp1-medication-card.tsx`; no other list-page change in diff. |
| Focus return dropdown→sheet→dialog | PASS | Radix DropdownMenu returns focus to trigger on `onSelect`; ResponsiveSheet renders real `SheetTitle`/`DialogTitle` so accessible name + focus trap are managed; AlertDialog over sheet returns focus correctly. |
| Dead/duplicate DOM id | PASS | no `id` collision; `PAUSE_SWITCH_ID` unchanged + single-mounted. data-slot redundancy only (L-3). |
| German label layout collapse <44px | PASS | trigger label is `common.edit`; sheet/dropdown items are full-width rows. No sub-44px German label. |
@@ -0,0 +1,99 @@
# G-5 — v1.5.6 simplification review
Scope: `git diff d54addd6..release/v1.5.6`, RECENTLY CHANGED code only.
READ + analyze only — no commits made. Behavior-preserving suggestions.
Three workstreams reviewed: (A) medication detail-page rewrite + `advanced-settings-sheet.tsx`; (B) step-consolidation job + populator; (C) security hardening (safeFetch, avatar bounded reader, glitchtip/umami/send, ESLint plugin).
---
## High impact
### H-1 — Dead M-7 `landingIntent` / `landingStepForEdit` path in the wizard
`src/components/medications/wizard/MedicationWizardDialog.tsx:66,97,117,123,132,147`
plus `src/components/medications/wizard/wizard-payload.ts` (the `landingStepForEdit` export) and `src/components/medications/cadence-summary-row.tsx:9` (stale doc comment).
The detail-page rewrite (`page.tsx`) dropped both `wizardIntent` state and the `landingIntent` prop on every mount. Grep confirms **zero non-test, non-wizard callers** now pass `landingIntent`; the wizard always opens at Step 1. The `landingStepForEdit(hydrated, landingIntent)` branch, the `landingIntent?: "cadence" | "summary" | "name"` prop, its inclusion in `stateKey`, and the `landingStepForEdit` helper + its test are now dead per CLAUDE.md "no backwards-compat shims for hypothetical callers."
Fix: remove the `landingIntent` prop + `landingStepForEdit` import/usage from the dialog (default the initial step to Step 1 for edit mode), delete `landingStepForEdit` from `wizard-payload.ts`, fix the `cadence-summary-row.tsx:9` comment that still references `landingIntent: "cadence"`, and drop `wizard-payload.test.ts`'s `landingStepForEdit` cases.
Est. LOC delta: **-60 to -90** (helper + branch + prop + test cases).
NOTE: verify the wizard's own non-edit (`mode="new"`) callers and the `cadence-summary-row` standalone surface don't still rely on intent before deleting — if `cadence-summary-row` on the LIST page still wires an intent, scope the removal to the `"name"`/`"summary"` arms only.
### H-2 — `bucketLegacyStepRows` + `sumLegacyStepValues` duplicate the drain helpers
`src/lib/measurements/consolidate-legacy-steps.ts:` (`bucketLegacyStepRows`, `sumLegacyStepValues`)
vs. `src/lib/measurements/drain-per-sample-cumulative.ts:` (`bucketRowsByUserDay`, `sumBucketValues`).
`sumLegacyStepValues` is byte-identical to `sumBucketValues`. `bucketLegacyStepRows` differs from `bucketRowsByUserDay` only in (a) the skip-prefix constant (`STEP_DAILY_STATS_PREFIX` vs the literal `"stats:"`) and (b) returning the bare `Map` instead of `{ byDay }`. Both could collapse onto the existing exports — pass the skip-prefix as a parameter (default `"stats:"`) and read `.byDay` at the call site.
Fix: reuse `sumBucketValues` directly; parameterize `bucketRowsByUserDay(rows, tz, skipPrefix = "stats:")` and call it from the consolidation pass instead of the new copies.
Est. LOC delta: **-25 to -35** (delete two helpers + their dedicated unit tests in `consolidate-legacy-steps.test.ts` that re-cover already-tested bucketing).
---
## Medium impact
### M-1 — `wizardPayload` / `payload` double-binding in the detail page
`src/app/medications/[id]/page.tsx` (the `useMemo` `wizardPayload` + `const payload = wizardPayload as MedicationPayload`).
The page memoizes `wizardPayload: MedicationPayload | null`, then immediately re-aliases it to `payload` with an `as MedicationPayload` cast and a comment explaining the non-null narrowing. The early returns above already guarantee `medication` is defined at that point, so the memo can compute a non-null `MedicationPayload` and skip the alias+cast entirely — or just inline `snapshotToWizardPayload(medication)` once into a single const (it is now used in only two spots: `CadenceSummaryRow` and the wizard mount, both after the guard).
Fix: drop the `| null` union by computing the memo after the guard, or replace the memo+alias with one `const payload = snapshotToWizardPayload(medication)`. Removes the `as` cast and the explanatory comment.
Est. LOC delta: **-6 to -10**.
NOTE: `useMemo` was added "F-1 M-2" for object identity; a plain const recomputes per render. If the wizard remount churn was real, keep the memo but return non-null and delete only the `payload` alias + cast + comment (~ -5 LOC).
### M-2 — `CadenceSummaryRow` now receives a no-op `onEdit`
`src/app/medications/[id]/page.tsx` (`<CadenceSummaryRow ... hideEdit onEdit={() => {}} />`).
With `hideEdit` set, the row suppresses its own edit affordance, so `onEdit={() => {}}` is a dead handler kept only to satisfy a required prop. Make `onEdit` optional on `CadenceSummaryRow` (it is already gated behind `hideEdit`) and drop the empty arrow.
Est. LOC delta: **-1** in the page, clearer prop contract in `cadence-summary-row.tsx`.
### M-3 — `SettingsSection` phase-sheet branch carries two ways to open the same sheet
`src/components/medications/sections/settings-section.tsx` (`onRequestPhaseSheet ? onRequestPhaseSheet() : setPhaseSheetOpen(true)` + `{showPhases && !onRequestPhaseSheet && <PhaseConfigSheet/>}` + self-managed `phaseSheetOpen`).
The section now supports BOTH a self-hosted phase sheet (standalone surface) and a sibling-swap callback (detail-page surface). That dual mode keeps `phaseSheetOpen` state, the inline `<PhaseConfigSheet>`, and the ternary alive. Check whether the standalone surface still exists after the detail rewrite — `SettingsSection` is now only mounted via `AdvancedSettingsSheet`, which ALWAYS passes `onRequestPhaseSheet`. If there is no remaining caller without the callback, the self-managed branch (`phaseSheetOpen` state + inline `PhaseConfigSheet` + ternary fallback) is dead.
Est. LOC delta: **-10 to -18** if the standalone path is confirmed gone.
NOTE: requires confirming `SettingsSection` has no caller other than `AdvancedSettingsSheet`. If a standalone surface remains, leave as-is — the dual mode is then load-bearing.
### M-4 — `consolidate-legacy-steps.ts` dry-run branch mirrors the write branch counters
`src/lib/measurements/consolidate-legacy-steps.ts` (the `else` after `if (!dryRun)` re-deriving `dailyRowsUpserted` / `legacyRowsSoftDeleted`).
The dry-run else-branch re-increments the same totals the transaction would, duplicating the `!hadExistingTotal` and row-count logic. Since the bucket already records `legacyRowCount` and `hadExistingTotal`, the totals can be accumulated once from the bucket regardless of dry-run, and only the DB writes gated behind `if (!dryRun)`. Collapses the two counter sites into one.
Est. LOC delta: **-5 to -8**, removes a divergence risk between preview and real counts.
---
## Low impact
### L-1 — Oversized header doc-block on `step-consolidation.ts` / `consolidate-legacy-steps.ts`
`src/lib/jobs/step-consolidation.ts:1-25` and `src/lib/measurements/consolidate-legacy-steps.ts:1-45`.
Both files open with ~25-45 line narrative comments that partly restate the code immediately below (the discovery-query shape, the idempotency argument, the field-by-field upsert rationale). The architectural "why" (matches `rollup-full-backfill`, queue must be in `allQueues`, double-count avoidance) is worth keeping; the per-step numbered walkthrough that mirrors the function body is restatement. CLAUDE.md: "no comments stating the obvious."
Est. LOC delta: **-20 to -30** of comment, no behavior change. Low priority — these are accurate and the queue-registration warning is genuinely useful.
### L-2 — `runStepConsolidationForUser` silent-logger comment-only closure
`src/lib/jobs/step-consolidation.ts` (`log: () => { /* Silent ... */ }`).
The `log` sink is passed an empty arrow whose only content is a comment explaining it is silent. Since `consolidateLegacySteps` already defaults `log` to `console.log`, and the queue wants silence, pass `log: () => {}` (the comment restates the obvious) — or expose a `silent: true` option on `StepConsolidationOptions` if silence recurs. Minor.
Est. LOC delta: **-2**.
### L-3 — `BodyTooLargeError` class for a single internal throw
`src/app/api/user/avatar/route.ts` (`class BodyTooLargeError`).
A dedicated error subclass is declared but only thrown+caught within the same module against one `instanceof`. A sentinel is fine, but the class + name assignment is more ceremony than the single call site needs; a module-private `const BODY_TOO_LARGE = Symbol()` reject or a plain `Error` matched by a boolean flag would be lighter. Borderline — keep if a second size-capped route is imminent (none today; grep shows `readBoundedBody` is single-use).
Est. LOC delta: **-4 to -6** if simplified. Leave as-is if the bounded reader is slated to be shared.
### L-4 — `IntakeImportDialog` now conditionally mounted AND still takes a nullable id
`src/components/medications/sections/intake-history-preview.tsx` (`{importOpen && <IntakeImportDialog medicationId={medicationId} ... />}`).
The dialog moved from always-mounted-with-null-id to conditionally-mounted-with-real-id. Good change, but confirm `IntakeImportDialog`'s `medicationId: string | null` prop and its internal `medicationId ? ... : null` open-gate are now redundant — if this is the only caller, the prop can drop `| null` and the internal gate goes away.
Est. LOC delta: **-3 to -5** in `IntakeImportDialog`, pending caller audit.
---
## Reviewed and intentionally NOT flagged
- **safeFetch option objects** (withings/ai/glitchtip/umami/send): each call passes a distinct `init` + a small `{ requirePublicHost }` or `{ timeoutMs }` third arg. `safeFetch` already centralizes `redirect: "manual"` + `AbortSignal.timeout` defaults, so the per-call objects are NOT duplicated boilerplate — they're genuinely per-endpoint. No change.
- **`readBoundedBody`** (avatar route): single-use, no duplication; the `pipeTo`-into-counting-sink approach is the correct bounded drain. Comment is dense but load-bearing (explains the clone-in-parallel rationale). Keep.
- **ESLint `safe-fetch-required.js`**: new rule, no simplification target.
- **`dayKeyForUserTz` / `canonicalDailyTimestamp` / `PerSampleRow`**: correctly IMPORTED from `drain-per-sample-cumulative.ts`, not re-implemented. Good reuse (the only leakage is H-2's two bucket/sum copies).
- **`Europe/Berlin` tz fallback**: repeated literal in both consolidation + drain, but it's a one-liner default already established project-wide; not worth a shared const for two sites.
@@ -0,0 +1,265 @@
# I-1 — Inventory of features lost in v1.5.4 `medication-form.tsx` + `phase-config-dialog.tsx` deletion
Reference commit: `105fcee2` — "refactor(meds): route /medications/new and the edit trigger through the new dialog" (Thu 2026-05-28).
Files deleted: `src/components/medications/medication-form.tsx` (1327 LOC) + `src/components/medications/phase-config-dialog.tsx` (268 LOC).
Snapshot inspected: parent commit `105fcee2^` — the moment before deletion.
The v1.5.4 cut routed both the "+ Neu" button and the per-card "Bearbeiten" trigger through `MedicationWizardDialog`. The wizard owns the field-by-field create / edit form but does NOT surface the kebab menu the flat form carried. Every action that lived behind that kebab is now unreachable from the UI even though the underlying API routes are still live and the list page still imports `IntakeImportDialog` + `ApiEndpointDialog` (with the trigger paths cut).
Marc's verbatim demands (anchor for the v1.5.5 redesign):
- „Einnahmen löschen"
- „Einnahmen bearbeiten"
- „Die ganzen API Roots"
## 1. TL;DR — every lost action
| # | German label (Marc's voice) | Old trigger | API | Detail-page home (v1.5.5) |
|---|---|---|---|---|
| 1 | Bearbeiten | Sheet kebab → field grid + Save | `PUT /api/medications/[id]` | "Stammdaten" section, primary action in header |
| 2 | Löschen | Sheet kebab → AlertDialog | `DELETE /api/medications/[id]` | "Gefahrenzone" footer section |
| 3 | Pausieren / Aktivieren | Sheet kebab toggle (writes via `active` flag on PUT) | `PUT /api/medications/[id]` (`active` field) | "Status" card in header, switch + chip |
| 4 | Benachrichtigungen aus / ein | Sheet kebab toggle (writes via PUT) | `PUT /api/medications/[id]` (`notificationsEnabled`) | "Erinnerungen" section, switch + Quiet-hours preview |
| 5 | Einnahmen importieren | Sheet kebab → mounts `IntakeImportDialog` | `POST /api/medications/[id]/intake/import` | "Einnahmen" section header, secondary action |
| 6 | API-Endpunkt | Sheet kebab → mounts `ApiEndpointDialog` | `GET/POST /api/medications/[id]/api-endpoint` + `tokens` | "API-Zugriff" section, full block |
| 7 | Titrations-Phasen konfigurieren | Sheet kebab → `PhaseConfigDialog` modal | `GET/PUT/DELETE /api/medications/[id]/phase-config` | "GLP-1 Phasen" sub-section (visible only for GLP1 + Course-Window-Mode) |
| 8 | Aufzeichnungen löschen ("Purge") | Sheet kebab destructive item → AlertDialog | `DELETE /api/medications/[id]/intake/purge` | "Gefahrenzone" footer, above "Löschen" |
| 9 | Zurücksetzen (Formular leeren, create only) | Sheet kebab item | client-side only | n/a (not on detail page; lives on wizard) |
| 10 | GLP-1 Wochentags-Preset | Form body, day-button row | client-side write into schedule | "Cadence" section on detail page; preset row visible for `treatmentClass === GLP1` |
| 11 | Schedule hinzufügen / entfernen | Form body, per-schedule kebab | client-side schedule array | "Cadence" section, add/remove rows |
| 12 | Legacy reminder window (windowStart/windowEnd) Fallback | Form body, visible only on `legacyOnLoad` | client-side | "Cadence" section, fallback block (defensive) |
| 13 | One-Shot Switch | Form body switch | client-side; PUT `oneShot:true` | "Cadence" header, switch + warning |
| 14 | Course-Window-Picker (startsOn / endsOn) | Form body | PUT body `startsOn`/`endsOn` | "Cadence" → "Kurs-Fenster" sub-section |
| 15 | Einnahmen bearbeiten (per-dose) | `IntakeHistoryListV2` row kebab | `PUT /api/medications/[id]/intake/[eventId]` | "Einnahmen" row kebab — embed list on detail page |
| 16 | Einnahmen löschen (per-dose) | same | `DELETE /api/medications/[id]/intake/[eventId]` | same |
(15 + 16 were not deleted in v1.5.4 — they live on `IntakeHistoryListV2`, which is currently only mounted on `/medications/[id]/history`. Marc's „Einnahmen bearbeiten / löschen" demand still applies because the detail page is the new home and the history list should be embedded there.)
## 2. Per-action deep-dive
### Bearbeiten (PUT medication)
- **Original location.** Sheet body (every form field) + Save button in the portalled footer slot.
- **Trigger.** `<Button type="submit" form={formId}>` in the footer; the kebab was decorative for the destructive / settings rail.
- **API.** `PUT /api/medications/${initial.id}` — body excerpt:
```ts
body: JSON.stringify({
name, dose, category, treatmentClass,
...(dosesPerUnit ? { dosesPerUnit: Number(dosesPerUnit) } : isEdit ? { dosesPerUnit: null } : {}),
...(isEdit ? {
active,
...(notificationsEnabled !== (initial?.notificationsEnabled ?? true) && {
notificationsEnabled,
}),
} : {}),
...(startsOn !== null && { startsOn: courseStartsOnIso }),
...(endsOn !== null && { endsOn: courseEndsOnIso }),
...(oneShot && { oneShot: true }),
schedules: serialisedSchedules,
}),
```
- **Invalidates.** `await invalidateKeys(queryClient, medicationDependentKeys)` — `medications()` + `analytics()` + `insightsRoot()` + `insightsTargets()` + `gamificationAchievements()`.
- **Toast.** `toast.success(t("common.saved"))`.
- **Auth.** Cookie session, scope `requireAuth()`. Per-medication ownership enforced server-side.
- **Status.** Calls `onSuccess?.()` → list-page `closeDialog()`.
- **v1.5.5 home.** Detail page hero — "Stammdaten" section (name + dose + category + treatment class + course window). Use inline edit + Save bar that sticks to the bottom on small screens, matches the existing Quiet-hours pattern in Settings.
### Löschen (medication)
- **Original location.** Footer kebab → AlertDialog confirmation.
- **API.** `DELETE /api/medications/${initial.id}`.
- **Invalidates.** Same `medicationDependentKeys` set.
- **Toast.** No success toast (only sets error on failure); list refresh is the visual confirmation.
- **Confirmation.** Two-step: kebab item opens `<AlertDialog>` with `medications.deleteConfirm` title + description, destructive variant action button.
- **Auth.** Cookie session only.
- **Status.** Calls `onSuccess?.()` → closes sheet, list refreshes.
- **v1.5.5 home.** Detail page "Gefahrenzone" footer card, red outline, requires confirmation. Below "Aufzeichnungen löschen" (purge).
Snippet — handler:
```ts
async function handleDelete() {
if (!initial) return;
setError(null); setDeleting(true);
try {
const res = await fetch(`/api/medications/${initial.id}`, { method: "DELETE" });
const json = await res.json().catch(() => null);
if (!res.ok) { setError(json?.error ?? t("medications.deleteError")); return; }
await invalidateKeys(queryClient, medicationDependentKeys);
onSuccess?.();
} catch { setError(t("medications.deleteError")); }
finally { setDeleting(false); }
}
```
### Pausieren / Aktivieren
- Sheet kebab; icon flips between `<Pause />` and `<Play />`.
- `setActive((prev) => !prev)` — local state, rides the next Save; folds into `PUT /api/medications/[id]` body via `active: <bool>`.
- No dedicated toast.
- **v1.5.5 home.** "Status" card in the detail-page header — `<Switch>` next to a Pausiert / Aktiv chip. Wire a direct `PUT /api/medications/[id]` with `{ active }` so the toggle is instant; mutate the medication query cache optimistically.
### Benachrichtigungen aus / ein
- Sheet kebab toggle; emits `notificationsEnabled` on the PUT body when local state diverges from the initial value. Server cascades to `MedicationReminder` row state (v1.4.49 disable-cascade invariant test pins the contract).
- **v1.5.5 home.** "Erinnerungen" section — Switch + Quiet-hours preview + per-channel chip (APNs / Telegram / ntfy / Web-Push). Inline note for the v1.4.49 `clientManaged` opt-in for iOS users.
### Einnahmen importieren
- Sheet kebab → `editActions.onImportIntakes()` → list-page state `setImportMedId(id)` → mounts `IntakeImportDialog`.
- `POST /api/medications/${id}/intake/import` (CSV / JSON paste). Toast + error banner internal to the dialog. Cookie session.
- **v1.5.5 home.** "Einnahmen" section header — small "Importieren" outline button next to "+ Neue Einnahme".
### API-Endpunkt
- Sheet kebab → `editActions.onApiAccess()` → list-page state `setApiMed(...)` → mounts `ApiEndpointDialog`.
- `GET /api/medications/${id}/api-endpoint` for status + `POST` for token mint; underlying table is `tokens` (HMAC-SHA256, narrow per-medication scope per `tokens.create`). Cookie session for the management surface; the issued Bearer is per-medication-scoped.
- **v1.5.5 home.** "API-Zugriff" section — full block: endpoint URL, token list, "Token erzeugen" button, copy-to-clipboard helpers, scoping note („kann nur Einnahmen für dieses Medikament melden"), revoke action. This is „Die ganzen API Roots" Marc called out.
### Titrations-Phasen konfigurieren (GLP-1)
- Sheet kebab → `setPhaseConfigOpen(true)` → `<PhaseConfigDialog medicationId={initial.id} … />` modal.
- `GET /api/medications/${id}/phase-config` (load), `PUT` (save), `DELETE` (reset to defaults). Invalidates `queryKeys.medicationPhaseConfig(id)`. Inline status banner via `setStatusMessage(...)` auto-clears after 2 s (NOT sonner). Cookie session.
- **v1.5.5 home.** "GLP-1 Phasen" sub-section — only visible when `treatmentClass === "GLP1"` AND a course window is set. Inline grid (green / yellow / orange / red rows), no nested dialog. Restore the "Auf Standard zurücksetzen" action as an outline button at the section foot. See §4.
### Aufzeichnungen löschen (Purge intake history)
- **Original location.** Sheet kebab → destructive item → `<AlertDialog>` confirmation.
- **API.** `DELETE /api/medications/${initial.id}/intake/purge`.
- **Invalidates.** `medicationDependentKeys`.
- **Toast.** No success toast; the dialog closes and the list updates.
- **Auth.** Cookie session.
- **Status.** Closes the alert dialog. Keeps the medication row; only drops the linked intake history.
- **v1.5.5 home.** "Gefahrenzone" footer card on the detail page, above the medication delete. Same two-step confirmation pattern, different copy ("Nur die Einnahmen löschen, das Medikament selbst behalten").
Snippet:
```ts
async function handlePurge() {
if (!initial) return;
setPurging(true);
try {
const res = await fetch(`/api/medications/${initial.id}/intake/purge`, { method: "DELETE" });
const json = await res.json().catch(() => null);
if (!res.ok) { setError(json?.error ?? t("medications.purgeError")); return; }
await invalidateKeys(queryClient, medicationDependentKeys);
setPurgeDialogOpen(false);
} finally { setPurging(false); }
}
```
### Zurücksetzen (form reset, create-only)
- **Original location.** Sheet kebab when `isEdit === false`. Resets every controlled input back to defaults.
- **API.** Client-side only.
- **v1.5.5 home.** Not relevant on the detail page; lives on the wizard (currently retired — separate decision whether the wizard needs it back).
### GLP-1 Wochentags-Preset
- Form body when `treatmentClass === "GLP1"`. Day-button row (Sun…Sat); selecting a day writes `cadence.kind = "weekdays"` with the single BYDAY token + persists the choice to localStorage (`medication-form:last-weekly-weekday`). Below the row, a "Hattest du letztes Mal…" suggestion link applies the last-used weekday.
- **v1.5.5 home.** "Cadence" section, visible only for GLP1 + recurring. Restore the localStorage anchor.
### Schedule hinzufügen / entfernen
- Per-schedule kebab on each schedule card + "+ Neuer Zeitplan" link. Folds into PUT body `schedules: [...]`.
- **v1.5.5 home.** "Cadence" section on the detail page. The wizard collapses to a single schedule; the detail page should support the multi-schedule edit path the flat form did (this is the compose-mode the commit message defers — currently lost).
### Legacy reminder window fallback
- Visible only when `schedule.legacyOnLoad === true` (no v1.5 fields populated). Surfaces `windowStart` / `windowEnd` time inputs as a defensive fallback during the v1.5.x migration window. Feeds the dual-write derivation `deriveLegacyWindow(...)`.
- **v1.5.5 home.** "Cadence" section, conditional render. Same defensive intent.
### One-Shot Switch
- Inside the "Kurs-Fenster" fieldset. Flipping it on collapses to a single schedule with one `timesOfDay` entry and warns via `toast.warning(t("medications.scheduling.oneShot.collapseWarning"))` when this would drop additional schedules. PUT body emits `oneShot: true` only when true.
- **v1.5.5 home.** "Cadence" header — same Switch with the same warning toast on collapse.
### Course-Window-Picker (startsOn / endsOn)
- Uses `<CourseWindowRow startsOn={…} endsOn={…} lockEndsToStart={oneShot} onChange={...} />`. PUT body `startsOn` + `endsOn` are ISO date-only, encoded by `toIsoDateOnly()` from UTC components (avoids the cross-timezone drift `toISOString().slice(0, 10)` introduces east of UTC).
- **v1.5.5 home.** "Cadence" → "Kurs-Fenster" sub-section. The component still exists; reuse.
## 3. Hooks + utility code the form depended on
Every item below still exists in the tree — none of them fell with the form. The detail-page redesign can lean on them without porting work.
| Helper | Path | Status |
|---|---|---|
| `invalidateKeys(...)` + `medicationDependentKeys` | `src/lib/query-keys.ts:349` | Lives. Use unchanged. |
| `queryKeys.medicationPhaseConfig(id)` | `src/lib/query-keys.ts:131` | Lives. Reuse for the inline GLP-1 section. |
| `CadencePicker` + `TimesOfDayChips` + `CourseWindowRow` | `src/components/medications/scheduling/*` | Lives. Used by the wizard; can be embedded on the detail page too. |
| `inferCadenceFromLegacy` + `legacyPairFromCadence` | `src/components/medications/scheduling/legacy-bridge.ts` | Lives. Required for the `legacyOnLoad` fallback. |
| `parseScheduleRecurrence` | `src/lib/medication-schedule.ts` | Lives. List page still uses it to hydrate the wizard. |
| `IntakeImportDialog` (inline class on list page) | `src/app/medications/page.tsx:344` | Lives but ORPHANED — no trigger reaches it. The detail page is the new home. |
| `ApiEndpointDialog` (inline class on list page) | `src/app/medications/page.tsx:559` | Lives but ORPHANED — same. |
| `IntakeHistoryListV2` | `src/components/medications/intake-history-list-v2.tsx` | Lives. Currently only mounted on `/medications/[id]/history`. Embed on detail page. |
| `ResponsiveSheet` | `src/components/ui/responsive-sheet.tsx` | Lives. The dialog/sheet split the form used. The detail page is a full route, so this is only relevant for nested confirmation flows. |
| Sonner `toast.success` / `toast.warning` | dependency | Lives. Use for Save / one-shot-collapse warning. |
| `<AlertDialog>` from shadcn | dependency | Lives. Use for purge + delete confirmation. |
| `useTranslations()` + `t()` keys under `medications.*` | `src/lib/i18n/context.tsx` + `messages/*` | Lives. Every key listed in the snippets above resolves today (the i18n-call-site-coverage test would have failed otherwise on v1.5.4). |
## 4. PhaseConfigDialog — full surface
The 268-LOC component was a `ResponsiveSheet` over a 4-row form. It was reachable from exactly one place: the medication-form kebab "Phasen konfigurieren" item. With both deleted, the per-phase configuration is now unreachable from the UI even though the API + query key + i18n strings are all still live.
**Props.**
```ts
interface PhaseConfigDialogProps {
medicationId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Internal state model.**
```ts
interface PhaseConfigData {
greenValue: number; greenMode: "MINUTES" | "PERCENT";
yellowValue: number; yellowMode: "MINUTES" | "PERCENT";
orangeValue: number; orangeMode: "MINUTES" | "PERCENT";
redValue: number; redMode: "MINUTES" | "PERCENT";
}
```
Defaults: green 60 min before end / yellow 30 min before end / orange 0 min after end / red 240 min after end (every mode = MINUTES).
**Read.** `useQuery({ queryKey: queryKeys.medicationPhaseConfig(medicationId), queryFn: () => fetch('/api/medications/${id}/phase-config').then(r => r.json()).data })` — fires only when `open === true`.
**Mutations.**
- `saveMutation` — `PUT /api/medications/${medicationId}/phase-config` with `JSON.stringify(config)`. On success: clears local form, invalidates `medicationPhaseConfig(id)`, shows inline status banner `t("medications.phaseSaved")` for 2 s.
- `resetMutation` — `DELETE /api/medications/${medicationId}/phase-config`. On success: same cache invalidate + status banner `t("medications.phaseReset")`.
**Actions surfaced.**
- "Auf Standard zurücksetzen" — outline button bottom-left (calls `resetMutation`).
- "Abbrechen" — outline button bottom-right (calls `onOpenChange(false)`).
- "Speichern" — primary button (calls `saveMutation`).
- Per-row: numeric input (0…1440) + MINUTES/PERCENT toggle + suffix caption ("vor Kursende" / "nach Kursende"). Wraps under `sm:` for Galaxy Fold widths.
**Where it should land on v1.5.5.** Inline section on the detail page titled "GLP-1 Phasen". No nested modal. Rows render flat under "Kurs-Fenster". The save / reset actions become the section footer; status banner becomes a sonner toast.
**Auth.** Cookie session, owner-only. Server route at `src/app/api/medications/[id]/phase-config/route.ts` has GET / PUT / DELETE handlers under `apiHandler(...)`.
**TitrationSection cross-check.** `src/components/medications/TitrationSection.tsx` does NOT reference `PhaseConfigDialog`. It hits `/api/medications/${medicationId}/titration` only — a separate route that reads the GLP-1 ladder catalog. The section is therefore intact and functional today; it just shows a different concern (which titration step the user is on) than `PhaseConfigDialog` did (when the green/yellow/orange/red bands fall around the dose window). Both have a home in v1.5.5: TitrationSection embeds as-is, the GLP-1 Phases section replaces the dialog.
## 5. Critical gaps Marc cannot work around today
Three actions have NO accessible surface in v1.5.4 — they are fully orphaned:
1. **Einnahmen löschen / bearbeiten (per-dose).** `IntakeHistoryListV2` lives only on `/medications/[id]/history`. The list page does not embed it, the wizard does not surface it. The only path is a manual URL navigation.
2. **Die ganzen API Roots (per-medication API token + endpoint).** The `ApiEndpointDialog` is mounted on the list page but no trigger calls `setApiMed(...)`. The kebab that called it died with the form.
3. **Aufzeichnungen löschen + Medikament löschen.** Both AlertDialog flows died with the form. The list page has no row-level "Löschen" action and the wizard's footer is Save/Cancel only — the only way to delete a medication today is the API.
Secondary gaps (workarounds exist but are awkward):
- **Pause / Activate.** Can be flipped via Bearbeiten → wizard, but the wizard step that exposes `active` is inside the multi-step flow rather than a one-tap toggle.
- **Notifications enable/disable per medication.** Same as Pause — buried in the wizard.
- **GLP-1 Phasen konfigurieren.** No UI path; only `PUT /api/medications/[id]/phase-config` directly.
- **Einnahmen importieren.** `IntakeImportDialog` orphaned identically to `ApiEndpointDialog`.
Total inventory: **16 distinct actions** lost or rendered unreachable. The detail page redesign needs to land every one of items 1-14 in §1 to restore parity; items 15 + 16 are existing-component-relocations rather than rebuilds.
@@ -0,0 +1,89 @@
# R-2 — Detail-page patterns across the leading medication trackers
External research informing the v1.5.5 detail-page rebuild. Walks six reference apps, then synthesises an architecture verdict for `/medications/[id]/page.tsx`. Citations point at official Apple documentation, App Store listings, design-press, German-language user guides, and patient-forum threads where the user-side complaint carries the signal.
## 1. Apple Health — Medications, individual detail page (iOS 17, iOS 18+)
The reference. Apple's official guide pins the scroll order: drug name + dose at the top (no status badge — active is the absence of archived), a **Log** strip with day-of-week circles, then **Details** (icon, nickname, notes) and at the bottom **Options** where destructive controls live ([Apple Support, "Add and log medications"](https://support.apple.com/en-us/105064); [Apple Support guide](https://support.apple.com/guide/iphone/track-your-medications-iph811670c81/ios)). The schedule editor opens behind a **Reset Schedule** label rather than inline — Apple gates the destructive shape of a schedule rewrite explicitly. iOS 18 added **On a Cyclical Schedule** and **On Specific Days of the Week**, surfaced as dedicated rows inside the schedule editor — explicit acknowledgement that flexible-rhythm cases (HRT, GLP-1, cycled steroids) deserve top-level layout.
Intake history is a frequency rollup drillable to day / week / monthly / half-year / year ([TidBITS overview](https://tidbits.com/2022/10/07/an-apple-a-day-ios-16-medications-feature-provides-alerts-logging-and-peace-of-mind/)). Per-event editing is buried; patients on Apple Discussions complain "with iOS 17 the delete function has disappeared" and "the official instructions do not match what I see on my phone" ([discussions.apple.com/thread/254888869](https://discussions.apple.com/thread/254888869)). The split between **Archive** (swipe-left, preserves history, no future reminders) and **Delete Medication** (purges everything, bottom of Options) is the cleanest in the category — but discoverability is so poor that "where is delete?" dominates the forum signal. Notifications are a global Medications-section toggle, not per-drug; there is no per-medication pause; titration is implicit through the cyclical-schedule editor. Visual rhythm is pure settings-app vernacular: grouped-inset cards, subtle separators, no per-drug accent (the user-chosen icon carries identity), 17 pt body / 22 pt headline / SF Pro Rounded for section labels.
## 2. MyTherapy — German-origin, condition-module, patient-focused
MyTherapy's detail page is a single scrolling form: drug name + dose, then **Einnahmezeiten** as a stack of time rows (each with per-time settings), per-weekday adjustments, **Erinnerungston**, **Vorratsverwaltung** ([meine-gesundheitshelfer.online walk-through](https://www.meine-gesundheitshelfer.online/mytherapy-medikamente-eingeben/)). Save is **Sichern**, top-right. Intake history isn't on the detail page at all — it lives in a sibling **Tagebuch → Meine Werte → Medikamente** screen, status glyphs ("grauer Haken" = taken, "rotes Kreuz" = skipped/pending), tap a day to open an edit sheet ([Nacherfassen walk-through](https://www.meine-gesundheitshelfer.online/medikamente-nacherfassen-in-mytherapy/)). A parallel **Logbuch** opens from a clock glyph on **Heute** with **erledigt**, **übersprungen**, **automatisch übersprungen**. The split — settings on the detail page, history in a separate diary surface — is the source of the dominant user-voice thread; a representative request describes "medications such as blood pressure, blood thinners, anticonvulsants (and others) that are periodically held back" and asks for a pause primitive the current detail surface doesn't expose ([mytherapy.uservoice.com #32753707](https://mytherapy.uservoice.com/forums/304831-collection-of-ideas-for-mytherapy/suggestions/32753707-pause-all-medications)). Notifications are per-time, not per-medication; pause exists only as "skip today" inside a reminder notification; titration is not modelled. Visual rhythm is dense form-row vernacular, white background, no card grouping — closer to a settings list than a hero surface.
## 3. Medisafe — US market leader, dense surface
Medisafe's detail page is reached from the bottom-bar **Medications** tab; tapping the row opens a read view, **a pencil icon top-right** enters edit mode ([Tech-Enhanced Life walk-through](https://www.techenhancedlife.com/citizen-research/how-use-medisafe-app)). The surface stacks pill image + name + dose, then frequency, refill thresholds, **Instructions** notes, and a doctor-described "what does this medication do" panel. Intake history is not the headline — the daily pillbox metaphor (morning / afternoon / evening / night) carries the history affordance from the home screen; the detail page surfaces only the most recent intakes. Delete lives at the bottom of edit mode ("look for the 'delete' choice at the bottom of the screen"). Pause is absent as a labelled control — users either skip from the reminder or delete and re-add. Notifications are per-medication. Visual rhythm is pillbox-iconography heavy, four-quadrant colour wheel, drug-photo thumbnails — bright, friendly, comparatively cluttered next to Apple Health.
## 4. Round Health — design-press darling, restrained typography
Round Health's signature primitive is the **reminder window** — a range of time around a target dose, with notifications at the beginning, middle, and end of the window ([iMedicalApps review](https://www.imedicalapps.com/2016/11/round-health-app-personal-assistant-pill-taking/)). The detail page stacks drug name + nickname, the window as a visual band, frequency type (**scheduled** / **as-needed**), stock count, notes. Edit and delete are mode-switched rather than always-visible. Intake is logged from the notification (swipe left) or from a circle on the detail card (tap to fill). Round trades feature breadth for restraint — users praise the "beautiful and satisfying" interface and the "subtle, persistent reminders [that] go beyond awkward phone alarms" ([SteadyHealth](https://www.steadyhealth.com/review/round-health-app-a-simple-medication-reminder-and-tracker-for-iphone-and-apple-watch); [AppGrooves reviews](https://appgrooves.com/ios/1059591124/round-health/circadian-design-inc)). Visual rhythm: large drug-name typography, generous white space, single muted accent per medication (colour swatch picked on creation), no chrome-heavy cards. Titration absent; pause absent.
## 5. Dosecast — rolling-cadence-native
Dosecast's detail page is built around schedule flexibility: the top exposes **daily/weekly/monthly**, **every X days/weeks**, and the distinguishing **"X hours/days since last dose"** rolling-cadence mode ([Dosecast App Store](https://apps.apple.com/us/app/dosecast-my-pill-reminder-app/id365191644); [Montuno features](https://www.montunosoftware.com/features/)). A reviewer captures the why: "the only medication reminder that automatically adjusts your next reminder based on the amount of hours intervals … every 4 hours after last dosage taken." Below the schedule the detail page lists **drug name, type, dosage, strength, route**, then **doctor + pharmacy + prescription number** as linkable rows, then **maximum doses per day** as an explicit overdose-guard, then expiration, then photo. History lives in a separate **Dose History** screen (PRO tier), exportable as email. Delete sits in edit mode. Notifications are per-medication; pause exists as a "Postpone" action on the reminder. Visual rhythm is dense table-row vernacular — Dosecast prioritises information density over hero typography. The rolling-cadence affordance is the single most copied pattern in the category.
## 6. Shotsy + Glapp — GLP-1, titration-native
Both apps build the detail page around the titration arc. Shotsy's surface stacks **Medication Name + Dosage Strength**, then a **"Preview Estimated Levels"** chart that morphs as the dose changes, then **Shot History** filtered by medication ([Shotsy App Store](https://apps.apple.com/us/app/shotsy-glp-1-tracker/id6499510249); [Shotsy site](https://shotsyapp.com/)). Titration is first-class: an "advanced scheduling system [that] allows you to set more precise schedules for your GLP-1 medications, including separate schedules, countdowns, and reminders for multiple concurrent medication types" — each dose step is its own scheduled entity. Edit/delete is bulk: **Show all records** inside **Manage My Data**, supporting bulk selection — a structural concession to chronic-care users who accumulate weeks of identical-shape records. Glapp's detail surface highlights "live drug cycle and medication levels", a visualised **drug cycle after each shot**, and the ladder ("2.5 mg → 5 mg → 7.5 mg → higher doses") rendered as a stepper rather than free-text ([Glapp site](https://glapp.io/); [GLP1 Effect](https://glp1effect.com/p/are-glp-1-app-medication-charts-reliable)). Both treat the medication-level chart — not the intake list — as visual hero. The lesson for HealthLog: for any course-window or titration-shaped medication, the **dose ladder** is the headline; the intake list is supporting context.
---
## Synthesis
### A. Detail-page architecture verdict
Section order for `/medications/[id]/page.tsx` — argued from the evidence above, optimised for HealthLog's chronic-care + titration-aware reality:
1. **Header band** — drug name + current-dose + status pill (`Aktiv` / `Pausiert` / `Beendet`) + small icon. Single accent colour per medication (user-picked on creation, mirrors Round Health). Pure typographic hierarchy, no chrome.
2. **Today's dose card** — one tap to log `Genommen` / `Übersprungen` / `Verschoben`. Mirrors Apple Health's pinned-today affordance; iOS muscle memory.
3. **Cadence summary** — single readable sentence ("Täglich um 08:00 + 20:00", "Wöchentlich, mittwochs", "Alle 12 h nach letzter Einnahme") with a **Bearbeiten** affordance opening the wizard. Dosecast proves this single-line summary works; Apple Health proves opening the editor as a dedicated surface protects against accidental rewrites.
4. **Dose ladder / phase configuration** — only rendered when the medication carries a titration plan or course window. Stepper visualisation (Shotsy / Glapp pattern), highlighting the current step. Hidden cleanly when not relevant — avoids the "advanced" trap MyTherapy falls into.
5. **Intake history** — preview of the last 7–14 entries, **Alle Einnahmen anzeigen** affordance opening a dedicated history surface for weeks/months. See section B.
6. **Notifications** — per-medication switch + reminder-window controls. Apple Health and MyTherapy don't surface this per drug; that's the gap HealthLog closes.
7. **Settings** — pause/resume, API-token linkage, external-logger integration, advanced cadence overrides. See section C.
8. **Destructive zone** — `Pausieren` (reversible), `Beenden` (preserves history, no future reminders — Apple's archive equivalent), `Löschen` (purges history; destructive confirmation). See section D.
This sequence puts daily actions (log today, see recent history) at the top of the scroll and rare ones (delete, advanced settings) at the bottom. Apple's Archive / Delete split is the model — but the Apple Discussions "where is delete?" complaints point at discoverability, not the split itself. Keep the split, label the destructive zone explicitly so it is findable on first scroll.
### B. Intake history pattern
Preview-then-expand. The detail page shows the last 7–14 events as a grouped list — **Heute**, **Diese Woche**, **Älter** — each row carrying timestamp, dose-as-taken, and a quiet status glyph (`Genommen` / `Übersprungen` / `Verschoben`). Each row swipes-to-reveal `Bearbeiten` and `Löschen` (iOS-native gesture; matches Apple Health's swipe-archive and MyTherapy's tap-day-to-edit). An **Alle Einnahmen anzeigen** affordance opens a dedicated history surface with month-grouped grouping, search by date, and bulk delete (Shotsy's `Show all records` pattern, justified by the chronic-care reality of accumulating weeks of identical-shape records). Empty state: `Noch keine Einnahmen erfasst.` paired with a single affordance to log the first — typography, not illustration. The verbatim DE labels: `Einnahmen`, `Heute`, `Diese Woche`, `Älter`, `Bearbeiten`, `Löschen`, `Alle Einnahmen anzeigen`, `Genommen`, `Übersprungen`, `Verschoben`, `Noch keine Einnahmen erfasst.`
### C. Settings consolidation
Dedicated `<SettingsSection>` on the detail page, **not** a separate Sheet/Drawer. Three reasons. First, Apple Health's official guide keeps `Details` and `Options` inline on the detail page — no separate sheet ([Apple Support](https://support.apple.com/guide/iphone/track-your-medications-iph811670c81/ios)). Second, Nielsen Norman is explicit: "a bottom sheet is a transient UI element that is not intended to be a stable place for users to return to" ([NN/G bottom sheets](https://www.nngroup.com/articles/bottom-sheet/)). Per-medication settings — notifications, pause, API token, phase config — are exactly the kind of surface a chronic-care user does return to. Third, the v1.5.4 narrow-asymmetric report — pushing settings into a sheet repeats that mistake. Inline keeps the scroll continuous, lets every control breathe at the same width as the cadence + history sections. Round Health and Dosecast keep settings inline; MyTherapy and Medisafe lean on edit-mode-on-the-detail-page rather than a sheet. Category consensus is inline.
API tokens and external-logger integration are HealthLog-specific. Place them as a labelled subsection inside `<SettingsSection>` titled `Externe Integration` — one row per token, masked, copy + revoke affordances. Marc's "ganze API Roots" complaint is solved by giving this section heading the same typographic weight as `Erinnerungen`, so it stops feeling like an afterthought.
### D. Pause / delete destructive flow
Apple Health's three-tier split is the strongest in the category. MyTherapy collapses pause into "skip today" — the user-voice forum makes clear that's the wrong abstraction. Medisafe lacks pause. Three-tier model for HealthLog:
- **`Pausieren`** — reversible, suppresses future reminders, preserves history, shows a `Pausiert` pill in the header. Tap to resume. No confirmation dialog — pause is fully reversible. Closes MyTherapy's user-voice gap.
- **`Beenden`** — Apple's Archive equivalent. Stops future reminders, preserves history, removes from the active list, surfaces under a `Beendete Medikamente` filter. Single-step confirmation (`Wirklich beenden?`).
- **`Löschen`** — purges everything including intake history. Two-step confirmation with the medication name typed back as a guard, matching the Apple Discussions-forum pattern of users accidentally deleting and losing weeks of compliance data ([discussions.apple.com](https://discussions.apple.com/thread/254888869)).
No undo banner. The audit log is the system-of-record; an undo affordance would imply soft-delete semantics medications don't honour.
### E. Visual rhythm + dialog polish
The v1.5.4 narrow-asymmetric report has direct evidence. iOS 26 design-press confirms two shifts. **Left-aligned text replaces centre-aligned** in alerts, onboarding, and sheets — "left-aligned text is easier to read, especially for longer paragraphs" ([learnui.design](https://www.learnui.design/blog/ios-design-guidelines-templates.html)). **Concentric corner radii** — a nested element's radius subtracts the padding from its parent, "which makes it easy to get the perfect corner radius for things like a button within a card" ([same source](https://www.learnui.design/blog/ios-design-guidelines-templates.html)). Linear adds: "not every element of the interface should carry equal visual weight — navigation should recede while task-critical areas remain prominent" ([linear.app refresh](https://linear.app/now/behind-the-latest-design-refresh)).
Token direction for the design-direction doc:
- **Dialog width**: `min(560px, calc(100vw - 32px))` on desktop, full-bleed on mobile. The v1.5.4 dialog was tighter and read as cramped; 560 px lets the cadence-summary line breathe.
- **Section padding**: 24 px outer, 20 px inner — concentric.
- **Section gap**: 32 px between top-level sections so the scroll reads as a document, not a stack of cards.
- **Card corner radius**: 16 px outer, 12 px inner — concentric, matches iOS 26 default.
- **Font cascade**: 28 pt / 700 drug name, 17 pt / 600 section headers, 15 pt / 400 body, 13 pt / 500 labels + timestamps, SF Pro Text / Inter fallback. Left-aligned throughout.
- **Single accent colour per medication** — user picks on creation, drives only the status pill and today-card border. Surface stays neutral grey + foreground; no category-coloured sections. Round Health's restraint over Medisafe's colour wheel.
Nielsen Norman's iOS 26 critique is blunt: "anything placed on top of something else becomes harder to see" ([nngroup.com on Liquid Glass](https://www.nngroup.com/articles/liquid-glass/)). HealthLog stays on opaque surfaces; the dignity comes from typography and spacing, not material effects.
### F. Status-bar morph animation
Pin to **Material 3 Expressive's shape-morph pattern**, not iOS 26's Liquid Glass. Three reasons. M3E's looping shape-morph "captures attention through fluid motion, morphing between a series of shapes" and was designed for the kind of multi-step progress narrative a course-window or titration ladder represents ([9to5Google M3E loading](https://9to5google.com/2025/05/16/material-3-expressive-loading-indicator/); [supercharge.design](https://supercharge.design/blog/material-3-expressive)). Google's research backed it as "more usable" across all age groups. iOS 26's Liquid Glass, by contrast, is reserved for the navigation layer — Apple's own guidance: "Liquid Glass is best reserved for the navigation layer that floats above the content of your app" ([developer.apple.com](https://developer.apple.com/documentation/SwiftUI/Applying-Liquid-Glass-to-custom-views)). A progress indicator sits in the content layer; using Liquid Glass there misuses the material. Nielsen Norman's critique — "motion for motion's sake is not usability. It's distraction with a side of nausea" — is aimed at gratuitous motion, but explicitly endorses motion that communicates state-change. A shape-morph advancing on intake or phase carries genuine state-change semantics.
Concrete direction: a horizontal progress band at the top of the detail page (under the header) that fills as the course window or titration ladder advances. The shape morphs at each step boundary — circle at step 0, rounded square at midpoint, elongated capsule at completion — using a Framer Motion `spring` with damping 25–30 (responsive, not bouncy). Stroke in subtle accent, fill neutral. For one-shot medications without a course window the band collapses to a thin line with an "Aktiv seit DD.MM." caption — same component, dignified rest state. M3E's seven-shape sequence is the upper bound on visual ambition; HealthLog needs three shapes because the state space is discrete and small.
Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

@@ -0,0 +1,143 @@
# Admin area audit — 2026-05-29
Read-only audit of the HealthLog admin surface. No files edited.
## Admin surface inventory
Routing: `/admin` (overview) + dynamic `/admin/[section]` driven by
`ADMIN_SECTION_SLUGS` (`src/components/admin/section-slugs.ts`). The shell
(`src/components/admin/admin-shell.tsx`) renders sidebar + mobile strip; the
renderer (`src/app/admin/[section]/renderer.tsx`) maps each slug to a section
component. Nav order is the slug-array order.
| # | Slug | Component | What it controls | Backing API |
|---|------|-----------|------------------|-------------|
| — | `/admin` | `system-status-summary` + `recent-audit-preview` | Version/db/worker snapshot + last 10 auth audit rows | `/api/admin/status`, `/api/version`, `/api/admin/audit-log?filter=auth` |
| 1 | `system-status` | `SystemStatusSection` + `HostMetricsChart` | Host load chart (load1/mem/disk), db/worker/counts/integration status, offline-geo | `/api/admin/status`, `/api/admin/host-metrics`, `/api/version` |
| 2 | `general` | `GeneralSettingsSection` | registrationEnabled, defaultLocale, defaultUserTimezone | `/api/admin/settings` |
| 3 | `services` | `ServicesSection` | Global on/off: Telegram, ntfy, webPush, API | `/api/admin/settings` |
| 4 | `integrations` | `IntegrationsGroupSection` (Umami+GlitchTip+WebPushVapid+BugReport) | Analytics/error-monitoring/VAPID keys/bug-report repo + test buttons | `/api/admin/settings`, `/api/admin/monitoring/{umami,glitchtip}-test` |
| 5 | `ai-quality` | `AiQualitySection` | Read-only helpful-rate table per severity×provider×prompt | `/api/admin/ai-quality` |
| 6 | `assistant` | `AssistantSection` | Master + 5 sub feature flags | `/api/admin/settings/assistant-flags` |
| 7 | `coach-feedback` | `CoachFeedbackSection` | Read-only Coach helpful-rate table | `/api/admin/ai-quality` (coachBuckets slice) |
| 8 | `feedback` | `FeedbackInboxSection` | In-app feedback triage + GitHub issue link | `/api/admin/feedback*` |
| 9 | `reminders` | `RemindersSection` | late/missed minutes + test-notification + reminder-check dry run | `/api/admin/settings`, `/api/admin/notifications/{test,reminder-check}` |
| 10 | `users` | `UserManagementSection` | User list, reset-password, force-logout, delete | `/api/admin/users*` |
| 11 | `api-tokens` | `ApiTokenOverviewSection` | Read-only token list (all users) | `/api/admin/tokens` (GET-only) |
| 12 | `login-overview` | `LoginOverviewSection` | Auth audit log w/ filter/pagination/CSV | `/api/admin/audit-log`, `/api/admin/audit-log/actions` |
| 13 | `app-logs` | `AppLogPreviewSection` | Recent app-log preview | `/api/admin/app-logs` |
| 14 | `backups` | `BackupsSection` | List/run/upload/download/restore backups | `/api/admin/backups*` |
| 15 | `danger-zone` | `DangerZoneSection` | Wipe-all-data | `/api/admin/data` (DELETE) |
| 16 | `about` | `AboutSection` (from settings surface) | Version/update info | — |
Admin API routes with NO UI consumer (curl/CLI-only, documented in `docs/ops`):
`status-overview`, `backup/test`, `import-apple-health-export`,
`rollups/recompute`, `drain-per-sample-cumulative`, `ai-settings`,
`notifications/diagnostic`, `monitoring/*-test` (test routes are wired; listed
where bare).
---
## Findings
### 1. IA / arrangement
**1a. AI-related sections are scattered across three non-adjacent concepts but
three different icons collide.** `ai-quality` (#5), `assistant` (#6),
`coach-feedback` (#7) are adjacent (good) but all three use the same `Sparkles`
icon in `admin-shell.tsx:77,82,87`. Three identical icons in the sidebar make
them visually indistinguishable.
Fix: give `assistant` a distinct icon (e.g. `SlidersHorizontal`/`ToggleRight`)
and `coach-feedback` a `MessageSquare`/`ThumbsUp` icon; keep `Sparkles` only on
`ai-quality`.
**1b. `integrations` slug uses the `AlertTriangle` icon**
(`admin-shell.tsx:73`). AlertTriangle reads as "warning/danger", but the
section is benign config (Umami/GlitchTip/WebPush/BugReport). `danger-zone`
correctly uses `ShieldAlert`, so two destructive-looking icons compete.
Fix: use `Plug`/`Boxes` for `integrations` (note `services` already uses
`Plug` at line 67 — pick distinct ones, e.g. `services`→`Radio`,
`integrations`→`Plug`).
**1c. Two overlapping audit views.** The `/admin` overview shows
`RecentAuditPreview` (last 10 auth rows from `/api/admin/audit-log?filter=auth`)
and `login-overview` (#12) shows the same auth audit log with filters. This is
intentional (preview vs full), but the overview preview and the login-overview
both label themselves around "auth/login" with no cross-link.
Fix: add a "View all" link from the overview preview to `/admin/login-overview`
(low priority — functional, just a discoverability gap).
### 2. Duplicates
**2a. Duplicated `/api/version` fetch hook.** `useOfflineGeoState()` in
`system-status-section.tsx:35-45` and `useVersion()` in
`system-status-summary.tsx:38-48` are byte-identical (same queryKey
`publicVersion()`, same shape). Two copies of the same hook.
Fix: extract one `usePublicVersion()` into `_shared.tsx` and import in both.
(They share the queryKey so the cache is already deduped — this is code
duplication, not a runtime bug.)
**2b. `ai-quality` and `coach-feedback` both fetch `/api/admin/ai-quality`** and
render near-identical helpful-rate tables (`ai-quality-section.tsx:69`,
`coach-feedback-section.tsx:61`) with copy-pasted `helpfulRateColour()`
helpers (`ai-quality-section.tsx:56`, `coach-feedback-section.tsx:48`). Same
endpoint, two query keys, duplicated colour logic.
Fix: hoist `helpfulRateColour` to `_shared.tsx`. Functionally fine (different
slices of one payload) but consolidate the helper.
### 3. Removed / dead
**3a. `moodLogGlobal` is a fully-wired backend setting with NO admin
control.** It is read+written+returned by `/api/admin/settings/route.ts`
(lines 58, 92, 272) and enforced by the reminder worker
(`src/lib/jobs/reminder-worker.ts:1219-1221`), and it sits in the
`AdminSettings` type (`_shared.tsx:88`), but no section renders a toggle for
it. `ServicesSection` (`services-section.tsx`) exposes Telegram/ntfy/webPush/API
globals but omits moodLog. An operator cannot turn the global mood-log reminder
off from the UI.
Fix: add a `moodLogGlobal` `SettingsToggle` to `ServicesSection` (alongside the
other four globals), defaulting `settings?.moodLogGlobal ?? true`.
**3b. `/api/admin/status-overview` is an orphaned endpoint.** No component
fetches it (confirmed: only its own route + test reference it; the one apparent
match in `backups/route.ts:42` is a comment, plus stale docs in
`docs/migration/v1.3-to-v1.4.md:115` and `docs/audit/v1425-summary.md:427`).
The live overview uses `/api/admin/status` instead. Dead admin endpoint
carrying its own probe logic (`status-overview/route.ts` ~230 LOC).
Fix: either delete the route + test (if `/api/admin/status` fully supersedes
it) or wire it into the overview if its richer probe data is wanted. Confirm no
external monitor/uptime check hits it before removing.
### 4. Hallucinated content
**4a. `host-metrics` route comment promises a tooltip that doesn't exist.**
`host-metrics/route.ts:135-143` computes and returns `meta.memTotalBytes` with
the comment "so the chart can render an absolute label ('4.2 GiB / 8 GiB') in
the tooltip". The chart (`host-metrics-chart.tsx`) never reads
`meta.memTotalBytes` — the memory tooltip only shows the percentage
(`host-metrics-chart.tsx:284-285`). The promised "4.2 GiB / 8 GiB" label does
not exist anywhere.
Fix: either implement the absolute-bytes label in the tooltip formatter (the
data is already on the wire) or drop the misleading comment and the unused
`memTotalBytes` payload field. Not user-facing (comment only), but the label
was promised to the maintainer.
**4b. (Verified NOT hallucinated, noted for the maintainer):** AI-quality,
coach-feedback, host-metrics, danger-zone wipe counts, umami/glitchtip test
buttons, assistant flags, and the system-status grid all render real
backend-sourced data with honest empty states. No fake/placeholder data found
in the rendering paths.
---
## No-issue confirmations
- Every admin section component is reachable from the shell nav and the
renderer switch is exhaustive (`slug satisfies never`).
- Danger-zone wipe (`/api/admin/data` DELETE) deletes exactly the entities its
result message reports — counts match (`data/route.ts:51-95`).
- `requireAdmin()` gates every admin route checked; `tokens` route is GET-only
by design (admin token view is read-only — revocation lives in user
settings, intentional separation, not a dead control).
- Empty states are honest (ai-quality/coach-feedback show "no data yet" rather
than zero rows; host-metrics shows a "sampler warming up" hint).
@@ -0,0 +1,88 @@
# Dead-code & structure audit — 2026-05-29
Read-only. No edits made. Scope: `src/` (generated client excluded). Tooling: `pnpm knip --reporter compact` (enforcing), `wc -l` ranking, targeted caller-tracing.
## Tooling baseline
`knip` is very clean — exactly two findings, both already known:
- `src/components/medications/todays-dose-card.tsx` — unused file.
- `src/lib/ai/coach/medication-extract-prompt.ts` — unused exported type `DoseUnit`.
Both verified below. The rest of this report is what knip misses.
---
## 1. DEAD CODE
### 1a. `todays-dose-card.tsx` — orphaned (confirmed)
- File: `src/components/medications/todays-dose-card.tsx`
- Only referent is a test: `src/app/medications/__tests__/detail-page-history-surface.test.ts`. No production importer.
- `medications/**` is under active rework per the brief — **list only, do not prescribe deletion**. Flagging so it is not lost when the rework lands.
### 1b. `DoseUnit` exported type — dead (confirmed)
- `src/lib/ai/coach/medication-extract-prompt.ts` exports `DoseUnit`; zero importers anywhere in `src/` (non-generated). Safe to drop, but same medications-rework caveat applies — list only.
### 1c. No commented-out code blocks
Searched for `// const|let|return|if (|await|function|import|export` line prefixes. Every hit is prose ("…returns…", "…function arity…") inside legitimate explanatory comments. The tree carries **no** stale commented-out code. Good signal.
### 1d. No stale feature flags
The only flag system is the assistant matrix in `src/hooks/use-feature-flags.ts` (backed by `GET /api/feature-flags`), which is live and gates Coach/briefing/insight surfaces on web + iOS. `NEXT_PUBLIC_*` env reads are all load-bearing (version poller, Withings callback URL, web-vitals origin). No dead flags found.
---
## 2. OLD / QUESTION-FOR-REMOVAL
### 2a. The v1.5.1 read-flip did NOT happen — legacy walker still on the live path (HIGH value, MEDIUM risk)
The schema promises it (`prisma/schema.prisma:910-914`): *"the migration backfills `rrule` for every existing row and v1.5.1 flips the readers to consult the new fields first. v1.6.0 drops this column."* The canonical engine `src/lib/medications/scheduling/recurrence.ts` exists and its own header (`recurrence.ts:8-11`) says the today-projector, cadence chart, medication card, and form helpers *"migrate in v1.5.1 per the read-flip plan."*
**That migration is incomplete.** The canonical engine has exactly ONE production caller: `worker-helpers.ts` → `reminder-worker.ts`. Every other path still runs the legacy walker `expandTodayIntakes` in `src/lib/medication-schedule.ts`, which reads ONLY `daysOfWeek` + `windowStart` and *ignores* `rrule`/`timesOfDay`/`rollingIntervalDays`:
- `src/lib/medications/scheduling/project-today-intakes.ts:31` imports `expandTodayIntakes`; it is the shared projector for **both** `/api/medications/intake?scope=today` AND `/api/dashboard/summary` (`src/app/api/dashboard/summary/route.ts:350`).
- The legacy walker even documents its own correctness gap: `medication-schedule.ts:132-138` *conservatively skips* `intervalWeeks > 1`, while the canonical engine (`recurrence.ts:27-34`) explicitly fixes that bi-weekly bug.
**Consequence:** GLP-1 / bi-weekly and any rolling/RRULE-only schedule are projected differently by the reminder worker (canonical) vs the dashboard/intake today-tile (legacy). This is a live divergence, not just tech debt.
**Recommendation:** Before `daysOfWeek` can be dropped in v1.6.0, the read-flip must actually land — route `project-today-intakes.ts` (and the card/chart/form readers) through `recurrence.ts`. Risk is MEDIUM (touches the iOS today tile + intake backfill); pin with the existing `recurrence.test.ts` parity cases. Do NOT drop the column until this is done — the schema comment's v1.6.0 plan is currently unsafe to execute.
### 2b. `legacy-bridge.ts` dual-write — correctly scoped, keep until 2a + column drop (LOW value now)
- File: `src/components/medications/scheduling/legacy-bridge.ts` (204 LOC, pure, well-tested).
- Purpose: dual-write the legacy `(daysOfWeek, intervalWeeks)` pair alongside the new `rrule` during the v1.5.x window so legacy readers still see the row. Imported by `wizard/wizard-payload.ts`.
- This shim is **load-bearing precisely because 2a is incomplete** — the legacy readers it feeds are still live. It cannot be removed before the read-flip and the v1.6.0 column drop. Listed for the v1.6.0 cleanup checklist, not for now.
### 2c. Boot-time backfills — converging, self-terminating, keep (INFO)
`step-consolidation.ts` (v1.5.6, `0087`), `rollup-full-backfill`, `geo-backfill`, `medication-compliance-rollups` backfills all follow the documented converging-discovery pattern (discovery query enqueues one job per still-unconverged user; idempotent across reboots; matches zero rows once converged). These are designed to stay in the tree and cost nothing after convergence — **not** dead code. No action.
---
## 3. STRUCTURE / MAINTAINABILITY
Largest non-generated files (`wc -l`):
| LOC | File | Assessment |
|----|------|-----------|
| 2454 | `src/lib/jobs/reminder-worker.ts` | **God-file — split (see below).** |
| 1896 | `src/components/settings/ai-section.tsx` | Large but single-concern (AI provider config form). Split candidate, lower priority. |
| 1870 | `src/app/privacy/page.tsx` | Static bilingual legal doc — size is inherent. Leave. |
| 1811 | `src/components/charts/health-chart.tsx` | Core chart; Recharts is frozen. Candidate to extract sub-series renderers, but visual-identity rule makes this delicate — low priority. |
| 1785 | `src/lib/openapi/routes.ts` | Generated-adjacent registry; size is structural. Leave. |
| 1514 | `src/app/page.tsx` | Dashboard composition root. Review for extractable tiles, medium priority. |
| 1318 | `src/app/api/insights/targets/route.ts` | Single route at 1.3k LOC — review for handler extraction. |
| 1213 | `src/components/measurements/measurement-list.tsx` | Large list component, medium priority. |
### Top structural finding: `reminder-worker.ts` is a 2454-LOC pg-boss god-dispatcher
It hosts ~30 unrelated handlers in one file: `handleReminderCheck`, `handleWithingsFallbackSync` / `…ActivitySync` / `…SleepSync`, five `handle*StatusGenerate` variants, `handleMoodLogSync`, `handleMoodReminderCheck`/`…Cleanup`, `handlePushAttemptCleanup`, `handleRateLimitCleanup`, `handleIdempotencyCleanup`, `handleAuditLogCleanup`, `handleWithingsOAuthStateCleanup`, `handleHostMetricSample`, `handleFeedbackAggregator`, `handleGeoBackfill`, `handlePrDetection`, `handleMedicationInventoryExpire`, `handleIntakeAutoSkip`, `handleOffhostBackup`, `handleDataBackup`, plus `cleanupScheduledTelegramDeletions` and `startReminderWorker` wiring.
These share almost nothing beyond the pg-boss `Job<>` envelope. The codebase already extracts SOME handlers into siblings (`src/lib/jobs/intake-auto-skip.ts`, `step-consolidation.ts`, `geo-backfill.ts`, `pr-detection.ts`) — the pattern exists; the file just stopped following it. **Recommendation:** continue the established extraction — move each handler family into `src/lib/jobs/<family>.ts` and keep `reminder-worker.ts` as a thin registration/wiring shell (`startReminderWorker` + queue→handler map). Mechanical, test-covered, high maintainability payoff. No behaviour change.
### Codemap health: GOOD
- Naming conventions are consistent (kebab-case files, `use-` hooks); the known PascalCase outliers under `medications/`+`onboarding/` are the documented pre-existing drift.
- Comment discipline is strong — the WHY is documented at the non-obvious points (tz two-pass solver, read-swap fallbacks, fail-closed crypto). No comment-rot found.
- The job layer is the one place the structure has drifted (one mega-file vs the per-family pattern it already uses elsewhere).
- The medication-scheduling layer has an **incomplete migration** (2a) that is the single highest-value item: it is both a structural smell (two parallel engines) AND a live correctness divergence, and it blocks the planned v1.6.0 column drop.
## Priority order
1. **2a** — finish the v1.5.1 read-flip (live divergence + blocks v1.6.0). HIGH.
2. **§3** — split `reminder-worker.ts` along the existing per-family pattern. MEDIUM, mechanical.
3. **1b / 1a** — drop `DoseUnit` + `todays-dose-card.tsx` *as part of the medications rework* (list-only per brief). LOW.
4. **2b** — retire `legacy-bridge.ts` dual-write at v1.6.0, after 2a. LOW, gated.
@@ -0,0 +1,323 @@
# Public-surface audit — docs, landing, README, SEO (2026-05-29)
Worklist for a follow-up engineer. Research only; nothing here was edited.
Scope: `healthlog-docs` (Astro Starlight, branch `docs/apns-pinning-selfhost`),
`healthlog-landing` (Next.js, main), `HealthLog/README.md`.
Ground truth used: `CLAUDE.md` "What HealthLog is" + `CHANGELOG.md` through v1.5.6.
Current app line: **v1.5.6**. Native iOS client: **v0.8.x** public beta via TestFlight.
Voice/content rules enforced below: English, factual maintainer voice, no
AI/agent/marathon vocabulary, no personal names / health figures, every claim
verifiable.
---
## CROSS-CUTTING: the single biggest gap
**The native iOS client + Apple HealthKit live two-way sync is the app's headline
strength and it is almost invisible on the two marketing surfaces.**
- README covers it well (full "Native iOS client" section, TestFlight badge).
- **Landing page (`page.tsx`): zero iOS section, zero TestFlight link, zero iOS
screenshots.** The hero says "offline-capable, open source" and never mentions
the phone. This is the #1 fix.
- **Docs: no dedicated iOS page.** iOS is mentioned in passing inside
`api/native-clients`, `apple-health`, `notifications`, but a self-hoster
cannot find a "Install the iOS app / what it does / HealthKit sync" page.
Treat the three P1 iOS items below as the top of the worklist.
---
## SCREENSHOT INVENTORY (`.planning/screenshots-2026-05-29/`)
Map of the 11 PNGs to where each belongs. Two are duplicates; one is NOT usable.
| File | Content | Use |
|---|---|---|
| `dashboard.png` | Dashboard top: metric tiles (Weight/BP/pulse/mood) + Weight & BMI charts | **Replace** `landing/public/screenshots/desktop-dashboard-opt.webp`. Cleanest of the dashboard set (no browser chrome). |
| `Bildschirmfoto…22.04.12 (2).png` | Same dashboard top, slightly different crop | Backup of `dashboard.png`; pick one. |
| `1.png` | Dashboard lower charts: Medications heatline + Sleep + Steps | New landing showcase tab "Trends", or docs `features/health-metrics` hero. Has full browser chrome — crop it. |
| `Bildschirmfoto…22.04.06.png` | Same lower charts, no chrome | Preferred version of `1.png` — use this, drop `1.png`. |
| `Bildschirmfoto…22.04.12.png` | Measurements list table (clean, no chrome) | **Replace** `desktop-measurements-opt.webp`. |
| `Bildschirmfoto…22.04.25.png` | Same measurements list, full browser chrome | Duplicate of above; skip. |
| `Bildschirmfoto…22.04.37.png` | Medications page: 4 compliance cards (7-day / 30-day rings, next-intake) | **Replace** `desktop-medications-opt.webp`. Strong shot — shows compliance + GLP-1. |
| `Bildschirmfoto…22.04.56.png` | Achievements / gamification grid | New docs image for `features/gamification` + `features/achievements-hidden`. Optional landing "59 achievements" proof shot. |
| `Bildschirmfoto…22.05.19.png` | Admin console / Administration overview | Docs `configuration/admin-settings` + `admin/backups` hero. NOT for landing (too operator-facing). |
| `Bildschirmfoto…22.03.55 (2).png` | **A developer terminal (typecheck/lint/git output) — NOT app UI** | **DO NOT publish anywhere.** Contains local paths + commit subject lines referencing internal terms. Exclude. |
Action: optimise the chosen PNGs to `.webp` (the showcase already serves webp;
landing `next.config.ts` sets `images.unoptimized: true` because of static export,
so pre-compress them). Strip browser chrome from any with a visible URL bar — the
demo URL `demo.healthlog.dev` shows in several; the showcase frame already renders
its own fake `health.myserver.com` chrome, so feed it chrome-less captures.
---
## REPO 1 — LANDING (`/Users/marc/Projects/healthlog-landing`)
### P1 — Add a native-iOS / HealthKit section
- **File:** `src/app/page.tsx`
- **Wrong:** No iOS presence at all. The phone + live HealthKit sync is the
differentiator vs. "yet another self-hosted tracker"; the page never says it.
- **Change:** Add a new `<section id="ios">` between AI Coach and Features.
Headline e.g. "Your iPhone writes straight to your server." Body (verifiable
against README + CHANGELOG): live two-way Apple Health sync (HealthKit), local
medication reminders with action buttons, passkey/Face ID sign-in, on-device AI
Coach on iOS 26+, doctor-report export. Add a real CTA to
`https://testflight.apple.com/join/bucuTBpa` and a link to the iOS repo. Pull
the four iOS screenshots that already ship in the main repo at
`HealthLog/docs/ios/ios-*.png` (dashboard, insights, medication-detail,
mood-entry) into a phone-frame strip. Do NOT mention "Stanford Spezi" framework
internals on the marketing page — keep that in the README/docs.
### P1 — Refresh the AppShowcase screenshots
- **File:** `src/components/AppShowcase.tsx` (+ `public/screenshots/`)
- **Wrong:** Three webp screenshots, undated; the new captures are sharper and
current (v1.5.x dashboard tiles + medication compliance rings + measurements
source badges).
- **Change:** Replace the three webp files with the mapped captures above. Add a
fourth tab "Medications" is present, but consider adding "Trends" (the
Sleep/Steps/Medication-heatline shot, `22.04.06`). Update `alt` text to mention
Withings + Apple Health source badges (visible in the new measurements shot) for
SEO. Keep `object-cover object-top` framing.
### P1 — Fix stale structured-data version
- **File:** `src/app/layout.tsx` line ~162
- **Wrong:** `softwareVersion: "1.4.32"` — app is on **1.5.6**.
- **Change:** Bump to `1.5.6`. Add a code comment that this tracks the server
release and must bump on each release (it already has a comment; keep it).
### P1 — Hero + meta does not surface iOS / Apple Health
- **Files:** `src/app/page.tsx` (hero `<p>`), `src/app/layout.tsx` (title,
description, keywords, OG, JSON-LD featureList).
- **Wrong:** Hero subhead lists "Weight, blood pressure, medications, mood …
Offline-capable. Open source." — omits the two strongest SEO/marketing hooks:
**native iOS app** and **Apple Health sync**. Meta `description` and `keywords`
omit "Apple Health", "HealthKit", "iOS app", "blood glucose", "AI Coach".
- **Change:**
- Hero subhead: add a clause "Syncs live with Apple Health on iOS, or Withings
on any device."
- `metadata.description`: rewrite to e.g. "Self-hosted, open-source health
tracker. Native iOS app with live Apple Health (HealthKit) sync, Withings
device sync, multi-provider AI insights, and a client-side doctor-report PDF.
AES-256-GCM encrypted. Docker deploy in minutes."
- `metadata.keywords`: add `apple health sync`, `healthkit self-hosted`,
`ios health app open source`, `blood glucose tracker`, `ai health insights`,
`withings self-hosted`, `doctor report pdf`.
- JSON-LD `featureList`: add "Native SwiftUI iOS app with live Apple Health
(HealthKit) two-way sync" as the first entry.
### P2 — `sitemap.xml` is stale and incomplete
- **File:** `public/sitemap.xml`
- **Wrong:** Only lists `/` with `lastmod 2026-03-08`; omits `/privacy` and
`/support` which are indexable (`robots: index` set on both). Date is 11 weeks old.
- **Change:** Add `/privacy` and `/support` entries; bump `lastmod` on `/` to the
deploy date. Static export, so either hand-edit or generate at build.
### P2 — Comparison table omits the iOS/Withings/Apple-Health strengths
- **File:** `src/app/page.tsx` (Comparison section)
- **Wrong:** Rows don't include the differentiators the README table has
("Apple Health import", "Withings device sync", "Doctor-report PDF", "native
client"). The landing table compares only generic axes.
- **Change:** Add rows: "Native iOS app" (HealthLog: Yes — public beta), "Apple
Health sync" (HealthLog: Two-way / others: their own silo), "Withings sync",
"Doctor-report PDF". Mirror the README comparison so the two surfaces agree.
### P2 — JSON-LD: add `SoftwareApplication` iOS + breadcrumb/FAQ richness
- **File:** `src/app/layout.tsx`
- **Change:** Consider a second `SoftwareApplication` node (or `operatingSystem:
"iOS, Web, Docker"`) so the iOS app is eligible for rich results. Optionally add
a small `FAQPage` JSON-LD with 3–4 real Q&As ("Is HealthLog free?", "Does it
sync with Apple Health?", "Where is my data stored?") — strong for SEO snippets
and all answerable truthfully.
### P3 — `og-image.png` likely predates v1.5 UI
- **File:** `public/og-image.png` (referenced by OG + Twitter + JSON-LD screenshot)
- **Change:** Regenerate from the new `dashboard.png` so social cards show current
UI. Verify it's 1200×630.
### P3 — Tech-stack marquee names a specific AI vendor
- **File:** `src/app/page.tsx` `techItems` lists "OpenAI API".
- **Change:** Fine to keep, but consider "AI: OpenAI / Anthropic / local" to match
the multi-provider story the AI Coach section tells. Minor.
---
## REPO 2 — DOCS (`/Users/marc/Projects/healthlog-docs`)
Note: branch `docs/apns-pinning-selfhost` adds TLS-certs, cert-pinning, APNs push
pages — legitimate, leave them.
### P1 — No `@astrojs/sitemap`; no sitemap.xml shipped
- **Files:** `astro.config.mjs`, `package.json`
- **Wrong:** Starlight does not emit a sitemap without the integration. A docs site
this size (60+ pages) with no sitemap is a real SEO loss.
- **Change:** `pnpm add @astrojs/sitemap`, import and add to `integrations` in
`astro.config.mjs` (it auto-uses the configured `site`). Confirm `robots.txt`
(add one under `public/` pointing at `/sitemap-index.xml`).
### P1 — No dedicated iOS app page in docs
- **Files:** new `src/content/docs/getting-started/ios-app.mdx` (or under a new
"iOS App" sidebar group), `astro.config.mjs` sidebar.
- **Wrong:** iOS is only referenced obliquely. A self-hoster who connects the
native app has no install/feature/troubleshooting page.
- **Change:** Add a page covering: TestFlight join link, iOS 18+ requirement (26+
for on-device Coach), how the app pairs with a self-hosted instance (server URL +
passkey/refresh-token), HealthKit permission flow, what syncs live, local
medication reminders, and a pointer to the iOS repo + iOS CHANGELOG. Cite the
`api/native-clients` page for the wire contract. Add to sidebar.
### P2 — `features/health-metrics.mdx` has zero explanatory external links
- **File:** `src/content/docs/features/health-metrics.mdx`
- **Wrong:** This is the most clinical page (ESH 2023, ADA 2024, NICE NG115, SpO₂,
body composition) and carries no outbound references. Only 3 docs files use any
external link.
- **Change:** Add helpful explanatory links the first time each term appears:
Wikipedia for [blood pressure](https://en.wikipedia.org/wiki/Blood_pressure),
[body mass index](https://en.wikipedia.org/wiki/Body_mass_index),
[pulse oximetry](https://en.wikipedia.org/wiki/Pulse_oximetry),
[blood glucose](https://en.wikipedia.org/wiki/Blood_sugar_level),
[body composition](https://en.wikipedia.org/wiki/Body_composition); and link the
actual guideline bodies (ESH, ADA, NICE) to their canonical pages. Keeps the
page factual and helps SEO entity association. Apply the same lightly to
`insights/how-it-works` and `features/ai-insights`.
### P2 — Introduction "Key Features" out of date / understated on iOS
- **File:** `src/content/docs/getting-started/introduction.mdx`
- **Wrong:** Feature bullets don't list the **native iOS app** at all; "Native API
Clients" bullet mentions iOS only as a consumer. Achievements say "59" (verify
against current count — README says "30+", landing says "59"; pick the true
number and unify across all three surfaces).
- **Change:** Add an "iOS App" bullet linking the new iOS page. Reconcile the
achievement count (CHANGELOG/code is the source — confirm before publishing; do
not ship two different numbers).
### P2 — Add screenshots to feature docs
- **Files:** `features/medications.mdx`, `features/gamification.mdx`,
`features/health-metrics.mdx`, `configuration/admin-settings.mdx`,
`admin/backups.mdx`
- **Wrong:** Feature pages are text-heavy with no UI imagery (spot-check shows no
image embeds in health-metrics).
- **Change:** Drop the mapped captures in: medications card grid → medications doc;
achievements grid → gamification doc; measurements/dashboard → health-metrics;
admin overview → admin-settings/backups. Store under `src/assets/` and embed with
Starlight's image handling for optimisation.
### P3 — No custom OG/Twitter meta per page
- **Files:** `astro.config.mjs` (Starlight `head`), or a custom `Head` override
- **Wrong:** Pages have good `description` frontmatter (all MDX have one — verified)
but no site-wide OG image / Twitter card. Shared docs links render bare.
- **Change:** Add a `head` entry in the Starlight config injecting a default
`og:image` (reuse the landing OG image), `og:site_name`, and `twitter:card`.
Starlight already emits `<title>`/`<meta description>` from frontmatter.
### P3 — Sidebar ordering buries strengths
- **File:** `astro.config.mjs` sidebar
- **Change:** Minor — "Integrations" (Apple Health / Withings / AI) sits below
"Features"; consider surfacing Apple Health higher. Add the new iOS page near
Getting Started. Low priority.
---
## REPO 3 — README (`/Users/marc/Projects/HealthLog/README.md`)
The README is strong and current. Findings are targeted.
### P1 — API Reference table drift vs. shipped routes
- **File:** `README.md` lines ~278–418
- **Wrong / unverifiable claims to reconcile against code:**
- `GET /api/auth/me` description says "Current user profile + **Gravatar URL**" —
**Gravatar was removed in v1.5.5** (self-hosted avatar at `/api/user/avatar`;
`src/lib/gravatar.ts` retired). The privacy-positive change (Automattic no
longer sees the email hash) is a *selling point* that's now mis-stated as the
opposite. Fix: change to "+ avatarUrl (self-hosted)" and add an Avatar row.
- Medications table lists `GET /api/medications/:id/compliance` — verify; v1.5.x
uses `/cadence` and `/intake` routes. Add `POST /api/medications/extract`
(NL extraction, shipped v1.5.3) and `/api/medications/{id}/intake/bulk-delete`
(v1.5.5) and `/api/insights/layout` (v1.5.5) which are all live but absent.
- Confirm `/api/analytics` "7d/30d" still matches (rollup tier changed shapes).
- **Change:** Do a pass diffing the README API tables against `docs/api/openapi.yaml`
(the source of truth) and fix every drift. The OpenAPI file is CI-gated so it's
authoritative.
### P2 — Avatar / Gravatar privacy story
- **File:** `README.md` "Security and Privacy" section + API table.
- **Wrong:** Misses a genuine v1.5.5 privacy win.
- **Change:** Add a bullet under Security: "Self-hosted avatars — profile images
store as BYTEA on your own DB; no third-party (Gravatar) email-hash leak."
### P2 — Achievement count inconsistency
- **File:** `README.md` says "30+ persistent achievements" (line ~99); landing says
"59 Achievements"; docs say "59".
- **Change:** Pick the verified number and unify across README + landing + docs.
### P2 — Local Development prereq says "Node.js 20+"
- **File:** `README.md` line ~440
- **Wrong:** `CLAUDE.md` pins Node 22 (Alpine) via Dockerfile. 20 may still work but
the stated floor disagrees with the shipped runtime.
- **Change:** Align to "Node.js 22" to match the build image.
### P2 — pnpm version drift
- **File:** README Tech Stack / Local Dev. `CLAUDE.md` references pnpm 10.31.
README doesn't pin pnpm; fine, but if it does elsewhere, keep consistent. Low.
### P3 — Repo topics / GitHub "About" (SEO)
- **Not a file** — GitHub repo settings. The repo description + topics drive GitHub
search and Google.
- **Change (suggest to maintainer):**
- Description: "Self-hosted, open-source health tracker — native iOS app with live
Apple Health (HealthKit) sync, Withings, multi-provider AI insights, doctor PDF.
Docker, AES-256-GCM, PWA."
- Topics: `self-hosted`, `health-tracking`, `pwa`, `nextjs`, `typescript`,
`apple-health`, `healthkit`, `withings`, `medication-tracker`,
`blood-pressure`, `glucose`, `privacy`, `docker`, `ios`, `swiftui`,
`ai-insights`, `postgresql`, `prisma`, `agpl`.
### P3 — Roadmap "v1.4.x / v1.5 (current)" framing
- **File:** `README.md` Roadmap table.
- **Wrong:** Mildly stale — v1.5 is mid-line (1.5.6 shipped), medication scheduling
+ detail page landed. "current" is fine but the v1.5 row only mentions iOS.
- **Change:** Note medication scheduling (RRULE / rolling / one-shot) + the
medication detail page as v1.5 web work alongside iOS.
---
## SEO — keyword / topic strategy (concrete)
**Primary entity:** "self-hosted health tracker" (own this; low competition,
high intent). **Secondary high-value, currently under-targeted:**
- "self-hosted Apple Health" / "HealthKit self-hosted" — strong, near-zero
competition, matches the real differentiator. Target on landing + new docs iOS
page + Apple Health docs page.
- "open source health app iOS" / "private health app self hosted".
- "Withings self-hosted" / "Withings open source".
- "doctor report PDF self hosted" / "medication compliance tracker self hosted".
- "AI health insights privacy" / "local LLM health" (Ollama angle).
**Per-surface application:**
- Landing meta/title/keywords: add Apple Health, HealthKit, iOS app, blood glucose,
AI Coach (P1 above).
- Docs: ship a sitemap (P1), add the iOS page (captures the HealthKit query), add
explanatory external links (entity association), per-page OG.
- README: topics + description (P3) — GitHub ranks well for these terms.
**Cross-surface consistency to fix (search dilution):** achievement count, version
number (landing JSON-LD 1.4.32 → 1.5.6), Gravatar→avatar, Node 20→22. Same facts
must read identically on README, landing, and docs.
---
## Quick priority rollup
- **P1:** landing iOS section; landing screenshot refresh + iOS shots; landing
JSON-LD version 1.4.32→1.5.6; landing hero/meta add iOS+Apple Health; docs
sitemap integration; docs iOS page; README API-table drift (Gravatar etc.).
- **P2:** landing sitemap.xml; landing comparison rows; docs external links on
clinical pages; docs intro iOS bullet + achievement count; docs feature
screenshots; README avatar privacy bullet; README Node version; achievement-count
unification.
- **P3:** og-image regen; landing FAQ JSON-LD; docs per-page OG; docs sidebar
reorder; README topics/description; README roadmap refresh.
- **DO NOT publish:** `Bildschirmfoto…22.03.55 (2).png` (developer terminal).
@@ -0,0 +1,109 @@
# Frontend / shadcn architecture audit — 2026-05-29
Read-only senior-architect + frontend review of the HealthLog web frontend.
Scope excludes the medication surface (`src/components/medications/**`,
`src/app/medications/**`), which is under active rework. Covered: dashboard,
insights, AI coach, settings (incl. AI + integrations), onboarding, auth,
measurements, mood, workouts, layout/proxy, shared UI primitives.
Overall: the codebase is high quality. Accessibility and performance work is
extensive and well documented (skip link, 44px tap targets, `aria-current`,
`motion-reduce`, focus-visible rings, per-tile Suspense + layout-stable
skeletons, slim/thick analytics split, centralised query-key factory). Genuine
defects are few. No P0 (fully broken / blocking) issue was found.
---
## P0 — broken / blocking
None found.
---
## P1 — clear defects
### P1-1 Dashboard range colors bypass tokens and fail light-mode AA contrast
`src/app/page.tsx:126-128` (`getRangeColorClass`) and `:145`, `:151`, `:158`
(`getRangeHint` legend) return raw Tailwind `text-green-400` /
`text-orange-400` / `text-red-400`.
These pale `-400` shades are hard-coded rather than routed through the semantic
`--success` / `--warning` / `--info` tokens defined in
`src/app/globals.css`. Those tokens carry deliberate light-mode AA-contrast
overrides (`--success: #14720a`, `--warning: #a34d14`); the raw `-400` shades do
not, so green/orange range text fails WCAG 2.1 AA contrast on the light theme.
This is a genuine contrast defect, not just a consistency nit, because the same
information has no non-color encoding here.
Fix: replace the raw classes with the semantic token classes
(`text-success` / `text-warning` / destructive equivalent) so both themes get
the AA-compliant value. Audit `targets/page.tsx:241` (`text-orange-500`) in the
same pass for the matching inconsistency.
### P1-2 `togglePrivacy` swallows fetch failures
`src/components/settings/ai-section.tsx:1797-1805`. The handler does not check
`res.ok` and has no try/catch; it calls `onPrivacyChanged()` unconditionally
after the `await fetch(...)`. A 4xx/5xx or network error leaves the UI showing
the new privacy mode while the server kept the old one — a silent desync on a
privacy-sensitive control. Every sibling mutation in this file handles errors;
this one is the outlier.
Fix: guard on `res.ok`, surface a toast/error on failure, and only call
`onPrivacyChanged()` on success (mirror the sibling mutation pattern in the same
file).
### P1-3 25-character notes cap is too restrictive for health notes
`src/components/measurements/measurement-form.tsx:28`
(`const MAX_COMMENT_LENGTH = 25;`), mirrored in
`src/components/measurements/measurement-list.tsx:108`. 25 chars cannot hold a
meaningful clinical note ("took after large meal, felt dizzy standing up").
Fix: raise to a sensible health-note length (e.g. 200-280) in both call sites,
keep them in sync (ideally a single shared constant), and confirm the server
Zod schema + DB column accommodate the new bound.
---
## P2 — polish
### P2-1 ResponsiveSheet desktop dialog caps at `sm:max-w-md`
`src/components/ui/responsive-sheet.tsx:163`. The desktop Dialog branch caps at
`sm:max-w-md`, which squeezes the measurement form's `sm:grid-cols-3` BP layout
on wide screens. Consider a wider cap (or a per-instance `className` override)
for form-bearing sheets so desktop space is not wasted.
### P2-2 GLP-1 side-effect chips render for all users unconditionally
`src/components/mood/mood-form.tsx:255-301` (`GLP1_SIDE_EFFECT_KEYS`). The chips
show on every mood entry for every user regardless of whether they are on GLP-1
therapy; the inline comment admits "Always visible for now (cheap UX...)". This
adds irrelevant cognitive load for the majority who are not on GLP-1.
Fix: gate the chip block on an active GLP-1 medication signal (already available
elsewhere in the app's medication context) so it only appears when relevant.
### P2-3 1514-line dashboard client component
`src/app/page.tsx` is a single 1514-line `"use client"` component with a large
inline IIFE (lines ~697-1511) assembling the trendCards/charts arrays. It works
and renders correctly, but the size hurts maintainability and pushes
client-bundle weight. Consider extracting the card/chart assembly into typed
helper modules and splitting presentational sub-components; evaluate whether any
of the assembly can move to an RSC boundary.
---
## Strong positives observed (preserve on churn)
- `src/components/ui/button.tsx` — documented loading-icon-SWAP to avoid CLS;
good focus-visible + variant coverage.
- `src/components/layout/{auth-shell,top-bar,sidebar-nav,bottom-nav}.tsx` —
skip link, 44px targets, `aria-current`, focus rings, safe-area insets,
bottom-nav 5+More pattern.
- `src/components/ui/dialog.tsx`, `empty-state.tsx`, `native-select.tsx` —
solid primitives; close-X 44px mobile / 36px desktop (documented WCAG 2.5.8
exception).
- `src/app/auth/login/page.tsx` — open-redirect guard, `aria-describedby`
error wiring, underline-always link (axe `link-in-text-block`).
- `src/app/insights/page.tsx` + `daily-briefing.tsx` — dynamic imports +
layout-stable skeletons; per-metric routed key-finding rows.
- `aria-live` coverage across ~27 files; no stray `<div onClick>` /
`<span onClick>` or raw `<img>` outside the excluded medication surface.
+155
View File
@@ -0,0 +1,155 @@
# v1.6.0 marathon — state anchor (2026-05-29)
Maintainer offline, full autonomy, token budget irrelevant, bar = "very clean
functionality" + production-ready across **app + docs + landing + GitHub + SEO**.
## Working base
- Branch: `release/v1.6.0` off `main` @ `d01d1d1d` (main had 3 "Update README.md"
web commits past the v1.5.6 tag `8528c3b4`).
- Drive the medication overhaul MYSELF in the main tree (macOS case-insensitive
worktree misroute risk). Separable streams (docs/landing/SEO/README) → agents
in their OWN repos.
## CI is RED on main since v1.5.6 (must all go green)
- **Knip**: dead `src/components/medications/todays-dose-card.tsx` + unused export
`DoseUnit` in `src/lib/ai/coach/medication-extract-prompt.ts`. (Resolve in med build.)
- **Security & Quality**: OpenAPI drift — committed `docs/api/openapi.yaml` says
`version: 1.5.5`, regen yields `1.5.6` (v1.5.6 forgot to regen). Trivial: regen+commit.
- **e2e**: 14 failed = ALL medication-wizard specs (daily/weekly/biweekly/monthly/
rolling/oneshot/compose × desktop+mobile) — broken by v1.5.6 detail rewrite.
Must rewrite specs to the NEW editor flow.
## Open issues / PRs
- #218 safeFetch wrapper + #217 DNS-rebinding IP pin → v1.5.6 finished egress
migration w/ `requirePublicHost`. VERIFY vs `src/lib/safe-fetch.ts`; close or finish gap.
- PR #192 bump react + @types/react (React pinned EXACT 19.2.5 — evaluate carefully).
- PR #182 bump actions/download-artifact 4→8 (CI action; likely safe).
## Medication overhaul — design (APPROVED by maintainer)
Decisions: (a) wide DIALOG not route; (b) injection-site body-map picker shown for
injections but COLLAPSIBLE/optional.
1. ONE editor: merge 8-step wizard + AdvancedSettingsSheet. Desktop lg/xl = wide
(~max-w-4xl) 2-col scrollable sectioned form (Identity / Plan / Reminders /
Specific). Mobile = bottom-sheet grouped sections (accordion), fix min-h empty.
Kills the 3-4-tap nested path.
2. Detail page + card actionable: restore card REWIND/history icon (1 tap →
intake history) + direct edit pencil (opens editor, not picker). Make
CadenceSummaryRow editable again (remove dead `hideEdit` at [id]/page.tsx:290).
Replace `Edit ▾` two-option picker (medication-detail-header.tsx:125-138) with a
single "Bearbeiten". Bring SideEffects + Scheduling sections back onto detail page.
3. Restore lost fields into editor: GLP-1 once-weekly preset (weekday pills +
suggest-last-pick), `dosesPerUnit`, independent category vs treatmentClass,
per-schedule dose/label.
4. One-Time-Injection: NEW additive enum `deliveryForm (ORAL|INJECTION|…)` on
Medication, DECOUPLED from treatmentClass. one-time injection = oneShot=true +
deliveryForm=INJECTION. Injection-site picker available when INJECTION (not only
GLP1). Migration backfills ORAL. Schema already has `oneShot` + INJECTION_* enums.
5. Restore API token multi-language code examples (cURL/wget/JS/PowerShell) removed
in commit `ef21967d` (collateral of lifting IntakeImportDialog). i18n keys still
exist (`medications.requestExample`, etc.). Port into api-tokens-row.tsx; wire
minted token into templates; anglicise old German placeholders (DEIN_TOKEN).
## Pre-wizard baseline for archaeology = commit `e74eff29` (parent of 1757b353).
Old single dialog `medication-form.tsx` (1162 LOC) had everything inline. Lost:
GLP1 weekly preset, dosesPerUnit, category/class split, per-schedule dose/label,
6-item footer menu (import/API/phase/pause/notify/purge/delete), Reset action.
SideEffectsSection + SchedulingSection still live on legacy `/[id]/history` route.
## Responsive root cause
`ResponsiveSheet` (src/components/ui/responsive-sheet.tsx) flips at 768px
(use-is-mobile.ts:40). Desktop branch hardcodes `sm:max-w-md` (448px). Consumers:
AdvancedSettingsSheet `sm:max-w-lg` (512), wizard `sm:max-w-[560px]`. No lg/xl width.
## Background agents running (read-only, write to .planning/site-audit-2026-05-29/)
- frontend-arch.md — site-wide non-medication frontend/architect audit (agent ac6e6d0cfa556d5ca)
- admin-settings.md — admin IA / duplicates / dead / hallucinated (agent a9347de544e71e40e)
- dead-code-structure.md — dead code + maintainability + codemap (agent ad71613263c7fa273)
Consume these when they land; create fix-tasks.
## Other repos
- docs: `~/Projects/healthlog-docs` — Astro Starlight. ON BRANCH
`docs/apns-pinning-selfhost` (in-progress, not main) — check before editing.
- landing: `~/Projects/healthlog-landing` — Next.js, main, untracked `.notes/`.
- `-audit-fixes` siblings = stale v1.3.3 branches. `healthlog-marketing` = not a git repo.
- Screenshots: Desktop EMPTY. `~/Downloads` has mood imgs (1-Lausig..5-Super gut.png,
mood.jpg, May 24) — UNCONFIRMED if these are the "new" ones. Clarify w/ maintainer.
## Voice rules (every committed artefact, all repos)
English; maintainer voice (present-tense imperative commits, factual prose); NO
AI/agent/wave/phase/marathon vocabulary; no personal names; no real health figures.
Co-Authored-By: Claude Opus 4.7 (1M context) trailer is the only exception.
Docs/landing must read as human-written, natural, no hallucinated features, add
explanatory links (Wikipedia etc.). SEO push (dual agent) over docs+landing,
push iOS + HealthKit strengths.
## Task list (durable): #7 detail-edit (in_progress), #8 responsive editor,
#9 restore fields, #10 one-time-injection, #11 token examples, #12 QA+gates,
#13 site-wide audit act, #14 green CI, #15 issues #217/#218, #16 dependabot,
#17 docs, #18 landing, #19 README/repo, #20 SEO.
## CRITICAL finding (dead-code agent, DONE — see dead-code-structure.md)
- **v1.5.1 read-flip never landed.** Canonical engine `src/lib/medications/scheduling/
recurrence.ts` has ONE caller (reminder worker). Dashboard-summary + intake-today
still use legacy walker `expandTodayIntakes` (`src/lib/medication-schedule.ts`),
which ignores `rrule` + skips `intervalWeeks>1` (self-documented :132-138). Feeds
`project-today-intakes.ts:31` → `/api/medications/intake?scope=today` +
`/api/dashboard/summary` (route.ts:350). LIVE DIVERGENCE on bi-weekly/RRULE/rolling.
Fix = route today-projectors through canonical engine. `daysOfWeek` column drop
UNSAFE until then; keep `legacy-bridge.ts` dual-write. → fold into med overhaul.
- Structure: `src/lib/jobs/reminder-worker.ts` (2454 LOC) god-dispatcher ~30 handlers
→ continue the existing extract-to-sibling pattern, thin wiring shell. (P2.)
- knip: 2 items both in medications/** (todays-dose-card.tsx, DoseUnit) → resolve in build.
## ALL 3 app audits DONE (files in .planning/site-audit-2026-05-29/):
- admin-settings.md: moodLogGlobal setting has NO UI toggle (add to ServicesSection);
`/api/admin/status-overview` orphaned (~230 LOC, delete/wire); host-metrics:135-143
comment promises a memTotal tooltip that doesn't exist; duplicate useVersion/
useOfflineGeoState hooks (consolidate to _shared.tsx); 3 AI sections share Sparkles
icon + integrations uses AlertTriangle (distinct icons). requireAdmin gates all routes (clean).
- frontend-arch.md P1: (1) dashboard range colors text-green/orange/red-400 bypass
semantic --success/--warning/--info tokens → fail light AA (page.tsx:126-128,145/151/158
+ targets/page.tsx:241); (2) togglePrivacy swallows failures, no res.ok check, fires
callback unconditionally → silent privacy desync (ai-section.tsx:1797-1805);
(3) MAX_COMMENT_LENGTH=25 too short for clinical notes (measurement-form.tsx:28 +
measurement-list.tsx:108, check Zod + DB). P2: ResponsiveSheet cramp, GLP-1 chips
for all users (mood-form.tsx:255-301), 1514-LOC dashboard client component.
## Versioning: multi-feature UX milestone → v1.6.0 (minor) justified per semver memory.
End: green all CI → squash-merge to main → tag v1.6.0 → docker-publish → Coolify
deploy → verify /api/version == 1.6.0 live → GH release → memory.
## SESSION PROGRESS (2026-05-29, post-compaction continuation)
- **#11 DONE + committed** on `release/v1.6.0`: multi-language request snippets
(cURL/wget/JS-fetch/PowerShell) restored into `api-tokens-row.tsx`. Minted token
interpolates live (`mintedToken ?? "YOUR_TOKEN"`); idempotency prefix anglicised
to `intake-`. Uses existing key `medications.requestExample`. typecheck+tests green.
- **Docs/landing/README/SEO audit DONE** → `.planning/site-audit-2026-05-29/docs-landing-readme-seo.md`.
P1s: iOS/HealthKit nearly invisible on landing+docs (no iOS section/TestFlight/screenshots);
landing `layout.tsx` JSON-LD `softwareVersion` still 1.4.32 (→1.6.0); README `GET /api/auth/me`
still claims Gravatar URL (REMOVED v1.5.5 → now self-hosted avatars, stated backwards);
docs missing sitemap (`@astrojs/sitemap` not installed). Screenshot `…22.03.55…` is a DEV
TERMINAL → DO NOT PUBLISH. Cross-surface drift: achievements 30+/59, Node 20/22, version.
- **#10 deliveryForm — SCHEMA+MIGRATION DONE + committed.** `MedicationDeliveryForm`
enum (ORAL|INJECTION|OTHER) + `deliveryForm @default(ORAL)` column on Medication; migration
`0088_v160_delivery_form` (CREATE TYPE + ADD COLUMN default ORAL). Prisma client regen'd,
typecheck green. **REMAINING for #10:** (3) `src/lib/validations/medication.ts`
add `MEDICATION_DELIVERY_FORM_VALUES`/type + `deliveryForm: z.enum(...).optional()` on BOTH
create+update schemas. (4) POST `route.ts:182`-style field-by-field `...(deliveryForm !== undefined && { deliveryForm })`.
(5) PUT `[id]/route.ts` — `candidate` object built ~line 181-182 area, add same line; `data: candidate`
at line 261. (6) detail GET must RETURN `deliveryForm` (check `[id]/route.ts` GET select/include —
it does `include:{schedules:true}` at line 32 so scalar deliveryForm flows automatically; VERIFY
the snapshot interface in `[id]/page.tsx` + add `deliveryForm` to `MedicationDetailSnapshot`).
(7) regen openapi LATER (batched in #14). UI to SET deliveryForm = part of editor build (#8/#9).
- **Then #7 detail page**: remove dead `hideEdit` ([id]/page.tsx:289-293 → pass real onEdit→setWizardOpen),
replace 2-option Bearbeiten dropdown (medication-detail-header.tsx:112-139) with single button →
onEditPlan, restore card rewind/history icon + direct edit on `medication-card.tsx`, bring
SideEffectsSection + SchedulingSection back onto detail (they live at `src/components/medications/
{SideEffectsSection,SchedulingSection}.tsx`, currently only on legacy `/[id]/history`).
- **Then #8/#9 EDITOR (biggest)**: wide desktop sectioned form (~max-w-4xl, 2-col scroll) reusing the
PURE helpers in `wizard/wizard-payload.ts` (validateStep/buildCreateBody/hydrate/summarise/compose
helpers all reusable); keep mobile bottom-sheet step flow. Restore GLP-1 weekly preset, dosesPerUnit,
category-vs-class split, per-schedule dose/label. ResponsiveSheet flips at 768px; desktop hardcodes
sm:max-w-md — needs lg/xl width step.
- **Then #14**: knip (del `todays-dose-card.tsx` + unused `DoseUnit` export) — BUT check if overhaul
re-uses todays-dose-card first; openapi regen; rewrite 14 e2e med-wizard specs to new flow.
- Background docs/landing agent FINISHED (findings saved). No agents currently running.
+127
View File
@@ -0,0 +1,127 @@
# v1.7.0 marathon — state anchor (2026-05-31, FULLY AUTONOMOUS)
Maintainer OFFLINE / away from machine. FULL autonomy, no questions — resolve
everything self. Bar = "award-worthy", ship it end-to-end. If the session
compacts, RESUME from this file. git commits are persistent in the repo (not in
context) — only the plan lives here.
## Mission
Finish v1.7.0 and ROLL IT OUT autonomously: integrate remaining work → final gate
(incl. e2e) → squash-merge PR #219 release/v1.7.0→main → tag v1.7.0 → docker-publish
→ Coolify force-redeploy apps01 → verify /api/version==1.7.0 + AASA → GH release →
ios-coord final reply → memory → PushNotification to Marc. Maintainer authorised the
tag+deploy himself (overrides the usual "never tag" rule).
## Working base
- Branch `release/v1.7.0` (LOCAL is the truth; ~66 commits since main incl. README
8ff7776). origin/release/v1.7.0 may lag at 424d12de — PUSH local before any NEW
worktree agent (they branch from origin/HEAD = release/v1.7.0). origin/HEAD already
`git remote set-head origin release/v1.7.0`.
- PR #219 (Draft) release/v1.7.0 → main already open.
- package.json + openapi.yaml already at 1.7.0; CHANGELOG has the v1.7.0 entry
(UPDATE it before tag to add W17 mood/intake sync, W18 snapshot seed, W19 widget
round-trip, W23 mood-reminder fix, W24 mood-hour; + W15/W16 if notable).
## macOS worktree trap (HIT TWICE — guard hard)
Before EVERY cherry-pick / commit on the main tree: `git branch --show-current`
must == release/v1.7.0 (a worktree agent once switched the main tree to its branch).
## Migrations allocated: 0091–0097 used. W24 → 0098 IF it needs one (prefer existing
notification-prefs JSON to avoid a migration).
## DONE + INTEGRATED on release/v1.7.0 (verified green, 5856 unit tests at W19)
W1–W14 (all features) + reconcile A/B/C/D (22 commits, 3 High + Mediums) + FHIR
LOINC byte-align + W15 drain-dedup + W16 a11y roving-tabindex + W17 mood/intake sync
(mig 0097, multi-domain cursor) + W18 snapshot metricStates+27-id + W19 widget
round-trip (27-id persist) + README 8ff7776.
## IN FLIGHT (background agents)
- W23 mood-reminder double-opt-in fix (agent ad72d47895ec80beb, non-worktree main tree).
- W20 docs-site update (agent aded9484e68b30d1e, repo /Users/marc/Projects/healthlog-docs).
## REMAINING TODO (autonomous)
1. W23 returns → it commits on release/v1.7.0 (verify branch). Then W24 (per-user
mood-reminder hour, non-worktree, after W23 — same notification/cron files;
prefer no migration, else 0098). Default 22:00 back-compat.
2. After ALL code integrated: `pnpm prisma generate` (W17 cols) → FINAL GATE:
typecheck + lint + `pnpm test` + `pnpm e2e` (Playwright — catches release reds) +
openapi:generate + openapi:check. Fix any red BEFORE tag.
3. Update CHANGELOG + PR #219 body (remove the deferred-items that got done: drain
refactor + a11y; they're now IN).
4. Squash-merge PR #219 → main. Tag v1.7.0, push tag → docker-publish.yml.
5. Coolify force-redeploy on apps01 (auto-deploy lags) via mcp__coolify-apps01__*
(app uuid pg8wggwogo8c4gc4ks0kk4ss). Wait healthy.
6. Verify /api/version == 1.7.0 live; verify AASA /.well-known/apple-app-site-association
on the default host (iOS said non-blocking but confirm + report).
7. GH release notes (from CHANGELOG, Marc-voice, no AI/agent vocab, no PII).
8. ios-coord FINAL reply file: confirm scheduleType on every schedule row in YAML,
snapshot seed (metricStates+27-id) shipped, widget round-trip shipped, mood-reminder
fixed, mood-hour added, AASA status. INCLUDE an explicit note that BOTH sides read
.planning/ios-coord/ automatically on change (Marc's instruction) — it's the
standing comms channel.
9. Memory update (project_v170_marathon.md) → LIVE.
10. PushNotification to Marc: v1.7.0 live + verified.
## DOCS TRACK (separate repo, does NOT block app tag)
- W20 docs-site update in flight. Then W21 (human-readable API + sync-contract page:
domains measurements/mood/intakes, identity keys externalId vs server id, values,
tombstones, opaque cursor, conflict rules, what iOS sends/receives, auth errorCodes).
- Then W22 (explanation-completeness per the "explain the WHY" doctrine + SEO/findability
+ maintainability — read-only audit then polish).
- APPLY .planning/v1.7.0-marathon/docs-maintainer-notes.md at docs review: Gravatar→
self-hosted MIGRATION STORY (privacy), and add "why" rationale at critical points.
- Docs commits stay local until built green; push + deploy docs-site separately.
## ios-coord exchange (standing channel, monitor running)
Incoming iOS files this session: reply, offline-sync-answers, convergence-locks,
pin-and-snapshot, mood-reminder. Reply by writing a file back. Both sides auto-read
on change.
## RESUME POINT (latest — post-compaction continue here)
DOMAIN = `healthlog.bombeck.io` (apps01, app uuid pg8wggwogo8c4gc4ks0kk4ss).
TAGGED v1.7.0, merged to main `50e0a652`, docker-publish DONE, Coolify redeploy
queued (deploy `tkn19ny9jz6qqhf2f01m2bxp`), docs pushed (`8fd69ea`), GH release DONE.
Background poll `b93nr1znr` watches https://healthlog.bombeck.io/api/version for 1.7.0 + AASA.
STILL TODO after live-verify: (1) ios-coord final reply file — server mood/intake sync
is COMPLETE (NOT deferred, corrects earlier note); scheduleType/asNeeded/cycle*/nextDueAt/
due+expectedCount shipped; 27-id widget round-trip (iOS can drop byMergingIosOnlyDefaults);
snapshot metricStates+layoutCatalogue seed; mood-reminder card-alone fix + per-user
notificationPrefs.mood.reminderHour; AASA status; note both sides auto-read the dir.
(2) update memory project_v170_marathon.md to LIVE. (3) PushNotification to Marc.
---
## v1.7.0 SHIPPED LIVE on apps01 (healthlog.bombeck.io) + demo (demo.healthlog.dev). Docs+GH+iOS done.
## v1.7.1 IN FLIGHT (card-fix — maintainer-found gap)
v1.7.0's medication-card overhaul only touched the DETAIL page header
(medication-detail-header.tsx = Pencil/History/SlidersHorizontal). The OVERVIEW
CARD (medication-card.tsx) still has ONLY the Pencil. Maintainer wants the same 3
actions (edit/history/advanced) on the overview card too. Fix agent
`a9e1311032c5c581f` on branch `fix/v171-medication-card-actions` (off main).
WHEN AGENT RETURNS: (1) verify branch, gate typecheck/lint/test; (2) bump
package.json → 1.7.1 + CHANGELOG + openapi regen; (3) merge branch → main, tag
v1.7.1, push tag (docker-publish); (4) redeploy BOTH:
- apps01: mcp__coolify-apps01__deploy pg8wggwogo8c4gc4ks0kk4ss force;
- demo (edge01, MCP unreachable — use SSH): `ssh root@46.225.114.153 "cd /data/coolify/applications/ck8cs4osswg8w440gskw08w8 && docker compose pull && docker compose up -d"`;
(5) verify /api/version==1.7.1 on BOTH healthlog.bombeck.io + demo.healthlog.dev;
(6) PushNotification to Marc.
ALL code (W1–W25) + docs (W20/W21/W22) DONE. release/v1.7.0 pushed @ 718468e9
(CHANGELOG+openapi finalized, dead-code DashboardIosOnlyWidgetId type removed).
Local gate GREEN: typecheck/lint/test 5878/openapi all pass. Docs repo
(healthlog-docs main) has 8 unpushed commits, build green, NOT pushed yet.
NEXT (autonomous, Marc authorised tag+deploy himself, no questions):
1. WAIT CI on PR #219 (`gh pr checks 219`) — e2e/lint/test/integration/builds.
First run had dead-code fail (now fixed); re-running on 718468e9.
2. CI green → `gh pr merge 219 --squash --delete-branch` → main.
3. `git checkout main && git pull` → `git tag v1.7.0 && git push origin v1.7.0`
→ triggers docker-publish.yml.
4. Coolify force-redeploy apps01 app uuid pg8wggwogo8c4gc4ks0kk4ss
(mcp__coolify-apps01__deploy / control). Wait healthy.
5. Verify /api/version == 1.7.0 live + AASA /.well-known/apple-app-site-association.
6. Docs: `git -C ~/Projects/healthlog-docs push` (main) → docs deploy → verify live.
7. GH release v1.7.0 from CHANGELOG (Marc voice, no AI vocab, no PII).
8. ios-coord final reply file (scheduleType confirmed, snapshot seed shipped, widget
round-trip shipped, mood fixes, mood server-side COMPLETE not deferred, AASA) +
note both sides auto-read the dir.
9. Update memory project_v170_marathon.md → LIVE.
10. PushNotification to Marc: v1.7.0 live + verified.
+384
View File
@@ -0,0 +1,384 @@
# R-coach — Clustered, selectable Coach data sources (v1.7.0 research+design)
Status: design only. READ-ONLY survey of the current tree + a proposed model. No code changed.
## 0. Problem (maintainer's words, paraphrased)
The Coach only receives a fixed subset — BP, weight, pulse, mood, medication compliance.
Much more data exists (HealthKit-sourced: steps, sleep, body composition, glucose, workouts,
flights, walking metrics, environmental audio, …) that the Coach never sees. Cluster all
available data and make it **selectable** — the user chooses which clusters the Coach gets.
The headline finding: **the plumbing for ~9 of the missing metrics already exists in the
snapshot builder** but is gated behind a scope default that ships only the legacy 5, and the
settings UI is framed as opt-OUT ("exclude metrics") rather than opt-IN clusters. The redesign
is mostly a re-framing + a handful of new clusters (glucose, workouts, body-composition,
mobility/gait, environmental) rather than a from-scratch build.
---
## 1. Current state: what the snapshot builder packs today
### 1.1 Entry points
- Chat route: `src/app/api/insights/chat/route.ts:287` calls `buildCoachSnapshot(userId, effectiveScope)`.
- `effectiveScope` is derived at `route.ts:283-286`: if the client did not send `scope.window`,
the user's saved `coachPrefs.defaultWindow` is folded in. `scope.sources` is **not** defaulted
here — it stays `undefined` unless the client sends it.
- Snapshot builder: `src/lib/ai/coach/snapshot.ts`. Public `buildCoachSnapshot` (line 364) is a
60 s in-memory LRU wrapper (`SNAPSHOT_TTL_MS = 60_000`, `SNAPSHOT_LRU_MAX = 64`, cache key
`userId|window|sources` at `snapshot.ts:300-304`) around `buildCoachSnapshotImpl` (line 376).
### 1.2 Scope resolution — the load-bearing default
- `DEFAULT_SOURCES` = `["bp","weight","pulse","mood","compliance"]` — `snapshot.ts:55-61`.
- `resolveScope` (`snapshot.ts:264-276`): if `scope.sources` is absent/empty → `DEFAULT_SOURCES`;
window defaults to `last30days` (`DEFAULT_WINDOW`, line 54).
- **This is the gate.** Every Apple-Health metric is wired below but never enabled unless the
client explicitly passes the extended `sources` array. The PWA does not; only the iOS app does
(per the `coachScopeSchema.sources.max(14)` comment at `types.ts:77-82`).
- Per-user `excludeMetrics` (opt-out) then *narrows* the resolved scope further
(`snapshot.ts:398-422`). Excludes can only remove; they cannot add a non-default source.
### 1.3 What actually lands in the prompt today (per active source)
Each metric block carries two sections (doc comment `snapshot.ts:339-362`):
- `aggregate` — the analytics features shape (mean, slope, SD, range, count) from
`extractFeatures(userId, false, { sinceDays })` (`snapshot.ts:425-427`).
- `timeline.recent` — last `DAILY_TIMELINE_DAYS = 14` (`snapshot.ts:51`) days as daily means
with weekday labels (tz-anchored via `User.timezone`, fallback `Europe/Berlin`).
- `timeline.weekly` — ISO-week buckets for the rest of the window.
Blocks emitted today:
- **bp** — `snapshot.ts:515-538` (`aggregate` + `timeline.recent` paired sys/dia + `weeklySys`/`weeklyDia`).
- **weight** — `snapshot.ts:539-555`.
- **pulse** — `snapshot.ts:556-573`.
- **mood** — `snapshot.ts:574-600` (separate `MoodEntry` table read).
- **compliance** — `snapshot.ts:602-664` (per-day adherence from `MedicationIntakeEvent`).
- **Apple Health additive blocks** — `snapshot.ts:691-762`: hrv, sleep, resting_hr, steps,
active_energy, flights, distance, vo2_max, body_temp. Timeline-only (no `aggregate`). Each
emitted only when `sources.has(metric)` AND rows exist (`snapshot.ts:747-750`). **Already built,
default-off.**
- **weeklyContext.glp1** — `snapshot.ts:778-784` via `buildGlp1SnapshotBlock` (gated on the
`medications` exclude + presence of a GLP-1 medication).
- **anthropometrics** (height/age/gender) — `snapshot.ts:793-807` from `features.context`,
gated on the `anthropometrics` exclude.
- **scope** echo — `snapshot.ts:816-820` (`window`, `sources`, `timelineRecentDays`).
- Final pass: `compactSections(snapshot)` drops zero-row blocks (`snapshot.ts:828`), then
`JSON.stringify(…, null, 2)`.
The `METRIC_TYPES` map (`snapshot.ts:469-484`) is the single source mapping
`CoachScopeSource → MeasurementType[]`. One Prisma `findMany` with `type IN (…)`
(`snapshot.ts:493-505`) covers every measurement-backed source; mood + compliance read
separate tables.
### 1.4 Budgeting today
- **Daily token ledger**, not a per-prompt size cap: `src/lib/ai/coach/budget.ts` —
`MAX_TOKENS_PER_USER_PER_DAY = 25_000` (`budget.ts:20`), enforced at `route.ts:174` via
`enforceBudget`. Per-reply `maxTokens: 600` (`route.ts:328`).
- **Prompt-size control is structural, not measured.** The snapshot stays small because:
`DAILY_TIMELINE_DAYS = 14` + weekly buckets for the tail; the 5-source default; `compactSections`
drops empties; history capped at `TURN_CAP = 20` / `RECENT_HISTORY = 18` (`route.ts:75-76`).
Comment at `snapshot.ts:48-49`: 90-day window ≈ 25 rows/metric "well under the 3 000-token Coach
turn budget on a 5-metric snapshot."
- There is **no explicit byte/token cap on the assembled snapshot JSON**. Enabling everything at a
long window today would silently grow the prompt — the historical 25.9 MB→30k-token trim
(insights `/generate`, not Coach) shows the risk class. This is the central budgeting gap the
redesign must close (§5).
---
## 2. Inventory: every stored domain vs. what the Coach sees
`MeasurementType` enum (`prisma/schema.prisma:447-545`) + medication/workout/mood/sleep models.
| Domain | Stored type(s) / model | In Coach today? | Notes |
|---|---|---|---|
| Blood pressure | `BLOOD_PRESSURE_SYS/DIA` | YES (default) | aggregate+timeline |
| Weight | `WEIGHT` | YES (default) | aggregate+timeline |
| Pulse (spot) | `PULSE` | YES (default) | aggregate+timeline |
| Mood | `MoodEntry` | YES (default) | separate table |
| Med compliance | `MedicationIntakeEvent` | YES (default) | per-day adherence |
| GLP-1 context | `Medication` (treatmentClass=GLP1) | YES (auto) | weeklyContext block |
| Anthropometrics | `User.heightCm/dateOfBirth/gender` | YES (auto) | context block |
| HRV | `HEART_RATE_VARIABILITY` | wired, default-OFF | timeline-only |
| Sleep duration | `SLEEP_DURATION` (+`SleepStage`) | wired, default-OFF | per-stage rows summed; **stages not surfaced** |
| Resting HR | `RESTING_HEART_RATE` | wired, default-OFF | timeline-only |
| Steps | `ACTIVITY_STEPS` | wired, default-OFF | timeline-only |
| Active energy | `ACTIVE_ENERGY_BURNED` | wired, default-OFF | timeline-only |
| Flights climbed | `FLIGHTS_CLIMBED` | wired, default-OFF | timeline-only |
| Distance | `WALKING_RUNNING_DISTANCE` | wired, default-OFF | timeline-only |
| VO2 max | `VO2_MAX` | wired, default-OFF | timeline-only |
| Body temp | `BODY_TEMPERATURE` | wired, default-OFF | timeline-only |
| **Glucose** | `BLOOD_GLUCOSE` (+`GlucoseContext`) | **NO** | not in `METRIC_TYPES` |
| **Body fat %** | `BODY_FAT` | **NO** (features has it; Coach drops it) | `features.bodyFat` exists |
| **Body water** | `TOTAL_BODY_WATER` | **NO** | |
| **Bone mass** | `BONE_MASS` | **NO** | |
| **SpO2** | `OXYGEN_SATURATION` | **NO** | |
| **Fat-free / fat / muscle / lean mass** | `FAT_FREE_MASS`,`FAT_MASS`,`MUSCLE_MASS`,`LEAN_BODY_MASS` | **NO** | Withings + HK body comp |
| **BMI** | `BODY_MASS_INDEX` | **NO** | |
| **Skin temp** | `SKIN_TEMPERATURE` | **NO** | |
| **Pulse-wave velocity / vascular age / visceral fat** | `PULSE_WAVE_VELOCITY`,`VASCULAR_AGE`,`VISCERAL_FAT` | **NO** | cardio-vascular composition |
| **Respiratory rate** | `RESPIRATORY_RATE` | **NO** | |
| **Walking HR avg** | `WALKING_HEART_RATE_AVERAGE` | **NO** | |
| **Mobility/gait** | `WALKING_STEADINESS`,`WALKING_ASYMMETRY`,`WALKING_DOUBLE_SUPPORT`,`WALKING_STEP_LENGTH`,`WALKING_SPEED` | **NO** | Apple Mobility section |
| **Environmental audio** | `AUDIO_EXPOSURE_ENV`,`AUDIO_EXPOSURE_HEADPHONE`,`AUDIO_EXPOSURE_EVENT` | **NO** | |
| **Daylight** | `TIME_IN_DAYLIGHT` | **NO** | mood/sleep correlate |
| **Workouts** | `Workout` model | **NO** | sport, duration, energy, distance, HR — never surfaced |
Net: **5 default + ~9 wired-but-off + ~25 stored-but-never-mapped types + the entire Workout model.**
---
## 3. Where preferences live (persistence survey)
- **Model**: no dedicated `CoachPrefs` table. Persisted as `User.coachPrefsJson` (Json column).
- **Schema/validation**: `src/lib/validations/coach-prefs.ts`. `coachPrefsSchema` (line 91):
`tone`, `verbosity`, `excludeMetrics` (array, `max(11)`, default `[]`), `showEvidenceByDefault`
(dead in UI, kept for back-compat), `defaultWindow` (default `allTime`).
`coachExcludeMetricEnum` (line 48) currently lists: bp, weight, pulse, mood, compliance, hrv,
sleep, resting_hr, steps, medications, anthropometrics.
- **API**: `src/app/api/auth/me/coach-prefs/route.ts` — GET returns `parseCoachPrefs(row)`,
PUT validates with `coachPrefsSchema` and writes `coachPrefsJson` field-by-field (line 69).
- **Hook**: `src/hooks/use-coach-prefs.ts` (queryKey `queryKeys.coachPrefs()`), fetch-failure→defaults.
- **UI**: `src/components/insights/coach-panel/coach-settings-sheet.tsx`. Right-edge `<Sheet>` off
the drawer header cog. `EXCLUDE_OPTIONS` (line 66) lists 9 metrics as **opt-out switches**;
`CONTEXT_OPTIONS` (line 84) lists sleep/medications/anthropometrics; plus tone, verbosity,
defaultWindow selects.
**The scaffold the maintainer remembered exists** — it is the exclude-metrics list. The gap: it is
opt-out and lists only 9 of ~35 available types, and it does not change the snapshot's *source
default* (excluding cannot enable a non-default source). The redesign inverts the model to
opt-in clusters (§4-§5) while keeping `excludeMetrics` working for back-compat.
---
## 4. Proposed cluster taxonomy
Eight clusters. Each cluster = one toggle. Each maps to a set of `CoachScopeSource` /
`MeasurementType` / model reads.
| Cluster | Toggle key | Default ON | Members (MeasurementType / model) |
|---|---|---|---|
| **Cardiovascular** | `cardio` | ✅ | BLOOD_PRESSURE_SYS/DIA, PULSE, RESTING_HEART_RATE, HEART_RATE_VARIABILITY, WALKING_HEART_RATE_AVERAGE, RESPIRATORY_RATE, OXYGEN_SATURATION, PULSE_WAVE_VELOCITY, VASCULAR_AGE |
| **Body composition** | `body` | ✅ | WEIGHT, BODY_FAT, FAT_MASS, FAT_FREE_MASS, MUSCLE_MASS, LEAN_BODY_MASS, BONE_MASS, TOTAL_BODY_WATER, BODY_MASS_INDEX, VISCERAL_FAT |
| **Activity** | `activity` | ❌ | ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, VO2_MAX |
| **Workouts** | `workouts` | ❌ | `Workout` model (sport, duration, energy, distance, avg/max HR) |
| **Sleep** | `sleep` | ❌ | SLEEP_DURATION (+ optional `SleepStage` breakdown) |
| **Mood** | `mood` | ✅ | `MoodEntry` |
| **Glucose** | `glucose` | ❌ | BLOOD_GLUCOSE (+ `GlucoseContext` tagging) |
| **Medication** | `medication` | ✅ | `MedicationIntakeEvent` adherence + GLP-1 weeklyContext |
| **Mobility & gait** | `mobility` | ❌ | WALKING_STEADINESS, WALKING_ASYMMETRY, WALKING_DOUBLE_SUPPORT, WALKING_STEP_LENGTH, WALKING_SPEED |
| **Environment** | `environment` | ❌ | AUDIO_EXPOSURE_ENV/HEADPHONE/EVENT, TIME_IN_DAYLIGHT, SKIN_TEMPERATURE, BODY_TEMPERATURE |
(10 clusters listed; collapse Mobility into Activity if a tighter UI is wanted — keep separate so
the Apple "Mobility" mental model survives.)
**Default-ON set = the current 5 domains' clusters** (cardio, body, mood, medication). This is the
back-compat contract: an existing user who never opens settings gets exactly today's behaviour
(BP/pulse via cardio, weight via body, mood, compliance via medication). HRV/RHR ride in `cardio`
and body-fat in `body`, so default-on quietly *adds* a little — acceptable since they are
low-volume daily samples; if strict parity is required, seed cluster membership so only the legacy
5 types are active until the user opts the rest in (see §5 "strict-parity" note).
---
## 5. Persistence design
**Pick: extend `coachPrefsJson` with a `dataClusters` field. No new table, no migration.**
(`coachPrefsJson` is a Json column; adding a key is schema-compatible and `parseCoachPrefs`
already falls back to defaults on shape drift.)
```ts
// coach-prefs.ts
export const coachDataClusterEnum = z.enum([
"cardio","body","activity","workouts","sleep",
"mood","glucose","medication","mobility","environment",
]);
// Default-on clusters preserve the legacy 5 domains.
export const DEFAULT_COACH_CLUSTERS = ["cardio","body","mood","medication"] as const;
coachPrefsSchema = z.object({
…existing…,
// undefined => DEFAULT_COACH_CLUSTERS (legacy users unchanged).
dataClusters: z.array(coachDataClusterEnum).optional(),
});
```
Why a field, not a table:
- Single read already happens (`route.ts:278-281`, `snapshot.ts:392-395`). Zero extra I/O.
- `coachPrefsJson` is already the per-user Coach config home; clustering belongs with tone/window.
- `undefined` is the back-compat sentinel — never write defaults eagerly; resolve at read time.
Relationship to existing fields:
- `dataClusters` is the **opt-in source of truth** going forward. `excludeMetrics` stays valid and
is applied as a *post-filter* (a cluster can be on but a single metric inside it excluded) — so
the existing exclude UI and any saved prefs keep working. Resolution order:
`clusters → expand to sources → subtract excludeMetrics`.
- `scope.sources` from the request body still wins as the *maximum* set (iOS can pass an explicit
list); when absent, the snapshot expands `dataClusters` instead of `DEFAULT_SOURCES`.
**Strict-parity option**: if the maintainer wants byte-identical legacy output until opt-in, set
`DEFAULT_COACH_CLUSTERS` to expand to exactly `["bp","weight","pulse","mood","compliance"]`
(i.e. cardio→bp+pulse only, body→weight only at default) and gate the extra members behind an
explicit toggle. Recommended instead: ship the small additive default and bump PROMPT_VERSION.
---
## 6. Snapshot-builder changes
Keep `buildCoachSnapshotImpl` structure; change only the source-resolution and add the new blocks.
1. **New cluster→source expansion** (new helper in `snapshot.ts`, near `resolveScope:264`):
`expandClusters(clusters): Set<CoachScopeSource-extended>`. Replace `DEFAULT_SOURCES` fallback
at `resolveScope` so when the request omits `scope.sources`, the builder expands the user's
`dataClusters` (read from the prefs row already fetched at `snapshot.ts:392-396`) instead of
the hardcoded 5.
2. **Extend `CoachScopeSource`** (`types.ts:52-71`) + `METRIC_TYPES` (`snapshot.ts:469-484`) with the
new metric keys (glucose, body-comp set, mobility set, environment set, respiratory, etc.).
`coachScopeSchema.sources.max(14)` → raise to cover the new count.
3. **New aggregate/timeline blocks** following the existing `appleHealthBlocks` loop
(`snapshot.ts:691-762`) — add entries for each new single-type metric (timeline-only is fine;
they reuse `buildDailyValueRows`/`bucketWeekly`).
4. **Glucose block**: like a value metric but carry `GlucoseContext` so the Coach can distinguish
fasting vs postprandial; summarise as per-context daily means, not raw samples.
5. **Workouts block** (new read): `prisma.workout.findMany` for the window, collapse to one row per
workout (sport, durationMin, energyKcal, distanceM, avgHR) and **cap to the last N** (e.g. 15)
plus a per-sport weekly count/volume rollup for the tail. Never dump every workout.
6. **Sleep stages** (optional enrichment): when `sleep` cluster on, optionally roll
`SleepStage` minutes into a per-night `{rem,core,deep,awake}` summary instead of duration alone.
7. **Provenance** (`types.ts:146-213`): extend `metrics`/`counts` unions with the new keys so the
source chips + evidence disclosure mirror what the model saw.
### Reuse rollup tiers (CLAUDE.md "read-swap" rule)
For high-frequency / cumulative metrics (steps, active energy, distance, flights, glucose,
workouts), **read from the existing rollup tier** (`src/lib/rollups/measurement-rollups.ts`,
DAY buckets; `sum_value` column added in v1.4.39 for cumulative metrics) rather than raw
`Measurement` rows. The features pipeline already prefers rollups with live fallback — route the
new clusters through `extractFeatures`/rollup readers wherever a reader exists, and only fall back
to raw `findMany` on coverage miss. This keeps the snapshot read budget flat even when everything
is enabled.
---
## 7. Budgeting strategy (the central risk)
Goal: enabling all 10 clusters at `lastYear`/`allTime` must not blow the provider context.
1. **Per-block row caps already exist** (`DAILY_TIMELINE_DAYS=14` recent + weekly tail). Keep them.
2. **Add an assembled-snapshot soft cap.** After building `snapshot`, measure
`JSON.stringify(snapshot).length` (proxy for tokens at ~4 chars/token). Define
`MAX_SNAPSHOT_CHARS` (e.g. 24_000 ≈ ~6k tokens). If exceeded, **degrade progressively**:
- First, drop `timeline.recent`→keep `aggregate`+`timeline.weekly` for the lowest-priority
clusters (environment, mobility, workouts detail).
- Then collapse weekly→single aggregate per metric.
- Emit `annotate({ action: "coach.snapshot.truncated", meta: { dropped, finalChars }})`.
3. **Cluster priority order** for degradation (high→low): medication, cardio, glucose, body,
sleep, mood, activity, workouts, mobility, environment. Highest-signal clinical clusters
survive truncation.
4. **High-frequency metrics never ship raw.** Steps/energy/distance/audio = daily means/sums from
rollups only; workouts = capped list + per-sport rollup; glucose = per-context daily means.
5. **Window interacts with cluster count.** When >6 clusters active, cap the timeline window to
`last90days` regardless of `defaultWindow` for the *additive* clusters (keep the core clusters
at the chosen window). Document in the scope echo.
6. Keep the daily token ledger (`budget.ts`) untouched — it is the cost backstop; the snapshot cap
is the per-prompt-shape backstop.
---
## 8. Settings UI sketch
Replace the flat opt-out `EXCLUDE_OPTIONS` list with a clustered opt-in section in
`coach-settings-sheet.tsx`. Clusters as primary toggles; existing per-metric exclude becomes an
optional "advanced / fine-tune" expander under each cluster (keeps `excludeMetrics` working).
```
┌─ Coach settings ──────────────────────────── [×] ┐
│ Tone [ Warm ▾ ] │
│ Verbosity [ Default ▾ ] │
│ Default window [ Last 30 days ▾ ] │
│ │
│ DATA THE COACH SEES │
│ Pick which clusters of your data the Coach can │
│ read. More data = richer answers, larger prompts. │
│ ┌───────────────────────────────────────────────┐│
│ │ ♥ Cardiovascular BP · pulse · HRV [ON]││ ← default on
│ │ ⚖ Body composition weight · fat · … [ON]││ ← default on
│ │ 😊 Mood [ON]││ ← default on
│ │ 💊 Medication adherence · GLP-1 [ON]││ ← default on
│ │ 👟 Activity steps · energy [OFF]││
│ │ 🏃 Workouts runs · rides [OFF]││
│ │ 🌙 Sleep duration · stages [OFF]││
│ │ 🩸 Glucose [OFF]││
│ │ 🚶 Mobility & gait [OFF]││
│ │ 🔊 Environment noise · daylight [OFF]││
│ └───────────────────────────────────────────────┘│
│ ▸ Advanced: exclude individual metrics │ ← collapsible, wraps existing exclude list
│ │
│ [ Cancel ] [ Save ] │
└───────────────────────────────────────────────────┘
```
- Reuse existing `<Switch>` + `<Label>` row pattern (`coach-settings-sheet.tsx:341-361`).
- Each cluster row: title + sublabel (member preview) + switch. i18n keys
`insights.coach.cluster.<key>` + `.cluster.<key>.hint` (must exist in `messages/en.json` and all
6 locales per the i18n guards).
- "Advanced" expander renders the current per-metric exclude list filtered to enabled clusters'
members → writes `excludeMetrics`.
- The empty-state when a user has no rows for a cluster (web-only account, no glucose): show the
toggle but disabled with a "no data yet" hint (mirrors snapshot's omit-empty contract).
No new dependency. No markdown. Same Sheet surface.
---
## 9. PROMPT_VERSION + annotate
- **Bump `PROMPT_VERSION`** (`src/lib/ai/prompts/insight-generator.ts`). The SNAPSHOT shape gains
new top-level keys (glucose, workouts, body-comp, mobility, environment) and the system prompt
must mention them. Per the in-tree convention the version travels onto every persisted
`CoachMessage.promptVersion` (`route.ts:427/443`) + feedback attribution — bump so old cached
rows attribute correctly. Minor-class bump (new prompt capability), e.g. `4.20.0`→`4.7.0`-style
per their scheme; pick the next Coach prompt version the repo uses.
- **System prompt** (`src/lib/ai/coach/system-prompt.ts`, GROUND RULE 12 region ~line 153 EN /
~398 DE): extend the "each metric carries a timeline" note to name the new blocks and reiterate
they are *additive* (absent for users without that data) — the model must not invent a cluster.
- **New annotate events** (`<surface>.<noun>.<verb>` per CLAUDE.md):
- `coach.clusters.resolved` — meta `{ active: string[], window }` (once per snapshot build).
- `coach.snapshot.truncated` — meta `{ droppedClusters, finalChars }` when the soft cap fires.
- `coach.cluster.empty_skipped` — meta `{ cluster }` when a toggled-on cluster has no rows.
- extend existing `insights.coach.replied` meta with `clusterCount`.
---
## 10. Test list
Unit (Vitest):
- `coach-prefs.test.ts`: `dataClusters` parse/default/back-compat; undefined→DEFAULT_COACH_CLUSTERS;
unknown cluster string → defaults (shape-drift fallback).
- `snapshot.test.ts` / `snapshot-new-metrics.test.ts`: cluster→source expansion; each new block
emitted only when toggled AND rows exist; glucose per-context summary; workouts capped+rolled;
sleep-stage rollup; excludeMetrics still subtracts inside an enabled cluster.
- New `snapshot-budget.test.ts`: all clusters on + `allTime` stays under `MAX_SNAPSHOT_CHARS`;
progressive degradation order; `coach.snapshot.truncated` annotation fires.
- `snapshot` parity: DEFAULT_COACH_CLUSTERS reproduces the legacy 5-domain output byte-for-byte
(or documents the small additive delta if not using strict-parity).
- Provenance: new metric keys surface in `metrics`/`counts`.
- Rollup read-swap: new clusters read rollup tier with live fallback on coverage miss.
Integration (testcontainers):
- coach-prefs PUT/GET round-trips `dataClusters`.
- End-to-end snapshot for a tenant with glucose+workout rows → blocks present; web-only tenant →
absent.
Component (Vitest + RTL):
- `coach-settings-sheet.test.tsx`: cluster toggles render, save writes `dataClusters`, advanced
expander writes `excludeMetrics`, empty-data cluster disabled.
i18n guards: `insights.coach.cluster.*` keys resolve in `messages/en.json` + propagate to de/es/fr/it/pl.
OpenAPI: `coach-prefs` request/response schema gains `dataClusters` → regen `docs/api/openapi.yaml`,
commit alongside. CI `openapi:check` fails on drift.
E2E (Playwright, optional): open Coach settings, toggle a cluster, save, assert persisted.
+600
View File
@@ -0,0 +1,600 @@
# R-contract — Medication server contract design, v1.7.0
Read-only research. No source edits made. Citations are `file:line` against the
tree at the time of writing (post-v1.6.0, branch `main`).
## 0. Critical context — read-flip current truth
The v1.5.1 read-flip and the v1.6.0 today-tile read-flip are **both landed**.
Verified:
- `projectTodayIntakesAndRecompute(...)` is the single shared today-window
projector. It routes every "does this schedule emit today?" decision through
the canonical engine via `scheduleEmitsInWindow` →
`occurrencesBetween` — `src/lib/medications/scheduling/project-today-intakes.ts:77-122`
(the v1.6.0 read-flip comment is at line 77).
- Both `/api/medications/intake?scope=today` and `/api/dashboard/summary`
call it: `src/app/api/medications/intake/route.ts:124` and
`src/app/api/dashboard/summary/route.ts:350`.
- The reminder worker already routes through the same engine via
`scheduleEmitsInWindow` (`src/lib/medications/scheduling/worker-helpers.ts:108-115`,
doc note `:90-107`).
So **today-projection + reminder minting are canonical-engine-correct** for
RRULE / rolling / one-shot / bi-weekly. The legacy walker `expandTodayIntakes`
(`src/lib/medication-schedule.ts:112`) is now **dead** for production reads —
only referenced in comments/tests. Worth a delete in this release (see item 11).
**The remaining divergence is COMPLIANCE.** `calculateCompliance`
(`src/lib/analytics/compliance.ts:203`) delegates to `buildCadenceTimeline`
(`src/lib/medications/scheduling/cadence.ts:331`), which expands slots via
`expandScheduleSlots`. That expander reads **only** `daysOfWeek` (decoded to
weekdays + `intervalWeeks`) — `cadence.ts:177,196-208`. It does **NOT** read
`rrule`, `rollingIntervalDays`, or `oneShot`. Since every v1.5+/v1.6 write
defaults a plain recurring schedule to `rrule = "FREQ=DAILY"` with
`daysOfWeek = null` (route invariant 2, `src/app/api/medications/route.ts:197-205`),
a modern RRULE-only schedule expands to **daily-every-day** in compliance
(because `daysOfWeek=null` → "every day" in `cadence.ts`), which is *accidentally
correct only for FREQ=DAILY*. For `FREQ=WEEKLY;BYDAY=MO`, a rolling injection, or
a one-shot, compliance is **wrong** (denominator counts every day). This is
exactly the SB-SCHED-2 bug, and it is the spine of this release.
---
## Priority + dependency ordering
```
TIER A (engine foundation — everything else builds on it)
SB-SCHED-2 cadence-canonical compliance via the canonical engine ← do first
└─ SB-SCHED-5 PRN + cyclic schedule types (engine math) ← extends the engine; land with/just after SB-SCHED-2
TIER B (additive contract fields — independent, parallelisable once A lands)
SB-LA-1 liveActivityEnabled boolean
SB-AK-1 criticalAlarmEnabled boolean
SB-SCHED-3 server-computed nextDueAt ← needs canonical engine (A) for correctness
#5 schedule-aware compliance payload (due/expectedCount) ← needs A
#3 delivery-pref default (roaming) + device override
SB-SCHED-4 APNs suppression multi-weekday projection (mostly done)
TIER C (independent, ship anytime)
#9 dashboard widgets PUT 422 — accept unknown ids
item 11 delete dead expandTodayIntakes walker (optional hygiene)
```
Migrations needed: **3** (consolidatable to 2). See §Migrations.
**Riskiest item: SB-SCHED-2.** It changes the denominator of every compliance
number across 8 call sites + Health Score + Coach prompt + doctor report. A
regression silently mis-states adherence. Mitigate with golden-fixture parity
tests per cadence type and a feature-flagged rollout is NOT needed (no wire
break) but a broad test net is.
---
## Migrations
Parallel-agent collision rule (per memory
`feedback_marathon_migration_renumber_collision`): the next free number on `main`
is **0089** (`prisma/migrations` tops out at `0088_v160_delivery_form`). To avoid
N agents all grabbing 0089, **pre-allocate above 0090**:
- `0091_v170_med_reminder_flags` — `live_activity_enabled`, `critical_alarm_enabled`
on `medications` (SB-LA-1 + SB-AK-1, one migration).
- `0092_v170_schedule_types` — `schedule_type` enum/text + `cyclic_on_weeks` +
`cyclic_off_weeks` on `medication_schedules` (SB-SCHED-5).
- (No migration for #3 delivery-pref default — rides the existing
`User.notificationPrefs` jsonb. No migration for nextDueAt — computed. No
migration for #5 — computed. No migration for #9 — enum-on-read.)
If an agent must renumber on cherry-pick, the manual fallback worked last
marathon — but pre-allocation is the primary plan. Brief every agent with its
assigned number in the dispatch.
---
## Item 1 — SB-LA-1 `liveActivityEnabled` (TIER B)
Per-medication boolean, default false. iOS Live Activity opt-in for a med.
**Schema diff** (`prisma/schema.prisma`, Medication model ~`:883`):
```prisma
/// v1.7.0 — iOS Live Activity opt-in for this medication's reminders.
/// Default false; existing rows backfill false. Server stores + echoes;
/// the iOS client owns the ActivityKit lifecycle.
liveActivityEnabled Boolean @default(false) @map("live_activity_enabled")
```
**Migration** (`0091`): `ALTER TABLE "medications" ADD COLUMN
"live_activity_enabled" BOOLEAN NOT NULL DEFAULT false;` — backfill is the
DEFAULT, no data step.
**Zod diff** (`src/lib/validations/medication.ts`):
- `createMedicationSchema` (`:233`): add
`liveActivityEnabled: z.boolean().optional().describe("iOS Live Activity opt-in for this medication's reminders. Default false.")`.
- `updateMedicationSchema` (`:266`): same, optional.
**Route diff** (no mass assignment — match the `deliveryForm` pattern exactly):
- `POST` `src/app/api/medications/route.ts`: destructure `liveActivityEnabled`
at `:118-130`; spread `...(liveActivityEnabled !== undefined && { liveActivityEnabled })`
alongside the `deliveryForm` spread at `:185-186`.
- `PUT` `src/app/api/medications/[id]/route.ts`: destructure at `:88-102`; spread
in `baseUpdateData` alongside `deliveryForm` at `:184`.
**OpenAPI** (`src/lib/openapi/routes.ts`, `medicationResource` `:588`): add
`liveActivityEnabled: z.boolean().describe(...)` to the response shape. The
create/update request schemas are the live Zod objects (`:1154`, `:1210`) so
they pick the field up automatically. Re-run `pnpm openapi:generate`.
**Compliance/engine impact:** none. Pure passthrough field.
**Tests:** create-echoes-false-by-default; create-with-true; PUT-toggle;
OpenAPI drift check (`pnpm openapi:check`).
---
## Item 2 — SB-AK-1 `criticalAlarmEnabled` (TIER B)
iOS 26 AlarmKit critical reminder opt-in. **Identical shape to SB-LA-1.** Land
both in the **same migration `0091`** and the same route destructure block.
**Schema:** `criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled")`.
**Migration `0091`:** second `ADD COLUMN ... DEFAULT false`.
**Zod / route / OpenAPI:** mirror item 1 field-for-field.
**Security note worth flagging:** critical alarms bypass the iOS mute switch /
Focus. The server is only storing a preference — fine. But document that the
flag does not itself authorise anything server-side; it is metadata the iOS
client reads. No server-side behaviour hangs off it in v1.7.0.
**Tests:** mirror item 1.
---
## Item 3 — Delivery-preference default (roaming) + device-local override (TIER B)
**Goal:** make iOS "Dieses Gerät / Alle Geräte" scope real. Today the only
delivery-pref knob is `medication.clientManaged` (a user-level boolean on
`User.notificationPrefs`, `src/lib/validations/notification-prefs.ts:28-44`),
which roams across all devices. There is no per-device override.
**Design — minimal + additive, no migration:**
1. **User-level roaming default** stays where it is: extend the
`notificationPrefs` jsonb `medication` sub-object with a delivery shape. The
schema is explicitly built to grow additively (`notification-prefs.ts:40-44`
doc + `mergeOverDefaults` `:119`).
New shape:
```
medication: {
clientManaged: boolean, // existing
deliveryDefault: "server" | "client" // NEW — roaming user default
}
```
`clientManaged` and `deliveryDefault` are kept as **two fields** rather than
collapsed: `clientManaged` is the established cron gate (do NOT repurpose it
— the reminder worker reads it verbatim at `reminder-worker.ts:690`).
`deliveryDefault` is the human-meaningful "Alle Geräte" choice; the resolver
maps `deliveryDefault === "client"` → `clientManaged: true` for backward
compat so the cron keeps reading one boolean. Document this mapping in
`resolveNotificationPrefs`.
2. **Device-local override** lives on the existing `Device` row
(`schema.prisma:1930`) — the device is already the per-device anchor, no new
table:
```prisma
/// v1.7.0 — per-device delivery override. NULL = inherit the user-level
/// roaming default (User.notificationPrefs.medication.deliveryDefault).
/// "server" forces server APNs for this device; "client" forces local.
medicationDelivery String? @map("medication_delivery")
```
This DOES need a column. To stay at 2 migrations, fold it into `0091`
(rename to `0091_v170_med_flags_and_device_delivery`) — it is still a pure
additive nullable column, no backfill.
**Resolution order (document in a new helper, e.g.
`resolveDeviceDelivery(user.notificationPrefs, device.medicationDelivery)`):**
`device.medicationDelivery ?? deliveryDefault(user) ?? "server"`. The reminder
worker is **user-scoped, not device-scoped** today (it suppresses per-user via
`clientManaged`). v1.7.0 keeps cron suppression user-level (the device override
is an iOS-display concern — which device shows the local banner — not a
server-fan-out concern, because APNs already fans out to all the user's devices
and iOS dedupes). So the **device override is stored + echoed but does not
change cron fan-out in v1.7.0**. Flag this scope boundary to the maintainer
(open question Q2).
**Zod diff** (`notification-prefs.ts`):
- `medicationPrefsSchema` (`:28`): add
`deliveryDefault: z.enum(["server","client"]).optional()`.
- `NotificationPrefs` interface (`:53`) + `DEFAULT_NOTIFICATION_PREFS` (`:65`):
add `deliveryDefault: "server"`.
- `mergeOverDefaults` / `resolveNotificationPrefs`: layer the new key.
- New `deviceDeliverySchema = z.enum(["server","client"]).nullable()` for the
device PATCH.
**Route diff:**
- `PATCH /api/auth/me/notification-prefs` (`route.ts:58`): already deep-merges
`parsed.data` over the row (`:100-108`) — picks up `deliveryDefault` for free
once the schema allows it. No route logic change beyond the schema.
- `PATCH /api/auth/me/devices/[id]` (`src/app/api/auth/me/devices/[id]/route.ts`):
add `medicationDelivery` to its update schema + field-by-field `data`. (Verify
this route exists as a PATCH; if it is GET/DELETE-only today, add the field to
`POST /api/devices` registration body + a small PATCH. Found at
`src/app/api/auth/me/devices/[id]/route.ts`.)
**OpenAPI:** extend the `NotificationPrefs` component + the `Device` component
schemas. Re-generate.
**Compliance/engine impact:** none.
**Tests:** default resolves to `server`; PATCH user default → roams; PATCH
device override → wins over default; `clientManaged` ↔ `deliveryDefault`
mapping; null device override inherits.
---
## Item 4 — SB-SCHED-2 cadence-canonical compliance (TIER A — do first)
**Root cause (confirmed):** `calculateCompliance` → `buildCadenceTimeline` →
`expandScheduleSlots` reads only `daysOfWeek`+`intervalWeeks`
(`cadence.ts:177,196-208`), ignoring `rrule`, `rollingIntervalDays`, `oneShot`.
The `ComplianceSchedule` input type doesn't even carry those fields
(`compliance.ts:153-157`).
**Design — route the expected-slot grid through the canonical engine
(`recurrence.ts`), not the cadence walker.** Two viable shapes:
- **Option A (preferred): teach `calculateCompliance` to call
`occurrencesBetween`.** Change the input contract so the caller passes the
full canonical-capable schedule rows + a `RecurrenceContext` (medication
`startsOn/endsOn/oneShot/createdAt`, `timeZone`, `lastIntakeAt`). Expected
slots = `occurrencesBetween(canonical, effectiveStart, now, ctx)` per
schedule. Pair recorded events against those slots with the same ±12 h radius
the cadence chart uses. This makes ONE engine authoritative for both
projection and compliance — the stated v1.5 design goal.
- **Option B (smaller diff): make `expandScheduleSlots` delegate to
`occurrencesBetween` internally** when `rrule`/`rollingIntervalDays`/`oneShot`
are present, keep the legacy weekday path only when all three are null. Lower
blast radius on the chip/timeline surfaces that also use `cadence.ts`.
**Recommend Option B** for v1.7.0: it fixes compliance AND the detail-page
cadence chart in one place, and `cadence.ts` is already the shared expander for
both. The signature of `buildCadenceTimeline` already accepts `anchor` +
`timeZone` (`cadence.ts:331-337`); extend `ScheduleLike` (`cadence.ts:33`) to
carry `rrule`, `rollingIntervalDays`, and the medication context needed by the
engine (`oneShot`, `startsOn`, `endsOn`, `createdAt`, `lastIntakeAt`).
**`ComplianceSchedule` diff** (`compliance.ts:153`): add
`rrule?: string | null; rollingIntervalDays?: number | null;` and thread a new
optional `medicationContext` param into `calculateCompliance` carrying
`{ startsOn, endsOn, oneShot, createdAt, lastIntakeAt, timeZone }`. When the new
fields are absent the function must behave exactly as today (legacy callers /
fixtures keep passing) — that backward-compat is the safety net.
**ALL 8 call sites** (per CLAUDE.md; enumerated from grep — there are 9 distinct
`calculateCompliance(` invocations across 8 surfaces):
| # | Surface | File:line | What it must pass now |
|---|---|---|---|
| 1 | Per-med compliance endpoint | `src/app/api/medications/[id]/compliance/route.ts:44,50` | already has `medication.schedules` (full rows) + `medication` ctx; add rrule/rolling + ctx |
| 2 | Insights targets | `src/app/api/insights/targets/route.ts:893,899` | ensure the `med.schedules` select includes `rrule`,`rollingIntervalDays`; pass med ctx + `lastIntakeAt` |
| 3 | Insights comprehensive | `src/app/api/insights/comprehensive/route.ts:296,297` | same select widening + ctx |
| 4 | Insights features (Health Score input) | `src/lib/insights/features.ts:925,926,927` | same; note `:892` comment says it deliberately fetches a narrow shape — widen it |
| 5 | BP-status gate | `src/lib/insights/blood-pressure-status.ts:309,315` | same |
| 6 | Medication-compliance status pillar | `src/lib/insights/medication-compliance-status.ts:187,193` | same |
| 7 | Health-Score fast-path | `src/lib/analytics/health-score-fast-path.ts:333,348` | same; it already pins `now` via options — keep that |
| 8 | (per-med endpoint daily map, same file as #1) | `compliance/route.ts:60-135` | the `dailyCompliance` loop also needs the per-day DUE computation — see item 5 |
Each call site needs: (a) widen the Prisma `select`/`include` on `schedules` to
include `rrule` + `rollingIntervalDays` (most already `include: { schedules: true }`
so they get it free — verify the narrow `select` ones at #2/#4), and (b) fetch
`lastIntakeAt` (latest non-skipped `takenAt`) for rolling correctness, and (c)
pass medication `startsOn/endsOn/oneShot/createdAt` + user `timeZone`.
For the rolling case, `lastIntakeAt` is already groupBy'd in some routes (e.g.
`medications/route.ts:40-44`); reuse that pattern rather than a new query per med.
**PRN exclusion:** PRN schedules (item 7) emit zero expected slots — handled by
the engine returning `[]`, so `denom=0` → `rate:100` (the existing empty-window
contract `compliance.ts:276-278`). No extra branch needed.
**OpenAPI:** no request-shape change (compliance route is GET). Response shape
gains `due`/`expectedCount` per item 5.
**Tests (this is the high-risk item — be exhaustive):**
- Parity: legacy `daysOfWeek`-only schedule produces identical numbers
pre/post (the no-context path).
- `FREQ=WEEKLY;BYDAY=MO`: 30-day window, took every Monday → 100% (today it
reports ~13%).
- `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE` bi-weekly → denominator counts only the on-weeks.
- Rolling `rollingIntervalDays=7` → only the next-due slot counts; logging
re-anchors.
- One-shot → exactly one expected slot on `startsOn`.
- PRN → `rate:100`, `totalExpected:0`.
- DST boundary day (Europe/Berlin spring-forward) parity.
- Each of the 8 call sites: integration test asserting the widened select +
ctx threading produces a sane rate for an RRULE med.
---
## Item 5 — Schedule-aware compliance PAYLOAD (`due` / `expectedCount`) (TIER B, needs A)
**Problem:** the per-med compliance endpoint's `dailyCompliance` map stamps
`expected: schedulesPerDay` on **every** day (`compliance/route.ts:126-134`),
so iOS history renders empty "missed" marks on non-due days (off-weeks,
non-matching weekdays, PRN days).
**Design — additive per-day field.** Extend `DailyComplianceEntry`
(`compliance.ts:57-65`) and the route's per-day object:
```ts
export interface DailyComplianceEntry {
expected: number; // existing — but now = engine's actual due count for THAT day
expectedCount: number; // NEW — explicit alias; the true due-slot count for the day
due: boolean; // NEW — expectedCount > 0
taken: number;
skipped: number;
onTime: number;
late: number;
veryLate: number;
early?: number;
}
```
Compute `expectedCount` by asking the engine how many occurrences land in
`[dayStart, dayEnd)` for the med's schedules (reuse the Option-B expander from
item 4 — `occurrencesBetween(canonical, dayStart, dayEnd, ctx).length` summed
across schedules). `due = expectedCount > 0`. iOS renders a mark only when
`due === true`.
Keep `expected` populated (now = `expectedCount`) so existing web consumers
that read `expected` don't break; `expectedCount`+`due` are the new explicit
fields iOS keys off. This is additive — no field removed → no wire break.
**Route diff:** `compliance/route.ts:62-135` — replace the static
`schedulesPerDay` (`:59`) with a per-day engine query. Performance: 90 days × N
schedules engine calls per medication-detail open; acceptable for a single-med
detail view, but memoise the canonical-schedule build outside the loop.
**OpenAPI:** add `due` + `expectedCount` to the daily-compliance component (the
compliance route response is currently loosely typed — add/extend a
`MedicationComplianceDaily` component). Re-generate.
**Tests:** off-week day → `due:false, expectedCount:0`; matching weekday →
`due:true, expectedCount:N`; PRN day → `due:false`; iOS-shape snapshot.
---
## Item 6 — SB-SCHED-3 server-computed `nextDueAt` (TIER B, needs A)
**Goal:** stop iOS re-implementing the recurrence engine. Server computes the
next due instant per medication/schedule.
**Where computed:** `nextOccurrenceAfter(schedule, now, ctx)` already exists and
is correct for all cadences (`recurrence.ts:147`). Build a thin helper
`computeNextDueAt(medication, schedules, now)` that:
- builds `RecurrenceContext` (needs `lastIntakeAt` — one query per med),
- calls `nextOccurrenceAfter` per schedule,
- returns the **earliest** `at` across schedules (and per-schedule values).
**Where exposed:**
- `GET /api/medications` list (`medications/route.ts:74-79` map): add
`nextDueAt: string | null` to each list row. `lastIntakeAt` is already
groupBy'd at `:40-44` — reuse it to avoid N+1.
- `GET /api/medications/[id]` detail (`[id]/route.ts:55`): add `nextDueAt` +
optionally per-schedule `nextDueAt` on each schedule object.
- The cadence endpoint already returns `next` (`MedicationCadenceResponse`
`:712-730`) — keep it; `nextDueAt` is the lighter list-level field.
**Caching gotcha:** the list GET is cached 60 s on userId
(`medications/route.ts:91-96`). `nextDueAt` is time-derived; a 60 s staleness is
already accepted for `todayEventCount` and is fine here too. Document it.
**Schema/migration:** none — computed, not stored.
**Zod/OpenAPI:** add `nextDueAt: z.iso.datetime({offset:true}).nullable()` to
`medicationResource` (`:588`) and `medicationListEntry` (`:634`). Re-generate.
**Compliance/engine impact:** read-only reuse of `nextOccurrenceAfter`.
**Tests:** daily → tomorrow's window; weekly BYDAY → next matching weekday;
rolling with last intake → lastIntake+N; one-shot in the past → null; endsOn
crossed → null; multi-schedule → earliest wins.
---
## Item 7 — SB-SCHED-5 PRN/as-needed + cyclic (on/off weeks) (TIER A, with SB-SCHED-2)
Both approved for v1.7.0.
**Schema** (`MedicationSchedule`, `schema.prisma:915`):
```prisma
/// v1.7.0 — schedule type discriminator. SCHEDULED is the default
/// (rrule / rolling / legacy cadence as today). PRN = as-needed: never
/// projected, never reminded, excluded from compliance expected-count,
/// still loggable. CYCLIC = N weeks on / M weeks off from the anchor.
scheduleType String @default("SCHEDULED") @map("schedule_type")
/// v1.7.0 — cyclic on/off weeks. Only meaningful when scheduleType=CYCLIC.
/// Repeats (cyclicOnWeeks on, then cyclicOffWeeks off) from startsOn ??
/// createdAt. Within an "on" week the rrule/legacy cadence applies as usual.
cyclicOnWeeks Int? @map("cyclic_on_weeks")
cyclicOffWeeks Int? @map("cyclic_off_weeks")
```
Store `scheduleType` as TEXT (matches the `medication_categories` /
`deliveryForm`-as-enum precedent; TEXT keeps headroom without a Prisma enum
migration — but `deliveryForm` IS a Prisma enum, so for consistency a Prisma
`MedicationScheduleType` enum is also acceptable. **Recommend Prisma enum** to
match `MedicationDeliveryForm` precedent and get DB-level validation).
**Migration `0092`:** add the enum type + 3 columns; backfill
`scheduleType='SCHEDULED'` (the DEFAULT covers it), `cyclic*` NULL.
**Engine math** (`recurrence.ts`):
- **PRN:** `occurrencesBetween` returns `[]` and `nextOccurrenceAfter` returns
`null` when `scheduleType === "PRN"`. Add an early-return at the top of
`occurrencesBetween` (`:116`). PRN is loggable (intake route untouched) but
invisible to projection + reminders + compliance-expected.
- **CYCLIC:** wrap the existing dispatch. After computing a candidate
occurrence, gate it by the cyclic phase: `weeksFromAnchor =
floor((occurrenceWeekStart - anchorWeekStart)/WEEK_MS)`, `cycleLen =
onWeeks+offWeeks`, `phase = weeksFromAnchor mod cycleLen`; keep the slot iff
`phase < onWeeks`. This composes with rrule/legacy (the inner cadence still
decides which days within an on-week emit). Mirror the existing
`intervalWeeks` phase math at `recurrence.ts:410-419`. Add to all three
emitters (rrule/legacy; rolling is a single-slot semantic — apply the gate to
the computed `nextDue`).
**`CanonicalSchedule` diff** (`recurrence.ts:47`): add `scheduleType: string`,
`cyclicOnWeeks: number | null`, `cyclicOffWeeks: number | null`.
`buildCanonicalSchedule` (`worker-helpers.ts:55`) + the project-today select
(`project-today-intakes.ts:62-73`) thread the new columns.
**Compliance impact:** PRN → zero expected (handled). CYCLIC → expected only in
on-weeks, automatically correct once compliance routes through the engine
(item 4). This is *why* item 7 must land with/after item 4.
**Zod diff** (`scheduleSchema`, `medication.ts:69`):
- `scheduleType: z.enum(["SCHEDULED","PRN","CYCLIC"]).optional()`.
- `cyclicOnWeeks: z.number().int().min(1).max(52).optional()`,
`cyclicOffWeeks: z.number().int().min(0).max(52).optional()`.
- New `.refine`: when `scheduleType==="CYCLIC"`, both cyclic fields required;
when `"PRN"`, rrule/rolling must be absent (PRN has no cadence).
**Route diff** (POST `route.ts:196`, PUT `[id]/route.ts:195`): add the three
fields to the per-schedule `data` build, field-by-field. Update route invariant
2 (the `FREQ=DAILY` default at `:197-205`) to **skip defaulting for PRN**.
**OpenAPI:** extend `MedicationScheduleInput` + `MedicationSchedule` components
with the three fields + the refine semantics in the description. Re-generate.
**Editor implications (NOTE ONLY — UI is a separate agent):** the wizard needs a
schedule-type selector (Scheduled / As-needed / Cyclic) gating the
cadence+cyclic-week inputs. PRN hides the reminder + times-of-day block.
**Tests:** PRN → no projection, no reminder, `rate:100`, still loggable via
intake route; CYCLIC 2-on/1-off → emits weeks 0,1 / skips week 2 / resumes
week 3; cyclic phase anchored to `startsOn`; cyclic + BYDAY composition; cyclic
`nextDueAt` lands in the next on-week.
---
## Item 8 — SB-SCHED-4 APNs suppression / multi-weekday projection (TIER B)
**Already there:** user-level `clientManaged` suppression of `MEDICATION_REMINDER`
APNs — `reminder-worker.ts:689-715`, gated by
`isMedicationReminderClientManaged` (`notification-prefs.ts:109`), emits the
`medication_reminder_suppressed_client_managed` annotation. The
`MEASUREMENT_ANOMALY`/`MOOD_REMINDER`/etc. paths flow unchanged (confirmed by
the comment block `:686-688`).
**What's missing for multi-weekday projection:** the worker's missed-dose
mint + reminder dispatch loop iterates per-schedule and mints at
`schedule.windowStart` (`reminder-worker.ts:650-664,692`). With first-class
`timesOfDay` (multiple per day) the engine already emits one occurrence per
time-of-day (`recurrence.ts:349-358`), but the worker's reminder dispatch still
keys on the single `windowStart`. Verify the worker iterates **every**
`timesOfDay` entry, not just `windowStart`, when deciding which slot is due on
this tick — if it only checks `windowStart`, a med with `timesOfDay=["08:00","20:00"]`
gets one reminder/day, not two. This is the real SB-SCHED-4 gap: **multi-time
projection in the dispatch loop**, separate from suppression (which is done).
**Design:** in the worker tick, for each schedule that
`scheduleEmitsInWindow(...)`, expand `occurrencesBetween(canonical, tickWindow,
ctx)` and dispatch/mint per returned occurrence (per time-of-day), not once per
schedule. The suppression check (`:689`) wraps each dispatch unchanged.
**Suppression × device override (item 3):** keep user-level. Open question Q2.
**Schema/Zod/OpenAPI:** none (worker-internal + already-shipped flag).
**Tests:** med with two times-of-day → two reminder dispatches/day; with
`clientManaged` → both suppressed + two annotations; bi-weekly off-week → zero;
PRN → zero.
---
## Item 9 — Dashboard widgets PUT 422 on iOS-only widget IDs (TIER C)
**Enum location:** `widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS)`
(`src/app/api/dashboard/widgets/route.ts:45`), source list
`DASHBOARD_WIDGET_IDS` (`src/lib/dashboard-layout.ts:17-45`). On READ, unknown
ids are already silently dropped (`dashboard-layout.ts:253-260`). On WRITE, the
strict enum rejects the **entire blob** if any id is unknown → the documented
422 → "Layout konnte nicht gespeichert werden" toast.
**Two options:**
- **(a) Expand the enum** with the iOS widget ids. Requires the server to know
every id iOS ships — tight coupling, recurring 422 every time iOS adds a tile
before a server release.
- **(b) Accept-and-ignore unknown ids on write** (preprocess: filter
`widgets[].id` against `DASHBOARD_WIDGET_IDS` before Zod, mirroring the read
path's `knownIds` filter at `:259`). The server persists only ids it knows;
iOS-only ids are dropped server-side but iOS keeps its own local layout.
**Recommend (b)** — it is the safer, forward-compatible choice and mirrors the
existing read-side tolerance (`dashboard-layout.ts:253`). Tight enum coupling
across two release cadences is exactly what bit `achievements`/`glp1` before.
**Route diff** (`widgets/route.ts`, before `layoutSchema.safeParse` at `:140`):
preprocess `body.widgets = body.widgets?.filter(w => KNOWN.has(w.id))`. Keep the
enum for the **remaining** ids (still reject genuinely malformed shapes — a
non-string id, missing `order`). Emit an annotation
`dashboard.widgets.unknown-id-dropped` with the dropped ids + count so operators
can see iOS drift without a 422 storm. Do **not** drop the audit-ledger
breadcrumb for true validation failures (other-field 422s still fire).
**Risk flag:** (b) means a typo in a *known* widget id silently vanishes instead
of erroring. Acceptable — the read path already does this, and the alternative
(blocking the whole save) is worse. Mention in the annotation so it's
greppable.
**Schema/migration:** none. **OpenAPI:** the request schema's `id` field
description should note "unknown ids are ignored, not rejected". Re-generate.
**Tests:** PUT with one known + one unknown id → 200, known persisted, unknown
dropped, annotation emitted; PUT with malformed entry (missing `order`) → still
422; round-trip GET returns only known ids.
---
## Item 11 (hygiene, optional) — delete dead `expandTodayIntakes`
`expandTodayIntakes` (`src/lib/medication-schedule.ts:112`) is no longer on any
production read path (both today-projection routes use
`projectTodayIntakesAndRecompute`). Only comments + its own test reference it.
Safe to delete the function (keep `parseScheduleRecurrence` /
`serializeScheduleRecurrence` — still used by the route + the engine's legacy
fallback at `recurrence.ts:44,372`). Out of scope if time-boxed; flag for the
simplifier pass. Schema column `daysOfWeek` is slated for v1.6.0 drop per its
own comment (`schema.prisma:933`) but is **still read** by the legacy engine
fallback — do NOT drop the column in v1.7.0.
---
## Breaking-change risk summary
| Item | Wire break? | Risk |
|---|---|---|
| SB-LA-1 / SB-AK-1 | No (additive, defaulted) | Low |
| Delivery-pref default + device override | No (additive jsonb + nullable col) | Low; scope boundary on cron fan-out (Q2) |
| **SB-SCHED-2 compliance** | **No wire break, but semantic change to every adherence number** | **High — exhaustive parity tests required** |
| Compliance payload `due`/`expectedCount` | No (additive fields) | Low |
| nextDueAt | No (additive) | Low; 60 s cache staleness documented |
| PRN + cyclic | No (additive, defaulted SCHEDULED) | Medium — new engine branches; cyclic phase math |
| APNs multi-weekday | No (worker-internal) | Medium — verify per-time dispatch isn't a regression for single-time meds |
| Widgets 422 | No (more permissive) | Low; silent-drop trade-off |
## Open questions for the maintainer
- **Q1 (SB-SCHED-2 strategy):** Option A (rewrite `calculateCompliance` onto the
engine) vs Option B (delegate inside `expandScheduleSlots`). Design recommends
B for smaller blast radius + fixing the cadence chart in the same place. Confirm.
- **Q2 (device delivery scope):** v1.7.0 stores + echoes the per-device override
but keeps cron suppression user-level (APNs fans out to all devices; iOS
dedupes locally). Is per-device server-side fan-out suppression required this
release, or is store+echo enough for the iOS "Dieses Gerät / Alle Geräte" UI?
- **Q3 (scheduleType storage):** Prisma enum (`MedicationScheduleType`, matches
`deliveryForm` precedent, DB-validated) vs TEXT (matches category side-table).
Design recommends Prisma enum.
- **Q4 (migration count):** 3 pre-allocated (0091 flags+device-delivery, 0092
schedule-types). Confirm the 0091/0092 pre-allocation to avoid parallel-agent
collision, or assign explicit numbers in the dispatch briefs.
+476
View File
@@ -0,0 +1,476 @@
# R-export — Health-record / doctor-handover export (v1.7.0 flagship)
Research + implementation-ready design. READ-ONLY pass over source; one deliverable.
Conventions consulted: `CLAUDE.md` (no-markdown-library rule, `safeFetch` egress, `apiHandler`
wrapper, Zod `safeParse` + `returnAllZodIssues`, OpenAPI-registry-is-source-of-truth,
audit-log + Postgres rate-limit, `annotate()` wide events, RSC-by-default, kebab-case files).
---
## 0. TL;DR recommendation
- **Structured interchange format: lead with HL7 FHIR R4, packaged as a single `Bundle`
of `type: "document"` (a Composition "cover page" + Patient + Observations +
MedicationStatement/MedicationRequest), emitted as one `.json` file.** It is the only
format a German practice's PVS / the gematik ePA can actually ingest as structured data
(ePA accepts FHIR Document Bundles + PDF/A — see §4). Ship a **flat JSON** schema as a
secondary "developer / self-archive" format (we already have `full-backup`), and keep CSV
per-domain (already shipped). **Do NOT build CDA/C-CDA** — XML-heavy, US-centric, far
higher cost for zero German-practice upside.
- **PDF: enhance the existing jsPDF + jspdf-autotable generator** (`doctor-report-pdf-core.ts`)
rather than swap libraries. Add a real cover page (full patient identity incl. the new
full-name + insurer + insurance-number profile fields), a clinical summary block, optional
embedded trend charts, and extend the selection schema beyond the current "big five".
- **The PDF should be PDF/A-flavoured enough to satisfy ePA** (embedded fonts, no external
resources) — jsPDF embeds Helvetica by default and pulls no network resources, so we are
close; full PDF/A-1b conformance is a stretch goal, not a blocker (§3.5).
---
## 1. What exists today (cite file:line)
HealthLog already ships a mature doctor-report PDF and a per-domain export surface. The v1.7.0
work is **consolidation + a structured FHIR layer on top of an existing aggregator**, not a
greenfield build.
### 1.1 PDF generator
- **Library:** `jspdf` `^4.2.1` + `jspdf-autotable` `^5.0.8` (`package.json`). `pdf-parse`
`^2.4.5` is present too but only for Apple-Health import parsing, not report generation.
- **Renderer:** `src/lib/doctor-report-pdf-core.ts:144` `buildDoctorReportPdfDocument(data, {t, locale, now, userTz})`
— isomorphic (runs in browser AND Node; `doc.output("arraybuffer")` at `:704`). i18n-driven:
every label is a `t("doctorReport.*")` key, number/date formatting via `makeFormatters(locale, userTz)`
(`:149`). Locale defaults to **German** server-side (`pdf/route.ts:170`) — correct for the
clinical context.
- **Current sections** (all i18n, A4 portrait, 20 mm margin):
- Title + subtitle + optional **practice name** cover line (`:170-198`).
- Patient block: username, DOB, gender, height, reporting period, created-on (`:201-241`).
**No full name, no insurer, no insurance number** — those profile fields do not exist yet (§2.3).
- Vitals table (`DOCTOR_REPORT_VITAL_TYPES` at `:79`): WEIGHT, BP sys/dia, PULSE, BODY_FAT,
TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION — columns: parameter / current / avg / min / max / n.
- Per-context glucose rows (FASTING/POSTPRANDIAL/RANDOM/BEDTIME) with effective ranges (`:266-282`).
- BP classification (ESH grades, `getBpClassificationKey` `:128`), BMI classification
(`getBmiClassificationKey` `:119`), glucose in/below/above-target lines.
- Medication compliance table (taken/skipped/missed/total/rate, `:400-456`).
- GLP-1 therapy block: dose-titration history, weight delta over window, side-effect counts,
per-med compliance (`:463-603`).
- Mood summary + distribution (privacy-gated; opt-in only, `:605-668`).
- Footer disclaimer + source timestamp on every page (`:670-690`).
- **Data aggregator (single source of truth):** `src/lib/doctor-report-data.ts:255`
`collectDoctorReportData(userId, range, { practiceName, sections })`. Excludes soft-deleted
rows (`deletedAt: null`, `:279`). Range normalisation `normaliseDateRange` (`:198`) caps custom
windows at **730 days**; `days` fallback caps at 365; default 90. Mood is **never queried** when
the toggle is off (`:307`) — privacy-by-default, the audit log proves the read never happened.
- **No charts in the PDF today** — tables only. (Limit: a physician skimming trends has no curve.)
### 1.2 API surface (PDF)
- `POST /api/doctor-report` (`src/app/api/doctor-report/route.ts:34`) — returns the aggregated
JSON, **client** renders the PDF via jsPDF in the browser.
- `POST /api/doctor-report/pdf` (`src/app/api/doctor-report/pdf/route.ts:33`) — **server**-renders
the finished PDF bytes (used on iOS/Safari where client-side jsPDF download UX is unreliable).
- Both: `requireAuth()` (cookie OR Bearer), rate-limit `doctor-report:<userId>` **10/h**, audit
`doctor-report.generate` / `doctor-report.pdf.generate` recording days/range/locale/sections.
Body is Zod-tolerant: `doctorReportPrefsSchema.safeParse(...).success ? data : {}` (never 422s on drift).
- `GET /api/doctor-report/availability` exists (data-presence preflight for the dialog).
- Prefs persistence: `User.doctorReportPrefsJson` + `User.lastReportPracticeName`; validation in
`src/lib/validations/doctor-report-prefs.ts` (`DEFAULT_DOCTOR_REPORT_PREFS` at `:60`, mood=false default).
### 1.3 Selection UI today
- `src/components/settings/export-section.tsx` — Settings → Export. Hero `<ArztberichtHeroCard>`
(the flagship PDF flow) + 4 secondary cards: Measurements CSV, Medications CSV, Mood CSV, Full JSON backup.
- `src/components/doctor-report/doctor-report-dialog.tsx` + `doctor-report-section-toggles.tsx` —
the section-toggle dialog (the existing "selectable data" surface).
### 1.4 Raw export surface (already shipped)
- `GET /api/export?format=csv|json&type=measurements|medications|intake|mood|all`
(`src/app/api/export/route.ts`).
- Per-domain CSV: `GET /api/export/measurements`, `/medications`, `/mood` (`since`/`until` filters).
- `GET /api/export/full-backup` (`src/app/api/export/full-backup/route.ts`) — single-file JSON
matching `backupPayloadSchema`, round-trippable via admin restore.
- Formatting helpers + RFC-4180 CSV escaping + per-user-tz ISO-with-offset timestamps:
`src/lib/export.ts`. All export routes share the `export:<userId>` 10/h bucket + audit `user.export.*`.
**Current limits to fix in v1.7.0:**
1. No structured **interchange** format (FHIR) — only flat backup JSON + CSV.
2. PDF cover lacks full patient identity (full name / insurer / insurance number — see §2.3).
3. PDF selection is coarse (`bp/weight/pulse/bmi/mood/compliance/sleep`); the 40+ HealthKit metric
types (HRV, RHR, VO2max, glucose contexts, body-composition family) have **no** per-section toggle
(`MEASUREMENT_TYPE_SECTION` at `doctor-report-data.ts:591` only maps 5 types).
4. No charts in the PDF.
5. No single "build me the handover package" entry point that bundles PDF + FHIR together.
---
## 2. Data-domain inventory (Prisma models)
All user-scoped, all filterable by `[start,end]`, all soft-delete-aware where applicable.
| Domain | Prisma model | Notes / cite |
|---|---|---|
| Vitals + body comp + HealthKit metrics | `Measurement` (`schema.prisma:540`) | 40+ `MeasurementType` enum values (`:447-503`): WEIGHT, BLOOD_PRESSURE_SYS/DIA, PULSE, BODY_FAT, SLEEP_DURATION, ACTIVITY_STEPS, BLOOD_GLUCOSE (mg/dL + `GlucoseContext`), TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION, HEART_RATE_VARIABILITY, RESTING_HEART_RATE, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE, FAT_FREE_MASS, FAT_MASS, MUSCLE_MASS, SKIN_TEMPERATURE, PULSE_WAVE_VELOCITY, VASCULAR_AGE, VISCERAL_FAT, AUDIO_EXPOSURE_*, TIME_IN_DAYLIGHT, WALKING_STEADINESS/ASYMMETRY/DOUBLE_SUPPORT/STEP_LENGTH/SPEED, RESPIRATORY_RATE, BODY_MASS_INDEX, LEAN_BODY_MASS, WALKING_HEART_RATE_AVERAGE. `source` ∈ {MANUAL, WITHINGS, IMPORT, APPLE_HEALTH}. `SleepStage` per-row for SLEEP_DURATION. |
| Workouts | `Workout` (`:682`) + `WorkoutRoute` (`:744`) | Activity sessions; not in current PDF. Candidate FHIR `Observation`/`Procedure` — defer to phase 2. |
| Personal records | `PersonalRecord` (`:778`) | Gamification; **exclude** from clinical export. |
| Medications | `Medication` (`:840`) + `MedicationSchedule` (`:915`) | name, dose, `treatmentClass` (GENERIC/GLP1), `deliveryForm` (ORAL/INJECTION, v1.6.0), `oneShot`, `startsOn`/`endsOn`, schedules. → FHIR `MedicationStatement`/`MedicationRequest`. |
| Dose titration | `MedicationDoseChange` (`:1054`) | GLP-1 dose history → FHIR `MedicationRequest` history or `dosageInstruction`. |
| Intake / compliance | `MedicationIntakeEvent` (`:1017`) | scheduledFor / takenAt / skipped / `injectionSite`. Compliance % computed in `src/lib/analytics/compliance.ts`. → FHIR `Observation` (medication-adherence) or rendered as PDF table only. |
| Side effects | `MedicationSideEffect` (`:1175`) | EMA-EPAR taxonomy, 1-5 Likert. → FHIR `Observation` (symptom) or `AdverseEvent`. |
| Inventory | `MedicationInventoryItem`/`Event` (`:1082/:1113`) | **exclude** from clinical export. |
| Mood | `MoodEntry` (`:1418`) | score 1-5, tags, `moodLoggedAt`. **Opt-in only** (privacy default off). → FHIR `Observation` (mood) — gate behind explicit selection. |
| AI Insights / Coach | `CoachConversation`/`CoachMessage` (`:2080/:2111`, `encryptedContent` Bytes), `User.insightsCachedText` (`schema.prisma:38`) | AI-generated prose. **Exclude from FHIR** (not clinical observations; could mislead a physician as if machine-diagnosed). Optionally render the latest **briefing summary** as a non-clinical "Patient notes" appendix in the PDF, clearly labelled "AI-generated, informational" — flag for maintainer (§7). |
| Patient identity | `User` (`:15`) | username, email, `heightCm`, `dateOfBirth`, `gender`, `timezone`, `glucoseUnit`, `thresholdsJson`. **Missing**: legal full name, insurer, insurance number (§2.3). |
### 2.3 New profile fields (added THIS release)
The brief states full name + insurer + insurance number are being added to the profile in v1.7.0.
They do **not** exist in `schema.prisma` yet (grep for `insurer|insurance|fullName|legalName` →
zero hits) and are **not** in any validation schema. The export design assumes:
```prisma
// additions to model User
fullName String? @map("full_name") // legal name for the report cover / FHIR Patient.name
insurerName String? @map("insurer_name") // e.g. "AOK Bayern" / FHIR Patient.contact or Coverage.payor
insuranceNumber String? @map("insurance_number") // KVNR (10-char) / FHIR Patient.identifier (system = KVNR)
```
- These are **PII at rest** — per CLAUDE.md they must NOT be encrypted-only-if-policy-says, but
note `insuranceNumber` (German KVNR) is a quasi-identifier; recommend storing it **encrypted**
(`*Encrypted` convention, `src/lib/crypto.ts`) to match the project's at-rest posture. Flag to
maintainer (§7) — the other profile fields (DOB, gender, height) are currently plaintext, so
there's a consistency call to make.
- All three are **optional** on the cover: omitted lines collapse exactly like `practiceName` does
today (`doctor-report-pdf-core.ts:183`).
- KVNR validation: 10 chars, leading letter + 9 digits, mod-10 check digit — add a Zod refine.
---
## 3. The enhanced PDF
### 3.1 Library decision
**Reuse `jspdf` + `jspdf-autotable`.** Rationale: isomorphic (the dual client/server render parity
is a load-bearing property at `doctor-report-data.ts:1-9`), already wired through i18n + tz, embeds
fonts with no network fetch (matters for ePA/PDF-A and for the no-external-resource CSP posture).
Swapping to `@react-pdf/renderer` or Puppeteer/Playwright-print would break isomorphism, add a
headless-Chromium dependency to the Alpine image (heavy, arm64 pain), and require explicit approval
per the Recharts-style "replacement requires approval" convention. **No new PDF library.**
### 3.2 Cover page (new)
```
┌──────────────────────────────────────────────────────────┐
│ Gesundheitsbericht [logo] │ ← title (existing)
│ Persönlicher Gesundheitsbericht für die ärztliche Vorlage │ ← subtitle
│ ──────────────────────────────────────────────────────── │
│ Praxis: Dr. med. Mustermann │ ← practiceName (existing)
│ ──────────────────────────────────────────────────────── │
│ Patient: <fullName ?? username> │ ← NEW fullName line
│ Geburtsdatum: 1980-01-01 │
│ Geschlecht: männlich │
│ Größe: 180 cm │
│ Krankenkasse: AOK Bayern │ ← NEW insurerName
│ Versichertennr.: A123456789 │ ← NEW insuranceNumber (KVNR)
│ Berichtszeitraum: 2026-01-01 — 2026-05-31 │
│ Erstellt am: 2026-05-31 │
└──────────────────────────────────────────────────────────┘
```
Implementation: extend the `patientInfo[]` builder (`doctor-report-pdf-core.ts:203-241`) with three
new optional lines, and widen the `DoctorReportData.patient` shape (`doctor-report-data.ts:57-62`)
+ the `collectDoctorReportData` `select` (`:314-323`) to pull the new columns. New i18n keys:
`doctorReport.patientFullName`, `doctorReport.insurer`, `doctorReport.insuranceNumber` (×6 locales —
`i18n-call-site-coverage.test.ts` + `i18n-locale-integrity.test.ts` enforce this).
### 3.3 Clinical summary block (new, top of body)
A 4-6 line auto-generated factual summary above the vitals table (NOT AI — deterministic, like the
existing BP/BMI classification lines):
- "Über N Tage erfasst: X Messwerte über Y Parameter."
- Latest + trend arrow per primary vital (↑/↓/→ from first-half vs second-half mean).
- Medication-adherence headline (weighted mean across active meds).
- Any value crossing the user's effective threshold (`getEffectiveRange`) flagged "außerhalb Zielbereich".
This is the "physician reads one paragraph and knows the story" feature. Pure data, reuses
`effective-range.ts` + `compliance.ts`.
### 3.4 Charts (new, optional per selection)
- Render a **sparkline/trend line per selected vital** as a PNG and `doc.addImage(...)` into the PDF.
- Generation path: **server-side** chart-to-PNG. Do NOT pull headless Chromium. Use a lightweight
pure-JS approach — render the line to an SVG string from the existing measurement series, then to
PNG. Two viable options without a Chromium dependency:
- `@napi-rs/canvas` or `canvas` to draw the polyline directly (no Recharts) — small, deterministic.
- or hand-roll the polyline as vector lines using jsPDF's own `doc.lines()` primitive (**zero new
dependency** — preferred for v1 to avoid a native module in the Alpine build). A jsPDF-native
mini-sparkline (axis + polyline + min/max labels) is enough for a doctor handover and keeps the
isomorphic + no-native-dep posture.
- Charts must remain visually consistent with the app's Recharts look only loosely — this is a
print artefact, not the live dashboard, so the "charts must stay visually identical" rule
(Recharts memory) does NOT bind here; flag as a deliberate scope note.
- **Selection-gated**: chart per domain only when that domain's toggle is on AND a new
`includeCharts` flag is true (default true; off for a compact text-only report).
### 3.5 PDF/A (stretch, ePA-friendly)
ePA accepts PDF/A documents for unstructured upload. jsPDF does not emit PDF/A-1b conformance
metadata (XMP, OutputIntent, document-ID) out of the box. v1 ships a "PDF/A-leaning" file (embedded
standard fonts, no external resources, no JavaScript actions) which most practice systems accept;
**full PDF/A-1b conformance is a documented stretch goal**, not a v1.7.0 blocker. Flag to maintainer (§7).
### 3.6 Selection mechanics (PDF)
Extend `doctorReportPrefsSchema` (`doctor-report-prefs.ts:25`) from the current 7 flags to a
**grouped** shape, additive (forward-compat preserved by `.partial()` + defaults merge):
```ts
{
vitals: { weight, bp, pulse, bodyFat, oxygenSaturation, bodyComposition },
cardioFitness: { restingHeartRate, hrv, vo2max },
activity: { steps, distance, energy, sleep },
glucose: boolean, // all contexts together (already per-context inside)
medications: { list, compliance, glp1, sideEffects },
mood: boolean, // privacy default OFF (unchanged)
bmi: boolean,
includeCharts: boolean, // default true
}
```
`MEASUREMENT_TYPE_SECTION` (`doctor-report-data.ts:591`) grows to map every clinically-relevant
`MeasurementType` to its group key. The mood privacy-by-default contract (never-query-when-off) is
preserved and extended: each new group's data is only read when its toggle is on.
---
## 4. Structured interchange export (FHIR R4)
### 4.1 Why FHIR R4, document Bundle, single JSON
- **German reality:** the ePA ("für alle", mandatory in all practices since 2025-10-01) ingests
structured data as **FHIR Document Bundles** and unstructured data as PDF/A. There is no realistic
path where a German physician imports a CDA/C-CDA XML from a patient PWA; FHIR is the structured
lingua franca gematik standardised on (MIOs are FHIR). [gematik ePA], [HL7 PHR-format IG].
- **Apple parity:** Apple Health Records shares with doctors via FHIR + SMART; the iOS ecosystem the
maintainer benchmarks against is FHIR-native. [Rhapsody/Apple-FHIR], [Apple HealthKitV2 export].
- **R4 not R5:** R4 (4.0.1) is the deployed, tooling-saturated baseline; R5/R6 are ballot/edge. Use R4.
- **Bundle.type = "document"** with a leading `Composition` ("cover page": author=Patient/app,
subject=Patient, sections per domain) is the PHR-recommended shape for "a self-contained record
handed to a clinician" — vs `collection`/`searchset` which are API-transport shapes. [HL7 PHR IG],
[IPS Bundle examples].
- **Single `.json` file** (PHR IG: under 16 MB → Bundle saved as `.json`; over 16 MB → NDJSON bulk).
Our per-user windowed export is comfortably under 16 MB, so **one `.json`** — no zip needed for the
FHIR file itself. (A combined "handover package" zip that holds PDF + FHIR JSON together is offered
separately, §5.3.)
### 4.2 Resource mapping per domain
| HealthLog data | FHIR R4 resource | Coding (system / code / unit) |
|---|---|---|
| User identity | `Patient` | `name` (fullName), `birthDate` (dateOfBirth), `gender`, `identifier` (KVNR: system `https://gematik.de/fhir/sid/telematik-id` or the KVNR namespace), `Observation`(body-height 8302-2). Insurer → `Coverage.payor` (display only) or `Patient.contact`. |
| WEIGHT | `Observation` (vital-signs profile) | LOINC **29463-7**, UCUM **kg** |
| BODY_MASS_INDEX / computed BMI | `Observation` | LOINC **39156-5**, **kg/m2** |
| BLOOD_PRESSURE_SYS+DIA | `Observation` (BP panel) | panel LOINC **85354-9**, components **8480-6** (sys) + **8462-4** (dia), **mm[Hg]** |
| PULSE / heart rate | `Observation` | LOINC **8867-4**, **/min** |
| RESTING_HEART_RATE | `Observation` | LOINC **40443-4**, **/min** |
| RESPIRATORY_RATE | `Observation` | LOINC **9279-1**, **/min** |
| BODY_TEMPERATURE | `Observation` | LOINC **8310-5**, **Cel** |
| OXYGEN_SATURATION | `Observation` | LOINC **2708-6** (or 59408-5 SpO2 by pulse ox), **%** |
| BODY_FAT (%) | `Observation` | LOINC **41982-0**, **%** |
| BLOOD_GLUCOSE | `Observation` | fasting LOINC **1558-6**, generic glucose **2339-0**, **mg/dL** (or mmol/L per user unit). Map `GlucoseContext` to the context-specific LOINC. |
| VO2_MAX | `Observation` | LOINC **84478-5**, **mL/min/kg** |
| HEART_RATE_VARIABILITY (SDNN) | `Observation` | LOINC **80404-7**, **ms** |
| ACTIVITY_STEPS | `Observation` | LOINC **41950-7** (steps/24h), **/{steps}/d** |
| SLEEP_DURATION | `Observation` (+ component per `SleepStage`) | LOINC **93832-4** (sleep duration), **min** |
| Body-composition family (TBW, bone, muscle, fat-mass, lean, visceral, PWV, vascular age) | `Observation` | LOINC where one exists; otherwise local `CodeableConcept` with `text` + UCUM unit. Document the local-code fallback inline. |
| Medications (active) | `MedicationStatement` | `medicationCodeableConcept` (free-text `.text` = name; no ATC lookup in v1), `dosage`, `effectivePeriod` (startsOn/endsOn), `status`. |
| Dose titration history | `MedicationStatement` per dose-change OR `dosage[]` history | reuse `MedicationDoseChange`. |
| Compliance % | `Observation` (medication-adherence) | LOINC **71799-1** (medication adherence) or local code; value = % over window. |
| Side effects (GLP-1) | `Observation` (symptom) | severity 1-5 as `valueInteger` + `interpretation`; EMA-EPAR category in `category`. |
| Mood | `Observation` | LOINC **76542-6** (mood) — **opt-in only**, omitted from Bundle when toggle off (read never issued, mirrors §1.1). |
| AI insights / coach | — | **NOT mapped** to FHIR (not a clinical observation). |
Every `Observation` carries `category = vital-signs|laboratory|activity|...`, `subject = Patient ref`,
`effectiveDateTime` (the per-user-tz ISO-with-offset already produced by `formatInUserTz`,
`src/lib/export.ts:79`), `status = final`, and a stable `id` (cuid → `Observation/<cuid>`).
### 4.3 Builder module
New `src/lib/fhir/build-bundle.ts` — pure function
`buildFhirDocumentBundle(data: DoctorReportData, patient, selection): FhirBundle`.
**Reuse the existing `collectDoctorReportData` aggregator** so the FHIR export and the PDF are
guaranteed to describe the same numbers (same source-of-truth property the two PDF endpoints already
share). FHIR types: hand-roll narrow TypeScript interfaces (do NOT pull a multi-MB `@types/fhir` or a
FHIR SDK — consistent with the "hand-rolled fetch over documented wire, no vendor SDKs" AI-provider
convention). Zod-validate the emitted shape in tests against a minimal Bundle schema.
### 4.4 No-markdown / XSS note
FHIR JSON is data, not rendered HTML — no markdown library involved, no `dangerouslySetInnerHTML`.
The `Composition.text` narrative (xhtml div) must be **escaped plain text**, not user-HTML.
---
## 5. API surface
New namespace `/api/export/...`, every route `apiHandler`-wrapped, `requireAuth()` (cookie OR Bearer),
shared `export:<userId>` **10/h** bucket (so structured + PDF + CSV can't be parallelised past the cap),
audit-logged, `annotate()`-instrumented. Same-origin; no CORS.
### 5.1 `POST /api/export/health-record` (the flagship route)
Single entry point that produces **either** the PDF **or** the FHIR bundle **or** the combined zip,
driven by a Zod-validated selection payload.
```ts
// src/lib/validations/health-record-export.ts
const exportSelectionSchema = z.object({
format: z.enum(["pdf", "fhir", "package"]), // package = zip of both
range: z.object({ // reuse normaliseDateRange semantics
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
days: z.number().int().min(1).max(365).optional(),
}).optional(),
sections: doctorReportPrefsSchemaV2.partial().optional(), // grouped shape, §3.6
locale: z.enum(locales).optional(), // PDF only
practiceName: z.string().max(120).optional(),
includeCharts: z.boolean().optional(),
}).strict();
```
- **No `userId` in the body** (CLAUDE.md hard rule — narrowed from `requireAuth()`).
- Validation via `safeParse` + `returnAllZodIssues` → 422 multi-issue envelope on bad input (this
route is strict, unlike the legacy doctor-report route which tolerates drift — the flagship route
should fail loudly).
- Response:
- `format: "pdf"` → `application/pdf`, `Content-Disposition: attachment; filename="healthlog-report-<date>.pdf"`.
- `format: "fhir"` → `application/fhir+json`, filename `...-<date>.json`. (Bare body, not the
`apiSuccess` envelope — same pattern as `full-backup`.)
- `format: "package"` → `application/zip` containing `report.pdf` + `bundle.json` + a `README.txt`.
Use a tiny zip writer; if none present, prefer `fflate` (small, no native dep) — confirm with
maintainer before adding (§7). Falls back to two sequential single-file downloads if zip is descoped.
- Audit `health-record.export` with `{ format, days, sections, charts }` (never the values).
- `annotate({ action: { name: "export.health-record.build" }, meta: { format, bytes, days } })`.
- `Cache-Control: no-store` (sensitive PHI).
### 5.2 `GET /api/export/fhir` (optional convenience)
Thin GET wrapper for "give me everything as FHIR, default 90-day window" — for power users / scripting
against a Bearer token. Same auth/rate-limit/audit. Optional; `POST .../health-record` covers it.
### 5.3 Keep existing routes
`/api/doctor-report`, `/api/doctor-report/pdf` (iOS depends on the server-render path), `/api/export*`
all stay. The new `health-record` route is additive — the iOS client can migrate to it later.
---
## 6. Selection UI sketch (ASCII)
Promote the existing dialog into a full "Health Record Export" surface (still inside Settings → Export,
above the secondary CSV cards). One screen, format toggle drives what's shown (per the
"no top/bottom split, dropdown drives the form" preference).
```
┌─ Gesundheitsbericht / Health Record Export ───────────────────────────┐
│ │
│ Format: ( • PDF ) ( ○ Strukturiert (FHIR) ) ( ○ Paket: PDF+FHIR ) │
│ │
│ Zeitraum: [ Letzte 90 Tage ▾ ] oder von [____] bis [____] │
│ │
│ Praxis (optional): [ Dr. med. Mustermann____________________ ] │ ← PDF only
│ │
│ Enthaltene Daten: │
│ ┌─ Vitalwerte ──────────────────────────────────────────────┐ │
│ │ [✓] Gewicht [✓] Blutdruck [✓] Puls [✓] SpO₂ │ │
│ │ [✓] Körperfett [ ] Körperzusammensetzung │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌─ Herz & Fitness ──────────────────────────────────────────┐ │
│ │ [ ] Ruhepuls [ ] HRV [ ] VO₂max │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌─ Aktivität & Schlaf ─[ ] Schritte [ ] Distanz [ ] Schlaf ─┐ │
│ └────────────────────────────────────────────────────────────┘ │
│ [✓] Blutzucker [✓] Medikamente & Einnahmetreue │
│ [ ] Stimmung (standardmäßig aus — sensible Daten) │ ← mood opt-in
│ [✓] BMI [✓] Trend-Diagramme einbetten (nur PDF) │
│ │
│ ℹ Strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel). │ ← shown when FHIR/package
│ │
│ [ Vorschau ] [ Erstellen ⤓ ] │
└────────────────────────────────────────────────────────────────────────┘
```
- Format = FHIR/package hides PDF-only fields (practice name, charts checkbox) and reveals the
ePA-compat note. Mood stays default-off everywhere. Section groups collapse on mobile (cards stack).
- "Vorschau" hits `POST /api/doctor-report` (JSON) to show a count summary before generating.
- New components: `health-record-export-panel.tsx` + `export-selection-groups.tsx` (kebab-case,
`"use client"`; the group definitions live in a shared const so PDF + FHIR selection stay in sync).
---
## 7. Open questions for the maintainer
1. **Insurance number at rest** — store `insuranceNumber` (KVNR) **encrypted** (matches AES-256-GCM
posture) while DOB/gender stay plaintext, or keep all three plaintext for consistency? (Recommend encrypted.)
2. **Zip dependency** — OK to add `fflate` (tiny, no native dep) for the `format: "package"` zip, or
ship PDF + FHIR as two separate downloads in v1 and defer the bundle?
3. **AI briefing in the PDF** — include the latest deterministic briefing summary as a clearly-labelled
"AI-generated, informational only" appendix, or keep all AI strictly out of the clinical artefact?
(Lean: keep out, or behind an explicit off-by-default toggle.)
4. **PDF/A-1b** — pursue full conformance (XMP + OutputIntent, needs work jsPDF won't do natively) this
release, or ship "PDF/A-leaning" and document full conformance as a follow-up?
5. **Chart rendering** — accept the jsPDF-native polyline sparkline (zero new dep) for v1, or invest in
`@napi-rs/canvas` for richer charts (native module in the Alpine arm64 build)?
---
## 8. Test list
Unit (Vitest):
- `build-bundle.test.ts` — Bundle is `type:"document"`, leading Composition, every selected domain →
correct LOINC/UCUM; mood absent when toggle off; soft-deleted rows absent; empty-domain omits resource.
- `health-record-export.validation.test.ts` — Zod `.strict()` rejects unknown keys, `userId` in body
rejected, range cap honoured, returnAllZodIssues multi-issue envelope.
- PDF cover: new full-name/insurer/insurance lines render + omit cleanly when null (extend
`doctor-report-pdf-core.test.ts`).
- KVNR Zod refine: valid/invalid check-digit cases.
- Clinical-summary block: deterministic output for a fixture, threshold-cross flagging.
- Selection-group → measurement-type mapping coverage (analogue to the existing enum-coverage test).
- i18n: new `doctorReport.*` keys resolve in all 6 locales (existing guards catch this).
Integration (testcontainers Postgres):
- `POST /api/export/health-record` format=fhir → valid Bundle, only the auth'd user's rows, audit row
written, rate-limit shared bucket enforced (11th call in an hour → 429).
- format=pdf → `application/pdf` `%PDF-` magic bytes, correct Content-Disposition.
- format=package → zip contains both entries (if zip shipped).
- Cross-user isolation: user A's token can't export user B's data.
E2E (Playwright): selection panel — toggle format hides/shows PDF-only fields; mood default-off;
generate triggers a download; axe pass on the panel.
OpenAPI: `openapi:check` stays green after registry additions (§9).
## 9. OpenAPI additions
Add to `src/lib/openapi/registry.ts` + `routes.ts`, then `pnpm openapi:generate` and commit the YAML:
- `POST /api/export/health-record` — request `exportSelectionSchema`; responses 200
(`application/pdf` | `application/fhir+json` | `application/zip` — document the content-type
variance per `format`), 422 (multi-issue envelope), 429.
- `GET /api/export/fhir` (if shipped) — query `days`; 200 `application/fhir+json`.
- Register the FHIR `Bundle`/`Composition`/`Observation`/`MedicationStatement` response schema shapes
(narrow, only the fields we emit) with `.meta()` annotations so the contract is documented for the
iOS client and any third-party consumer.
---
## Sources
- HL7 FHIR R4 Vital Signs profile (LOINC + UCUM): https://hl7.org/fhir/R4/observation-vitalsigns.html
- FHIR R4 Observation: https://hl7.org/fhir/R4/observation.html ; BP example: https://www.hl7.org/fhir/R4/observation-example-bloodpressure.html
- HL7 Personal Health Record Format IG (Bundle .json <16MB, NDJSON bulk, Composition cover, .sphr zip):
https://build.fhir.org/ig/HL7/personal-health-record-format-ig/en/recordkeeping.html
- International Patient Summary Bundle examples: https://build.fhir.org/ig/HL7/fhir-ips/Bundle-IPS-examples-Bundle-01.json.html
- gematik ePA "für alle" (FHIR Document Bundles + PDF/A, mandatory 2025-10-01):
https://www.gematik.de/anwendungen/epa-fuer-alle ; https://fachportal.gematik.de/anwendungen/epa-fuer-alle
- Apple Health Records uses FHIR/SMART: https://rhapsody.health/blog/apple-health-fhir/ ;
Apple HealthKitV2 clinical-records export format: https://support.mydatahelps.org/hc/en-us/articles/4412375685523
+421
View File
@@ -0,0 +1,421 @@
# R-firstpaint — Unified Dashboard Snapshot (v1.7.0)
Research + design for the dashboard cold-load stagger. Goal: every above-the-fold
tile paints together on first byte, TTFB stays low, and no AI text is ever
generated synchronously during a page load.
READ-ONLY research. This document is a design, not an implementation.
---
## 1. Current dashboard data path (the stagger root cause)
The dashboard is `src/app/page.tsx` (there is no `(app)` route group). It is a
single `"use client"` component, ~1516 LOC, top-to-bottom client-fetched. The
iOS `/api/dashboard/summary` route is **NOT used by the web dashboard at all** —
it serves the SwiftUI client only (`src/app/api/dashboard/summary/route.ts:1-11`).
That route is a useful template for the shape but is not on the web path.
### 1a. The serial auth gate (primary stagger cause)
Every dashboard query carries `enabled: isAuthenticated`. `isAuthenticated`
comes from `useAuth()` (`src/hooks/use-auth.ts:63-82`), which is itself a
`useQuery` against `GET /api/auth/me` (`:44-46`). It flips true only **after**
`/api/auth/me` resolves. So the cold load is:
```
boot JS → /api/auth/me (round-trip 1, gating)
└─▶ THEN fan-out (round-trips 2..N, parallel):
/api/analytics?slice=summaries (tile headlines)
/api/analytics (thick: bpInTargetPct, glucose)
/api/mood/analytics (mood tile + chart)
/api/dashboard/widgets (layout / visibility / order)
+ per-chart fetches inside each <HealthChartDynamic>
```
The mood tile "appears first" because `/api/mood/analytics` is a small, fast,
single-purpose endpoint, while the per-type measurement tiles wait for the slim
`/api/analytics?slice=summaries` slice. When both analytics slices are cold
(LRU evicted / new process), the tile strip shows the skeleton
(`src/app/page.tsx:1324-1327`, `1391-1414`) and then all measurement tiles
"pop in" as one burst the moment the slim slice resolves — exactly the
"~1 second later all tiles pop in staggered" symptom.
### 1b. Per-tile / per-query independence (secondary stagger cause)
The tile data and the layout/visibility data resolve from **independent** query
cells that complete at different times:
| Source | Drives |
|---|---|
| `data` (merge of slim+thick analytics, `page.tsx:217-247`) | per-type tile values, BD-Zielbereich, glucose |
| `moodData` (`page.tsx:261-274`) | mood tile + mood chart |
| `layoutData` (`page.tsx:249-259`) | which tiles/charts are visible + order |
| `user` (`useAuth`) | greeting, height/age/gender-derived target bands |
Tiles only render once **both** `data` (or `moodData`) **and** `layoutData`
have resolved, because `isTileVisible()` / `widgetOrder()` read `layoutData`
(`page.tsx:356-364`). Four independent cells with four independent completion
times = four independent paint moments.
### 1c. The per-tile `<Suspense>` boundaries are NOT the cause
`page.tsx:1467-1477` wraps each trend card and `:1507` each chart in its own
`<Suspense>`. The comments (`:1457-1466`, `:1483-1506`) confirm these are
structural no-ops today — tile bodies are synchronous, chart skeletons live
inside the `next/dynamic` factory. The stagger is the **upstream query
waterfall**, not Suspense resolving independently. (Charts below the fold are a
separate, acceptable concern — they each fetch their own `["chart-data", …]`.)
### Tile inventory (above the fold)
Every tile in the strip, its data source, and where it renders:
| Tile | Source field | Endpoint | Gate |
|---|---|---|---|
| Weight | `summaries.WEIGHT` | `/api/analytics?slice=summaries` | `page.tsx:288,367,400` |
| BP systolic | `summaries.BLOOD_PRESSURE_SYS` | slim analytics | `:289,368,401` |
| BP diastolic | `summaries.BLOOD_PRESSURE_DIA` | slim analytics | `:290,401` |
| Pulse | `summaries.PULSE` | slim analytics | `:291,369,402` |
| Body fat | `summaries.BODY_FAT` | slim analytics | `:292,370,403` |
| Mood | `moodData.summary` | `/api/mood/analytics` | `:299,371,404` |
| Sleep | `summaries.SLEEP_DURATION` | slim analytics | `:293,372,405` |
| Steps | `summaries.ACTIVITY_STEPS` | slim analytics | `:294,373,406` |
| VO₂ max | `summaries.VO2_MAX` | slim analytics | `:298,374,407` |
| BD-Zielbereich | `bpInTargetPct*` | `/api/analytics` (THICK) | `:390-397,408` |
| Glucose ×N | `glucoseByContext[ctx]` | `/api/analytics` (THICK) | `:434-445` |
| Layout/order for all | `layoutData` | `/api/dashboard/widgets` | `:249-259` |
The BD-Zielbereich + glucose tiles ride the **thick** slice, which is the
slowest of the analytics reads — they arrive last today by design (the
v1.4.39.2 slim/thick split, `page.tsx:197-216`).
### 1d. Analytics route internals (rollup tier)
`GET /api/analytics` (`src/app/api/analytics/route.ts`):
- `?slice=summaries` → `computeSummariesSlice(user.id)` (2 SQL passes), wrapped
in `caches.analytics` keyed `${user.id}|summaries`, 60 s TTL
(`route.ts:51-60`, `server-cache.ts:210-214`).
- default (thick) → `ensureUserRollupsFresh` + `probeRollupCoverage`, then
`computeBpInTargetFastPath` / `computeUserHealthScoreFastPath` /
`computeCorrelationHypothesesFastPath` (all imported `route.ts:11-15`). These
read DAY/WEEK/MONTH/YEAR `measurement_rollups` with a live-SQL fallback on a
coverage miss (CLAUDE.md read-swap). **None of these is an LLM call** — health
score and correlations are deterministic SQL, despite the AI-sounding names.
So the snapshot can assemble every tile field server-side from the rollup tier +
the mood-analytics read + the widget layout, with no LLM in the path.
---
## 2. AI pre-generation audit
The ONLY LLM-generated text in the product is the comprehensive insight, which
carries the `dailyBriefing` + `trendAnnotations` blocks. The per-status cards
(BP/weight/pulse/bmi/mood/compliance) are also LLM, cached in `audit_logs`. The
Coach is SSE chat. Health-score + correlations are **not** LLM.
### Audit table
| Artifact | Generated by | Cached where | On-demand at load? |
|---|---|---|---|
| Comprehensive insight + `dailyBriefing` + `trendAnnotations` | `POST /api/insights/generate` (`src/app/api/insights/generate/route.ts:181-570`) — LLM via provider chain | `User.insightsCachedText` + `insightsCachedAt`, 24 h TTL (`route.ts:212-238,530-536`) | **YES — fires on `/insights` mount.** `useInsightsAdvisorQuery(isAuthenticated)` (`use-insights-advisor.ts:149-160`) auto-POSTs on mount; the POST is cache-or-generate (`route.ts:212-238`). On a cache MISS (>24 h old, or never generated) it **synchronously calls the LLM inside the request**, bounded by an 8 s client `AbortController` (`use-insights-advisor.ts:63,75-90`). This is the stall the maintainer fears — but it is on `/insights`, NOT the dashboard. |
| Per-status cards (bp/weight/pulse/bmi/mood/compliance) | `/api/insights/{scope}-status` routes (LLM) | `audit_logs` rows `insights.<scope>-status.<locale>`, per-day eviction (`generate/route.ts:59-83`) | YES on `/insights` sub-cards; same cache-or-generate pattern. Not on the dashboard. |
| Coach chat | `POST /api/insights/chat` SSE | `CoachMessage.encryptedContent` | On user send only — never on page load. |
| Health score | `computeUserHealthScoreFastPath` (SQL, `analytics/route.ts:14`) | `caches.analytics` 60 s | No LLM. Deterministic. Thick analytics only. |
| Correlations | `computeCorrelationHypothesesFastPath` (SQL, `analytics/route.ts:15`) | `caches.analytics` 60 s | No LLM. Deterministic. |
### Critical finding (answers the maintainer's worry directly)
**Is any AI text generated synchronously during a DASHBOARD load? NO.** The
dashboard (`src/app/page.tsx`) does not import `use-insights-advisor`, does not
render `<DailyBriefing>`, and does not call `/api/insights/generate`. The
v1.4.27 B1 change retired the dashboard's `<InsightsCardPreview>`
(`page.tsx:276-280`). The dashboard cold-load slowness is 100 % the
auth-gated query waterfall (§1), with zero LLM exposure.
**Is AI text generated synchronously on a PAGE load anywhere? YES, on
`/insights`.** First visit (or after the 24 h cache expires), the mount-time
advisor POST cache-misses and blocks on the provider chain inside the request
handler. There is **no pg-boss cron** that pre-generates the briefing — search
of `src/lib/jobs/` shows workers for reminders, mood-reminder, step-consolidation,
PR-detection, geo-backfill, inventory-expire, idempotency/audit cleanup,
apple-health-import, offhost-backup — but **no insight/briefing generation
worker**. The maintainer's belief that "we said we'd pre-generate those" is
**not yet implemented**: every briefing is lazily generated on first request and
merely cached for 24 h afterward. This is a real gap, and §4 below proposes the
cron to close it.
---
## 3. Design: `GET /api/dashboard/snapshot`
One `apiHandler`-wrapped GET that assembles every above-the-fold tile field in a
single server round-trip from the rollup tier + the mood read + the widget
layout + pre-generated AI artifacts (embedded, never generated). Tiles paint
together because one query resolves them all.
### 3.1 Endpoint
`src/app/api/dashboard/snapshot/route.ts`
```ts
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.snapshot" } });
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const body = await cached(
caches.analytics as ServerCache<DashboardSnapshot>,
`${user.id}|dashboard-snapshot`,
() => buildDashboardSnapshot(user, userTz),
annotate,
);
return apiSuccess(body, NO_STORE_BUT_BFCACHE);
});
```
`buildDashboardSnapshot` runs ONE `Promise.all` over the existing helpers (reuse,
do not re-implement):
- `computeSummariesSlice(user.id)` → per-type `summaries` + `lastSeenByType`
(slim slice, already cached internally; the snapshot read shares the cell).
- `computeBpInTargetFastPath(...)` → `bpInTargetPct*`.
- glucose-by-context block (lift the existing thick branch from
`analytics/route.ts:326+`).
- mood-analytics read (lift from `/api/mood/analytics`) → `mood.summary` +
`mood.entries`.
- resolved widget layout (`resolveDashboardLayout` over `user.dashboardWidgetsJson`,
same as `widgets/route.ts`) → `layout`.
- the user-profile fields the page derives target bands from (`heightCm`,
`dateOfBirth`, `gender`, `glucoseUnit`, `timezone`, `username`,
`onboardingTourCompleted`) — already on `requireAuth().user`.
- AI artifact read-ONLY: `prisma.user` `insightsCachedText` + `insightsCachedAt`.
Parse + lift `dailyBriefing` via `dailyBriefingSchema.safeParse`. **Never call
the LLM here.** If absent/stale → `briefing: null` + `briefingState:
"preparing"` and the cron (§4) fills it.
Each sub-read is `time()`-wrapped (mirror `summary/route.ts:394-400`) and
surfaced under `meta.snapshot.sub_*_ms` so a regression is attributable without
re-instrumenting.
### 3.2 Response shape (single envelope `{ data, error, meta }`)
```ts
interface DashboardSnapshot {
user: {
username: string;
timezone: string;
heightCm: number | null;
dateOfBirth: string | null; // ISO
gender: "MALE" | "FEMALE" | null;
glucoseUnit: string | null;
onboardingTourCompleted: boolean;
greetingHour: number; // server-computed in userTz (kills the
// client Intl.DateTimeFormat hour call)
};
layout: DashboardLayout; // resolved widgets[] with visible/tileVisible/order + comparisonBaseline
summaries: Record<string, DataSummary>; // every per-type tile headline
lastSeenByType: Record<string, { lastSeenAt: string; daysAgo: number } | null>;
bpInTargetPct: number | null;
bpInTargetPct7d: number | null;
bpInTargetPct30d: number | null;
bpInTargetPctAllTime: number | null;
bpInTargetPctPriorMonth: number | null;
bpInTargetPctPriorYear: number | null;
glucoseByContext: Record<string, DataSummary>;
mood: {
summary: DataSummary | null;
entries: Array<{ date: string; score: number; samples: number }>;
};
briefing: DailyBriefing | null; // pre-generated, embedded read-only
briefingState: "ready" | "preparing" | "disabled";
briefingUpdatedAt: string | null;
generatedAt: string; // ISO
}
```
The Zod source schema lives under `src/lib/openapi/` and feeds
`docs/api/openapi.yaml` via `pnpm openapi:generate` (CI gate).
### 3.3 Page consumption — RECOMMENDATION
**Recommend: one client `useSnapshotQuery()` that hydrates ALL tiles, replacing
the four independent per-tile/layout/mood/thick cells.** Keep the page a
`"use client"` component (it owns the quick-entry sheets, dropdowns, dialog
state — converting to RSC is a far larger rewrite and risks the warm-feel). But
collapse the data layer to a single query so all tiles share one completion
moment.
Why one client query and NOT an RSC server-fetch for v1.7.0:
- The page is already client. An RSC `page.tsx` + server fetch would require
splitting interactive chrome into a client island and hydrating, a large diff
that risks the "blazing fast warm" feel the maintainer prizes. Defer to a
later milestone if TTFB needs the SالسSR boost.
- One client query still fixes the stagger: tiles arrive together because there
is exactly one cell, one completion.
- Warm navigation stays instant: `staleTime: 60_000` means a return-to-dashboard
within a minute is a free cache hit, identical to today's
`DASHBOARD_QUERY_OPTS` (`page.tsx:56-59`).
To also kill the **auth-gate waterfall** (§1a — the single biggest first-byte
win): make the snapshot query **NOT** gated on `isAuthenticated`. It runs
`requireAuth()` server-side and returns 401 if the cookie is absent; on the
dashboard route the cookie is always present (the user is past the
`src/proxy.ts` onboarding/auth redirect). Firing `/api/dashboard/snapshot` in
parallel with `/api/auth/me` instead of after it removes round-trip 1 from the
critical path. The page reads `snapshot.data.user` for greeting/bands and uses
`useAuth()` only for the global nav/coach gating it already needs elsewhere.
Query key (factory contract, `src/lib/query-keys.ts`):
```ts
dashboardSnapshot: () => ["dashboard", "snapshot"] as const,
```
Add to the factory; the in-repo `healthlog/queryKey-factory` ESLint rule
(`error`) forbids the bare array.
### 3.4 Embedding pre-generated AI text (never generate in this path)
- The snapshot reads `User.insightsCachedText` and lifts `dailyBriefing` only.
It NEVER POSTs `/api/insights/generate` and NEVER calls a provider.
- If `insightsCachedText` is null/unparseable/older than 24 h →
`briefing: null`, `briefingState: "preparing"`. The UI paints a graceful
shimmer/empty card (reuse `<DailyBriefing loading>` / empty-state at
`daily-briefing.tsx:283-361`). The cron (§4) refills; the user sees real text
on the next visit without ever blocking a paint.
- If the operator disabled the Coach surface (`requireAssistantSurface("coach")`
semantics) or `user.disableCoach` → `briefingState: "disabled"`, card hidden.
> Note: the dashboard does not render the briefing today (§2). Embedding it in
> the snapshot is OPTIONAL for v1.7.0 — include the field so the shape is
> future-proof and so a future "briefing on dashboard" toggle is a pure UI
> change. The `/insights` page keeps its own advisor query.
### 3.5 Cache strategy + invalidation
- Reuse `caches.analytics` (60 s TTL, `server-cache.ts:210-214`), per-user key
`${user.id}|dashboard-snapshot`. Same bucket family the slim/thick analytics
+ mood reads already use, so a single eviction sweep covers it.
- Wire the snapshot key into the existing invalidators in
`src/lib/cache/invalidate.ts`:
- `invalidateUserMeasurements(userId)` (`:30`) → also drop
`${userId}|dashboard-snapshot`.
- `invalidateUserMood(userId)` (`:48`) → drop it (mood block lives in the
snapshot now).
- `invalidateUserMedications(userId)` (`:63`) → drop it.
- `invalidateUserDashboardWidgets(userId)` → drop it (layout changed).
- On a fresh insight write (`generate/route.ts:530-536`) → drop it so the next
snapshot embeds the new briefing.
- A measurement/insight write therefore evicts both the legacy analytics cells
AND the snapshot, preserving CLAUDE.md "invalidate on new content".
---
## 4. The pre-generation cron (closes the §2 gap)
New pg-boss worker `src/lib/jobs/insight-pregenerate.ts`, registered in the
worker bootstrap alongside the existing crons (see `reminder-worker.ts:160-232`
for the registration pattern; add the queue to `allQueues` — the v1.4.37 W10 QA
catch proves an unregistered queue ships silently).
- Cadence: nightly, staggered per the 03:xx/04:00 cleanup-cron convention
(`feedback-aggregator.ts:4`, `medication-inventory-expire.ts:25`). Pick a free
slot (e.g. 04:30 Europe/Berlin).
- Discovery query: users with a configured provider chain AND
(`insightsCachedAt IS NULL` OR `insightsCachedAt < now() - interval '20 hours'`)
AND assistant surface enabled AND `disableCoach = false`. Bounded batch with
per-user budget gate (reuse `runRawCompletionWithFallback` + the existing
rate-limit/budget plumbing so a cron run can't blow the LLM budget).
- Effect: by the time a user opens `/insights` (or, later, sees the dashboard
briefing), the cache is warm → the mount-time advisor POST is a pure
cache-read, no synchronous LLM. This is the "pre-generate everywhere" the
maintainer expects.
- Must NOT run inside the request lifecycle and must NOT use `pnpm tsx`
(CLAUDE.md DO-NOTs); it is a recurring pg-boss task.
This cron is independent of the snapshot endpoint and can ship in the same
release or a follow-up. The snapshot's `briefingState: "preparing"` already
degrades gracefully if the cron has not run yet.
---
## 5. Test list
Unit / integration (Vitest):
1. `snapshot.route.test.ts` — envelope shape `{ data, error }`; every tile field
present; `requireAuth` 401 when unauthenticated.
2. Snapshot assembles all tiles in ONE `Promise.all` — assert sub-query count
and that no LLM client is reachable from the builder (mock provider chain →
assert never invoked).
3. Cache hit/miss: second call within 60 s hits `caches.analytics`; a
measurement write evicts `${userId}|dashboard-snapshot`.
4. `briefingState` matrix: ready (fresh cache) / preparing (null or >24 h) /
disabled (coach off) — and the builder NEVER calls `/api/insights/generate`.
5. Invalidation: `invalidateUserMeasurements/Mood/Medications/DashboardWidgets`
each drop the snapshot key (extend existing `invalidate.test.ts`).
6. queryKey factory: `queryKeys.dashboardSnapshot()` exists; ESLint
`healthlog/queryKey-factory` passes (no bare array in `page.tsx`).
7. Parity test: snapshot `summaries` byte-match `computeSummariesSlice` output;
`bpInTargetPct*` match the thick fast-path; mood block matches
`/api/mood/analytics`.
8. OpenAPI: `pnpm openapi:check` green after adding the snapshot Zod schema.
9. Cron `insight-pregenerate.test.ts` — discovery query selects only
provider-configured, stale-cache, coach-enabled users; respects budget gate;
queue is in `allQueues`.
E2e (Playwright, behind the flag):
10. Cold dashboard load with flag ON: assert all visible tiles appear within one
paint frame of each other (no mood-first-then-burst), via a single
`/api/dashboard/snapshot` network event (not 4 separate analytics/mood/widget
requests).
11. Warm navigation away-and-back within 60 s makes zero snapshot network calls
(cache hit) — guards the "blazing fast warm" feel.
12. Axe pass on the snapshot-driven dashboard (no regression vs. today).
---
## 6. Rollout (low-risk, reversible)
1. **Land the endpoint + cron first, no UI change.** Ship
`GET /api/dashboard/snapshot` + the Zod schema + OpenAPI regen + the
pre-generate cron. Inert until the page consumes it. Validate in prod via the
`meta.snapshot.sub_*_ms` annotations and the cron's wide-event.
2. **Flag the page swap.** Add a feature flag (reuse the existing
`src/lib/feature-flags/` infra). Flag OFF → the page keeps the four
independent queries (today's behaviour, zero risk). Flag ON → the page mounts
the single `useSnapshotQuery()` and reads every tile from it.
3. **Preserve the warm feel:** `staleTime: 60_000` + `refetchOnWindowFocus:
false` + `refetchOnMount: false`, identical cadence to `DASHBOARD_QUERY_OPTS`
(`page.tsx:56-59`). Keep the tile-strip skeleton (`page.tsx:1391-1414`) as the
single-cell loading state so the layout footprint is reserved.
4. **Verify, then default-on, then remove the legacy queries.** Once the flag is
ON in prod and the stagger is gone (e2e #10 + manual), flip the default, then
delete the `analyticsSlimQuery` / `analyticsThickQuery` / `moodData` /
`layoutData` cells from `page.tsx` (no back-compat shim — CLAUDE.md DO-NOTs).
5. **Auth-gate win:** in step 2, fire the snapshot query un-gated by
`isAuthenticated` (server-side `requireAuth` is the real gate) so round-trip 1
leaves the critical path. This is the largest TTFB improvement and is
independently reversible via the flag.
### Risks
- **Biggest risk — the thick block (BD-Zielbereich + glucose + bpInTarget) is
the slowest read and now sits inside the unified snapshot.** Today the page
paints measurement tiles from the slim slice FIRST and lets the thick block
stream in afterward (the deliberate v1.4.39.2 progressive-paint split,
`page.tsx:197-216`). Collapsing slim+thick into one snapshot means the WHOLE
strip waits for the slowest sub-query — on a cold rollup-coverage miss the
thick fast-path falls back to live SQL and can be multi-second, which would
make EVERY tile late instead of just the BD/glucose tiles. Mitigation: keep the
builder's `Promise.all` so thick runs concurrently with slim; gate the snapshot
behind rollup-coverage being warm (the boot `rollup-full-backfill` queue
converges new accounts); and consider a `tiles` (slim, fast) + `extras` (thick)
two-phase field in the snapshot where `extras` may arrive `null` on the first
payload and the tile shows a per-tile shimmer — preserving "everything else
together" while not regressing to a blocked-then-burst on the thick path. This
trade-off (paint-together vs. slowest-wins) is the core design tension and must
be measured on a power-user account before default-on.
- Secondary: un-gating from `isAuthenticated` must be verified to never fire on
the unauthenticated shell (proxy redirect should prevent the dashboard from
rendering pre-auth, but confirm in e2e).
- Tertiary: the pre-generate cron must respect the LLM budget gate or a nightly
fan-out across all users could spike provider cost.
+260
View File
@@ -0,0 +1,260 @@
# R-healthkit — HealthKit ingest consolidation, missing charts, unit fixes
Research + design for v1.7.0. Scope: (a) daily-aggregation for high-frequency HealthKit metrics, (b) chart surfaces for orphan metric types, (c) walking-speed km/h display.
All citations are `file:line` against the working tree at the start of this session. READ-ONLY pass — no source edited.
---
## 0. Executive summary
- The full `MeasurementType` enum is **41 values** (`prisma/schema.prisma:447-503`).
- **31 of the 41 types have NO dedicated chart surface today.** Only 10 have a chart (6 dashboard tiles + the insights sub-pages). Everything else (all v1.4.25 / v1.4.30 / v1.5.5 Apple-Health + Withings additions) is *ingested and stored* but *never plotted*. This is the "orphan data" the maintainer is describing — `flights climbed`, `walking speed`, `step length`, `environmental audio exposure`, plus ~27 more.
- The chart component (`HealthChart`) is **fully generic** and **already daily-aggregates** through the rollup/`aggregate=daily` read path. New charts are *registry + mount* work, not new chart code. The Recharts-stays rule is satisfied by reusing `HealthChart` verbatim.
- **Walking speed stores AND displays m/s today. There is NO km/h conversion anywhere in the tree** (grep for `km/h` / `3.6` returns only unrelated timezone math). This is the confirmed unit bug.
- High-frequency *spot* metrics (PULSE, RESPIRATORY_RATE, WALKING_SPEED, WALKING_STEP_LENGTH, AUDIO_EXPOSURE_*) are stored per-sample and are NOT drained — only the 5 *cumulative* types are. The display path averages them per day correctly *only on windows >7 days*; the consolidation gap is about storage volume + ≤7-day windows.
---
## 1. Full type → chart → unit → aggregation table
Enum source: `prisma/schema.prisma:447-503`. Stored unit source: `unitMap` in `src/lib/validations/measurement.ts` + `dbUnit` in `src/lib/measurements/apple-health-mapping.ts:146-473`. Ingest aggregation hint: `aggregation` field in the same map. "Cumulative" = member of `CUMULATIVE_HK_TYPES` (`apple-health-mapping.ts:490-496`). Chart surface = dashboard tile in `src/app/page.tsx` OR an insights sub-page under `src/app/insights/`.
| Type | Stored unit | Freq (HK) | Ingest agg | Cumulative? | Chart surface today | Notes |
|---|---|---|---|---|---|---|
| WEIGHT | kg | spot | latest | no | tile + `/insights/gewicht` | ✅ |
| BLOOD_PRESSURE_SYS | mmHg | spot | latest | no | tile + `/insights/blutdruck` | ✅ |
| BLOOD_PRESSURE_DIA | mmHg | spot | latest | no | tile + `/insights/blutdruck` | ✅ |
| PULSE | bpm | **high (per-sample)** | latest | no | tile + `/insights/puls` | ✅ but high-volume |
| BODY_FAT | % | spot | latest | no | tile | ✅ |
| SLEEP_DURATION | minutes | per-stage | sum | no | tile + `/insights/schlaf` | ✅ |
| ACTIVITY_STEPS | steps | **high** | sum | **yes** | tile | ✅ drained + consolidated |
| BLOOD_GLUCOSE | mg/dL | spot | latest | no | — (list only) | orphan |
| TOTAL_BODY_WATER | kg | spot | latest | no | — | orphan (Withings) |
| BONE_MASS | kg | spot | latest | no | — | orphan (Withings) |
| OXYGEN_SATURATION | % | spot | latest | no | `/insights/sauerstoff` | ✅ |
| HEART_RATE_VARIABILITY | ms | nightly | mean | no | `/insights/hrv` | ✅ |
| RESTING_HEART_RATE | bpm | daily | latest | no | `/insights/ruhepuls` | ✅ |
| ACTIVE_ENERGY_BURNED | kcal | **high** | sum | **yes** | `/insights/aktive-energie` | ✅ drained |
| **FLIGHTS_CLIMBED** | flights | **high** | sum | **yes** | **— orphan** | needs chart |
| **WALKING_RUNNING_DISTANCE** | m | **high** | sum | **yes** | **— orphan** | needs chart |
| VO2_MAX | mL/(kg·min) | sparse | latest | no | `vo2-max-chart-row` (overview) | ✅ partial |
| BODY_TEMPERATURE | celsius | spot | latest | no | `/insights/koerpertemperatur` | ✅ |
| FAT_FREE_MASS | kg | spot | latest | no | — | orphan (Withings) |
| FAT_MASS | kg | spot | latest | no | — | orphan (Withings) |
| MUSCLE_MASS | kg | spot | latest | no | — | orphan (Withings) |
| SKIN_TEMPERATURE | celsius | spot | latest | no | — | orphan (Withings) |
| PULSE_WAVE_VELOCITY | m/s | sparse | latest | no | — | orphan (Withings) |
| VASCULAR_AGE | years | sparse | latest | no | — | orphan (Withings) |
| VISCERAL_FAT | rating | sparse | latest | no | — | orphan (Withings) |
| **AUDIO_EXPOSURE_ENV** | dBA | **high (~30 s)** | mean | no | **— orphan** | needs chart |
| **AUDIO_EXPOSURE_HEADPHONE** | dBA | **high** | mean | no | **— orphan** | needs chart |
| TIME_IN_DAYLIGHT | minutes | daily | sum | **yes** | — orphan | drained, no chart |
| WALKING_STEADINESS | % | daily | latest | no | — orphan | needs chart |
| AUDIO_EXPOSURE_EVENT | count | event | sum | no | — orphan | event flag — chart optional |
| RESPIRATORY_RATE | breaths/min | **high (sleep/workout)** | mean | no | — orphan | needs chart |
| BODY_MASS_INDEX | kg/m² | spot | latest | no | `/insights/bmi` (derived) | ✅ via WEIGHT |
| LEAN_BODY_MASS | kg | spot | latest | no | — | orphan |
| WALKING_HEART_RATE_AVERAGE | bpm | daily | mean | no | — orphan | needs chart |
| WALKING_ASYMMETRY | % | daily | latest | no | — orphan | needs chart |
| WALKING_DOUBLE_SUPPORT | % | daily | latest | no | — orphan | needs chart |
| **WALKING_STEP_LENGTH** | m | **high** | mean | no | **— orphan** | needs chart |
| **WALKING_SPEED** | **m/s** | **high** | mean | no | **— orphan** | **needs chart + km/h fix** |
(BLOOD_PRESSURE counted once each; 41 enum members total.)
### Central registries (cite these when adding a type)
- **Stored unit:** `unitMap` → `getUnitForType()` — `src/lib/validations/measurement.ts:137-139`.
- **Plausibility range (ingest guard):** `VALUE_RANGES` — `src/lib/validations/measurement.ts:142-239`.
- **Label i18n key:** `MEASUREMENT_TYPE_LABEL_KEYS` — `src/components/measurements/measurement-list-meta.ts:39-84`.
- **Icon:** `MEASUREMENT_TYPE_ICONS` — `measurement-list-meta.ts:86-148`.
- **Color (Dracula chart palette):** `MEASUREMENT_TYPE_COLORS` — `measurement-list-meta.ts:150-206`.
- **UI category:** `MEASUREMENT_CATEGORIES` — `src/lib/measurements/categories.ts`.
- **HK ingest mapping (hkUnit, dbUnit, convertToDbUnit, aggregation):** `APPLE_HEALTH_TYPE_MAP` — `src/lib/measurements/apple-health-mapping.ts:146-473`.
- **Cumulative-SUM set:** `CUMULATIVE_HK_TYPES` — `apple-health-mapping.ts:490-496`.
- **AI chart-token allowlist:** `ALLOWED_CHART_TOKENS` — `src/lib/insights/chart-tokens.ts:23-82` (already lists every new type).
- **Insights metric gate enum:** `InsightMetric` — `src/lib/insights/metric-availability.ts:43-63`; gate fn `hasMetricData()` at `:93-106` (generic `summaries[metric].count > 0` fallback already covers new MeasurementType-backed metrics).
**Net:** every per-type *metadata* surface except `InsightMetric` and a per-metric insights page already has entries for the orphan types. The label/icon/colour/unit/range/category/token plumbing is DONE. The missing piece is the **chart mount** + a handful of i18n page-copy keys + the unit conversion for speed.
---
## 2. Step consolidation & the drain pattern (does it generalise?)
Two distinct mechanisms exist:
### 2a. Nightly cumulative drain — `src/lib/measurements/drain-per-sample-cumulative.ts`
- Runs over `CUMULATIVE_HK_TYPES` only (`drain-per-sample-cumulative.ts:320`, set defined `apple-health-mapping.ts:490-496`): `ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT`.
- Per user × type × calendar-day (user tz): SUM all `APPLE_HEALTH` per-sample rows whose `externalId NOT LIKE 'stats:%'`, UPSERT one row keyed `stats:<HKIdentifier>:<YYYY-MM-DD>` at local-noon, then **delete** the per-sample rows in the same tx (`:374-424`).
- 36-hour grace cutoff (`DRAIN_CUMULATIVE_CUTOFF_HOURS`, `:55`) keeps today's in-flight watch syncs raw for the live "today" view.
- Idempotent: re-running collapses zero rows (`:344-356`).
### 2b. Boot-time legacy step consolidation — `src/lib/jobs/step-consolidation.ts` + `src/lib/measurements/consolidate-legacy-steps.ts`
- A pg-boss queue (`STEP_CONSOLIDATION_QUEUE`, `step-consolidation.ts:19`) registered in `allQueues` (`reminder-worker.ts`). Discovery query enqueues one job per user still holding live legacy step rows (`step-consolidation.ts:95-104`).
- Difference from 2a: it **soft-deletes** (tombstones) the legacy rows rather than hard-deleting, and it **SUMs** legacy granular rows but **does not overwrite** an existing post-v1.5.0 daily total (avoids double-counting — `consolidate-legacy-steps.ts:230-262`).
- This is ACTIVITY_STEPS-specific (hardcoded `STEP_TYPE`, `STEP_HK_IDENTIFIER` — `:50-53`).
### Does the pattern generalise?
**Drain (2a) generalises cleanly to other cumulative types by SUM — and already covers FLIGHTS_CLIMBED + WALKING_RUNNING_DISTANCE + TIME_IN_DAYLIGHT.** So those three are *already consolidated server-side*; their only gap is the missing chart.
**The drain does NOT generalise to MEAN/spot high-frequency metrics**, and must not: summing PULSE or WALKING_SPEED across a day is meaningless. The correct consolidation for `aggregation: "mean"` types is a **daily-mean**, which the read path already computes (see §3). The maintainer's "daily-mean" intuition is exactly right for these.
---
## 3. The read-path already daily-aggregates — what's actually missing
The chart NEVER plots raw per-sample rows beyond a 7-day window:
- `HealthChart` (`src/components/charts/health-chart.tsx:619-622`): for `windowDays > 7` it requests `aggregate=daily&source=rollup`. The route reduces by **SUM for `CUMULATIVE_HK_TYPES`, AVG (daily-mean) for everything else** (`src/app/api/measurements/route.ts:504-510`, `:448`).
- For ≤7-day windows the chart pulls raw rows and aggregates client-side, again SUM-vs-mean keyed on `CUMULATIVE_HK_TYPES` (`health-chart.tsx:672-682`).
- So a WALKING_SPEED or PULSE chart over 30/90/all already shows **daily mean**, plotted as one point per day. Volume only bites the ≤7-day path and raw storage.
**Conclusion on (a):** for *display*, daily-mean is already implemented for every `mean`/spot type the moment a chart is mounted with a >7-day default window. The remaining work is:
1. **Storage volume** — high-frequency spot metrics (PULSE, RESPIRATORY_RATE, AUDIO_EXPOSURE_ENV/HEADPHONE, WALKING_SPEED, WALKING_STEP_LENGTH) accumulate per-sample rows forever. There is no drain for them. The maintainer's concern (a) is primarily a storage / "don't keep every raw sample" concern.
2. **Default window** — new chart cards should default to a >7-day range so they hit the daily-mean path immediately rather than the raw ≤7-day path.
### Design — daily-mean consolidation for high-frequency non-cumulative metrics
Add a **second drain mode** rather than overloading the SUM drain. Recommended: generalise `drain-per-sample-cumulative.ts` into a reducer-aware drain.
- Introduce `HIGH_FREQUENCY_MEAN_TYPES: ReadonlySet<MeasurementType>` next to `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts`: `{ PULSE, RESPIRATORY_RATE, AUDIO_EXPOSURE_ENV, AUDIO_EXPOSURE_HEADPHONE, WALKING_SPEED, WALKING_STEP_LENGTH }`. (HRV/walking-HR-avg/steadiness are already low-frequency — daily-or-sparser — so they need no drain.)
- New nightly job `mean-consolidation` (mirror `step-consolidation.ts` queue registration). Per user × type × completed day (respect the same 36 h grace): compute the **daily mean** of `APPLE_HEALTH` per-sample rows, UPSERT one `stats:<HKIdentifier>:<day>` row at local-noon with `value = mean`, **soft-delete** the per-sample rows (tombstone, matching the legacy-step choice at `consolidate-legacy-steps.ts:304-312` — keeps an audit trail, lower risk than hard-delete).
- Critical correctness note: the daily-stats read path currently treats a single `stats:` row as the day's value as-is. For a MEAN type the stored `stats:` row already IS the mean, so the chart's per-day reduction (`stats.sum / stats.count` with count=1) returns it unchanged. ✅ No reader change needed because mean types are not in `CUMULATIVE_HK_TYPES`, so the read path uses AVG and a single row averages to itself.
- **Do NOT add mean types to `CUMULATIVE_HK_TYPES`** — that would switch them to SUM and corrupt the value. Keep the two sets disjoint; assert disjointness in a test.
Risk: PULSE is also entered manually and synced from Withings spot readings — draining `source = 'APPLE_HEALTH'` only (as the existing drain does, `drain-per-sample-cumulative.ts:327`) keeps manual/Withings pulse rows intact. Honour that scoping.
Alternative (lower effort, no drain): rely entirely on the existing rollup DAY tier for display and accept unbounded raw storage with a retention sweep. Rejected — the maintainer explicitly wants consolidation, and the drain pattern is proven + idempotent.
---
## 4. Orphan types needing a chart (priority list)
The maintainer named four. The full orphan set with a chartable continuous series:
**P1 — explicitly requested:**
- FLIGHTS_CLIMBED (already SUM-drained; just mount a chart)
- AUDIO_EXPOSURE_ENV (Geräuschbelastung/Lärmbelastung Umgebung)
- WALKING_STEP_LENGTH (Schrittweite/Schrittlänge)
- WALKING_SPEED (Schrittgeschwindigkeit) — **+ km/h fix, see §5**
**P2 — same Mobility/Hearing/Activity clusters, cheap to add alongside:**
- WALKING_RUNNING_DISTANCE (already SUM-drained)
- AUDIO_EXPOSURE_HEADPHONE
- WALKING_STEADINESS, WALKING_ASYMMETRY, WALKING_DOUBLE_SUPPORT, WALKING_HEART_RATE_AVERAGE
- RESPIRATORY_RATE
- TIME_IN_DAYLIGHT (already SUM-drained)
**P3 — Withings body-comp / cardiovascular cluster (defer unless in scope):**
- LEAN_BODY_MASS, FAT_FREE_MASS, FAT_MASS, MUSCLE_MASS, TOTAL_BODY_WATER, BONE_MASS, VISCERAL_FAT, SKIN_TEMPERATURE, PULSE_WAVE_VELOCITY, VASCULAR_AGE, BLOOD_GLUCOSE.
**Not chartable as a line:** AUDIO_EXPOSURE_EVENT (discrete event flag — render as event markers/count, not a continuous chart).
---
## 5. Walking-speed unit conversion (the confirmed bug)
**Finding:** WALKING_SPEED is stored AND surfaced as **m/s**. `dbUnit: "m/s"` (`apple-health-mapping.ts:465-472`), `unitMap.WALKING_SPEED = "m/s"` (`measurement.ts:134`). `convertToDbUnit` is identity (`:470`). No display layer converts to km/h — grep for `km/h` / `* 3.6` across `src/` returns zero hits. WALKING_STEP_LENGTH similarly stays metres (correct — keep m, possibly show cm; 0.5–0.8 m reads fine as "0.65 m").
A casual gait of 1.3 m/s reads as "1.3 m/s" — unintuitive. Users expect km/h (1.3 m/s ≈ 4.7 km/h).
**Recommendation: display-time conversion, raw storage stays m/s (canonical SI).** Justification:
1. Preserves raw-data integrity + the locked iOS wire contract (`apple-health-mapping.ts:127-145` documents raw-SI passthrough as a hard convention; PULSE_WAVE_VELOCITY also stores m/s and must NOT be touched).
2. Mirrors the existing display-vs-storage split already used for BLOOD_GLUCOSE (canonical mg/dL, display mg/dL-or-mmol/L per user pref — `schema.prisma:455`).
3. A stored canonical change would need a migration + rewrite of every row + iOS round-trip churn for zero data-quality gain.
**Implementation shape:**
- Convert only at the chart/tile/list display boundary. `HealthChart` takes raw values; introduce a per-type display transform. Cleanest: a small `displayTransform` registry keyed by MeasurementType (`WALKING_SPEED: { factor: 3.6, displayUnit: "km/h", decimals: 1 }`), applied where the value + unit are rendered (chart `unit`/`yAxisUnit` props at the mount site, plus the measurement list cell and tooltip formatter `formatTooltipValue` `health-chart.tsx:1032`).
- Mount WALKING_SPEED's chart with `unit="km/h"`, `yAxisUnit="km/h"`, and feed the chart values multiplied by 3.6 (transform the series before passing to `HealthChart`, OR add an optional `valueScale` prop to `HealthChart` — preferred, keeps the chart generic and Recharts untouched). `valueScale` defaults to 1, so every existing chart is bit-identical (Recharts-stays + charts-visually-identical satisfied).
- The personal-baseline / trend / y-domain math all operate on the scaled series uniformly, so no per-feature special-casing.
**Other unit-conversion audit (gaps found):** none are *wrong*, but two are unintuitive and worth a display transform in the same pass:
- WALKING_RUNNING_DISTANCE stored in **metres** (`apple-health-mapping.ts:245-252`). A 5 km walk reads as "5000 m". Recommend display in km (factor 0.001) for daily totals — consistent with the speed fix. iOS-coord note `schema.prisma:464` already says "UI converts per locale" — this conversion was *planned but never built*.
- WALKING_STEP_LENGTH stored in metres; "0.65 m" is acceptable, optionally show cm (×100). Low priority.
- No imperial/metric user toggle exists for distance today — out of scope; ship metric km/h + km first.
---
## 6. Registry additions (per new chartable type)
Every entry below ALREADY exists for the orphan types in `measurement-list-meta.ts`, `categories.ts`, `apple-health-mapping.ts`, `chart-tokens.ts`, `validations/measurement.ts`. The remaining additions are: (i) `InsightMetric` enum members + page modules, (ii) page-copy i18n keys, (iii) the `displayTransform` for speed/distance, (iv) the `HIGH_FREQUENCY_MEAN_TYPES` set.
Confirmed-existing metadata for the four P1 types (for reference / regression checks):
| Type | unit (stored) | display unit | decimals | icon | colour | ingest agg | label key (exists) |
|---|---|---|---|---|---|---|---|
| FLIGHTS_CLIMBED | flights | flights | 0 | TrendingUp | chart-2 | sum | `measurements.typeFlightsClimbed` |
| AUDIO_EXPOSURE_ENV | dBA | dBA | 0 | Volume2 | chart-5 | mean | `measurements.typeAudioExposureEnv` |
| WALKING_STEP_LENGTH | m | m (or cm) | 2 | Footprints | chart-2 | mean | `measurements.typeWalkingStepLength` |
| WALKING_SPEED | m/s | **km/h** | 1 | Gauge | chart-2 | mean | `measurements.typeWalkingSpeed` |
New `displayTransform` registry (proposed, new file `src/lib/measurements/display-transform.ts`):
```
WALKING_SPEED: { factor: 3.6, displayUnit: "km/h", decimals: 1 }
WALKING_RUNNING_DISTANCE: { factor: 0.001, displayUnit: "km", decimals: 2 } // daily totals
// all other types: identity (factor 1, displayUnit = getUnitForType, decimals from existing axis logic)
```
New `InsightMetric` members (`metric-availability.ts:43-63`) for each new sub-page: `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `WALKING_SPEED`, `WALKING_STEP_LENGTH`, `WALKING_STEADINESS`, `RESPIRATORY_RATE`, `TIME_IN_DAYLIGHT`. The generic `hasMetricData` fallback (`:105`) already gates them on `summaries[metric].count > 0` — no new handler branches needed (note: it keys on the `InsightMetric` string, which must equal the `MeasurementType` string for these — it does).
Page modules: one ~10-line file per metric reusing `HealthKitMetricPage` (`src/components/insights/healthkit-metric-page.tsx:79-139`) — pass `measurementType`, `insightMetric`, `chartKey`, `i18nPrefix`, `color`, `unit`. For WALKING_SPEED also thread the new `valueScale={3.6}` (after adding the prop to `HealthChart`/`HealthChartDynamic`). German route slugs to match existing convention: `/insights/stockwerke`, `/insights/gehgeschwindigkeit`, `/insights/schrittlaenge`, `/insights/laermbelastung`, `/insights/gehstrecke`.
Each new `chartKey` must be added to `ChartOverlayKey` in `src/lib/dashboard-layout.ts` (overlay-prefs persistence).
---
## 7. i18n keys for new page copy (de/en/es/fr/it/pl)
Type **labels already exist in all six locales** (`measurements.type*` — verified present in en/de; locale-integrity test guarantees the other four). Missing keys are the per-page sub-page copy under `insights.*` and the nav pills.
Per new sub-page `<prefix>` (e.g. `insights.flightsClimbed`), add: `.title`, `.description`, `.chartTitle`, `.emptyState.title`, `.emptyState.description`, `.emptyState.cta` (the `HealthKitMetricPage` scaffold resolves exactly these — `healthkit-metric-page.tsx:98-128`). Plus a `insights.nav<Metric>` pill key (the nav currently has 15 `nav*` keys; tab strip in `insights/layout.tsx`).
Example EN/DE for the four P1 types:
```
insights.flightsClimbed.title EN "Flights Climbed" DE "Treppen"
insights.flightsClimbed.chartTitle EN "Flights climbed per day" DE "Treppen pro Tag"
insights.walkingSpeed.title EN "Walking Speed" DE "Gehgeschwindigkeit"
insights.walkingSpeed.chartTitle EN "Walking speed (km/h)" DE "Gehgeschwindigkeit (km/h)"
insights.walkingStepLength.title EN "Step Length" DE "Schrittlänge"
insights.audioExposureEnv.title EN "Environmental Audio" DE "Lärmbelastung (Umgebung)"
```
Provide es/fr/it/pl in the same shape. The two guard tests below FAIL the build if any locale is short a key — fix the bundle, never suppress (`i18n-locale-integrity.test.ts`, `i18n-call-site-coverage.test.ts`, per CLAUDE.md).
---
## 8. Test list
Unit:
- `apple-health-mapping.test.ts` — assert `HIGH_FREQUENCY_MEAN_TYPES` and `CUMULATIVE_HK_TYPES` are **disjoint** (a type in both would corrupt SUM-vs-mean).
- New `mean-consolidation` reducer test: per-day mean over N per-sample rows, idempotent re-run = 0 work, soft-delete tombstones the source rows, `source != APPLE_HEALTH` rows untouched, single existing `stats:` row not double-folded.
- `display-transform` test: WALKING_SPEED 1.3 m/s → 4.68 km/h (1 dp); WALKING_RUNNING_DISTANCE 5000 m → 5.00 km; identity for untransformed types.
- `getUnitForType` / `VALUE_RANGES` coverage — already gated by `measurement-type-enum-coverage.test.ts`; extend to assert every new `InsightMetric` maps to a real MeasurementType.
- `HealthChart` `valueScale` prop: scaled series leaves y-domain/baseline/trend math consistent; `valueScale=1` (default) renders byte-identical to pre-change (charts-visually-identical guard).
Integration (testcontainers Postgres):
- Batch-ingest a burst of WALKING_SPEED / AUDIO_EXPOSURE_ENV per-sample rows → run `mean-consolidation` → assert one `stats:` row/day with the correct mean and source rows tombstoned.
- `GET /api/measurements?type=WALKING_SPEED&aggregate=daily&source=rollup` returns daily-mean (not sum).
- Drain grace window: rows newer than 36 h stay raw.
i18n:
- `i18n-locale-integrity.test.ts` + `i18n-call-site-coverage.test.ts` green across de/en/es/fr/it/pl for every new `insights.*` and `nav*` key.
E2E (Playwright, only the new surfaces):
- Each new `/insights/<slug>` page renders a chart when data exists, an empty-state CTA when not; axe-clean.
- WALKING_SPEED page Y-axis shows km/h.
OpenAPI: no request/response schema change expected (consolidation is internal + display-only); if a new query param is added, re-run `pnpm openapi:generate` and commit (CI `openapi:check` gate).
---
## 9. Open questions / risks
- **Biggest risk:** the mean-consolidation drain is *destructive* (soft-delete of per-sample rows). If WALKING_SPEED/PULSE per-sample detail is ever wanted at sub-day resolution (e.g. a workout drill-down), tombstoning loses it. The legacy-step pass accepted this; confirm the maintainer accepts it for spot metrics, or gate the drain behind a longer grace window (e.g. 7 days) so recent detail survives.
- Should WALKING_RUNNING_DISTANCE display in km (recommended) ship in the same release as the speed km/h fix, or wait for a global metric/imperial user toggle? (No toggle exists today.)
- PULSE is high-frequency but already has a chart + heavy existing analytics dependence. Draining its per-sample rows to daily-mean could change correlation/scatter inputs that read raw PULSE. Verify `src/lib/insights/correlations.ts` + scatter chart tolerate a daily-grain PULSE before adding PULSE to the mean-drain set — safest to EXCLUDE PULSE from the first drain pass and only consolidate the genuinely-orphan high-frequency types (audio exposure, walking speed/step-length, respiratory rate).
- AUDIO_EXPOSURE_EVENT has no continuous chart — decide whether it surfaces as event markers on the audio-exposure chart or as a separate count tile.
+586
View File
@@ -0,0 +1,586 @@
# R-medui — Medication detail page + history view + advanced-settings redesign
Implementation-ready frontend spec for the v1.7.0 medication-detail
overhaul. Builds **on top of** the v1.5.6 G-1 / v1.6.0 composition (the
files cited below are the *current* tree, not a clean slate). Scope:
the `/medications/[id]` header button row, the history view, and the
advanced-settings dialog. The list page (`/medications`) is untouched.
Conventions honoured: RSC-by-default does not apply (these are all
`"use client"` surfaces with TanStack reads); every `t()` call must
resolve in `messages/en.json` and propagate across `de/en/es/fr/it/pl`
(`i18n-call-site-coverage.test.ts` + `i18n-locale-integrity.test.ts`);
queryKeys come from `src/lib/query-keys.ts`; envelope reads unwrap
`(await res.json()).data`. Recharts (`DrugLevelChart`) stays.
---
## 0. Current state (what we are changing)
- **Header** (`medication-detail-header.tsx:108-129`): two buttons —
`Pencil` "Edit" (outline) opening the wizard, and a `Settings2`
ghost icon opening `<AdvancedSettingsSheet>`. **There is no history
button.** History is reached only via the text link
`"View full history →"` at the bottom of the intake preview
(`intake-history-preview.tsx:220-228`) which routes to
`/medications/[id]/history`.
- **History route** (`[id]/history/page.tsx`): renders, in order,
`DrugLevelChart` (estimated active-ingredient curve) →
`SideEffectsSection` → `SchedulingSection` ("Rhythmus") →
`TitrationSection` → `IntakeHistoryListV2`. So the curve and the
schedule editor dominate, and the intake table is last. The
`IntakeHistoryListV2` default sort is `takenAt desc`
(`intake-history-list-v2.tsx:156-157`) — but skipped rows have
`takenAt: null`, rendered as `—` (line 329-331), which under
`desc` collation sort *after* real timestamps in Postgres `NULLS
LAST`… except the server default may emit `NULLS FIRST`, surfacing
skipped/`—` rows at the top. That is the "skipped first" nonsense.
- **AdvancedSettingsSheet** (`advanced-settings-sheet.tsx`): a
`<ResponsiveSheet contentWidth="lg">` (so desktop = `sm:max-w-lg` =
512px, not 448 — the cramped feel is the `lg` cap + the three
stacked `<MedicationDetailSection>` cards each with their own border
chrome). Hosts Notifications → Settings (API / CSV-stub / Phasen /
Grace) → DestructiveZone. **CSV import is only a one-line stub here**
(`settings-section.tsx:116-127`); the real dialog lives on the
intake preview header. Button styling is mixed: outline mint, ghost,
destructive, primary — no consistent grouping.
The maintainer's asks map onto: (1) restore a history icon, (2) make
the history view intake-only + date-desc + direct, (3) add a third
gear button, (4) widen + re-group the advanced dialog with real CSV
import. Below is the target.
---
## 1. Icon picks (Lucide)
| Slot | Icon | Justify |
|---|---|---|
| **Edit** (keep) | `Pencil` | Already the anchor (`medication-detail-header.tsx:21,116`). Universally "edit". Unchanged. |
| **History** (restore) | **`History`** | Lucide `History` *is* the clock-with-counterclockwise-arrow glyph (a clock face with a CCW rewind arrow wrapping its left side) — exactly "the old icon" the maintainer describes. It is already imported and used 107× across the tree (incl. `intake-history-list-v2.tsx:11` as the empty-state icon), so it is the established HealthLog "history" semantic. `RotateCcw` is a bare rewind arrow with *no* clock face (reads as "undo/reset", and is already the IntakeImportDialog reset action — reusing it would collide semantically). `Undo2` reads as "undo last action". **Pick `History`.** |
| **Advanced settings** (new 3rd button) | **`SlidersHorizontal`** | Must be visually distinct from the `Pencil` edit. `Settings2` (the current advanced icon) is a gear — fine, but gears read as "app/global settings" and this dialog is *per-medication data + lifecycle controls*. `SlidersHorizontal` (three horizontal sliders) reads as "tune / advanced options for this thing", is already in the tree (8×), and is unmistakably different from a pencil at 16px. **Pick `SlidersHorizontal`.** (If the maintainer prefers a gear, `Settings2` is the fallback — but the brief explicitly lists `Settings2` *or* `SlidersHorizontal` and asks for "distinct from the edit pencil"; sliders win on distinctness.) |
Status dots (`bg-[hsl(var(--success/warning))]`) and all other icons
unchanged.
---
## 2. Detail-page header button row (Ask 1 + 3)
### 2.1 Component changes — `medication-detail-header.tsx`
Replace the two-button cluster (lines 108-129) with a **three-button
icon row**, all `size="icon"` for visual rhythm except Edit which
keeps its label on `sm+` (Edit is the primary action; History +
Advanced are secondary icon-only with tooltips/`aria-label`).
Prop changes to `MedicationDetailHeaderProps`:
```ts
export interface MedicationDetailHeaderProps {
name: string;
dose: string;
active: boolean;
endsOn?: string | null;
onEditPlan: () => void; // keep — Pencil → wizard
onOpenHistory: () => void; // NEW — History → /history navigation
onOpenAdvanced: () => void; // keep — SlidersHorizontal → sheet
}
```
`onOpenHistory` is a `() => void` so the page can decide between a
client `router.push` and a `<Link>`. Spec uses `router.push` from the
page (the page already imports `useRouter`, `page.tsx:31,137`) so the
history button is a real button (icon-only buttons should not be
anchors when they carry an `aria-label`).
### 2.2 Button order + styling
Left→right: **Edit · History · Advanced**. Rationale: Edit is the
primary (labelled, `variant="outline"`); History is the most-used
read action (icon, `variant="ghost"`); Advanced is rare/heavy (icon,
`variant="ghost"`). DOM order = Edit→History→Advanced so the
screen-reader walk goes primary→read→config.
```tsx
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm"
className="min-h-11 sm:min-h-9" onClick={onEditPlan}
data-slot="medication-detail-edit-button">
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
<Button variant="ghost" size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={onOpenHistory}
aria-label={t("medications.detail.header.historyLabel")}
data-slot="medication-detail-history-button">
<History aria-hidden="true" className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={onOpenAdvanced}
aria-label={t("medications.detail.header.advancedLabel")}
data-slot="medication-detail-advanced-button">
<SlidersHorizontal aria-hidden="true" className="h-4 w-4" />
</Button>
</div>
```
`min-h-11 min-w-11` keeps the 44px touch target on mobile (the
settings 44px sweep test already exempts hidden inputs; these are
visible interactive buttons and must pass). Reuse `common.edit`; add
two header labels (§7).
### 2.3 Page wiring — `[id]/page.tsx`
- Pass `onOpenHistory={() => router.push(`/medications/${id}/history`)}`
to `<MedicationDetailHeader>` (line 269-276).
- **Remove the "View full history →" footer link** from the intake
preview (it is now redundant with the header button, and the brief
says the history icon should go *directly* to full history). See
§4.3.
- The page keeps mounting `IntakeHistoryPreview`, `AdvancedSettingsSheet`,
`PhaseConfigSheet`, `MedicationWizardDialog` as today.
### 2.4 Wireframe — header button row
Desktop (≥768px):
```
┌──────────────────────────────────────────────────────────────┐
│ ← Medications │
│ │
│ Mounjaro [✏ Edit] [🕑] [⇆] │
│ 2.5 mg ↑edit ↑hist ↑sliders │
│ ● Active │
└──────────────────────────────────────────────────────────────┘
```
Mobile (<768px) — same row, buttons 44×44, Edit keeps label:
```
┌────────────────────────────────┐
│ ← Medications │
│ Mounjaro │
│ 2.5 mg │
│ ● Active │
│ [✏ Edit] [🕑] [⇆] │
└────────────────────────────────┘
```
(`🕑` = Lucide `History`, `⇆` = `SlidersHorizontal`.)
---
## 3. (no section — numbering aligns with asks)
---
## 4. History view redesign (Ask 2)
The history view becomes **intake history only**, date-descending,
reached directly from the header `History` button. No active-ingredient
curve by default, no "Rhythmus bearbeiten" / schedule editing. The only
editable units are individual intakes; import is present but
de-emphasised.
### 4.1 Route — `[id]/history/page.tsx` rewrite
**Remove** these mounts (lines 97-137):
- `DrugLevelChart` (the estimated active-ingredient curve) — gone by
default. *Optional progressive disclosure:* a collapsed
`<details>`/disclosure "Show estimated drug-level curve" at the
bottom for GLP-1 only, default-closed. Recommended to keep it
*available* (it is genuine history context) but out of the default
read. If the maintainer wants it fully gone, drop the disclosure too.
- `SideEffectsSection` — this is logging UI, not pure history; it
belongs on the detail page (already mounted there,
`page.tsx:315`). Remove from history route.
- `SchedulingSection` ("Rhythmus") — schedule editing. **Remove** —
the only schedule editor is the wizard (header Edit pencil).
- `TitrationSection` — read-only ladder; same call as DrugLevelChart:
fold into the optional collapsed disclosure or drop. Default-closed.
**Keep + promote** `IntakeHistoryListV2` to the primary (only) surface.
New route body:
```tsx
return (
<div className="space-y-6">
{/* back → detail page, not list (history is a drill-down OF the med) */}
<Button variant="ghost" size="sm" asChild className="...-ml-2 gap-1">
<Link href={`/medications/${id}`}>
<ArrowLeft className="h-4 w-4" /> {t("medications.detail.history.back")}
</Link>
</Button>
{medication && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{medication.name}</h1>
<p className="text-muted-foreground text-sm">{medication.dose}</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.history.subtitle")}
</p>
</div>
{/* de-emphasised import — ghost icon, not a prominent CTA */}
<Button variant="ghost" size="sm" onClick={() => setImportOpen(true)}
className="text-muted-foreground min-h-11 sm:min-h-9"
data-slot="history-import-trigger">
<Upload className="h-4 w-4" /> {t("medications.detail.intake.importButton")}
</Button>
</div>
)}
{medication && (
<IntakeHistoryListV2
medicationId={id}
pageSize={25}
onEditIntake={setEditingEvent}
onDeleteIntake={setPendingDeleteId}
/>
)}
{/* optional, default-closed */}
{medication?.treatmentClass === "GLP1" && (
<CollapsibleDrugLevel medicationId={id} medication={medication} />
)}
{/* dialogs: IntakeImportDialog + IntakeEditDialog + delete AlertDialog */}
</div>
);
```
The edit/delete dialog plumbing (`IntakeEditDialog`, delete
`AlertDialog`, optional bulk-delete toolbar) is **lifted from
`intake-history-preview.tsx`** — that component already owns the exact
state machine (`editingEvent`, `pendingDeleteId`, `confirmRowDelete`,
bulk-delete). Best path: **extract a shared
`<IntakeHistoryEditable>`** wrapper (the body of `IntakeHistoryPreview`
minus the `<MedicationDetailSection>` chrome and the footer link) and
have both the detail-page preview and the full history route render it.
See §6 reuse table.
### 4.2 Sort: date-descending, skipped-rows-not-first
The bug: skipped rows (`takenAt: null`) sort to the top under the
current `sortBy=takenAt&sortDir=desc` because NULLs are not pinned
last. Two-part fix:
1. **Default the history view sort key to a non-null column.** Sort by
the *effective intake date* — for taken rows that is `takenAt`, for
skipped rows `scheduledFor`. The cleanest UI-side fix without a
server change: default `sortBy="scheduledFor"` `sortDir="desc"` on
the full-history surface (every completed row has a non-null
`scheduledFor`), and render the displayed date as
`takenAt ?? scheduledFor`. This guarantees a clean
today→yesterday→… descending order with no `—` rows floating up.
2. **Server hardening (recommended, separate task):** ensure the
`GET /api/medications/[id]/intake` ORDER BY pins `NULLS LAST` on
`takenAt` so the `takenAt` sort option is also correct. This is the
robust fix; verify the route's `orderBy` before relying solely on
the client default. *Open question O-1.*
`IntakeHistoryListV2` already supports both sort keys
(`intake-history-list-v2.tsx:128,156`); add a `defaultSortBy` prop
(default `"takenAt"` to preserve the detail-preview behaviour; the
history route passes `"scheduledFor"`). Display column: change the
"Taken" cell to show `event.takenAt ? dateTime(takenAt) : dateTime(scheduledFor)`
with a muted "(planned)" suffix for skipped — keeps the date column
always populated and chronological.
### 4.3 Detail-page intake preview
- **Remove** the footer "View full history →" link
(`intake-history-preview.tsx:220-228`) — the header History button
replaces it. Keep the preview's own table (14 rows) + import CTA +
per-row edit/delete on the detail page as today.
### 4.4 Wireframe — history view
Desktop:
```
┌────────────────────────────────────────────────────────────────┐
│ ← Mounjaro │
│ │
│ Mounjaro [🕑 Import] │ ← ghost, muted
│ 2.5 mg │
│ Full intake history │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Intake history │ │
│ │ ┌────────────┬────────────┬─────────┬────────┬──┐ │ │
│ │ │ Taken ↓ │ Scheduled │ Status │ Source │⋮ │ │ │
│ │ ├────────────┼────────────┼─────────┼────────┼──┤ │ │
│ │ │ 31.05 08:1 │ 31.05 08:0 │ ✓ Taken │ WEB │⋮ │ ← today │ │
│ │ │ 30.05 08:0 │ 30.05 08:0 │ ✓ Taken │ REM. │⋮ │ ← yesterday│ │
│ │ │ 29.05 (pl) │ 29.05 08:0 │ ⤼ Skip. │ WEB │⋮ │ │ │
│ │ │ … │ │ │
│ │ │ ‹ Prev Page 1/4 (87) Next › │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ▸ Show estimated drug-level curve (GLP-1, collapsed) │
└────────────────────────────────────────────────────────────────┘
```
Mobile: single-column, table scrolls horizontally inside the card
(existing `IntakeHistoryListV2` behaviour); import ghost button drops
under the title; the drug-level disclosure stays collapsed.
---
## 5. Advanced-settings dialog redesign (Ask 4)
Wider, sectioned, consistent buttons, generous spacing, CSV import
present and usable.
### 5.1 Width — extend the existing `contentWidth` system
`ResponsiveSheet` already accepts `contentWidth: "md"|"lg"|"2xl"|"3xl"|"4xl"`
(`responsive-sheet.tsx:71-84`). **No new prop needed** — change the
`AdvancedSettingsSheet` from `contentWidth="lg"` (512px) to
**`contentWidth="2xl"`** (`sm:max-w-2xl` = 672px). `2xl` is the right
call over `3xl/4xl`: the content is single-column sectioned forms, not
a two-column editor; 672px gives generous breathing room without
stranding controls across a too-wide line. Mobile is unaffected (the
Sheet branch always spans the viewport). This is a one-token change at
`advanced-settings-sheet.tsx:66`.
### 5.2 Section grouping — four labelled groups
Replace the three stacked `<MedicationDetailSection>` cards (each with
its own border) with **four labelled groups** under one consistent
visual system. Use a lightweight group header (uppercase micro-label +
hairline rule) instead of three full bordered section cards — this
reads as "one settings document, four parts" rather than "three
boxes". Map every action:
| Group | Heading key | Contents | Source component |
|---|---|---|---|
| **Data** | `…advanced.group.data` | CSV import (real, full affordance) · API endpoint URL + token mint + request examples | NEW inline import block + `ApiTokensRow` |
| **Reminders** | `…advanced.group.reminders` | Notifications switch + channel chips · Reminder grace minutes (primary-schedule) | `NotificationsSection` body + grace row from `SettingsSection` |
| **Lifecycle** | `…advanced.group.lifecycle` | Pause/resume switch · End (Beenden) · Phasen / course-window editor button (GLP-1 + window) | pause/end from `DestructiveZoneSection` Card A + Phasen row from `SettingsSection` |
| **Danger zone** | `…advanced.group.danger` | Purge intake history · Delete medication | `DestructiveZoneSection` Card B |
Key moves vs current:
- **CSV import is promoted from a one-line stub to a real block** in
the Data group. The brief explicitly wants it "clearly present and
usable" here. The block renders a `[⬆ Import CSV/JSON]` outline
button that opens the existing `IntakeImportDialog` (reused
verbatim). The import dialog state lifts to the page (it already
does — `page.tsx:144,326-329` owns `importOpen`); pass an
`onOpenImport` callback into the sheet. Delete the
`settings-section.tsx:116-127` stub copy.
- **Phasen** moves from "Settings" into **Lifecycle** (it is a
course-window / scheduling-lifecycle concern). Keep the
sibling-swap: `onRequestPhaseSheet` closes the advanced sheet then
the page opens `<PhaseConfigSheet>` (`page.tsx:247-250`) so modal
depth ≤ 2 stays intact.
- **Pause + End** move from the old "Verwaltung & Gefahrenzone" into
**Lifecycle**, separated from the irreversible Danger zone.
### 5.3 Consistent button styling
One rule, applied across the dialog:
- **Reversible / neutral actions** (Copy URL, Mint token, Import CSV,
Save grace, Open Phasen): `variant="outline" size="sm"`,
`min-h-11 sm:min-h-9`. Primary save-style only for the single most
expected action per group is unnecessary here — keep them all
outline for visual consistency (the prior mixed primary/outline/ghost
is what reads "generated").
- **Destructive actions** (End, Purge, Delete): `variant="destructive"
size="sm" font-semibold` (WCAG large-text band on
white-on-`#ff5555`, already the convention in
`destructive-zone-section.tsx:251,279,304`).
- **Toggles** (Notifications, Pause): `<Switch>` wrapped in `<label>`
for full-row hit target (already the pattern).
- Drop the `IntakeImportDialog`'s lone `MoreHorizontal` kebab-for-reset
in favour of a plain `[Reset]` text button inside the dialog footer
area (the kebab-for-one-item is the "generated" smell;
`intake-import-dialog.tsx:201-221`). Optional polish, low priority.
### 5.4 Spacing + typography tokens
- Sheet body: `bodyClassName` stays `gap-6`; inner wrapper
`space-y-8` between the four groups (was `space-y-6` between three
bordered cards — more air now that the borders are gone).
- Group header: `text-xs font-medium uppercase tracking-wide
text-muted-foreground` + a `border-border/60 border-t pt-1`
hairline; group body `space-y-4`.
- Row title: `text-foreground text-sm font-medium`; helper:
`text-muted-foreground text-xs`. (Matches the existing section
rows.)
- Within-group dividers: `<Separator />` between sub-rows (already
used in `settings-section.tsx`).
- Dialog title: keep `medications.detail.advanced.title`
("Advanced settings").
### 5.5 Refactor shape
Rather than four sub-components, keep the existing three section
components but **render their bodies under group headings** inside a
new layout in `advanced-settings-sheet.tsx`. Cleanest: introduce a
tiny presentational `<SettingsGroup label>{children}</SettingsGroup>`
helper (local to the medications folder) and re-slot the existing
section *bodies*. Because the three section components currently each
wrap themselves in `<MedicationDetailSection>` chrome, the lower-churn
path is:
1. Add a `chrome?: "section" | "bare"` prop to each of
`NotificationsSection`, `SettingsSection`, `DestructiveZoneSection`
(default `"section"` preserves any other consumer; the sheet passes
`"bare"`), OR
2. Split each section's *body* into an exported inner component
(`NotificationsBody`, etc.) and have the section wrapper +
the sheet both consume it.
Option 2 is cleaner (no conditional chrome branch) and matches the
`<IntakeHistoryEditable>` extraction in §4. Recommended. The Lifecycle
group then composes: pause+end body (from DestructiveZone Card A) +
phasen row (from SettingsSection) — so DestructiveZone's body splits
into `LifecycleManageBody` (Card A) + `DangerZoneBody` (Card B).
### 5.6 Wireframe — advanced-settings dialog (desktop, 672px)
```
┌───────────────────────────────────────────────────────────┐
│ Advanced settings [✕] │
├───────────────────────────────────────────────────────────┤
│ │
│ DATA ───────────────────────────────────────────────── │
│ CSV / JSON import │
│ Import past intakes from a file or pasted JSON. │
│ [ ⬆ Import CSV/JSON ] │
│ ────────────────────────────────────────────── │
│ External integration — Endpoint for "Mounjaro" │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ POST https://…/api/ingest/medication │ │
│ └─────────────────────────────────────────────────────┘ │
│ [ ⧉ Copy URL ] [ 🔑 Generate token ] │
│ Request example [ cURL ▾ ] │
│ ┌── code ──────────────────────────────────────────[⧉]┐ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ REMINDERS ──────────────────────────────────────────── │
│ Send a reminder ( ●—— ) │
│ Channels: [apns] [telegram] [ntfy] │
│ ────────────────────────────────────────────── │
│ Reminder window (primary schedule) │
│ [ 30 ] minutes [ Save ] │
│ │
│ LIFECYCLE ──────────────────────────────────────────── │
│ Pause reminders ( ——● ) │
│ ────────────────────────────────────────────── │
│ End course [ ▣ End ] │
│ ────────────────────────────────────────────── │
│ Phases / course window [ Manage phases ] │
│ │
│ DANGER ZONE ─────────────────────────────────────────── │
│ ┌──────────────────────────── border-destructive/40 ──┐ │
│ │ Clear intake history (87) [ 🗑 Clear ] │ │
│ │ ──────────────────────────────── │ │
│ │ Delete medication [ 🗑 Delete ] │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
```
Mobile (Sheet branch, full-width, `rounded-t-2xl`, `90dvh`, scroll):
same four groups stacked; sticky nothing (no footer — each control
self-saves, `advanced-settings-sheet.tsx:8-17`). Buttons 44px.
---
## 6. Reuse vs refactor table
| File | Action | Notes |
|---|---|---|
| `medication-detail-header.tsx` | **Refactor** | Add `onOpenHistory`; 3-button row; import `History` + `SlidersHorizontal`; drop `Settings2` import. |
| `[id]/page.tsx` | **Refactor** | Wire `onOpenHistory` → `router.push`; pass `onOpenImport` into sheet; no other structural change. |
| `[id]/history/page.tsx` | **Rewrite** | Intake-only; remove DrugLevelChart/SideEffects/Scheduling/Titration from default; mount `<IntakeHistoryEditable>` + optional collapsed drug-level; back-link to `/medications/[id]`. |
| `intake-history-preview.tsx` | **Refactor** | Extract body into `<IntakeHistoryEditable>` (table + edit/delete/bulk dialogs, no section chrome, no footer link). Preview = chrome + `<IntakeHistoryEditable pageSize={14}>`. Remove footer link. |
| **NEW** `intake-history-editable.tsx` | **Create** | Shared editable history (preview + full route consume it). `pageSize`, `defaultSortBy` props. |
| `intake-history-list-v2.tsx` | **Refactor** | Add `defaultSortBy?: SortKey`; date cell shows `takenAt ?? scheduledFor` with planned suffix. |
| `advanced-settings-sheet.tsx` | **Refactor** | `contentWidth="2xl"`; four `<SettingsGroup>`; pass `onOpenImport`. |
| **NEW** `settings-group.tsx` | **Create** | Presentational group header (uppercase micro-label + hairline). |
| `notifications-section.tsx` | **Refactor** | Export `NotificationsBody`; wrapper keeps section chrome for any standalone consumer. |
| `settings-section.tsx` | **Refactor** | Split grace row + phasen row + api row into bodies; **delete CSV stub** (lines 116-127). |
| `destructive-zone-section.tsx` | **Refactor** | Split `LifecycleManageBody` (Card A: pause+end) + `DangerZoneBody` (Card B: purge+delete). |
| `intake-import-dialog.tsx` | **Reuse verbatim** | Opened from advanced-sheet Data group + (de-emphasised) history route. Optional: swap kebab-reset for text button. |
| `ResponsiveSheet` | **Reuse** | `contentWidth` already supports `2xl`. No change. |
| `DrugLevelChart` / `TitrationSection` | **Reuse** | Only inside optional collapsed disclosure on history route. |
| `SideEffectsSection` / `SchedulingSection` | **Unchanged** | Stay on detail page only; removed from history route. |
---
## 7. i18n keys touched
Reuse existing wherever possible (`common.edit`, `common.cancel`,
`common.save`, `common.import`, `medications.detail.intake.importButton`,
`medications.detail.advanced.title`, `medications.intakeHistory`, all
`medications.detail.zone.*`, `…notifications.*`, `…api.*`,
`…settings.grace.*`, `medications.phaseConfig*`).
**New keys** (add to `messages/en.json`, propagate to de/es/fr/it/pl):
| Key | EN value |
|---|---|
| `medications.detail.header.historyLabel` | `View intake history` |
| `medications.detail.header.advancedLabel` | `Advanced settings` (or reuse `medications.detail.edit.advancedOption`) |
| `medications.detail.history.back` | `Back to medication` |
| `medications.detail.history.subtitle` | `Full intake history` |
| `medications.detail.history.plannedSuffix` | `planned` |
| `medications.detail.advanced.group.data` | `Data` |
| `medications.detail.advanced.group.reminders` | `Reminders` |
| `medications.detail.advanced.group.lifecycle` | `Lifecycle` |
| `medications.detail.advanced.group.danger` | `Danger zone` |
| `medications.detail.advanced.csvImport.title` | `CSV / JSON import` |
| `medications.detail.advanced.csvImport.helper` | `Import past intakes from a file or pasted JSON.` |
| `medications.detail.advanced.csvImport.button` | `Import CSV/JSON` |
**Removed copy:** `medications.detail.settings.csvImport.title` +
`…csvImport.stub` (the stub goes away). Leave the keys in the bundle or
delete them — deleting requires removing the call site first
(`settings-section.tsx:121,124`); since that whole stub block is being
removed, delete both keys from all six locales. Reuse
`…intake.viewAllLink` is no longer referenced after the footer-link
removal — delete it too (call site `intake-history-preview.tsx:226`
goes away).
Re-run `i18n-call-site-coverage.test.ts` + `i18n-locale-integrity.test.ts`
after the bundle edits; both walk every `t()` literal so any orphan
fails CI.
---
## 8. Verification checklist
- `pnpm typecheck` clean (new props on header/list/sections).
- `pnpm lint` — queryKeys untouched (no new keys needed; reads reuse
`medicationIntakeList`). No raw `fetch` added outside existing
same-origin client reads.
- i18n guards green (new keys in all six locales; stub keys removed).
- 44px touch-target sweep: the three header buttons + all dialog
buttons carry `min-h-11` on mobile.
- a11y: icon-only History + Advanced buttons carry `aria-label`; group
headings are presentational (`<p>`, not duplicate `<h2>` ids) so no
duplicate-id axe failure (the `notifications` heading-id split at
`notifications-section.tsx:67-68` is precedent — keep group labels
out of the `aria-labelledby` graph).
- Visual: open advanced sheet on desktop → 672px, four groups, all
buttons outline/destructive only, CSV import button present in Data.
- History route: skipped rows no longer float to top; order is
today→yesterday→… descending; no curve by default.
---
## 9. Open questions
- **O-1 (server sort):** does `GET /api/medications/[id]/intake` ORDER
BY pin `NULLS LAST` on `takenAt`? The client-side `defaultSortBy="scheduledFor"`
fix makes the history view correct regardless, but the detail-page
preview still defaults to `takenAt desc` — confirm the route's
`orderBy` before shipping, or switch the preview to `scheduledFor`
too. Needs a read of the intake route handler (out of scope for this
doc).
- **O-2 (drug-level disclosure):** keep the estimated active-ingredient
curve as a default-closed `<details>` on the history route, or remove
it entirely? Spec recommends keep-but-collapsed (it is genuine
context); maintainer may want it gone. One-line toggle either way.
- **O-3 (advanced icon):** `SlidersHorizontal` (recommended,
distinct-from-pencil) vs keeping `Settings2` (current). Maintainer's
brief lists both as acceptable.
+328
View File
@@ -0,0 +1,328 @@
# Docs + README audit vs. release/v1.7.0 (2026-05-31)
Read-only audit. Three sources cross-referenced:
- **Docs site** `/Users/marc/Projects/healthlog-docs` — branch **`main`**, clean working
tree. HEAD `6402505 docs: accuracy pass, explanatory links, and current version`.
(Not on an in-progress branch; the prior audit's `docs/apns-pinning-selfhost` work has
already merged — TLS, cert-pinning, iOS page, sitemap/robots, OG meta all present.)
- **README** `/Users/marc/Projects/HealthLog/README.md`.
- **Ground truth** = code on `release/v1.7.0` (`424d12de chore(release): v1.7.0`),
`CHANGELOG.md` (through v1.7.0), `docs/api/openapi.yaml` (`version: 1.7.0`, all v1.7
routes present).
Key context: **the OpenAPI spec is fully current to 1.7.0** (health-record export,
`/api/sync/changes`, `/api/sync/state`, `/api/dashboard/snapshot`, `nextDueAt`, schedule
fields all documented and CI-gated). The drift is entirely in the **hand-written MDX docs
pages** and the **README prose**, not in the machine contract.
Classification legend: **HALLUCINATED** (described, does not exist) / **STALE**
(old version / removed / wrong) / **MISSING v1.7.0** (shipped, absent from docs) /
**POORLY EXPLAINED** (needs an explainer link or diagram) / **STRUCTURE/SETUP**.
---
# SOURCE A — DOCS SITE (`healthlog-docs`, branch `main`)
## A. HALLUCINATED (highest priority)
### A-H1 — `/api/auth/me` "Gravatar URL" (the headline lie)
- **Doc:** `src/content/docs/api/authentication.mdx:91` — *"Returns the current user
including their Gravatar URL."*
- **Code evidence:** `src/app/api/auth/me/route.ts:46-53` returns a self-hosted
`avatarUrl` (relative `/api/user/avatar/...?v=<ms>`), explicitly *"Replaces the Gravatar
leak"*. Gravatar was retired in v1.5.5 (`src/lib/gravatar.ts` deleted; only stale
matches now are in `proxy.ts` CSP comment + generated Prisma). The route also returns
`unitPreference`, `glucoseUnit`, `disableCoach`, and the v1.7.0 `fullName` /
`insurerName` / `insuranceNumber` patient-identity fields — none documented.
- **Why it matters:** This states the *opposite* of a privacy win (Automattic no longer
sees the email hash). It is the single most wrong line in the docs.
- **Fix:** Rewrite to *"Returns the current user profile, including a self-hosted
`avatarUrl` (served from your own database — no third-party Gravatar request), the
display-unit preference, and the optional patient-identity fields used by the
health-record export."* Add the full response field list, mirroring the OpenAPI
`MeResponse` schema.
### A-H2 — `configuration/admin-settings.mdx:113` "the email Gravatar"
- **Doc:** Users panel *"lists every account … with the role badge, the email Gravatar,
the registration date …"*
- **Code evidence:** same as A-H1 — Gravatar removed v1.5.5; admin user list renders the
self-hosted avatar / initials fallback.
- **Fix:** Change "the email Gravatar" → "the profile avatar (self-hosted, or
username-initials fallback)".
### A-H3 — Medication schedule model is the pre-v1.5 flat shape
- **Doc:** `src/content/docs/features/medications.mdx:23-39` documents `MedicationSchedule`
as only `{ windowStart, windowEnd, label, daysOfWeek, dose }` and says multi-dose =
"create multiple schedules with different days". No mention of any recurrence beyond
comma-separated weekday numbers.
- **Code evidence:** The canonical recurrence engine supports `rrule` (RFC-5545),
`rollingIntervalDays`, `intervalWeeks`, one-shot (`oneShot`), and now (v1.7.0)
`asNeeded` (PRN) + cyclic (`cycleWeeksOn`/`cycleWeeksOff`/`cycleAnchor`). CHANGELOG
v1.5–v1.7 + migrations `0091`/`0092`. Also `deliveryForm` (ORAL/INJECTION/OTHER, mig
`0088`) and injection-site rotation (v1.6.0).
- **Classification:** borderline HALLUCINATED — the doc describes a schedule shape that no
longer reflects how the feature works; a reader building against it will be wrong.
- **Fix:** Rewrite the Schedules section around the recurrence engine: weekly / bi-weekly
(`intervalWeeks`) / rolling (`rollingIntervalDays`) / RRULE / one-time / PRN / cyclic,
plus route-of-administration and injection sites. Link RRULE → RFC 5545 (see C-2).
## B. STALE
### A-S1 — Whole feature-doc set frozen at v1.4.x
- **Docs:** `features/dashboard-customization.mdx`, `features/health-metrics.mdx`,
`features/achievements-hidden.mdx`, `configuration/admin-settings.mdx`,
`settings/ai-providers.mdx`, `insights/how-it-works.mdx` cite v1.4.5–v1.4.19
throughout; `ai-providers.mdx:3,14` frames everything as "the v1.4.16 layout";
`how-it-works.mdx:94` says per-user learning is *"planned for v1.4.17"*.
- **Evidence:** app is on 1.7.0; these surfaces changed materially through v1.5–v1.7.
- **Fix:** A version-string sweep. The descriptions don't need a version at all — drop the
"(v1.4.x+)" tags or replace with the current behaviour. At minimum stop describing the
AI panel as "v1.4.16's new layout".
### A-S2 — Pinned image tags disagree across pages
- **Docs:** `self-hosting/docker.mdx:55` pins `:1.6.0`; `self-hosting/updates.mdx:63,83`
show `:1.4.34` / `:1.4.25`; `self-hosting/scaling.mdx:25,31` show `:1.4.30.1`;
`self-hosting/coolify.mdx:55-62` use `:1.4.36`.
- **Evidence:** current release tag `1.7.0`.
- **Fix:** Bump the *example* tags to `1.7.0` (or use a neutral `<version>` placeholder)
so a copy-paste doesn't pull a year-old image. Keep historical tags only inside the
Coolify "this bug happened on v1.4.33→35" anecdote where the version is the point.
### A-S3 — `self-hosting/docker.mdx:17` "native arm64 image planned for v1.5"
- **Doc:** *"`linux/amd64` (native arm64 image planned for v1.5; build from source on
arm64 hosts in the meantime)"*.
- **Evidence:** CLAUDE.md + README state GHCR ships **multi-arch `amd64` + `arm64`** today.
- **Fix:** Change to "multi-arch `linux/amd64` + `linux/arm64`" and drop the "planned"
clause.
### A-S4 — `export-import.mdx` automatic-backup schedule contradicts itself
- **Doc:** line 89 says *"Sundays at 03:00"* (Europe/Berlin per `scaling.mdx:64`), but the
code block at line 95-99 says *"Sunday 03:00 UTC"*.
- **Fix:** Pick one (Berlin per `scaling.mdx` + CHANGELOG) and make both lines agree.
### A-S5 — `export-import.mdx` import example uses a mood scale that may be wrong
- **Doc:** `export-import.mdx:67` import example shows `"mood": 7`. README + mood docs
describe a **5-point** scale.
- **Evidence:** verify against `mood` Zod schema; if 1–5, `7` is an invalid example.
- **Fix:** Confirm the range and correct the example value; this is a copy-paste hazard.
## C. MISSING v1.7.0 (shipped, absent from docs)
The v1.7.0 release notes list these; **none** appear in any MDX content page (only oblique
Wikipedia-linked mentions of "FHIR" exist on `ios/ios-app.mdx`):
### A-M1 — Health-record export (PDF + HL7 FHIR R4 bundle) — **biggest gap**
- **Belongs in:** `features/export-import.mdx` (add a "Health-record export" section) and a
new `api/health-record.mdx` (or extend `api/external-ingest.mdx`).
- **Evidence:** `POST /api/export/health-record` — `src/app/api/export/health-record/`;
OpenAPI `openapi.yaml:41-162` (`format: pdf | fhir | package`, `application/fhir+json`
HL7 FHIR R4 document Bundle: LOINC-coded `Observation`s, BP panel, `MedicationStatement`,
`DiagnosticReport`; `export:<userId>` 10/h bucket; AI summary opt-in, flagged
not-clinically-validated). Patient identity (`fullName`/`insurerName`/`insuranceNumber`,
KVNR mod-10 validated + encrypted) feeds the FHIR `Patient`.
- **Fix:** New section documenting the three formats, the per-domain section selection,
the date range, the opt-in AI summary disclaimer, and the patient-identity prerequisite.
### A-M2 — PRN + cyclic schedules, `nextDueAt`, cadence-canonical compliance
- **Belongs in:** `features/medications.mdx` (rewrite per A-H3) + `api/medications.mdx`.
- **Evidence:** CHANGELOG v1.7.0 Added/Changed; OpenAPI `nextDueAt` at lines 3588/3748/5742;
compliance bucket gains `due`/`expectedCount`.
- **Fix:** Document `asNeeded`, `cycleWeeksOn/Off/Anchor`, the read-only `nextDueAt`, and
that compliance now counts against the real cadence (weekly/bi-weekly/rolling/RRULE/PRN)
not a daily denominator. The current 7d/30d/90d compliance section reads as if everything
is daily.
### A-M3 — Full HealthKit metric coverage + display-unit preference
- **Belongs in:** `features/health-metrics.mdx` + `api/measurements.mdx` (the type table at
`measurements.mdx:9-21` lists only 11 types).
- **Evidence:** v1.7.0 adds charts for flights climbed, audio exposure, walking speed/step
length/asymmetry/double-support, respiratory rate, body-composition family, mobility,
daylight, etc. (v1.5.5 already added RESTING_HEART_RATE/HRV/VO2_MAX/walking metrics).
Walking speed renders km/h, distance km; **canonical storage stays SI**. New
metric/imperial toggle (Settings → Display, mig `0094`), surfaced as `unitPreference` on
`/api/auth/me`.
- **Fix:** Expand the measurements type table to the full mapped set; add a "Units &
display preference" note explaining SI-canonical storage + render-time transform.
### A-M4 — Unified dashboard snapshot + nightly insight pre-generation
- **Belongs in:** `features/dashboard-customization.mdx` and/or `architecture/background-jobs.mdx`.
- **Evidence:** `GET /api/dashboard/snapshot` (OpenAPI `:1855`); nightly budget-gated
pg-boss job warms the comprehensive insight + briefing so `/insights` is a cache read.
- **Fix:** Add a short "First-paint snapshot & nightly pre-generation" note — explains why
the dashboard now paints together and why `/insights` no longer blocks on the model.
### A-M5 — Offline / sync delta feed
- **Belongs in:** `features/pwa-offline.mdx` and `api/native-clients.mdx`.
- **Evidence:** `GET /api/sync/changes` (opaque-cursor delta feed + measurement tombstones,
OpenAPI `:291`) and `GET /api/sync/state` (`:273`); measurement deletes now soft-delete
(tombstone) with refresh-token-keyed retention; `errorCode` on refresh distinguishes
revoked-family vs transient. Migration `0096`.
- **Fix:** Document the delta-feed contract for native/offline clients and the soft-delete
tombstone semantics.
### A-M6 — Coach data clustering
- **Belongs in:** `features/ai-insights.mdx`.
- **Evidence:** v1.7.0 — Coach accepts chosen clusters (cardiovascular, body comp,
activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft
budget cap that degrades lowest-signal clusters first.
- **Fix:** Add a "What data the Coach sees" subsection listing the clusters and the toggle.
### A-M7 — Profile identity fields on Account
- **Belongs in:** an Account/Settings page (none currently documents the Account section).
- **Evidence:** v1.7.0 full name / insurer / insurance number; v1.6.0 profile-photo upload.
- **Fix:** Document the Account identity card + avatar upload (ties to A-H1/A-H2 avatar fix).
## D. POORLY EXPLAINED (add an authoritative explainer link / diagram)
Clinical & infra jargon a self-hoster meets cold. `features/health-metrics.mdx` is still
the most term-dense page and (per prior audit P2) carries few outbound references. Concrete
links to add the first time each term appears:
| Concept | Where | Authoritative link to add |
|---|---|---|
| FHIR R4 | export-import / health-record page (A-M1) | https://hl7.org/fhir/R4/ |
| LOINC | health-record + measurements | https://loinc.org/ |
| UCUM (units) | measurements (unit transforms) | https://ucum.org/ |
| RRULE / RFC 5545 | medications (A-H3) | https://datatracker.ietf.org/doc/html/rfc5545 |
| AES-256-GCM | `security/overview.mdx`, installation | https://en.wikipedia.org/wiki/Galois/Counter_Mode |
| Passkeys / WebAuthn | already linked on ios-app; add on `api/authentication.mdx` | https://www.w3.org/TR/webauthn-2/ |
| AGPL-3.0 | introduction / footer | https://www.gnu.org/licenses/agpl-3.0.en.html |
| APNs | notifications / ios-app (linked) — add on notifications | https://developer.apple.com/documentation/usernotifications |
| Argon2id | `security/overview.mdx` | https://en.wikipedia.org/wiki/Argon2 |
| BYOK / local LLM (Ollama) | `settings/ai-providers.mdx` | https://ollama.com/ |
| ESH 2023 / ADA 2024 / NICE NG115 | health-metrics (named, unlinked) | ESH: https://www.eshonline.org/ · ADA *Standards of Care*: https://diabetesjournals.org/care · NICE NG115: https://www.nice.org.uk/guidance/ng115 |
| Postgres rollups (DAY/WEEK/MONTH) | `architecture/database.mdx` | concept needs a one-paragraph explainer + the existing rollup diagram if any |
**Diagram opportunity:** a "health-record export → FHIR Bundle" diagram (Patient +
Observation + BP panel + MedicationStatement + DiagnosticReport) on the new A-M1 page would
make the FHIR story legible. The source-priority SVG is already reused well on apple-health.
---
# SOURCE B — README (`HealthLog/README.md`)
The README is the strongest of the three surfaces and is largely current. Findings:
## B. STALE
### B-S1 — "Current line: v1.5" everywhere; no v1.7 awareness
- **Doc:** README `:41` *"Current line: v1.5"*; `:156` Tech-Stack "Native client … v1.5";
`:488` Roadmap "**v1.5** (current)" row mentions only iOS.
- **Evidence:** app is on 1.7.0; v1.6 (medication editor + route-of-administration) and
v1.7 (health-record export, flexible schedules, full HealthKit, dashboard snapshot)
shipped.
- **Fix:** Update the status line to v1.7 line; add a Roadmap row (or fold into v1.5) noting
v1.6 medication editor / route-of-administration and v1.7 health-record export +
PRN/cyclic + full HealthKit + first-paint snapshot.
### B-S2 — Key-Features list omits all v1.7.0 features
- **Doc:** README "Key Features" (`:71-105`) and the "How it compares" table (`:57-67`).
- **Evidence:** No bullet for **health-record / FHIR R4 export**, **PRN/cyclic schedules**,
**display-unit (metric/imperial) preference**, **dashboard first-paint snapshot**, or the
**sync delta feed**. The Medications bullet (`:81`) covers windows + intervalWeeks but not
PRN/cyclic or route-of-administration. The Doctor-Report bullet (`:89`) is PDF-only.
- **Fix:** Add a "Health-record export (PDF + FHIR R4)" bullet; extend Medications to
PRN/cyclic + injection route; add a units-preference note; optionally a "How it compares"
row "FHIR R4 export — Yes".
### B-S3 — Quick Start vs. `.env` filename mismatch (minor)
- **Doc:** README `:116` `cp .env.example .env` and `:444` Local Dev also `.env.example`.
- **Evidence:** repo ships `.env.production.example` (the one `pnpm check-env` validates);
confirm `.env.example` actually exists. If only `.env.production.example` exists, the
copy command is wrong.
- **Fix:** Verify the example filename and align (this also affects docs installation.mdx,
see B/Setup below).
## A. HALLUCINATED — none material
README's API tables were the prior audit's P1; spot-check shows they're now accurate:
`GET /api/auth/me` is described as *"Current user profile + avatar URL"* (`:326`, correct,
NOT Gravatar), and the avatar upload/delete rows (`:329-330`), Codex device-OAuth, and
`/api/auth/refresh` are present. The OpenAPI-locked subset is the source of truth and the
README agrees with it. (The v1.7 routes `/api/export/health-record`, `/api/sync/changes`,
`/api/dashboard/snapshot` are absent from the README tables — classify as **MISSING v1.7**
not hallucinated; add them to the relevant `<details>` blocks.)
## C. MISSING v1.7.0
- **B-M1:** README API tables don't list `POST /api/export/health-record`,
`GET /api/sync/changes`, `GET /api/sync/state`, `GET /api/dashboard/snapshot`,
`PATCH /api/auth/me/unit-preference`. Add to the "Health Data" / "Public + additions"
`<details>` blocks.
## D. POORLY EXPLAINED
README already does well (links Spezi, TestFlight, etc.). Light opportunity: first mention
of **AES-256-GCM** (`:165`), **AGPL-3.0** (`:39`), and **APNs** (`:95`) could carry the same
authoritative links listed in D above, but README convention is sparse-linking — low
priority. FHIR/LOINC appear only in the iOS section (`:516,524`) without links.
## STRUCTURE/SETUP (README)
- README Quick Start (`:113-135`) is correct and matches `.env.production.example` required
vars (`POSTGRES_PASSWORD`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`). It does **not**
generate a `SESSION_SECRET` — which is correct (see Setup finding below; the docs site is
wrong, not the README). GeoLite2 + APNs `.p8` setup is not in the README (acceptable —
they live in `docs/ops/` and are operator-advanced).
---
# CROSS-CUTTING — STRUCTURE / SETUP
### S-1 — `installation.mdx` generates a non-existent `SESSION_SECRET` (HALLUCINATED setup step)
- **Doc:** `getting-started/installation.mdx:30` —
`echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env`.
- **Code evidence:** `SESSION_SECRET` is referenced **zero** times anywhere in `src/`,
`scripts/`, `docker-compose.yml`, or `.env.production.example`. Sessions are
Postgres-backed (`src/lib/auth/session.ts`); there is no session-secret env var. The
README Quick Start correctly omits it.
- **Impact:** harmless but misleading — tells self-hosters to mint and (presumably) wire a
secret the app never reads, and implies a stateless-session model the app doesn't use.
- **Fix:** Delete the `SESSION_SECRET` line from `installation.mdx`. Match the README's
three-secret set exactly.
### S-2 — `.env` filename consistency across README + docs
- README uses `.env.example`; verify the repo's actual example file
(`.env.production.example` is the CI-validated one). Align README, `installation.mdx`,
and `quick-start.mdx` on whatever file actually ships.
### S-3 — Encryption-key rotation / `ENCRYPTION_KEYS` map not explained on docs install page
- `installation.mdx:36` mentions only the single legacy `ENCRYPTION_KEY` and warns it's
unrecoverable if lost — but never points at the modern `ENCRYPTION_KEYS` map +
`ENCRYPTION_ACTIVE_KEY_ID` + the rotation CLI (README `:166` covers this well; docs
should link `docs/ops/encryption-key-rotation.md`). POORLY EXPLAINED for operators.
### S-4 — iOS integration is well covered
- `ios/ios-app.mdx` is current and accurate (TestFlight, HealthKit two-way sync, pairing,
passkey, on-device Coach, doctor export, troubleshooting, cert-pinning). No findings —
this fully closes the prior audit's iOS P1s.
---
# PRIOR AUDIT (`site-audit-2026-05-29`) — status of its P1s/P2s
| Prior finding | Status |
|---|---|
| Docs: no `@astrojs/sitemap` / sitemap.xml (P1) | **CLOSED** — `b7bb708 docs(seo): add sitemap, robots.txt…`. |
| Docs: no dedicated iOS page (P1) | **CLOSED** — `e534b93 docs(ios): add a dedicated iOS app page`; `ios/ios-app.mdx` present + linked from intro. |
| Docs: external explanatory links on clinical pages (P2) | **PARTIAL** — ios-app.mdx links liberally; `health-metrics.mdx` still sparse (see D). |
| Docs: intro iOS bullet + achievement count (P2) | **CLOSED** — intro now has an iOS bullet (`:25`) and says "59 … (six hidden)". 59 verified against `achievements.test.ts:88` (`toHaveLength(59)`); hidden 5–8 (`:107-108`). |
| Docs: per-page OG/Twitter (P3) | **CLOSED** — `b7bb708` adds default OG/Twitter meta. |
| README: `GET /api/auth/me` "Gravatar" drift (P1) | **CLOSED in README** (`:326` now "avatar URL"), but the **same lie migrated/persists in the DOCS site** — `authentication.mdx:91` + `admin-settings.mdx:113` still say Gravatar (A-H1, A-H2). The prior audit only checked the README. |
| README: avatar/Gravatar privacy bullet (P2) | **OPEN** — README Security section still has no "self-hosted avatars / no Gravatar email-hash leak" bullet. |
| README: achievement count "30+" vs "59" (P2) | **CLOSED** — README now says "59 persistent achievements" (`:99`); the "30+" the prior audit cited is gone. |
| README: Node 20 → 22 (P2) | **OPEN** — README `:442` still says "Node.js 20+"; CLAUDE pins Node 22 (Alpine). |
| README: roadmap stale (P3) | **OPEN + worse** — now stale at v1.5 while app is v1.7 (B-S1). |
| Landing repo P1s/P2s | **out of scope** — this audit did not open `healthlog-landing`. The landing JSON-LD `softwareVersion 1.4.32` and iOS-section gaps from the prior audit should be re-verified separately. |
---
# Fix-priority rollup
1. **A-H1 / A-H2** — kill the Gravatar lie in `authentication.mdx:91` + `admin-settings.mdx:113`.
2. **S-1** — delete the phantom `SESSION_SECRET` from `installation.mdx:30`.
3. **A-H3 / A-M2** — rewrite `medications.mdx` schedule model (RRULE/rolling/intervalWeeks/
one-shot/PRN/cyclic + route-of-administration) + cadence-canonical compliance.
4. **A-M1** — add the health-record (PDF + FHIR R4) export to `export-import.mdx` + API docs.
5. **A-M3** — full HealthKit metric table + units-preference on `measurements.mdx`/`health-metrics.mdx`.
6. **A-S1 / A-S2 / A-S3** — version-string + pinned-tag + arm64 sweep across docs.
7. **B-S1 / B-S2 / B-M1** — README v1.7 status, feature bullets, API rows.
8. **D** — explanatory links (FHIR/LOINC/UCUM/RRULE/AES-GCM/WebAuthn/AGPL/APNs) + a FHIR-bundle diagram.
9. Cleanups: A-S4 backup schedule UTC↔Berlin, A-S5 mood scale example, B-S3/S-2 `.env` filename, S-3 key-rotation link.
@@ -0,0 +1,34 @@
# Docs update — maintainer framing notes (apply at docs review)
## Gravatar → self-hosted avatars: tell the migration story, don't just swap the fact
The two Gravatar hallucinations (`api/authentication.mdx`, `configuration/admin-settings.mdx`)
should NOT merely be replaced with "returns a self-hosted avatarUrl". Per the
maintainer (2026-05-31), state the HISTORY explicitly and frame it as a privacy win:
- HealthLog used to use Gravatar.
- It was deliberately changed to self-hosted avatars **for privacy reasons** — Gravatar
resolves an avatar from a hash of the user's email at a third-party service, which
leaks that the email is in use (and to whom) off the self-hosted instance.
- Now avatars are uploaded + served from the operator's own instance; nothing about
the user leaves it.
Keep it factual and short — one or two sentences in the maintainer's voice. No
marketing tone. This turns a stale doc line into a documented privacy improvement.
When the docs-site agent returns, verify this framing is present at both sites; if it
only swapped the fact, adjust to include the migration story.
## General principle (maintainer, 2026-05-31): explain the WHY at critical points
The maintainer wants the docs to explain *why* a decision was made at critical
architecture / privacy / security points — a short rationale, not just the what.
At the docs review, add a one-or-two-sentence "why" wherever a non-obvious choice
appears, e.g.:
- self-hosted avatars over Gravatar (privacy — above)
- Postgres-backed sessions (no SESSION_SECRET / no stateless JWT) — server-side
revocation, sliding expiry, no secret to leak
- AES-256-GCM encryption at rest — what's protected and why fail-closed
- FHIR R4 for the health-record export — interoperability / German ePA ingests FHIR
- soft-delete tombstones — needed so an offline client learns of deletions
- requireAdmin cookie-only — a Bearer token can never reach admin, by construction
- BYOK / multi-provider AI — no vendor lock-in, data stays under operator control
- AGPL-3.0 — why copyleft for a self-hosted health app
Keep each rationale short, factual, maintainer's voice. Don't bolt a "why" onto
trivial settings — only where a reader would reasonably ask "why this way?".
@@ -0,0 +1,106 @@
# iOS reply — convergence points to enforce at reconcile (W10)
Source: `.planning/ios-coord/v1.7.0-ios-to-server-reply.md` (iOS repo branch
`feat/v0100-marathon`). The implementation agents were dispatched before this
reply landed; their field names already match, but verify/adjust these exact
details when reconciling each branch.
## W1 — dashboard widgets allowlist (was: "accept-and-ignore unknown")
Allowlist the EXACT 11 iOS-only ids (union with the 16 server-known = 27). Do NOT 422 on any:
`restingHeartRate, hrv, walkingSpeed, walkingAsymmetry, walkingStepLength, bmi,
bodyTemperature, walkingDoubleSupport, respiratoryRate, audioExposureEnvironment,
audioExposureHeadphone`.
Server-known (16, already accepted): `weight, bp, pulse, bodyFat, mood,
medications, sleep, steps, glucose, totalBodyWater, boneMass, bpInTarget,
oxygenSaturation, achievements, vo2Max, recentWorkouts`.
Persist visibility/order for all; ignore the unknown-to-web ones (web has no row).
## W1/W2 — schedule field names are now LOCKED (iOS will decode these verbatim)
- PRN: `asNeeded` / `prn` boolean.
- Cyclic: `cycleWeeksOn` / `cycleWeeksOff` + anchor.
- `liveActivityEnabled`, `criticalAlarmEnabled` booleans default false (GET/PUT).
- `nextDueAt` nullable, read-only, `null` ⇒ PRN.
Keep these names; iOS pins them from openapi.yaml at the tag.
## W5 — FHIR R4 must converge with iOS, not fork
iOS already emits a FHIR R4 Document Bundle on-device. Match its conventions so
either side's Bundle is interchangeable:
- `Composition` first entry, type LOINC `11503-0` "Medical records"; sections
"Vital signs" + "Medications".
- `Patient` (name).
- `Observation` per vitals row AND per chart point; vital-signs LOINC-coded; UCUM
units; BP as systolic/diastolic components.
- `MedicationStatement` per active (status `active`) + archived (status `completed`);
dose+schedule as free-text Dosage.
- `DiagnosticReport` last entry; LOINC `85353-1` vital-signs panel; routes all
Observation refs as `result[]`.
iOS LOINC/UCUM source: `HealthLog/FHIR/MetricFHIRMapper.swift` + `LOINCCode.swift`.
Selection contract to match or exceed: date-range 30/60/90/180/365 + 5 section
toggles (`vitals, charts, medications, adherence, mood`). Server export should be
a SUPERSET. Backup JSON/CSV already served by `POST /api/export`.
## W6 — snapshot adoption conditions if we want iOS to consume it (optional)
iOS leans yes IF: (1) SWR-cacheable as ONE key; (2) carries per-metric latest
value/state seed (`metricStates`) else iOS still fans out; (3) includes the
iOS-only widget ids in the layout block; (4) additive — per-store endpoints stay.
Web first-paint is the primary driver; treat iOS adoption as a bonus — keep the
envelope additive + one-key cacheable at minimum.
---
## FINAL LOCKS — from `.planning/ios-coord/v1.7.0-ios-convergence-locks.md`
(Landed AFTER the implementation agents were dispatched. Enforce these at reconcile;
the lock doc is the authoritative table — read it directly when reconciling W5/W1/W7.)
### W5 / FHIR — byte-identical LOINC/UCUM (HIGHEST reconcile risk)
The server FHIR `Observation` codes MUST match the iOS table byte-for-byte:
same `code.coding[].code`, `display`, and `valueQuantity.{code,unit}`. Source of
truth: `healthlog-iOS/.../FHIR/MetricFHIRMapper.swift:102-374` + `LOINCCode.swift`.
- Systems: LOINC `http://loinc.org`, UCUM `http://unitsofmeasure.org`.
- UCUM is case-sensitive canonical, brackets load-bearing: `mm[Hg]`, `/min`,
`Cel` (NOT °C), `kg/m2`, `{steps}`, `{flights}`, `dB[A]`, `mL/min/kg`, `m/s`, `m`.
- 23 distinct LOINC codes (20 standard incl. BP panel 85354-9 + systolic 8480-6 +
diastolic 8462-4; + 3 glucose-context: random/bedtime `2339-0`, fasting/beforeMeal
`1558-6`, afterMeal `1521-4`).
- Key codes: weight `29463-7` kg; HR `8867-4` /min; resting HR `40443-4`; HRV(SDNN)
`80404-7` ms; body temp `8310-5` Cel; SpO2 `59408-5` %; resp rate `9279-1` /min;
body fat `41982-0` %; body water `73704-9` kg; bone mass `73708-0` kg; BMI
`39156-5` kg/m2; sleep `93832-4` h; steps `41950-7` {steps}; VO2max `96402-2`
mL/min/kg; walking speed `41957-2` m/s; walking asymmetry `91557-1` %; step
length `41955-6` m; active energy `41981-2` kcal.
- 6 HK-PLACEHOLDER codes (emit the HK identifier string AS the code, do NOT invent
a LOINC): walkingDoubleSupport `HKQuantityTypeIdentifierWalkingDoubleSupportPercentage` %;
audio env `HKQuantityTypeIdentifierEnvironmentalAudioExposure` dB[A]; audio
headphone `HKQuantityTypeIdentifierHeadphoneAudioExposure` dB[A]; flights
`HKQuantityTypeIdentifierFlightsClimbed` {flights}; distance
`HKQuantityTypeIdentifierDistanceWalkingRunning` m; daylight
`HKQuantityTypeIdentifierTimeInDaylight` min.
- ⚠ codes carry `physicianReviewPending:true` on iOS (UI disclaimer only); server
still emits the same code.
ACTION: diff W5's FHIR mapper against this table; align to byte-identical. If W5
built its own LOINC set from R-export.md, this table WINS.
### W1/W2 — final field names (adopt verbatim in openapi.yaml)
- PRN flag = **`asNeeded`** (Bool) — NOT `prn`. (iOS locked `asNeeded`.)
- Cyclic = `cycleWeeksOn` (Int?), `cycleWeeksOff` (Int?), `cycleAnchor` (ISO date String?).
- `liveActivityEnabled`, `criticalAlarmEnabled` (Bool, default false, GET/detail+PUT).
- `nextDueAt` (ISO8601 String?, read-only, null for PRN).
- Compliance per-day adds `due` (Bool) + `expectedCount` (Int).
- Widget enum: server source-of-truth file is `src/lib/dashboard-layout.ts` →
`DASHBOARD_WIDGET_IDS`; widen to the full 27-ID catalogue (16 server-known + 11
iOS-only from the convergence notes above). bmi IS one of the 11 iOS-only.
### W7 — walking speed stays m/s on the wire/FHIR; km/h is DISPLAY ONLY
iOS has NO km/h conversion and NO unit toggle. Our km/h is a presentation-layer
choice: convert display only (`km/h = m/s × 3.6`); persist + export `m/s`. The W5
FHIR walking-speed value MUST be `m/s` (UCUM `m/s`, LOINC `41957-2`) — so W7's
display transform must not leak into W5's export or the stored value. The global
unit toggle is web-only; fine, iOS unaffected.
### W6 — snapshot (if iOS is to consume): carry all 27 widget IDs + iOS MetricKind
raw values. Non-obvious raws: spo2="oxygenSaturation", bodyWater="totalBodyWater",
hrv="heartRateVariability", bmi="bodyMassIndex",
walkingAsymmetry="walkingAsymmetryPercentage",
walkingDoubleSupport="walkingDoubleSupportPercentage",
audioExposureEnvironment="environmentalAudioExposure",
audioExposureHeadphone="headphoneAudioExposure", activeEnergy="activeEnergyBurned".
@@ -0,0 +1,115 @@
# v1.7.0 Code-Correctness Review
Reviewer: senior code-correctness (read-only). Diff: `main..release/v1.7.0` (~36 commits).
Scope: correctness bugs / logic errors / edge cases / races. Style ignored.
Severity counts: Critical 0 · High 2 · Medium 5 · Low 4
---
## High
### H1 — Snapshot-flag dashboard shows EmptyState during first-paint instead of a skeleton
File: `src/app/page.tsx:1380-1383` (and the empty-state gate at `:1399-1420`)
Bug: the tile-strip skeleton gate is
```
const showTileStripSkeleton =
trendCards.length === 0 &&
analyticsSlimQuery.isLoading &&
configuredTileCount > 0;
```
When `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true`, `analyticsSlimQuery` is created with `enabled: !snapshotEnabled && isAuthenticated` → `false`. A disabled TanStack Query (v5) has `fetchStatus: "idle"`, so `isLoading` is **always `false`**. While `useDashboardSnapshot` is still in-flight, `snapshotQuery.data` is `undefined` → `data` is `undefined` → `trendCards`/`charts` are empty → `showTileStripSkeleton` is `false` → the empty-state branch at `:1399` fires. The user sees the "no data, log a measurement" EmptyState for the entire snapshot fetch, then it snaps to the populated strip. That is exactly the "blocked-then-burst"/flash the snapshot work set out to remove.
Fix: gate the skeleton (and suppress the empty state) on the active data source's loading flag. In snapshot mode use `snapshotQuery.isLoading` (or `snapshotEnabled && snapshotQuery.data === undefined`):
```
const primaryLoading = snapshotEnabled
? snapshotQuery.isLoading
: analyticsSlimQuery.isLoading;
const showTileStripSkeleton =
trendCards.length === 0 && primaryLoading && configuredTileCount > 0;
```
and add `&& !primaryLoading` to the empty-state guard at `:1399`.
### H2 — Multi-time-of-day Telegram reminders re-fire because the ledger is wiped per medication
File: `src/lib/notifications/senders/telegram.ts:27-52, 79-81` interacting with `src/lib/jobs/reminder-worker.ts:617-676`
Bug: SB-SCHED-4 added per-slot dedup keyed `(medicationId, scheduleId, date, phase, timeOfDay)`. But `sendViaTelegram` still calls `deleteExistingReminders(botToken, medicationId)` which `deleteMany({ where: { medicationId } })` — it deletes **every** ledger row for the medication (all dates, all phases, all times-of-day) before sending the new message. For a schedule with `timesOfDay = ["08:00","20:00"]`, when the 20:00 slot dispatches it deletes the 08:00 ledger row. The worker's dedup check (`telegramReminderMessage.findUnique` at `reminder-worker.ts:659`) then finds nothing for the 08:00 slot on the next tick whose 08:00 window is still in the same phase, and re-sends the morning reminder. Net effect: duplicate/repeated morning reminders whenever two time-of-day windows for the same medication overlap a phase within a day. The pre-v1.7 single-slot model never hit this because there was only ever one live row per medication.
Fix: scope the pre-send delete to the slot being replaced, not the whole medication, e.g. `deleteExistingReminders(botToken, medicationId, scheduleId, date, phase, timeOfDay)` deleting only `{ medicationId, scheduleId, date, phase, timeOfDay }` (the same composite the upsert keys on). If the "max one active Telegram message per medication" UX is intentional, then the worker dedup must not rely on a row that the sender will delete — move the dedup ledger to a separate non-deleted table, or guard the delete to only remove messages whose slot has advanced past its phase.
---
## Medium
### M1 — `due` / `expectedCount` cross-timezone off-by-one in the daily compliance heatmap
File: `src/app/api/medications/[id]/compliance/route.ts:77-83, 148-153` + `src/lib/analytics/compliance.ts:260-303`
Bug: the 90-day loop builds `dayStart`/`dayEnd` as UTC-midnight-aligned slices (`now - (d+1)*DAY` … `now - d*DAY`) and the `dateKey` is the UTC slice (`dayStart.toISOString().slice(0,10)`). `expectedSlotCountForDay` then calls `occurrencesBetween(canonical, dayStart, dayEnd-1ms)`, and the engine applies `timesOfDay` in the **user timezone**. For users far from UTC (e.g. Pacific/Auckland +13, America/Los_Angeles -8), the user-tz dose instant can land in the adjacent UTC slice from the one the `dateKey` labels. The `due`/`expectedCount` flag then attaches to the wrong calendar day in the heatmap (a "due" mark one cell off). `compliance7`/`compliance30` are unaffected (they group by local day inside `calculateCompliance`).
Fix: compute `dayStart`/`dayEnd` as user-tz local-day boundaries (reuse `startOfLocalDay` / the tz helpers in `cadence.ts`) and key `dateKey` off the user-tz day, matching the engine's frame of reference.
### M2 — Pre-generation cron does nothing in the 20–24 h cache window (20h discovery vs 24h generate TTL)
File: `src/lib/jobs/insight-pregenerate.ts:58, 95-109` + `src/lib/insights/comprehensive-generate.ts:51, 196-201`
Bug: discovery selects users with `insightsCachedAt < now-20h`, and the per-user budget bucket is 20h. But `generateComprehensiveInsight` re-checks the cache and returns `{status:"cached"}` (no LLM) when `now - insightsCachedAt < 24h` (CACHE_TTL_MS). So a user whose cache is 20–24h old is discovered, consumes the budget bucket, then short-circuits to `cached` — no pre-generation occurs. Pre-generation only actually fires once the cache is fully expired (>24h), which is precisely the lazy-on-mount path the cron was meant to pre-empt. The "warm the cache before the user's morning visit" intent is defeated for the common case.
Fix: pass a `force` flag from the cron into `generateComprehensiveInsight` (regenerate regardless of the 24h TTL) OR align the generate-side staleness check to the cron's 20h threshold. The budget bucket already prevents runaway cost.
### M3 — Soft-deleted measurement still returned by `GET /api/measurements/[id]`
File: `src/app/api/measurements/[id]/route.ts:27-43`
Bug: v1.7.0 converted DELETE to soft-delete (`deletedAt` set). The CLAUDE.md/PR contract is "every list / analytics / rollup read filters `deletedAt: null`." The single-resource `GET` still does `findUnique({ where: { id } })` with no `deletedAt` filter, so a tombstoned row is returned with full data on a direct GET (and PUT at `:53` can resurrect-edit it — `update` on a soft-deleted row succeeds and does not clear `deletedAt`, leaving an edited-but-still-tombstoned row). Inconsistent with the read-path invariant.
Fix: add `deletedAt: null` to the GET (return 404 for tombstoned) and to the PUT existence check, or have PUT explicitly refuse to edit a tombstoned row.
### M4 — Per-slot intake-count suppression depends on `timesOfDay` array order
File: `src/lib/jobs/reminder-worker.ts:617-631`
Bug: the multi-slot loop iterates `schedule.timesOfDay` in stored order and uses `if (eventCount > schedulesProcessed) { schedulesProcessed++; continue; }` to suppress reminders once "enough" intakes are logged. `eventCount` is the medication's total intake events today, undifferentiated by slot. If `timesOfDay` is stored unsorted (e.g. `["20:00","08:00"]`), the suppression attributes a logged morning dose to whichever slot iterates first (the 20:00 slot), so the evening reminder can be suppressed while the morning reminder still fires. The count→slot mapping is positional, not time-matched.
Fix: sort `slotTimes` chronologically before the loop, and ideally match logged intakes to slots by time-of-day proximity rather than a positional running counter, so a partially-dosed day reminds for the correct missing slot.
### M5 — `expandScheduleSlots` engine path is inclusive of `to`; legacy path is half-open `[from,to)`
File: `src/lib/medications/scheduling/cadence.ts:277-287` vs `:332` (`if (wStart >= to) continue;`)
Bug: the legacy walker excludes a slot at exactly `to` (`wStart >= to → continue`). The v1.7.0 engine path calls `occurrencesBetween(canonical, from, to, …)` which is inclusive of both ends (per the engine doc and `recurrence.ts:133`). So a dose landing exactly at the window boundary `to` is counted by the engine path but not the legacy path. In `buildCadenceTimeline`/compliance this can shift a boundary slot's inclusion between the canonical and legacy branches, producing a one-slot denominator difference at the exact window edge for canonical-shape schedules. Low-frequency (requires a dose instant exactly equal to `asOf`/`to`), but it is a behavioural divergence between the two branches the release explicitly claims are equivalent.
Fix: pass `new Date(to.getTime() - 1)` to `occurrencesBetween` in the engine branch (as `expectedSlotCountForDay` already does at `compliance.ts:296-298`) to make both branches half-open `[from, to)`.
---
## Low
### L1 — `consolidate-daily-mean` resolves the unit via a query that can return a soft-deleted row's unit
File: `src/lib/measurements/consolidate-daily-mean.ts:222-223, 304-313`
`resolveCanonicalUnit` does `findFirst({ where: { userId, type, source } })` with no `deletedAt: null` filter, and is called inside the day loop after earlier days in the same run may have soft-deleted per-sample rows. The unit is read from the in-hand `dayRows` conceptually but actually re-queried; it can pick up a soft-deleted row's unit. Units are homogeneous per type so this is harmless today, but it is an unnecessary extra query per day and reads from tombstoned rows. Use `dayRows[0]`'s unit (carry `unit` in the `PerSampleRow` select) instead of a separate query.
### L2 — Cyclic on/off-week gate uses UTC week start while inner cadence uses user-tz days
File: `src/lib/medications/scheduling/recurrence.ts:183-202`
`isInCyclicOnWeek` snaps anchor + instant to `startOfUtcWeek` (Sunday, UTC) while RRULE/legacy day emission applies time-of-day in the user timezone. A slot late on a Sunday-local night (already Monday UTC, or vice versa) can be assigned to the adjacent UTC week, flipping its on/off classification at the cycle boundary. Same UTC-week convention as the legacy `intervalWeeks` stride (`:488-498`), so internally consistent, but both share the cross-tz week-boundary edge. Consider anchoring the cyclic phase to the user-tz week to match day emission.
### L3 — Snapshot `extras: null` and `briefingState` cached together for 60 s can lag a mid-window convergence/regeneration
File: `src/app/api/dashboard/snapshot/route.ts:73-78` + `src/lib/dashboard/snapshot.ts:421-431`
The full snapshot (including `extras: null` on a coverage miss and `briefingState: "preparing"`) is cached 60 s keyed `${userId}|dashboard-snapshot`. If the boot rollup-backfill converges or the pre-generate cron writes a fresh briefing within that 60 s, the user keeps seeing the stale `null`/`preparing` until expiry. The pre-generate path does call `invalidateUserInsights` so a regenerate evicts it; the rollup-coverage convergence has no such eviction hook, so the BD/glucose tiles stay shimmering up to 60 s after coverage warms. Acceptable per the two-phase design; documented here as the known staleness window. No fix required unless faster convergence is desired (add a coverage-warm eviction).
### L4 — Sync feed `cursorExpired` horizon keys on `updatedAt`, but a re-deleted tombstone bumps `updatedAt` forward
File: `src/app/api/sync/changes/route.ts:111-123` + `src/app/api/measurements/[id]/route.ts:194-200`
A replayed soft-delete re-bumps `syncVersion` and `updatedAt` (idempotent at data level), pushing the tombstone's `updatedAt` forward past the retention horizon. That is benign (the row simply re-surfaces as a tombstone on the next delta, which the client re-applies idempotently). But it means a re-deleted row's effective retention extends indefinitely on each replay; the tombstone-cleanup job keys on `deletedAt`/`updatedAt` — confirm the cleanup horizon and the feed's `cursorExpired` horizon use the same column so a row can't be pruned while still inside a client's reachable cursor window. Verify `measurement-tombstone-cleanup` prunes on the same field the feed's `retentionHorizon` compares against (`cursor.updatedAtMs < now - RETENTION`). If cleanup prunes on `deletedAt` but the feed gates on `updatedAt`, a row deleted long ago but with a recent `updatedAt` replay is safe, but a row with old `updatedAt` and recent `deletedAt` mismatch could theoretically be pruned inside a live window. Low risk; worth a one-line assertion in the integration test.
---
## Areas verified clean
- Display-transform (`display-transform.ts`, `valueScale`): applied at render layer only; `health-chart.tsx:654` scales the in-memory series, never written back. FHIR (`build-bundle.ts`) and the doctor PDF consume `DoctorReportData` (canonical SI) directly — no km/h or km leak into stored/FHIR values. `valueScale` correctly participates in the `queryKeys.chartData` factory tuple so scaled series don't poison the raw cache.
- PRN: `occurrencesBetween` returns `[]` (recurrence.ts:148) and `nextOccurrenceAfter` returns null (`:221`) — PRN excluded from projection, reminders, compliance-expected; still loggable. Correct.
- `HIGH_FREQUENCY_MEAN_TYPES` is disjoint from `CUMULATIVE_HK_TYPES`; PULSE deliberately excluded from both. Mean drain scopes to `source='APPLE_HEALTH'`, excludes `stats:%` rows and `deletedAt`-set rows → idempotent re-run converges to zero. Upsert + soft-delete run in one `$transaction`.
- Coach snapshot soft-cap (`degradeToBudget`): walks reverse cluster priority, degrades lowest-signal clusters first across drop-recent → drop-weekly → drop-block passes; core clinical clusters (medication/cardio/glucose) degrade last. Measures against the pretty-printed form that actually ships. Multi-cluster window cap correctly spares core clusters.
- RRULE expansion: guards against user COUNT/UNTIL colliding with the engine UNTIL suffix (`:398`), pads the day-anchor window ±2 days for tz-shifted times-of-day, caps the walk at MAX_CHUNKS + 10-year hardCap. Parse failure returns `[]` with annotation, never throws.
- Sync keyset cursor: `(updatedAt, id)` total ordering with `take: limit+1` hasMore detection; tombstones-vs-upserts correctly split by `deletedAt`; `decodeCursor` returns null on garbage → treated as clean initial sync. Pagination is correct at same-millisecond page boundaries.
- Cache invalidation: dashboard-snapshot key lives under the analytics bucket so the `${userId}|` prefix sweep covers measurement/mood/medication writes; widget + insight invalidators (which don't touch analytics) call `invalidateUserDashboardSnapshot` explicitly. No stale-snapshot gap on mutation.
- Compliance denominator: skipped doses excluded from denominator, empty window returns rate 100, streak advances on no-expected-dose days, `medicationCreatedAt` floors the window. Legacy fallback (no medicationContext) is byte-stable.
+181
View File
@@ -0,0 +1,181 @@
# v1.7.0 — Frontend Design + Accessibility Review (code-level, READ-ONLY)
Reviewer scope: new/changed UI in `git diff main..release/v1.7.0`. No browser
available — every finding is a static read of classes/ARIA/structure. Items
tagged `[CI]` need a Playwright/axe or visual confirmation in CI.
## Severity counts
- Critical: 0
- High: 0
- Medium: 4
- Low: 6
- Pass (verified, no action): 8
---
## Medium
### M-1 — Unit-preference segmented control: touch targets below 44px on mobile
`src/components/settings/unit-preference-card.tsx:150-155`
The segmented `<button>`s use `min-h-9` (36px) with no mobile bump. Every other
new v1.7.0 affordance follows `min-h-11 sm:min-h-9` (44px mobile → 36px desktop)
— detail header, advanced sheet buttons, export panel format buttons, history
import, destructive zone. This control breaks that release-wide rule.
Fix: `min-h-11 sm:min-h-9` on the option buttons. Padding stays `px-4`.
### M-2 — Unit-preference radio buttons have no visible focus ring
`src/components/settings/unit-preference-card.tsx:150-155`
The raw `<button role="radio">`s carry no `focus-visible:ring-*`. They sit on a
`bg-muted` track, so the UA default outline is low-contrast / easy to miss. The
shadcn `<Button>` used by the export-panel radiogroup brings its own
focus-visible ring; these hand-rolled buttons don't. WCAG 2.4.7.
Fix: add `focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none`
(match the shadcn Button token), or render the options through `<Button>` like
the export panel does for consistency.
### M-3 — Radiogroups lack arrow-key navigation / roving tabindex
`src/components/settings/unit-preference-card.tsx:132-161`,
`src/components/settings/health-record-export-panel.tsx:177-191`
Both use `role="radiogroup"` + `role="radio"` + `aria-checked`, but each radio is
independently tabbable and there is no Arrow-key handler. An ARIA radiogroup is
expected to be one tab stop with Arrow keys moving selection (roving tabindex).
Note: this matches the pre-existing house pattern (Step2Class, mood-form,
SideEffectsSection all do the same), so it is NOT a v1.7.0 regression — but the
two new groups extend the gap. `[CI]` axe will not fail this (it's a
keyboard-interaction expectation, not a static rule); flag for the broader
radiogroup-pattern cleanup. Lowest-friction alternative: drop `role="radiogroup"`
and use a real `<fieldset>`+radio-input set, or implement roving tabindex once in
a shared `<SegmentedControl>`.
### M-4 — Export panel: "Medikamente" toggle silently controls only `compliance`; `medList` has no UI
`src/components/settings/health-record-export-panel.tsx:320-324, 49-67, 90`
The single `medications` ToggleRow is bound to `sections.compliance`; `medList`
defaults `true` and is never exposed, so unchecking "Medikamente" still ships the
medication list in the payload (`{ list: true, compliance: false }`). The label
implies it governs the whole medication block. Either bind the toggle to both
flags, split into two rows, or relabel to "Adherence/Compliance" so the control
matches what it does. Functional-correctness adjacent; surfaces as a confusing
selection UI.
---
## Low
### L-1 — `<SettingsGroup>` section has no accessible name
`src/components/medications/settings-group.tsx:31-41`
The group `<section>` wraps a plain `<p>` micro-label not connected via
`aria-labelledby`. A bare `<section>` with no accessible name is a generic region
to AT. This is a deliberate choice (the comment explains it avoids a duplicate-id
axe failure and the dialog already owns the title) and is defensible, but the
four groups read as unlabelled regions. Optional: give the `<p>` an `id` and set
`aria-labelledby` on the section (ids are unique per-group, so no dup-id risk), or
drop `<section>` for a `<div>` to avoid an unnamed landmark-ish region. `[CI]`
confirm axe stays green either way.
### L-2 — Export panel error renders raw HTTP status with no friendly mapping
`src/components/settings/health-record-export-panel.tsx:131-134, 375-379`
On a non-OK response the panel sets `error` to the bare status code and renders
`t("settings.healthRecord.error", { code })`. `role="alert"` is correct, but a
413/429/500 surfaces as "…(500)" with no guidance. Low — copy/UX polish.
### L-3 — Export download has no success affordance
`src/components/settings/health-record-export-panel.tsx:135-147`
On success the blob just downloads; the spinner stops and nothing announces
completion (no `role="status"` toast). For a multi-second PDF/package this leaves
the user unsure it worked. Low — add a polite status line mirroring the
unit-preference card's pattern.
### L-4 — `<details>`/`<summary>` disclosure: no `aria-expanded` mirror, animation-free (good)
`src/app/medications/[id]/history/page.tsx:137-143`
Native `<details>` is the right primitive and exposes expanded state to AT for
free; no fix needed. Noting only that the summary chevron is text-only (no icon
rotation), so there is zero motion concern here — consistent with the W9
auto-scroll remediation. Verified PASS-adjacent; listed for completeness.
### L-5 — Export panel format change can leave AI-summary/charts toggles set but hidden
`src/components/settings/health-record-export-panel.tsx:101-102, 337-350`
Switching to FHIR hides `includeCharts`/`includeAiSummary` but keeps their state;
switching back restores the prior values (fine), and the POST only reads them when
PDF-like via the spread — but they ARE still sent in the body regardless of format
(`includeCharts`, `includeAiSummary` always in the payload at 126-127). Backend
presumably ignores them for FHIR; confirm the server drops them so a stale
`includeAiSummary: true` can't leak an AI summary into a FHIR export. `[CI]`/backend.
### L-6 — Detail header status dot relies on colour + always-on text label (good); muted-foreground "ended" dot contrast
`src/components/medications/medication-detail-header.tsx:90-95`
Status is conveyed by both an always-visible text label and a colour dot
(`aria-hidden`), so colour is not the sole channel — correct. The "ended" dot uses
`bg-muted-foreground` inside a `secondary` Badge; the dot is decorative so contrast
isn't required, but visually it can be hard to distinguish from the badge text.
Cosmetic only.
---
## Verified PASS (no action)
1. **Detail header three-button row** — Edit is a labelled outline button; History
+ Advanced are icon-only with distinct `aria-label`s and `aria-hidden` icons;
all carry `min-h-11 min-w-11 sm:min-h-9 sm:min-w-9` (44px mobile). DOM order
name→dose→status→edit→history→advanced is sensible for AT.
`medication-detail-header.tsx:122-153`.
2. **Advanced settings sheet redesign ("Kraut und Rüben" fix)** — Genuinely
consistent now: four `<SettingsGroup>` blocks, uniform `space-y-8` between
groups + `space-y-4` within, `<Separator>` between rows, neutral actions
`outline` / destructive `destructive font-semibold` / toggles as labelled
switches. Widened to `2xl` (token exists in ResponsiveSheet: `sm:max-w-2xl`).
No mixed dark/light button salad remains. `advanced-settings-sheet.tsx`,
`settings-group.tsx`.
3. **History view** — intake-only; sort defaults `scheduledFor desc` so skipped
rows don't float; drug-level curve is a default-CLOSED native `<details>`;
import de-emphasised as ghost; back link present; loader respects
`motion-reduce:animate-none`. `[id]/history/page.tsx`,
`intake-history-editable.tsx`.
4. **Bulk/row delete dialogs** — AlertDialog with title+description, destructive
action styled consistently, `aria-busy` on pending, selection toolbar is a
labelled `role="region"`. `intake-history-editable.tsx`.
5. **HealthKit chart pages (24 new)** — All thin wrappers over
`HealthKitMetricPage`; identical envelope to the existing wave-A pages. Line
colours are Dracula hex via the `color` prop (`#50fa7b`, `#ffb86c`, `#bd93f9`,
`#ff79c6`, `#8be9fd`) — same mechanism as existing chart pages. **No raw
`text-green/red/orange/yellow-*` Tailwind utilities introduced** (grep clean),
so the known prior status-text contrast issue is not reintroduced.
`valueScale` is folded at the single read boundary and re-keys the query cache;
no contrast/structure change to the chart itself. `[CI]` visual smoke on a few
pages to confirm empty-state + axis render.
6. **Destructive / lifecycle / notifications / settings section bodies** — Switches
wrapped in `<label>` (whole-row hit target), distinct `aria-labelledby` +
`aria-describedby`, the duplicate-id heading split is preserved
(SECTION_TITLE_ID vs ROW_TITLE_ID), decorative channel chips `aria-hidden`,
destructive CTAs carry `font-semibold` for the WCAG large-text band, every
spinner `motion-reduce:animate-none`, all action buttons `min-h-11 sm:min-h-9`.
7. **Export panel core structure** — `<section aria-labelledby>`, `<fieldset>` +
`<legend>` for format + included-data groups, labelled `<NativeSelect>` for
range, labelled `<Input>` for practice name, format buttons `min-h-11 sm:min-h-9`
with `aria-checked`, generate button `motion-reduce:animate-none`, `role="alert"`
on error, mood default-OFF (privacy). Focus order is natural DOM order.
8. **Motion / reduced-motion** — No new auto-scroll or dizzy animation introduced.
insights-tab-strip change is data-only (new sub-page + group entries), no motion.
Every `animate-spin` in new code pairs with `motion-reduce:animate-none`.
Avatar change is `object-cover` (fixes squashed photos) — no motion.
---
## CI follow-ups
- `[CI]` axe sweep on: medication detail page, `/medications/[id]/history`,
Settings → Display (unit card), Settings → Export (health-record panel),
advanced-settings sheet open state (mobile + desktop ResponsiveSheet).
- `[CI]` 44px touch-target sweep will catch M-1 if the sweep covers the unit-pref
control — verify the segmented buttons aren't exempted like the hidden avatar
file input was.
- `[CI]` keyboard-only walk of both radiogroups (M-3) — Tab/Arrow behaviour.
- `[CI]`/backend: confirm FHIR export drops `includeCharts/includeAiSummary` (L-5)
and that `medList` selection semantics match the label (M-4).
+101
View File
@@ -0,0 +1,101 @@
# i18n review — v1.7.0 (release/v1.7.0)
READ-ONLY review. Scope: ~50+ new keys × six locales added in `main...release/v1.7.0`
(medication detail/advanced, health-record export panel + AI-summary labels, coach
cluster taxonomy + ~26 metric labels, 24 HealthKit chart pages, unit preference, sync).
## 1. Guard tests — GREEN
```
pnpm test i18n
Test Files 7 passed (7)
Tests 90 passed | 1 skipped (91)
```
`i18n-call-site-coverage` + `i18n-locale-integrity` both pass. Every `t("ns.key")` in the
diff resolves in `messages/en.json` and propagates to de/es/fr/it/pl. No missing keys.
All six locale files parse as valid JSON.
## 2. Non-English locales — ACTUALLY TRANSLATED (no English stubs)
Spot-checked every at-risk surface across de/es/fr/it/pl:
- **Coach cluster labels + hints** (cardio/body/activity/workouts/sleep/mood/glucose/
medication/mobility/environment) — genuinely translated in all five non-en locales.
e.g. cardio: DE "Herz-Kreislauf", ES "Cardiovascular", FR "Cardiovasculaire",
IT "Cardiovascolare", PL "Sercowo-naczyniowe".
- **24 HealthKit chart pages** (title / description / chartTitle / emptyState) — fully
translated. Verified walkingSpeed, flightsClimbed, skinTemperature, pulseWaveVelocity,
vascularAge blocks line-by-line in es/it/pl; descriptions and empty-states are native,
not English carry-over. A value-side English-leak scan ("over time", "appears here",
"once Apple Health syncs", "data yet", "readings appear") returned ZERO hits in any
non-en locale.
- **Health-record export panel** (format_*, range*, includedData, group*, generate,
error, fhirNote, includeAiSummary) — translated in all locales.
- **"AI summary — not clinically validated" string** — translated, not stubbed:
DE "KI-Zusammenfassung — nicht klinisch validiert",
ES "Resumen de IA — no validado clínicamente",
FR "Résumé IA — non validé cliniquement",
IT "Riepilogo IA — non validato clinicamente",
PL "Podsumowanie AI — niezweryfikowane klinicznie".
- **Unit preference** (metric/imperial/title/description/saved/saveError) — translated.
No locale ships English-as-placeholder for the v1.7.0 new keys. Clean.
## 3. German UTF-8 / umlaut integrity — CLEAN
No mojibake (no `Ã`, `â€`, `Â`), no stripped diacritics. Verified intact:
Größe, Gefäßalter, Körperfett, Körperwasser, Schrittlänge, ärztliche Beurteilung,
Über … Tage, Gehstabilität, Doppelstützzeit, Lärm-Ereignisse, Umgebungslautstärke.
## 4. Placeholder / ICU consistency — CONSISTENT
Templated strings carry identical `{vars}` in every locale:
- `summaryReadings` → `{days} {count} {params}`
- `summaryTrend` → `{label} {latest} {arrow}`
- `summaryAdherence` → `{rate}`
- `healthRecord.error` → `{code}` (present in en/de/es/fr/it/pl)
No dropped, renamed, or extra placeholders in any locale.
## 5. Missing keys in en.json — NONE
Guard test (`i18n-call-site-coverage`) walks every `t()` call site under `src/` and
asserts resolution against `messages/en.json`. Green. No orphan `t()` calls in the diff.
## 6. German medical terms — NATURAL (one Low consistency nit)
The new chart/metric labels read as natural German, not literal machine translation:
- flightsClimbed → "Treppen" / "Stockwerke … erklommen" (idiomatic)
- audio_env → "Umgebungslautstärke", audio_event → "Lärm-Ereignisse",
hearingParent.header → "Gehör & Lärmbelastung" (correct domain term)
- walking_step_length → "Schrittlänge", walking_speed → "Gehgeschwindigkeit",
walking_steadiness → "Gehstabilität", walking_asymmetry → "Gangasymmetrie",
pulse_wave_velocity → "Pulswellengeschwindigkeit", vascular_age → "Gefäßalter",
lean_body_mass → "Magermasse", fat_free_mass → "Fettfreie Masse" — all correct
clinical German.
### LOW — DE double-support term diverges within the app
`messages/de.json`: coach metric label `analytics.metricLabels.walking_double_support`
= **"Doppelstützzeit"**, while the chart page (`charts.walkingDoubleSupport.*` +
`navWalkingDoubleSupport`) = **"Doppelstandphase"**. Both name the same HealthKit metric
(`HKQuantityTypeIdentifierWalkingDoubleSupportPercentage`). Apple Health's own German
localization uses "Doppelunterstützungszeit". Not wrong, but the same metric gets two
different German names in two surfaces — a user comparing the Coach prompt list to the
chart nav sees a mismatch.
Fix (optional, post-release acceptable): pick one term across both surfaces. Recommend
"Doppelstützzeit" (shorter, matches the coach label) for both, or align to Apple's
"Doppelunterstützungszeit". Other locales are internally consistent for this metric.
## Severity counts
- Critical: 0
- High: 0
- Medium: 0
- Low: 1 (DE double-support term divergence — cosmetic, not a guard failure)
## Verdict
i18n is release-ready for v1.7.0. Guard green, all six locales genuinely translated,
German UTF-8 clean, ICU placeholders consistent. The single Low finding is a
within-German terminology nit that does not block the tag.
+136
View File
@@ -0,0 +1,136 @@
# v1.7.0 Security Review — new attack surface
Reviewer: security (READ-ONLY). Scope: `git diff main..release/v1.7.0`.
Verdict: **No Critical or High findings.** New surface is well-scoped; auth,
own-data narrowing, encryption-at-rest, and redaction patterns hold.
## Severity counts
- Critical: 0
- High: 0
- Medium: 0
- Low: 2
- Informational: 3
---
## 1. Health-record export (`POST /api/export/health-record`)
PASS on every checked control.
- `requireAuth()` (cookie OR Bearer); `userId` taken from session only.
`exportSelectionSchema` is `.strict()` with NO `userId` field — a smuggled
`userId` (or any unknown key) 422s via `returnAllZodIssues`.
(`src/app/api/export/health-record/route.ts:53,64-67`,
`src/lib/validations/health-record-export.ts:84-101`)
- Rate-limited: `export:<userId>` 10/h shared bucket (`route.ts:56-59`).
- Audit-logged `health-record.export` with format/days/section-booleans only —
never values (`route.ts:111-124`).
- Data collected via `collectDoctorReportData(user.id, …)` — userId-scoped; no
cross-user path in FHIR/PDF/zip. `range.days` capped 1..365, `practiceName`
max 120 + `sanitisePracticeName`.
## 2. KVNR / insurance number
PASS.
- Encrypted at rest via `crypto.ts` `encrypt()` on write
(`src/lib/auth/profile-update.ts:119-123`), stored as
`insuranceNumberEncrypted`. Validated + normalised (mod-10 check digit)
before encrypt (`src/lib/validations/auth.ts:46-60`, `kvnr.ts`).
- Decrypt is **fail-soft** in all three readers (export, `/api/user/profile`,
`/api/auth/me`): `try { decrypt } catch { insuranceNumber = null }`. Fail-soft
is safe here — on a key-rotation gap the value is omitted, no plaintext leak,
no 500. (`route.ts:92-99`, `user/profile/route.ts:48-55`, `me/route.ts:26-32`)
- Plaintext KVNR reaches ONLY own-data GET responses and the own-data
PDF/FHIR export body. Never in `annotate`/wide-event meta, never in
`auditLog` details, never in an error envelope. The profile PATCH routes do
not call `buildPayloadDiagnostic`, so the request body (plaintext KVNR) is not
captured into any wide-event excerpt. Zod refine message is a static string
(no value echo).
### LOW-1 — `insuranceNumber` not in the central redaction denylist
`SENSITIVE_KEY_PATTERNS` (`src/lib/observability/redact-payload.ts:27-46`) has
no `insurance` / `kvnr` pattern. Today nothing routes a body containing
plaintext `insuranceNumber` through `redactSensitiveFields` (the profile routes
don't build a payload diagnostic), so there is no live leak. This is
defence-in-depth: if a future change adds `buildPayloadDiagnostic` /
`redactSensitiveFields` to a profile route or a generic body-excerpt is added to
`apiHandler`, the KVNR (and `fullName`/`insurerName`) would land verbatim in the
wide-event/GlitchTip excerpt.
Fix: append `/insurance/i` and `/kvnr/i` to `SENSITIVE_KEY_PATTERNS` now, while
the surface is small.
## 3. Sync endpoints (`/api/sync/changes`, `/api/sync/state`)
PASS.
- Both `requireAuth()` (cookie + Bearer).
- `/api/sync/changes` feed is scoped `where: { userId: user.id, ...cursorFilter }`
(`src/app/api/sync/changes/route.ts:143-147`). The cursor only carries
`(updatedAtMs, id)` and is ANDed with `userId`, so a forged/foreign cursor can
only reposition within the caller's OWN rows — no cross-user leak. Tombstones
are own-data (same scoped scan). Cursor decode is total / null-on-garbage
(`src/lib/sync/cursor.ts:35-54`). Rate-limited `sync:changes:<userId>` 120/min.
- `/api/sync/state` aggregates are all `userId`-scoped counts (`state/route.ts:48-64`).
- No token internals in any auth/sync error.
### INFO-1 — `/api/sync/state` GET performs a write
GET bumps `User.lastSyncedAt` (`state/route.ts:72-75`). The call is documented
as "the handshake IS the call." Self-scoped, idempotent, authenticated — not a
vuln, but a GET-with-side-effect is unusual (no CSRF exposure: cookie GET writes
are harmless here and Bearer is the iOS path). Noted only.
## 4. Profile fields (fullName / insurer)
PASS.
- Mass-assignment safe: `applyProfileUpdate` builds `updates` field-by-field,
never spreads `parsed.data` (`src/lib/auth/profile-update.ts:88-124`). Both
`/api/auth/profile` and `/api/user/profile` funnel through it. `fullName` /
`insurerName` bounded `max(120)`.
- XSS in FHIR narrative: `escapeXml()` applied to the only XHTML sink (the
Composition narrative `div`, `src/lib/fhir/build-bundle.ts:380`), and
`displayName` is escaped again inside `narrativeText` (line 352) — double-safe.
Patient `name[].text` (line 121) is a JSON value, not XHTML — serialiser-safe.
PDF renderer draws KVNR/name as plain pdf-lib text (not an injection sink).
## 5. Coach data clustering
PASS.
- `dataClusters` is a bounded enum array (`.max(10)`,
`src/lib/validations/coach-prefs.ts:98-146`). Clusters expand the snapshot
only over the calling user's OWN userId-scoped reads
(`src/lib/ai/coach/snapshot.ts:434-490,748,780,956,1078`). Enabling a cluster
cannot pull another user's data and cannot pull anything the user couldn't
already read. No free-text → no new prompt-injection vector beyond the user's
own values. No markdown rendering added (chat route still plain text;
`insights/chat/route.ts` change is an annotate counter only).
## 6. Widget accept-and-ignore (`PUT /api/dashboard/widgets`)
Mostly PASS — `.min(1).max(20)` on the widgets array survives the pre-Zod
unknown-id filter, so the persisted layout stays bounded.
### LOW-2 — unbounded `dropped_ids` in wide-event on large unknown-id arrays
The unknown-id filter (`route.ts:150-174`) runs `.map().filter()` over the FULL
incoming `widgets` array BEFORE Zod's `.max(20)` applies, and writes every
dropped id verbatim into `annotate({ meta: { dropped_ids } })` with no count
cap. `safeJson` is called WITHOUT `maxBytes`, so the array length is bounded only
by the Next.js default body limit — a single large request can push thousands of
strings into one wide-event line (log amplification / wasted heap on the parse).
Not a data-leak (own session) and memory is body-limit-bounded.
Fix: cap before logging — e.g. `dropped_ids: droppedIds.slice(0, 20)` (keep the
`dropped_count`), and/or pass a tight `maxBytes` to `safeJson` on this route.
## 7. Unit-preference endpoint (`/api/auth/me/unit-preference`)
PASS. `requireAuth`, Zod `z.enum(["metric","imperial"])`, rate-limited 60/min,
field-by-field write, self-scoped, audit-logged
(`src/app/api/auth/me/unit-preference/route.ts:34-124`).
## 8. New outbound fetch / safeFetch
PASS. No new outbound (egress) `fetch` under `src/lib` or `src/app`. FHIR/PDF
export build in-process (no network). The one added `fetch(` is
`src/lib/queries/use-dashboard-snapshot.ts:36` → `fetch("/api/dashboard/snapshot")`,
a same-origin relative-path client read explicitly exempt from the
`safe-fetch-required` rule by construction.
## Cross-cutting
- INFO-2: Measurement soft-delete (single + by-external-ids) does the ownership
404 check before the `update`/`updateMany`; single-`[id]` uses `where:{id}`
AFTER `existing.userId !== user.id` guard, by-external-ids scopes
`where:{ userId, deletedAt:null }`. Tombstones in the sync feed are own-data.
All list/series/rollup reads filter `deletedAt: null`. No cross-user delete or
tombstone leak. (`measurements/[id]/route.ts` DELETE, `by-external-ids/route.ts`)
- INFO-3: `/api/auth/me/devices/[id]` PATCH scopes the write
`updateMany({ where:{ id, userId } })` and only reads back after count>0 —
no cross-user device patch or existence leak.
+149
View File
@@ -0,0 +1,149 @@
# v1.7.0 — Senior/Staff review (architecture + migration safety)
Scope: `git diff main..release/v1.7.0`. READ-ONLY. Branch `release/v1.7.0`.
## Summary counts
- Critical: 0
- High: 1
- Medium: 2
- Low: 3
- Info / verified-clean: 8
## High
### H1 — Detail-page compliance NOT flipped onto the canonical engine (stale path; reintroduces the v1.6.0-fixed divergence)
`src/app/api/medications/[id]/cadence/route.ts:98-114`
The v1.7.0 SB-SCHED-2 "Option B delegation" added an optional `engineCtx`
parameter to `expandScheduleSlots` / `buildCadenceTimeline` /
`computeNextDose` so compliance reads route through the canonical recurrence
engine (`occurrencesBetween`) for RRULE / rolling / cyclic / one-shot / PRN
schedules. The **dashboard/intake** path was wired (`src/lib/analytics/compliance.ts:404-436`
builds and threads `engineCtx`). But the **medication detail page** route
calls all three helpers with `userTz` only and no `engineCtx`:
```
const timeline = buildCadenceTimeline(med.schedules, events, asOf, windowDays, anchor, userTz);
const chips = complianceChips(med.schedules, events, asOf, windowDays, anchor, userTz);
const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz);
```
Worse, `complianceChips` (`src/lib/medications/scheduling/compliance.ts:129`)
and `missedDoses` (`src/lib/medications/scheduling/cadence.ts:487`) have **no
`engineCtx` parameter at all** — they cannot delegate even if the route wanted
to. So on the detail page:
- An `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule still expands daily-every-day
via the legacy weekday walker (`daysOfWeek = null` reads as "every day") —
the exact long-standing bug SB-SCHED-2 set out to kill, and the same
dashboard/intake divergence v1.6.0's today-tile read-flip closed
(`project_v160_outcome.md`).
- A **PRN** schedule, which the engine short-circuits to zero expected slots
(`recurrence.ts:148`), still expands as a real cadence on the detail page —
so the detail-page chips count expected doses / "missed" for an as-needed med.
- A **CYCLIC** schedule ignores its off-weeks on the detail page.
The dashboard tile and the detail page will disagree again for exactly the
schedule classes this release introduced.
Fix: add an optional `engineCtx?: CadenceEngineContext` param to
`complianceChips` and `missedDoses` (thread it straight into the
`buildCadenceTimeline` calls they already make), then in the cadence route
build the context once from `med` (`startsOn`, `endsOn`, `oneShot`,
`createdAt`, latest non-skipped intake, `userTz`) and pass it to all three
helpers — mirroring `analytics/compliance.ts:404-414`.
## Medium
### M1 — Sync delta feed has no supporting index for its keyset order (per-user full sort under a 120/min drain)
`src/app/api/sync/changes/route.ts:143-163`, `prisma/schema.prisma:624-629`
`/api/sync/changes` keyset-walks `prisma.measurement.findMany` filtered on
`userId` with `orderBy: [{ updatedAt: "asc" }, { id: "asc" }]`. The
`Measurement` model's only relevant indexes are `(userId, type, measuredAt)`
and `(externalId)` — neither supports `(userId, updatedAt, id)` ordering.
Postgres will filter by `userId` then sort the user's entire measurement set
in memory on every page. For a heavy Apple-Health tenant (hundreds of
thousands of rows) that is a full per-user sort per page, and the route allows
120 pulls/min/user. Given HealthLog's recurring shared-Prisma-pool starvation
history, a multi-device offline-sync drain is a realistic pool-pressure
source. The query is otherwise well-bounded (`take: limit+1`, cap 500,
rate-limited), so this is purely the missing index.
Fix: add `@@index([userId, updatedAt, id])` to `Measurement` and ship the
matching additive `CREATE INDEX` migration (0096). Additive, non-blocking with
`CREATE INDEX CONCURRENTLY` if desired, but a plain `CREATE INDEX` is also fine
at this table size in the maintenance window.
### M2 — Dashboard snapshot 60 s cache TTL is shorter than the 120 s client refetch interval (every scheduled refetch is a guaranteed cache miss)
`src/lib/queries/use-dashboard-snapshot.ts:47-51`, `src/app/api/dashboard/snapshot/route.ts:73-78`, `src/lib/cache/server-cache.ts:210-213`
`caches.analytics` TTL is `60_000` ms; the snapshot hook's
`refetchInterval` is `120_000` ms. Because 120 s > 60 s, the cached snapshot
has always expired by the time the client's interval fires, so every auto-
refresh is a cache MISS that runs the full `buildDashboardSnapshot`
(coverage probe + 4-way `Promise.all` incl. the thick `extras` reads on warm
tenants). This is not a cross-user stampede (the cache is per-user and the
builder fan-out is bounded — see Info I8), but the 60 s TTL is **not**
protective for the 120 s cadence as the route comment implies; it is the
opposite — the interval never benefits from the cache. The cost is one full
builder run per open tab per 120 s.
Fix (cheap, no behaviour change): either lengthen the analytics snapshot TTL
to >= 120 s for the `dashboard-snapshot` key (a per-key TTL, or a dedicated
cache bucket), or document that the 120 s refetch is intentionally a fresh
read and drop the "60 s cache is protective" framing. Functionally correct
today; flagged as a perf-intent mismatch, not a bug.
## Low
### L1 — `invalidate.ts` comment overstates the cron call site
`src/lib/cache/invalidate.ts:120-123`
The `invalidateUserInsights` doc says it is "Called from the
`/api/insights/generate` POST and the `insight-pregenerate` cron". The cron
(`src/lib/jobs/insight-pregenerate.ts`) never calls it directly — invalidation
happens inside `generateComprehensiveInsight` (`comprehensive-generate.ts:299`),
which both the route and the cron call. Behaviour is correct (the snapshot is
evicted on overnight regeneration); only the comment is imprecise. Tidy the
wording.
### L2 — KVNR decrypt is fail-soft (read paths) vs CLAUDE.md "fail closed everywhere"
`src/app/api/export/health-record/route.ts:90-99`, `src/app/api/user/profile/route.ts:46-54`
Both read paths `try { decrypt(...) } catch { = null }` so a key-rotation gap
omits the KVNR rather than 500ing the export/profile. This is a deliberate,
documented deviation from the encryption fail-closed rule and is defensible
for a read-only display/export surface (the WRITE path in
`profile-update.ts:123` correctly uses `encrypt()` fail-closed). Noting it so
the deviation is on record; no change required.
### L3 — `DEFAULT_COACH_PREFS` object omits `dataClusters` while the schema default leaves it `undefined`
`src/lib/validations/coach-prefs.ts:157-163`
`DEFAULT_COACH_PREFS` (the plain-object default) has no `dataClusters` key, so
it reads `undefined` — which is the intended back-compat sentinel and is
handled by `clusters.ts:127` (`undefined → DEFAULT_COACH_CLUSTERS`). Correct,
but a reader could mistake the omission for an oversight. Optional: add an
explicit `dataClusters: undefined` with a one-line comment.
## Info / verified clean
- **I1 — Migrations 0091-0095 are all additive + defaulted.** 0091 two `BOOLEAN NOT NULL DEFAULT false` + one nullable TEXT; 0092 new enum + `NOT NULL DEFAULT 'SCHEDULED'` + two nullable INT; 0093 three nullable TEXT (no default); 0094 nullable TEXT `IF NOT EXISTS`; 0095 `TEXT NOT NULL DEFAULT ''` + index re-key. No NOT-NULL-without-default on existing rows. No data loss.
- **I2 — Exactly one 0093.** `ls prisma/migrations | grep 009` shows 0091-0095 single-numbered; the renumbered collision left only `0093_v170_profile_identity_fields`.
- **I3 — schema.prisma matches migration SQL** for every new column/enum/index. The 0095 `DROP INDEX "telegram_reminder_messages_medication_id_schedule_id_date_phase_key"` matches the name created in `0015_add_reminder_phase_tracking`; the new unique index name matches the schema's `map:`. Prisma compound-key accessor `medicationId_scheduleId_date_phase_timeOfDay` is field-derived (not map-derived) so the telegram upsert (`senders/telegram.ts`) is correct.
- **I4 — All three new queues registered + scheduled/enqueue-bound + handler-bound.** `INSIGHT_PREGENERATE_QUEUE`: allQueues (1978) + schedule `30 4 * * *` (2070) + `boss.work` (2225). `MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE`: allQueues (1983) + schedule `40 3 * * *` (2073) + `boss.work` (2206). `MEAN_CONSOLIDATION_QUEUE`: allQueues (1957) + `boss.work` (2323) + boot-discovery enqueue (2527) — intentionally enqueue-driven (mirrors STEP_CONSOLIDATION), not cron-scheduled. No "scheduled-but-not-created" no-op.
- **I5 — Cron times do not collide destructively.** New crons `30 4 * * *` and `40 3 * * *` occupy unique slots. The two pre-existing shared slots (`0 * * * *`, `*/15 * * * *`) are on distinct queues; pg-boss runs them independently.
- **I6 — Cache invalidation complete.** Measurement/mood/medication writes all `caches.analytics.deleteByPrefix(\`${userId}|\`)`, which covers the `${userId}|dashboard-snapshot` key. Widget writes (`dashboard/widgets`, `chart-overlay-prefs`) and insight writes (`insights/generate` + the generator) call the explicit `invalidateUserDashboardSnapshot` / `invalidateUserInsights` because they don't touch the analytics bucket. All call sites verified.
- **I7 — `daysOfWeek` column kept.** `schema.prisma:974` still has `daysOfWeek String?`; no migration drops `days_of_week`. The legacy walker remains the byte-stable path for plain weekday schedules.
- **I8 — Snapshot `Promise.all` is bounded; sync feed is bounded.** Builder runs a coverage probe then a fixed 4-element `Promise.all` (the expensive `extras` gated behind `warm`) — not the historic 15-way per-type fan-out. Sync feed is a single keyset query, `take: limit+1`, cap 500, rate-limited 120/min/user. No new unbounded fan-out (pool concern is the missing index, M1, not the query shape).
- **FHIR builder** (`src/lib/fhir/build-bundle.ts`) is a pure function — consumes the same `DoctorReportData` from `collectDoctorReportData` the PDF uses (identical-numbers property holds by construction), `now` is an injectable param, no prisma/fetch/random. XML narrative is `escapeXml`-escaped plain text (no markdown lib).
- **Backwards-compat verified.** `coachPrefsJson.dataClusters` undefined → `DEFAULT_COACH_CLUSTERS` (clusters.ts:127). `unitPreference` null → `DEFAULT_UNIT_PREFERENCE = "metric"` (display-transform.ts:32). New medication/schedule fields all defaulted (false / 'SCHEDULED' / null). KVNR validated mod-10 (validations/kvnr.ts via auth.ts:49-60), encrypted field-by-field on write, never mass-assigned, never in the audit row (`hasInsuranceNumber` boolean only).
- **No TODO/FIXME/dead branch in the diff.** The only matches in added non-test lines are i18n "Todos" (Spanish), the `toDoctorReportPrefs` identifier, and an injectable `console.log` default logger sink in the consolidation helpers (consistent with the existing step-consolidation pattern; SWC strips it in prod, the worker passes `workerLog`).
@@ -0,0 +1,116 @@
# v1.7.0 — Simplification + dead-code review
Scope: `git diff main..release/v1.7.0` (174 files, +17 490 / −1 363). READ-ONLY review.
Tooling: `pnpm knip` (clean except one item), targeted grep/read across the ~8-agent boundaries.
## Severity counts
| Severity | Count |
|---|---|
| High | 0 |
| Medium | 2 |
| Low | 3 |
| Info / verified-clean | 6 |
No correctness or High-severity findings. The diff is unusually clean for an 8-agent
release: new modules are all wired, no console.* noise, no commented-out code, no orphan
files, and the v1.4.37 "unregistered queue ships silently" lesson was heeded.
---
## MEDIUM
### M1 — Dead export: `DestructiveZoneSection` (knip-confirmed)
`src/components/medications/sections/destructive-zone-section.tsx:451`
The v1.7.0 rework (`95a75c62`) split the section into `<LifecycleManageBody>` and
`<DangerZoneBody>` so `<AdvancedSettingsSheet>` can slot them under separate groups. The
old composing wrapper `DestructiveZoneSection` (+ its `DestructiveZoneSectionProps` at
:56) was left behind "for any caller that wants the bundled shape" (per the file's own
header comment, lines 6–12). knip confirms zero importers; the only matches in the repo
are the export site, its props interface, and the self-referential comment.
- **Dead, safe to delete** — both `DestructiveZoneSection` and `DestructiveZoneSectionProps`.
This is exactly the "no backwards-compat shims for hypothetical callers" DO-NOT in CLAUDE.md.
Recommendation: remove the wrapper + props; keep `LifecycleManageBody` / `DangerZoneBody`.
### M2 — Third copy of the per-user-day "consolidation drain" shape
`src/lib/measurements/consolidate-daily-mean.ts` (new) vs the pre-existing
`src/lib/measurements/drain-per-sample-cumulative.ts` and
`src/lib/measurements/consolidate-legacy-steps.ts`.
The new mean-consolidation module re-implements the same scaffolding the other two
already carry:
- `MEAN_CONSOLIDATION_CUTOFF_HOURS = 36` (:65) duplicates `DRAIN_CUMULATIVE_CUTOFF_HOURS = 36`
in drain-per-sample-cumulative.ts (:55) — same magic constant, separate declaration.
- `MeanConsolidationBucket` / `MeanConsolidationSummary` / `MeanConsolidationOptions`
(:70 / :85 / :96) mirror the `Drain*` and `Step*` triples field-for-field, including the
identical `dryRun?` + `log?` "Logger sink — defaults to console.log" option pattern.
- The `stats:`-prefix overwrite-drain + per-user-timezone day bucketing logic is the same
algorithm in all three.
The new module *does* correctly re-import the shared primitives it can (`PerSampleRow`,
`bucketRowsByUserDay`, the day-window helpers from drain-per-sample-cumulative.ts), so this
is partial duplication, not a clean-room re-write.
- **Genuine simplification, not just style, but NOT safe as a blind delete** — behaviour
differs (mean vs sum vs step-sum aggregation). Recommendation: extract the shared
`Options` shape (`dryRun` + `log` sink), the `*_CUTOFF_HOURS = 36` constant, and a generic
`Bucket`/`Summary` base into one `consolidation-common.ts` and have all three drains
parameterise on the aggregator function. Low risk because all three are job-only, fully
unit + integration tested. Defer-able to a follow-up if the release window is tight.
---
## LOW
### L1 — `ScheduleType` closed set declared twice
`src/lib/validations/medication.ts:169` hardcodes `z.enum(["SCHEDULED","PRN","CYCLIC"])`
while `src/lib/medications/scheduling/recurrence.ts:55` declares
`export type ScheduleType = "SCHEDULED" | "PRN" | "CYCLIC"`. Same closed set, two
representations that can drift independently (add a 4th type and only one updates).
- **Style/safety, safe** — derive the zod enum from a shared `const SCHEDULE_TYPES =
["SCHEDULED","PRN","CYCLIC"] as const` exported once (e.g. from recurrence.ts), then
`z.enum(SCHEDULE_TYPES)` and `type ScheduleType = (typeof SCHEDULE_TYPES)[number]`.
### L2 — `120_000` poll interval literal duplicated across two query hooks
`src/lib/queries/use-dashboard-snapshot.ts:50` and
`src/lib/queries/use-analytics-query.ts` both hardcode `refetchInterval: 120_000` +
`refetchIntervalInBackground: false`. The two are mutually exclusive at the call site
(`page.tsx` gates analytics with `enabled: !snapshotEnabled`), so there is NO redundant
double-poll — verified. But the magic interval lives in two places.
- **Style, safe** — a shared `OPEN_PAGE_POLL_MS = 120_000` const would keep them in lockstep.
Cosmetic; leave if the release is tight.
### L3 — `MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE` defined inline in the worker
`src/lib/jobs/reminder-worker.ts:277` declares the queue name as a local `const` string,
whereas the sibling new queues (`INSIGHT_PREGENERATE_QUEUE`, `MEAN_CONSOLIDATION_QUEUE`)
export their name constant from their own job module
(`src/lib/jobs/measurement-tombstone-cleanup.ts` does not export the queue name).
Asymmetric with the established one-constant-per-job-module convention.
- **Style, safe** — move the literal into `measurement-tombstone-cleanup.ts` as an export
and import it, matching the other two. It IS correctly registered in `allQueues` (:~1983),
so no behaviour bug — purely convention drift.
---
## INFO — verified clean (explicitly checked, no finding)
- **Legacy `expandTodayIntakes` walker**: GONE. Zero references anywhere in `src/`
(already removed before v1.7.0 by the read-flip). `todays-dose-card.tsx` and `DoseUnit`:
no files / no references. Nothing to delete.
- **Glucose maps**: NOT duplicated. FHIR `GLUCOSE_LOINC` (subtype→LOINC code map) and
`convertGlucose`/`resolveGlucoseUnit` come from the shared `@/lib/glucose` module — the
builder imports them rather than re-deriving mg/dL↔mmol/L. No second conversion path.
- **Unit conversion (W7 valueScale vs FHIR)**: NOT overlapping. `display-transform.ts` only
scales WALKING_SPEED (m/s→km/h) and WALKING_RUNNING_DISTANCE (m→km) at the render layer.
The FHIR builder's only inline conversion is sleep minutes→hours (`value/60`,
build-bundle.ts:156) and glucose via the shared helper. Different metrics, no duplicate logic.
- **Cluster / metric source lists**: single source of truth. `clusters.ts`
(`CLUSTER_SOURCES`, `CLUSTER_PRIORITY`) is imported by `snapshot.ts`; snapshot does NOT
re-declare its own source lists. The insights metric maps (`sub-page-metric.ts` slug→type,
`metric-availability.ts` type union) are different concerns, not copies.
- **New job queues registered**: `INSIGHT_PREGENERATE_QUEUE`, `MEAN_CONSOLIDATION_QUEUE`,
and `MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE` are all present in the worker's `allQueues`
array and scheduled — the v1.4.37 "drain queue not registered, ships silently" failure
mode is NOT repeated.
- **No leftover scaffolding**: zero `console.log/debug/info` in non-test diff (the two hits
are the intentional `log` option sink defaulting to console.log, matching the existing
consolidate-legacy-steps pattern). No commented-out code, no TODO/FIXME/HACK markers, no
orphan files (build-bundle→export route, sync/cursor→sync route, kvnr→auth validation,
snapshot-flag→page.tsx all wired). avatar.tsx `object-cover` change is a clean bugfix.
+263
View File
@@ -1,5 +1,268 @@
# Changelog
## [1.7.2] — 2026-05-31 — Coach source parity, first-paint snapshot, medication card cleanup
A focused follow-up to v1.7.0. The unified dashboard snapshot that shipped in v1.7.0 is now the default, so a cold dashboard paints its above-the-fold tiles together instead of letting the mood tile arrive ahead of the rest. The Coach's in-chat data-source rail now reads and writes the same persisted data clusters as the settings sheet — one source of truth that survives a reload, so what the panel shows always matches what reaches the model. The medication overview cards collapse their action row into a single overflow menu, and the detail page is pared back to the intake history under a read-only plan summary. The advanced-settings sheet widens into a two-column layout and offers a medications export beside the existing import.
### Changed
- **Unified dashboard snapshot on by default** — the above-the-fold tiles read from the single `/api/dashboard/snapshot` round-trip introduced in v1.7.0, so they share one completion moment instead of staggering in as independent queries resolve. The build-time toggle `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` falls back to the per-tile path if an operator needs it.
- **Coach data-source rail matches the settings sheet** — the in-chat rail now toggles the same persisted data clusters as the gear-icon settings, stored per user rather than reset when the panel closes. The chat request derives its scope from those saved clusters, so the rail always reflects exactly what is sent to the model, and the analysis window persists alongside.
- **Medication card actions in one menu** — the overview cards, standard and GLP-1 alike, move their edit / history / advanced actions into a single overflow menu; the card itself opens the detail page.
- **Medication detail page is history-first** — the redundant header action row and the schedule block are gone; the page leads with the intake history under a read-only summary (name, dose, status, plain-language cadence), and editing happens from the card menu. The estimated active-ingredient curve stays an opt-in disclosure.
- **Advanced-settings sheet widened** — a two-column layout (Data, Reminders, Lifecycle, with the danger zone full-width) so the sheet no longer scrolls on a desktop viewport, and a CSV export of that medication (its row plus intake history) sits beside the per-medication intake import.
### Fixed
- **Medication cards render identically** — the day-streak flame used two different oranges between the standard and GLP-1 cards; both now share one token. The repeated card sections are a single set of shared components, so the two variants stay in lockstep instead of drifting on each edit.
- **Intake import labelled honestly** — the import affordance described CSV/JSON but accepts a JSON intake array; the copy now says JSON.
## [1.7.1] — 2026-05-31 — Overview-card actions
The v1.7.0 medication detail page gained edit / history / advanced-settings actions in its header, but the medication cards in the overview list kept only the edit pencil. This patch brings the **history** (intake history) and **advanced-settings** actions onto the overview cards — both the standard and the GLP-1 variant — so the card and the detail page offer the same three actions. The advanced-settings sheet is mounted once on the list and reused; the history button routes to the same intake-history view.
## [1.7.0] — 2026-05-31 — Health-record export, flexible schedules, first-paint snapshot, full HealthKit coverage
A broad polishing release across the medication, dashboard, export, and HealthKit surfaces. Medications gain PRN (as-needed) and cyclic on/off-week schedules, a server-computed next-due instant, and cadence-canonical compliance so non-daily plans report adherence correctly everywhere — including the detail page, which previously still expanded through the legacy weekday walker. The dashboard now assembles its above-the-fold tiles in a single snapshot round-trip and pre-generates the daily briefing overnight, so a cold load no longer staggers tile-by-tile and never blocks on the model. The doctor handover grows into a selectable health-record export — an enriched PDF and a machine-readable HL7 FHIR R4 bundle, packaged together. Every previously-unplotted HealthKit metric gets a chart, walking speed reads in km/h, and the Coach can be fed any chosen cluster of data instead of a fixed five. The medication detail page restores its history view and gathers every setting into one redesigned advanced sheet.
### Added
- **PRN and cyclic schedules** — a medication schedule can be marked as-needed (`asNeeded`), excluded from the expected-dose count and reminder projection while staying loggable, or cyclic (`cycleWeeksOn` / `cycleWeeksOff` / `cycleAnchor`) for on/off-week dosing. Both are projected by the canonical recurrence engine. Migration `0092` adds the schedule-type enum and cyclic columns.
- **Server-computed next-due instant** — every medication carries a read-only `nextDueAt` (RFC-5545 + rolling math, `null` for PRN), so a client never has to duplicate the recurrence engine.
- **Schedule-aware compliance payload** — the per-day compliance bucket gains `due` / `expectedCount`, so a history view can skip empty marks on non-due days for weekly, bi-weekly, rolling, and cyclic plans.
- **Per-medication reminder flags** — `liveActivityEnabled` and `criticalAlarmEnabled` (both default off) ride the medication contract for clients that surface lock-screen or break-through reminders. A roaming user-level delivery default plus a per-device override let a device honour or locally override the default. Migration `0091`.
- **Health-record export** — a new `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF (patient identity, deterministic summary, native sparklines), an HL7 FHIR R4 document bundle (LOINC-coded `Observation`s, a BP panel, `MedicationStatement`s, a `DiagnosticReport`), or both packaged as one zip. The selection chooses date range and per-domain sections, and the AI summary is an explicit opt-in section clearly marked as not clinically validated. The bundle reuses the same aggregator the PDF consumes, so both describe identical numbers, and matches the iOS client's LOINC/UCUM conventions for interchangeable records.
- **Patient identity on the profile** — optional full name, insurer, and insurance number on Account, for the report cover and the FHIR `Patient`. The insurance number is validated (KVNR mod-10) and encrypted at rest.
- **Unified dashboard snapshot** — `GET /api/dashboard/snapshot` assembles the above-the-fold tile data in one round-trip from the rollup tier plus the cached briefing, so the tiles arrive together. A coverage miss returns the heavy slice as deferred rather than blocking the strip. The snapshot also carries a `metricStates` seed (latest value/timestamp/unit per metric) and the full widget-layout catalogue, so a native client can paint a cold launch from one cached request.
- **Nightly insight pre-generation** — a budget-gated pg-boss job warms the comprehensive insight (and its daily briefing) overnight, so the `/insights` mount is a cache read instead of a synchronous model call. A cache miss now shows a preparing state rather than blocking the request.
- **Charts for every stored HealthKit metric** — the previously-unplotted types (flights climbed, environmental and headphone audio exposure, walking speed, step length, asymmetry, double support, respiratory rate, body-composition family, mobility, daylight, and more) each gain a chart surface. Walking speed displays in km/h and distance in km via a render-time transform; stored and exported values stay canonical SI.
- **Display-unit preference** — a metric/imperial toggle in Settings → Display (migration `0094`), read by the chart display transforms.
- **Coach data clustering** — the Coach accepts a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) instead of a fixed set, with a soft budget cap that degrades the lowest-signal clusters first so enabling everything stays within the prompt budget. The default reproduces the prior domains.
- **Offline sync delta feed** — `GET /api/sync/changes` exposes a paginated, opaque-cursor delta feed with tombstones across three domains — measurements, mood entries, and medication intakes — over a single multi-domain cursor; `/api/sync/state` is now part of the documented API and reports the sync window. Measurement, mood, and intake delete routes soft-delete (tombstone) rather than hard-delete, pruned past a retention window keyed to the refresh-token lifetime. A stable `errorCode` on the refresh endpoint distinguishes a revoked family, a token reuse, and a transient failure. Migrations `0096` + `0097` add the keyset indexes and the mood/intake sync columns.
- **Configurable mood-reminder time** — the evening mood-reminder hour is now per-user (it was a fixed 22:00), stored in the roaming notification preferences so a native client and the web share one source of truth.
### Changed
- **Cadence-canonical compliance everywhere** — `calculateCompliance` and the medication detail page route expected-dose computation through the canonical recurrence engine, so `rrule`, `rollingIntervalDays`, weekday subsets, interval weeks, one-shot, and PRN all count correctly. The dashboard tile and the detail page no longer disagree for non-daily plans.
- **Medication detail surface** — the header offers a direct edit, a history view (the clock-with-counterclockwise-arrow icon), and an advanced-settings button. The history view is intake-only, sorted newest-first, with the estimated active-ingredient curve as an opt-in disclosure. The advanced sheet is widened and regrouped into Data, Reminders, Lifecycle, and a danger zone, with CSV/JSON import surfaced as a real action.
- **Dashboard widget layout round-trips the full catalogue** — saving a layout no longer rejects ids the web surface doesn't render; the full widget catalogue (web tiles + client-only tiles) is persisted and returned intact, so a native client keeps its own tiles without a local merge step. A genuinely unknown id is still ignored.
- **Keyboard navigation for segmented controls** — the metric/imperial, export-format, and mood-rating radiogroups gain roving-tabindex arrow-key navigation (Home/End, wrap), via one shared hook.
- **Dashboard charts refresh on an open page** — the snapshot and analytics queries poll on a shared interval so an open dashboard picks up new measurements without a manual reload, served from a warm per-user cache.
- **Profile photo preserves its aspect ratio** — the avatar renders with `object-cover` instead of stretching.
### Fixed
- **The mood-reminder card now delivers on its own** — enabling the reminder previously still required a buried, default-off per-event push preference, so a user who flipped the visible card received nothing; the card alone now drives delivery (an explicit per-event opt-out still suppresses).
- **Multi-time-of-day reminders no longer re-fire** — the reminder ledger delete is scoped to the single dispatched slot rather than wiping every row for a medication, and logged doses are matched to slots by time rather than array order.
- **Soft-deleted measurements stay deleted on direct access** — the single-resource GET and PUT now filter tombstoned rows.
- **Mobile settings navigation no longer auto-scrolls the page** — the section strip positions its active chip horizontally without nudging the document, removing the dizzy scroll on sub-page navigation.
### Tests
- 5628 → 5878 unit; 1 skipped. New coverage spans the recurrence-engine PRN/cyclic cases, cadence-canonical compliance parity per cadence type, the FHIR bundle shape and LOINC/UCUM mapping, the KVNR validator, the export route, the dashboard snapshot envelope and pre-generation cron, the measurement delta feed and soft-delete tombstones, the daily-mean consolidation drain, and the display-unit transforms. A six-reviewer audit (correctness, security, architecture, design/a11y, simplification, i18n) ran before release; every High and Medium finding was resolved.
## [1.6.0] — 2026-05-30 — Medication editor overhaul, route of administration, today-tile read-flip
v1.5.6 settled the medication detail page into a pure history view, but the create/edit plan still funnelled every dose through the same flat shape, the injection-site picker only surfaced for GLP-1, and the today-tile on the dashboard and the Erfassen sheet still expanded daily schedules through a legacy walker that silently skipped bi-weekly, rolling, RRULE, and one-time cadences. This release reworks the medication editor and its detail surface, gives route of administration a first-class column so any injection can carry a site, adds a one-time-injection shape, and flips the today-tile onto the canonical recurrence engine the reminder worker already uses — so what the tile shows matches what the worker mints. It also restores the multi-language request snippets on the API-tokens row, adds a profile-photo upload to Account, and finishes an admin-surface polish pass.
### Added
- **Route of administration** — a new `deliveryForm` column (`ORAL` | `INJECTION` | `OTHER`) decoupled from `treatmentClass`. The injection-site picker now surfaces for any `INJECTION` dose rather than only GLP-1, and the editor carries the route through create, update, and the detail snapshot. Migration `0088` adds the enum and backfills `ORAL` onto every existing row via a constant column default (a single non-blocking metadata operation).
- **One-time injection** — a one-off dose modelled as `oneShot = true` + `deliveryForm = INJECTION`, with its own editor path that drops the recurring-schedule step.
- **Profile-photo upload on Account** — Settings → Account gains an avatar card backed by the v1.5.5 upload endpoint (server-side magic-byte sniff, 2 MiB stream-level cap, 2048² dimension cap, per-user rate limit, owner-scoped read).
- **Admin global mood-log toggle** — the admin Services section can suspend mood-log reminders site-wide alongside the existing Web-Push and API toggles.
- **Host-metrics memory detail** — the admin host-metrics memory tooltip now reads `used / total GiB` alongside the percentage.
### Changed
- **Medication editor + detail surface overhaul** — the editor uses the available desktop width while keeping the mobile sheet, restores the edit fields the modal-wizard rework had dropped, and aligns the detail surface with the new route-of-administration and one-time shapes.
- **Measurement note cap raised 25 → 200 characters** — a 25-character note could not hold a meaningful clinical aside; the cap lifts to 200 through a single `MEASUREMENT_NOTES_MAX_LENGTH` constant the client char-counters import. The DB column was already unbounded.
- **Dashboard range colors route through semantic tokens** — the dashboard date-range chips read the shared `success` / `warning` / `info` / `destructive` tokens (with their light-mode contrast overrides) instead of hard-coded hues, and the range inputs gain bound-clamping.
- **Admin surface tidy** — every sidebar section gets a distinct icon, the shared `helpfulRateColour` helper and a `usePublicVersion` hook are consolidated into `_shared`, and the orphaned `status-overview` route is removed.
- **API-tokens row restores multi-language request snippets** — the per-token row again offers copyable cURL / JavaScript / Python examples.
### Fixed
- **Today-tile diverged from the reminder worker for non-daily cadences** — `/api/medications/intake?scope=today` and `/api/dashboard/summary` projected daily schedules through a legacy walker that read only `daysOfWeek` + `windowStart`, silently skipping `intervalWeeks > 1`, rolling, RRULE, and one-time cadences. Both routes now gate every "does this schedule emit today?" decision through the canonical recurrence engine (the same path the worker uses), anchoring the projected instant to `windowStart` so it stays byte-identical to the worker's row and dedupes against the existing unique index. The rolling-cadence baseline fetch mirrors the worker's per-medication `takenAt` query, so projector and worker resolve the same next-due instant.
### Tests
- 5635 → 5628 unit; 1 skipped. The today-tile read-flip carries new coverage on the intake and dashboard-summary projections; the orphaned status-overview route and its test are gone.
## [1.5.6] — 2026-05-29 — Pure-history detail page, legacy step consolidation, egress finish
v1.5.5 gave every retired medication feature a home on the detail page, but the page still carried the create/edit affordances that belong on the list. This release turns `/medications/[id]` into a pure history surface, collapses every setting into one advanced sheet, consolidates the pre-v1.5.0 granular step rows that bloated the per-sample read path, and finishes the outbound-fetch migration the `safeFetch` wrapper started.
### Added
- **Legacy step consolidation** — a pg-boss job (`STEP_CONSOLIDATION_QUEUE`) folds pre-v1.5.0 granular `ACTIVITY_STEPS` rows into one canonical daily-total row per user per day, anchored to the user's timezone, and soft-deletes the originals. A day that already carries a post-v1.5.0 `stats:` total is left untouched (no double-count) while its legacy rows are still tombstoned. Boot-time discovery enqueues only users still holding live legacy rows, so the pass converges to zero work and is idempotent across reboots. Migration `0087` adds a partial index scoped to live step rows to keep the discovery scan cheap.
- **`healthlog/safe-fetch-required` ESLint rule** — bans raw `fetch(` outside the wrapper, exempting only genuine same-origin relative paths. Positioned as the author-time egress floor.
### Changed
- **`/medications/[id]` is now a pure history view** — the Today's-dose card stays on the list page; the detail page reads the medication's past intake, cadence summary, and trends only. Every setting (notifications, phase config, grace window, pause/resume, end, purge) moves into a new `<AdvancedSettingsSheet>` reached from the header. The edit trigger becomes a two-option picker (plan vs. advanced). The `/medications` list page is unchanged apart from matching the detail-nav card glyph to its destination.
- **Outbound-fetch migration finished** — every remaining server-side fetch routes through `safeFetch`: Withings (token exchange, refresh, measurements, webhook subscribe/unsubscribe, activity + sleep sync), Codex OAuth + SSE client, the bug-report GitHub comment, and the operator-host calls (Umami script/send/test, GlitchTip envelope + store, Loki). Credential-carrying constant hosts inherit the `redirect: "manual"` default so secrets cannot replay onto a redirect hop; operator-supplied hosts additionally pin `requirePublicHost` for the connect-time IP guard.
- **`/api/insights/layout`** is registered in the OpenAPI spec so the contract matches the shipped route.
### Fixed
- **Intake-edit dialog opened empty** — editing a logged intake row now seeds the dialog from the row's real `takenAt` / `skipped` instead of an empty stub.
- **Step consolidation could strand a day** — a minted daily total can collide on the second unique index when a manual step row already sits at the day's canonical-noon instant; the collision rolled back the in-transaction soft-delete and the day re-appeared on every boot. The pass now catches the constraint violation, logs and counts the skip, and steps to the next day.
- **Avatar upload over-buffered on the abort path** — the upload body is now read once through a bounded reader that drops retained bytes past the 2 MiB cap, replacing the clone + parallel parse that left the native parse buffering an oversized body after the cap tripped.
- **Egress lint exemption let absolute URLs through** — the same-origin exemption now rejects protocol-relative (`//host`) and backslash (`/\host`) forms, which both resolve off-origin.
### Removed
- **Dead wizard landing-intent path** — the pure-history rewrite removed the last caller that passed a landing intent into the medication wizard, so the prop, its state-key segment, and the intent branch in `landingStepForEdit` are gone; the helper reduces to the schedule-count heuristic.
### Tests
- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green.
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
### Added
- **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
- **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218.
- **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
- **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load.
- **`POST /api/medications/{id}/intake/bulk-delete`** — owner-scoped, capped at 500 event IDs per call, rate-limited per user. Pre-work for the detail page's multi-select.
- **`GET / PUT / DELETE /api/insights/layout`** — mirrors `/api/dashboard/widgets` so insights tile order + visibility persist server-side and sync across devices. Default order: `overview`, `blutdruck`, `puls`, `sauerstoff`, `koerpertemperatur`, `gewicht`, `bmi`, `aktive-energie`, `workouts`, `schlaf`, `ruhepuls`, `hrv`, `stimmung`, `medikamente`. Default-visible: overview, blutdruck, puls, gewicht, bmi, workouts, stimmung, medikamente.
- **Eight new Apple Health quantity-type mappings** — `RESPIRATORY_RATE`, `BODY_MASS_INDEX`, `LEAN_BODY_MASS`, `WALKING_HEART_RATE_AVERAGE`, `WALKING_ASYMMETRY`, `WALKING_DOUBLE_SUPPORT`, `WALKING_STEP_LENGTH`, `WALKING_SPEED`. The convention block on `apple-health-mapping.ts` documents the project rule: raw HK values flow on the wire, the server scales ×100 server-side for percent metrics. Step length and walking speed flow raw in SI (m and m/s) — no scaling.
- **Three new series-kind enum values** — `RESTING_HEART_RATE`, `HEART_RATE_VARIABILITY`, `VO2_MAX`. Detail + trend views on those metrics now respond 200 instead of 422.
### Changed
- **Wizard polish** — dialog widens to `sm:max-w-2xl`; the X-close gets a 44 px target; spacing tokens converge on the existing shadcn cadence (`rounded-md` button, `rounded-lg` dialog, no new radii); the step-progress bar is a width-only `<Progress>` + Tailwind `transition-all` with `motion-reduce` snap; step transitions are fade-only. `landingStepForEdit` accepts an `intent` argument so the cadence-summary edit pencil drops the user on Step 5 instead of bouncing them through Step 1.
- **`/api/measurements/series` `days` cap** — raised from 365 to 3650, matching the recurrence engine's hard cap. The iOS app's „Alle"-range no longer paints a 422 banner on every metric.
- **`/api/dashboard/summary` MetricCard shape** — `title` and `unit` ship as i18n keys (`dashboard.metric.title.*` + `dashboard.metric.unit.*`) instead of hardcoded German strings. Web resolves via the existing `messages/*.json` path; iOS resolves against its `Localizable.xcstrings`. Wire-shape change — clients that decoded the legacy `title` / `unit` strings keep working with the iOS team's tolerant fallback decoder during the transition.
- **`assertMedicationOwnership` consistency sweep** — `purge`, `parent PUT`, `parent DELETE`, `intake/import`, `phase-config`, `bulk-delete`, `glp1 GET` all converge on the single ownership helper. The §10 invariant 24 from the design direction now holds across `src/app/api/medications/[id]/**`.
- **Trends-row chart slot** — raised from 140 → 180 px so the mini-card shell's header + padding stays inside the slot. The TrendAnnotation under each chart no longer collides with the chart envelope.
- **Wizard payload edit path** — `buildCreateBody` omits `notificationsEnabled` on edit so the toggle the user already set in the detail page Notifications section is not overwritten by the wizard's hydrated default.
- **`<NotificationsSection>` DOM ids** — section heading and row label carry distinct ids so the Switch's `aria-labelledby` resolves to the row title, not the section heading.
- **`phase-config` route surface** — PUT returns the multi-issue 422 envelope on Zod failure (matching every other v1.5.5 route), and the upsert builds the Prisma payload field-by-field instead of spreading `parsed.data`. Mass-assignment surface closes structurally.
### Fixed
- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
- **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation.
- **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete.
- **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows.
- **Compose-mode multi-schedule data loss** — the v1.5.4 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Closed in v1.5.4 by the compose-mode commits; the regression test pinning that lives at `wizard-payload.test.ts`.
### Tests
- 5594 → 5615 unit (+21 in the reconciliation sweep alone; the detail-page surface + audit fixes add ~110 across the cycle).
- 262 integration unchanged + the avatar upload integration test (`tests/integration/user-avatar.test.ts`).
- New Playwright spec pre-work — the detail-page surface is component-test pinned; e2e walk lands in a follow-up.
### Security
- `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
- Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
- `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`.
- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
### iOS coord
- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`.
- The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution.
### Notes
- **No new npm dependencies.** Avatar image-header parsing is hand-rolled; no `sharp` / `jimp`. The animation surface is shadcn `<Progress>` + Tailwind `transition-all`; no Framer Motion. Only `undici` was promoted from transitive to explicit so the safeFetch-dispatcher import sits on a documented contract.
- **Test totals.** 5615 unit + 1 skipped, 262 integration + 3 skipped. `pnpm typecheck`, `pnpm lint` (one pre-existing withings/resume warning), `pnpm openapi:check`, locale-integrity + call-site coverage all green.
## [1.5.4] — 2026-05-28 — Medication wizard: modal dialog, compose-mode, ten-bucket taxonomy
The v1.5.3 creation wizard shipped as a single-page card with seven inline steps. Patient feedback during the first day of live use was unanimous: the page felt dense, the step-by-step intent was lost, the "every N days from my last injection" cadence was unintelligible without a worked example, and the edit form was visibly wider than its container. The bi-weekly worker bug was closed in v1.5.3 but the surface a patient actually touches was not yet where it needed to be. This release replaces the wizard with a real modal-dialog flow, lands compose-mode so a single medication can carry multiple parallel schedules (the insulin short-acting + long-acting case), widens the treatment-class taxonomy to ten buckets, and retires the flat edit form.
### Added
- **Modal-dialog wizard**. `src/components/medications/wizard/MedicationWizardDialog.tsx` is the new entry surface — a shadcn `<Dialog>` on desktop, a `<Sheet side="bottom">` on mobile, switched through the existing `<ResponsiveSheet>` primitive at 768 px. The wizard renders its own header inside the body so the iconified step anchor sits where the patient reads, and the footer carries Back / Save with a fixed sticky position on mobile. Eight steps total, one focused question each: name → treatment class → dose → course window → cadence → sub-cadence detail → times of day → review with reminders toggle and per-schedule summary cards. Cadence + sub-cadence + times collapse out of the path when they do not apply (one-shot walks five, daily walks seven, recurring walks eight); the visible counter mirrors the path the patient actually walks.
- **Compose-mode**. A medication can now hold multiple parallel schedules, each with its own cadence and times. Steps 5–7 are per-schedule; Step 8 renders the schedule list with an "Bearbeiten" and "Entfernen" action on each card plus a "+ Weiteren Zeitplan hinzufügen" card at the bottom. The last remaining schedule is non-removable so the medication always carries at least one schedule. A medication opened in edit mode with more than one configured schedule lands the patient directly on the list view; single-schedule edits keep the Step 1 entry. The header on Steps 5–7 carries a small "Zeitplan {n} von {total}" caption when more than one schedule is configured.
- **Ten-bucket treatment-class taxonomy on Step 2** — Blutdruck, Diabetes, Hormone, GLP-1-Injektion, Schmerz, Allergie, Vitamine, Nahrungsergänzung, Antibiotikum, Sonstiges. Schmerz lands the chronic-pain cohort with its own rolling-rescue-medication pattern; Antibiotikum lands the one-shot course pattern cleanly so neither falls through to `OTHER` for analytics purposes. Each row carries a monochrome Lucide glyph (Stethoscope / Droplet / Activity / Syringe / Flame / Wind / Apple / Leaf / ShieldCheck / Tag).
- **`DIABETES` + `ANTIBIOTIC`** as first-class values of the `MedicationCategory` Zod enum so insights routes that filter on the clinical category surface those buckets without falling back to `OTHER`. Additive at the schema layer; existing rows do not need touching.
- **Rolling-cadence mental-model copy.** Step 5's "Flexibel ab letzter Einnahme" row carries the example sentence directly under the label (not behind a tooltip) — "Du lässt den nächsten Termin offen, drückst 'genommen' wenn du sie genommen hast — ab dem Zeitpunkt zählt der Counter wieder." The patient-pain research across chronic-illness adherence studies identified this exact gap as the #1 reason patients abandon medication apps in week one; the in-line example sentence closes it.
### Changed
- **Edit path routes through the same dialog.** "Bearbeiten" on a medication card opens `<MedicationWizardDialog mode="edit" initial={…} />`; the header swaps to "{name} bearbeiten", the CTA to "Änderungen speichern", and the entire payload hydrates from the existing medication shape including the schedule `id` so a PUT preserves the per-schedule identity. The flat `<MedicationForm>` (1314 LOC, surfaced as a `<ResponsiveSheet>` that rendered wider than its container on the medications list page) retires in the same change.
- **`/medications/new` retires** to a redirect that opens the dialog from the list page (`/medications?new=1`). Existing bookmarks survive without losing the entry point.
- **i18n.** A clean `medications.wizard.*` namespace replaces `medications.create.wizard.*` so the locale-integrity guard surfaces every dangling key during the cut. German and English carry native copy; Spanish, French, Italian, Polish ship the English string verbatim as a machine fallback per the project convention. Native polish for the four fallback locales follows.
- **`<CreationWizard>`** (v1.5.3's inline-stepped Card) and **`<PhaseConfigDialog>`** (an unused titration-phase helper that `knip` flagged once the list page swapped over) retire alongside the form.
### Fixed
- **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity.
- **The edit form being wider than its container** — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
### Tests
- `src/components/medications/wizard/__tests__/wizard-payload.test.ts` — 54 pure-helper cases pinning `validateStep`, `buildCreateBody`, `buildUpdateBody`, `summariseCadence`, the treatment-class row → request body mapping, the multi-schedule encoder, schedule-`id` preservation on edit, the hydration that lands on the list view when more than one schedule is present, the remove-guard against the last remaining schedule, and the add-then-active-bump behaviour.
- `e2e/medications-wizard-{daily,weekdays,biweekly,monthly,rolling,oneshot}.spec.ts` — six Playwright specs updated to walk the new dialog surface via the German label set; `e2e/medications-wizard-compose.spec.ts` walks a two-schedule create flow (daily Ramipril + weekly-Wednesday addendum) and asserts both summary cards land on Step 8 before save.
- Route tests assert POST `/api/medications` accepts a body with `category: "DIABETES"` and `category: "ANTIBIOTIC"`.
### Notes
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
- **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly.
- **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD.
## [1.5.3] — 2026-05-28 — Medication scheduling: RRULE cadences, rolling intervals, one-shot lifecycle, creation wizard
The medication surface in v1.5.2 covered daily and weekday-subset schedules cleanly, but everything else — bi-weekly, monthly, quarterly, yearly, "every N days from my last injection", single-dose appointments — either drifted or was unreachable from the UI. The reminder worker also carried a quiet pre-existing bug where `intervalWeeks` was ignored: a schedule meant to fire every other Wednesday fired every Wednesday. This release lands the full cadence surface, closes the worker bug behind a regression test, and adds a step-driven creation flow patients can walk without consulting a manual.
### Added
- **Four new cadence shapes**, modelled at the schema level with explicit columns instead of an overloaded legacy string:
- **Calendar-anchored RRULE patterns** (`rrule TEXT`) — `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`, `FREQ=MONTHLY;BYMONTHDAY=1`, `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10`, `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1` all expand correctly. Powered by the `rrule` npm package; the engine adds an `UNTIL` suffix derived from the medication's `endsOn` automatically (and skips that suffix when the user's RRULE already carries `COUNT` or `UNTIL`, so the two never collide).
- **Flexible-rolling cadence** (`rollingIntervalDays INT`) — "every N days from the last logged intake". The next-due date re-anchors when an intake is logged; skipped doses pause the schedule until the next real intake. The driving use case is the GLP-1 weekly injection where the calendar Wednesday does not match the user's actual cycle.
- **One-shot single-administration** (`oneShot BOOLEAN`) — vaccines, post-op final doses, anything with one scheduled occurrence and an auto-deactivate after the dose is logged.
- **Course window** (`startsOn DATE`, `endsOn DATE`) at the medication level. Required for one-shot; optional everywhere else. The reminder worker stops minting slots past `endsOn`.
- **`POST /api/medications/extract`** — natural-language extraction for the wizard's first step. A user types "Mounjaro 5mg weekly Wednesday morning starting next Monday" and the route returns a structured payload that pre-fills the wizard. Rate-limited per user, budget-gated, and carries a citation-coverage guard so the extraction cannot return a name or dose the user did not write.
- **`/medications/new`** — the seven-step creation wizard (compressed to five on the one-shot path) replacing cold-start entry into the legacy flat form. The summary step interpolates the actual picked weekdays / day-of-month / yearly date so the patient confirms the specifics rather than a category label.
- **Reusable picker primitives** under `src/components/medications/scheduling/`: `CadencePicker` (eight cadence kinds, mode-aware via `allowedKinds`), `TimesOfDayChips` (one or more `HH:mm` entries with morning / noon / evening / night presets), `CourseWindowRow` (start + optional end with `lockEndsToStart` for one-shot). Composed by both the wizard and the refactored edit form so any future cadence tweak reaches both surfaces at once.
- **Canonical recurrence engine** at `src/lib/medications/scheduling/recurrence.ts` exposing `occurrencesBetween`, `nextOccurrenceAfter`, and `matchesInstant`. The reminder worker routes through the engine via a narrow `worker-helpers.ts` adapter; the today-projector, the cadence chart, and the medication card continue to read the legacy fields through v1.5.x and switch over in the v1.5.4 read-flip.
- **Migration `0081_v15_medication_scheduling`** — adds the new columns, backfills `rrule` and `timesOfDay` for every existing schedule (closed-enum regex on the legacy `daysOfWeek` shapes, ELSE NULL fallback), and stages a CHECK constraint forbidding both `rrule` and `rolling_interval_days` populated on the same row.
- **OpenAPI coverage** for every medication route — `GET / POST / PUT / DELETE /api/medications`, `GET /api/medications/{id}`, `POST /api/medications/{id}/intake`, `GET /api/medications/{id}/cadence`, and the new `POST /api/medications/extract` — registered in `src/lib/openapi/routes.ts` and regenerated into `docs/api/openapi.yaml`. The `rrule XOR rollingIntervalDays` invariant is documented at both the schema description and the per-field descriptions so iOS code-gen surfaces the mutual exclusion.
### Fixed
- **Bi-weekly worker regression** — `src/lib/jobs/reminder-worker.ts` now consumes the canonical engine, which honours `intervalWeeks > 1` via the legacy fallback's week-phase math. A bi-weekly schedule that previously fired every Wednesday now correctly fires every other Wednesday. Pinned by an explicit regression test (`recurrence.test.ts` — `daysOfWeek = "i2;3"` emits 2 of 4 candidate Wednesdays in a 4-week window).
- **One-shot lifecycle reconciliation** — `src/lib/medications/lifecycle.ts` defines `reconcileOneShotState(prisma, medicationId, userId)` and the helper runs after every intake mutation (POST + PUT + DELETE). A user who logs the single dose then immediately undoes the log gets the medication back as `active: true`; a user who flips an existing intake from real to skipped also gets the medication reactivated. The helper is a no-op on non-one-shot medications.
- **Legacy fallback `startsOn` floor** — `expandLegacy` now respects `medication.startsOn` the way every other dispatch tier does. A legacy-shape schedule with a future `startsOn` no longer emits historical slots between today and the start date.
- **Course-window invariants** — the create and update schemas refuse `oneShot: true` without `startsOn` (the design contract requires the anchor date) and refuse any course where `endsOn < startsOn` (which previously produced a silently dead medication). Rolling-cadence schedules cap `timesOfDay` at one entry to match the engine's single-time emission.
- **Wizard `notificationsEnabled` toggle** — the Step 7 reminders switch now actually reaches the POST body. Earlier the toggle was visually live but its value was discarded by the body builder and every wizard-created medication ended up with the default `notificationsEnabled = true`.
- **Edit form: one-shot single source of truth** — the medication-level `oneShot` switch now drives the picker's `allowedKinds`, so the per-schedule picker can no longer encode `kind: "oneShot"` while the medication-level switch is off (or vice versa). Toggling the switch on with multiple schedules surfaces a confirmation toast before the collapse-to-single-schedule, instead of silently dropping the extras.
- **Wizard accessibility and tap-target hygiene** — focus advances into the new step's first input on Next; the cadence-picker rows are now full-row click targets (44 px); the nav buttons (Back / Next / Create) and the natural-language trigger rise to 44 px; the wizard card carries `aria-busy` while the submit is in flight.
### Changed
- **Recurrence-engine defence-in-depth** — `nextOccurrenceAfter` carries a `MAX_CHUNKS = 80` cap alongside the pre-existing 10-year `hardCap`, so a pathological RRULE that walks zero forward (e.g. leap-day-only) can no longer compound through many 90-day chunks. RRULE parse failures now surface via `annotate({ action: "medications.rrule.parse_error" })` instead of silently returning `[]`.
- **Edit form refactor** — `src/components/medications/medication-form.tsx` now composes the picker primitives for the edit path. Pre-v1.5 medications round-trip through `inferCadenceFromLegacy` on load and dual-write both shapes on save, so the existing legacy schedules keep working unchanged.
- **i18n** — 111 new keys × six locales populating the wizard, the picker primitives, the natural-language overlay, the edit-form sections, and the plain-language cadence summary. German and English carry native copy; Spanish, French, Italian, Polish ship the English string verbatim as a machine fallback for this cut. Native polish for the four fallback locales follows.
### Tests
- `src/lib/medications/scheduling/__tests__/recurrence.test.ts` — 28 unit cases covering every cadence kind plus the edge-case matrix (DST spring-forward, timezone shift mid-course, skipped + late + retroactive doses, paused medication, `endsOn` cap, missing `startsOn`, multi-schedule fan-out).
- Eight `tests/integration/v15-cadence-shapes.integration.test.ts` cases exercising every cadence shape against a testcontainer Postgres, plus the one-shot lifecycle (take → reconcile-deactivate → delete-intake → reconcile-reactivate → put-skip → reconcile-reactivate).
- `src/components/medications/scheduling/__tests__/{CadencePicker,TimesOfDayChips,CourseWindowRow,CreationWizard}.test.tsx` — 90 component tests against the picker primitives and the wizard helpers (`validateStep`, `buildCreateBody`, `summariseCadence`, `progressIndices`, `allowedKinds`).
- `src/lib/ai/coach/__tests__/medication-extract-prompt.test.ts` — five snapshot cases pinning the extraction prompt across the cadence shapes plus a citation-coverage guard test.
- `src/app/api/medications/extract/__tests__/route.test.ts` — six route cases covering auth, rate limit, budget, missing provider, parse failure, and the happy path.
- `e2e/medications-wizard-{daily,weekdays,biweekly,monthly,rolling,oneshot}.spec.ts` — six Playwright specs walking the wizard end-to-end via the German label surface, one per cadence shape. CI runs the full suite; locally the specs typecheck without executing the Next.js prod build.
### Notes
- **Dashboard read-flip caveat (v1.5.x window).** Until the v1.5.4 read-flip lands, the dashboard card, the cadence chart, and the medication-card "next dose" line read the legacy `daysOfWeek` / `intervalWeeks` columns directly. A wizard-minted medication with `rollingIntervalDays = 7` or an RRULE encoding renders a daily-looking next-due chip on the dashboard. The reminder worker, the integration tests, and the canonical engine all consume the new fields correctly — the schedule fires on the right date, only the visual chip on the card lags. The read-flip with legacy fallback is the next release.
- **OpenAPI structural enforcement.** The `rrule XOR rollingIntervalDays` invariant is enforced at four runtime layers (Zod refine, route invariant, engine dispatch, DB CHECK). The OpenAPI surface documents the constraint in prose; a structural `oneOf` discriminator lands before the iOS v0.7.x cut.
- **Worker `lastIntakeAt` aggregation.** The reminder worker currently fires one `findFirst` per rolling-medication per 15-minute tick. Functionally correct; one `groupBy` collapses it to a single round-trip per tick. Deferred to v1.5.4 with the read-flip.
- **Test totals.** 5494 unit + 1 skipped, 261 integration + 3 skipped, 12 Playwright wizard instances. Typecheck, lint, and `openapi:check` all green at HEAD.
## [1.5.2] — 2026-05-26 — Plumb SESSION_COOKIE_SECURE through docker-compose
v1.5.1 added a `SESSION_COOKIE_SECURE` env var so plain-HTTP self-hosts can drop the cookie's `Secure` flag. The Node helper read it correctly and the unit tests passed, but a self-hoster following the documented `.env` workaround reported that `docker compose exec app env | grep SESSION_COOKIE` came back empty — the value was set in `.env`, set in the helper, but never reached the running container.
+193
View File
@@ -0,0 +1,193 @@
# Contributor notes
Working notes for anyone (human or otherwise) editing this repository. Skim before the first PR; refer back when a convention question comes up. Pair with `README.md` (the public face) and `CHANGELOG.md` (the release history); this file is the maintainer's operating manual.
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.5.x.
## Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub. The `Co-Authored-By` trailer on commits is fine — it is git metadata and the project accepts that convention; anywhere else, no.
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
## Branch and release model
- `main` is release-only. Every commit on `main` is either a release commit (`chore(release): vX.Y.Z`), the matching tag push, or a maintenance commit (docs, gitignore, audit findings) that the maintainer chose to apply directly.
- Feature work targets the `develop` branch via topic branches (`fix/X`, `feat/X`), merged into `develop` through PRs with green CI.
- Releases merge `develop` → `main` (squash or merge-commit, document each), then a tag push (`v1.X.Y`) triggers the `docker-publish.yml` workflow.
- Hotfixes branch from `main`, merge back into both `main` and `develop`.
- Never force-push `main`. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
## Semver
Prefer patch bumps. A release dominated by bug fixes plus additive features without breaking changes ships as `vX.Y.Z+1`, not `vX.Y+1.0`. Bumping minor is reserved for breaking changes, removed flags, deprecated routes, or multi-feature milestones framed as such (e.g. v1.5 = native iOS client + Apple Health integration). Pre-existing example: a `v1.5.0` cut was rerouted to `v1.4.14` mid-flight when the diff turned out to be bugfix-dominated.
## Tech stack at a glance
| Layer | Tech | Notes |
|---|---|---|
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 47 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
## How a request flows
```
client
│ HTTP
▼
src/proxy.ts renamed from middleware.ts. Public-path allowlist,
│ demo-mode mutation block, server-side onboarding
│ redirect, worker-only refusal, request-id + nonce
│ generation, every security header.
▼
src/app/<route>/route.ts every API route wraps in `apiHandler(...)`
│
▼
src/lib/api-handler.ts WideEventBuilder + AsyncLocalStorage, auth resolver
│ (cookie OR Bearer), idempotency, error envelope,
│ GlitchTip forwarder. Cookie path can elevate to
│ admin via requireAdmin(); Bearer never can.
▼
handler body Zod parse (`safeParse` + `returnAllZodIssues`),
│ business logic, `annotate({ action, meta })`,
│ `auditLog()` for sensitive ops.
▼
src/lib/api-response.ts `apiSuccess(data, meta?)` / `apiError(msg, status,
│ meta?)` — every response is `{ data, error, meta? }`.
▼
src/proxy.ts security headers attach, request-id echoes.
▼
client
```
The architecture map in `.planning/codebase/arch.md` walks each layer with file:line citations.
## Code conventions
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary at `src/lib/api-handler.ts:414-431`, not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see `src/lib/rollups/measurement-rollups.ts:330-343`). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys.ts` is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
## Tests and commands
- `pnpm typecheck` — `tsc --noEmit`. Required before every commit.
- `pnpm lint` — ESLint flat config with `eslint-config-next` + the project's `healthlog/queryKey-factory` rule. One harmless warning in `withings/resume/__tests__/route.test.ts` is allowed; everything else should be clean.
- `pnpm test` — Vitest 4 unit + component suite. Required before pushing a fix; full suite is fast (~30 s).
- `pnpm test:integration` — Vitest against a `testcontainers/postgresql` Postgres 16. Needs Docker / OrbStack running. Slower but pins the contracts the unit-mocks can't.
- `pnpm e2e` — Playwright against `pnpm exec next build` + Postgres. Heavy; CI runs it, locally only when touching UI flows.
- `pnpm openapi:generate` — regenerates `docs/api/openapi.yaml` from the Zod registry. Run after any request / response schema change. CI's `openapi:check` fails the build on drift.
- `pnpm check-env` — validates that `.env.production.example` covers every required variable. Wired into CI as `env-check.yml`.
- `pnpm bundle-report` / `ANALYZE=1 pnpm build` — bundle inspection. Use when a feature adds material weight.
- One-shot scripts live in `scripts/`. Run them via `pnpm dlx tsx scripts/<file>.ts` — the production standalone image strips `tsx` and the bare `pnpm tsx ...` invocation fails inside the container. Recurring tasks belong on pg-boss, not on a CLI script.
## Critical-files map
| Concern | File | What it owns |
|---|---|---|
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
| API kit | `src/lib/api-handler.ts` (490 LOC) | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | `src/lib/ai/provider-chain.ts` + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is not registered in `resolveProvider()` — production cannot reach it. |
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes.ts` | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (47 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
## Security-relevant patterns
The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn:
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
- **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
- **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults.
- **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie.
- **Redaction at the egress boundary, not at the call site.** `redactSecrets()` + `redactSensitiveFields()` + `redactOptional()` are applied centrally in `WideEventBuilder` and `reportToGlitchtip`. Don't add new redactor calls in handler bodies — extend the central denylists in `src/lib/observability/redact-payload.ts` and `src/lib/logging/redact.ts`.
## Self-hosting gotchas
These keep tripping operators; they're worth knowing before you push something that breaks them.
- **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression.
- **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`.
- **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"<hex>","v2":"<hex>"}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.
- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups.
- **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds.
## iOS handoff
The native SwiftUI client lives in a separate repository and rides public beta via TestFlight. Backend contract the iOS app speaks against is locked in `docs/api/openapi.yaml` and has been continuously validated since v1.4.23. Coordination notes worth knowing on the backend side:
- `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (e.g. `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD`) as **overwrites** — a re-post replaces the row's value / unit / measuredAt / externalSourceVersion / deviceType / sleepStage. Every other externalId prefix (`uuid-*`, opaque HK identifiers) keeps the first-write-wins immutable contract because each sample is a canonical reading. Per-entry status field carries `"updated"` for an overwrite vs `"inserted"` for a fresh row.
- Refresh-token rotation is per-device; reuse-detection revokes the family scoped to that device.
- `requireAdmin()` is cookie-only by construction — a Bearer token, even with `["*"]` scope, cannot reach `/api/admin/*`. The iOS app is not an admin client.
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
## DO-NOTs
- **No `--no-verify` on commits.** If a pre-commit hook fails, fix the cause; never bypass.
- **No `--no-gpg-sign` unless explicitly asked.**
- **No force-push to `main`** (or to release branches, by extension).
- **No destructive git operations as shortcuts.** `git reset --hard`, `git clean -fd`, `git checkout .` etc. only when the maintainer asked or the state is unambiguously local-only.
- **No assistant / planning vocabulary in any committed artefact** — see Voice and privacy above.
- **No personal names** — the maintainer's, a reporter's, or anyone else's — in any committed artefact, including code comments referring to "the user who reported issue #X".
- **No secret-shaped strings in `.env.example` or git history.** Pre-existing scan shows clean.
- **No markdown library in dependencies.** Coach + briefing + insights render as React text children for XSS reasons; adding one is a security decision, not a UX one.
- **No `Access-Control-Allow-Origin` headers** anywhere except the documented webhook surfaces — and those authenticate via header secret, not cookie.
- **No `pnpm tsx scripts/foo.ts` in production.** The standalone image strips `tsx`. One-shot scripts run via `pnpm dlx tsx`; recurring tasks belong on pg-boss.
- **No backwards-compatibility shims for hypothetical callers.** When a change is clean, make it cleanly; do not leave dead branches "in case" something we don't ship needs them.
## Pointers
| What | Where |
|---|---|
| Public face | `README.md` |
| Release history | `CHANGELOG.md` |
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
| Self-hosting guide | `docs/self-hosting/` |
| Public API contract | `docs/api/openapi.yaml` |
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
| iOS coordination notes | `.planning/ios-coord/` |
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
+17 -11
View File
@@ -30,6 +30,7 @@
<a href="https://docs.healthlog.dev">Documentation</a> &middot;
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2316" height="1207" alt="home" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
---
@@ -37,7 +38,7 @@
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.5 — native iOS client now public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.7 — health-record export (PDF + FHIR R4), flexible medication schedules, full HealthKit metric coverage, and a first-paint dashboard snapshot. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
@@ -69,7 +70,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
## Key Features
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. Every stored HealthKit metric now has a chart surface — flights climbed, audio exposure, walking speed/step-length/asymmetry/double-support, respiratory rate, the body-composition family, mobility, daylight, and more — and a metric/imperial display toggle renders walking speed in km/h while storage and exports stay canonical SI.
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
@@ -77,25 +78,27 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
**Medication Compliance** -- Flexible scheduling with time windows, day-of-week recurrence, and `intervalWeeks` (so once-weekly GLP-1 injections and weekday-only doses score honestly, not against a daily denominator). Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
**Medication Compliance** -- Flexible scheduling driven by a single recurrence engine: time windows, day-of-week recurrence, `intervalWeeks`, rolling intervals, RFC-5545 RRULE, one-time doses, as-needed (PRN), and cyclic on/off-week plans — so once-weekly GLP-1 injections, weekday-only doses, and non-daily plans score honestly against their real cadence rather than a daily denominator, everywhere from the dashboard tile to the detail page. Route of administration (oral / injection / other) carries the injection-site picker for any injection. Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. A paginated, opaque-cursor sync delta feed (`GET /api/sync/changes`) with measurement tombstones lets native and offline clients reconcile against the server incrementally.
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), Web Push (VAPID), and Apple Push (APNs) for the native iOS client. One dispatcher fans medication reminders out to whichever channels the user has enabled, with late/missed escalation.
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported.
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported. The above-the-fold tiles arrive together in a single first-paint snapshot, and a nightly job pre-generates the daily briefing so `/insights` is a cache read rather than a synchronous model call.
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
**Internationalization** -- English (default) and German UI with 2500+ translation keys each, guarded by a CI integrity test that fails the build on duplicate keys, drift between locales, or call-sites referencing keys that no locale ships. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
@@ -152,7 +155,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
| PDF | jsPDF (client-side generation) |
| Testing | Vitest 4 |
| Deployment | Docker (multi-stage Alpine) |
| Native client | SwiftUI iOS app — v1.5, public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
| Native client | SwiftUI iOS app — public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
---
@@ -322,9 +325,11 @@ All mutations require authentication via session cookie. External ingest uses Be
| `POST` | `/api/auth/register` | Create account |
| `POST` | `/api/auth/login` | Password login |
| `POST` | `/api/auth/logout` | Destroy session |
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
| `GET` | `/api/auth/me` | Current user profile + avatar URL |
| `POST` | `/api/auth/password` | Change password |
| `PATCH` | `/api/auth/profile` | Update profile fields |
| `POST` | `/api/user/avatar` | Upload a profile photo (self-hosted)|
| `DELETE`| `/api/user/avatar` | Remove the profile photo |
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
| `POST` | `/api/auth/codex/device-start` | ChatGPT (Codex) device-OAuth start |
@@ -436,7 +441,7 @@ All mutations require authentication via session cookie. External ingest uses Be
## Local Development
```bash
# Prerequisites: Node.js 20+, pnpm, PostgreSQL
# Prerequisites: Node.js 22, pnpm, PostgreSQL
cp .env.example .env
pnpm install
@@ -482,7 +487,8 @@ For a single-process default the same container hosts both the web and worker (`
| Release line | Focus |
| ------------ | ----- |
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
| **v1.5** (current) | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.6 – v1.7** (current) | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
+2956 -41
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -122,6 +122,26 @@ bare Nginx — including the `X-Forwarded-For` chain shape that the
rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how
many hops to read back.
## Build-time feature toggles
A small number of toggles are `NEXT_PUBLIC_*` variables: the framework
inlines them into the client bundle at **build time**, so the pre-built
GHCR image cannot read them at runtime. Setting them in `.env` or the
compose `environment:` block has no effect — they only take hold when
you build your own image. They are intentionally absent from the
compose runtime whitelist for that reason.
| Variable | Default | Effect when set |
| --- | --- | --- |
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
To flip one, rebuild the image with the variable exported:
```bash
NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
# or pass it as a Dockerfile build ARG / Docker build --build-arg
```
## 7. Where to go next
| Next step | File |
+9
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.18 — per-chart overlay-controls popover.
@@ -19,6 +23,11 @@ test.describe("chart overlay controls", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a
// WEIGHT-only populated summary so the weight chart card paints.
// Legacy mocks below stay for the reversible `=false` path.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
// Mock analytics + measurements so the dashboard paints at least
// one chart card. Same seed shape the dashboard.spec.ts uses.
//
+6
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 A2 — mobile-viewport regression spec for chart cards.
@@ -31,6 +32,11 @@ test.describe("charts — mobile-viewport regression", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with the
// same populated four-metric set so the charts paint. Legacy mocks
// below stay for the reversible `=false` path.
await mockDashboardSnapshot(page);
// Mock analytics + measurements so the chart wrappers all clear
// their data-floor gates. Same pattern as `dashboard.spec.ts`.
//
+9
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
/**
* Authenticated dashboard render — proves the seed user can reach the
@@ -20,6 +21,14 @@ test.describe("authenticated dashboard render", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// v1.7.2 — the dashboard snapshot flag is default-ON, so the page
// reads every tile from `/api/dashboard/snapshot` and ignores the
// legacy cells below. Mock the snapshot with the same populated
// four-metric set so the tile strip + charts paint. The legacy
// `/api/analytics` + `/api/mood/analytics` mocks stay for the
// reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
await mockDashboardSnapshot(page);
// Mock analytics with a populated weight summary so the weight tile
// and chart render. `count` must be >0 for the tile to clear the
// data-floor gate in `src/app/page.tsx`.
+96
View File
@@ -0,0 +1,96 @@
import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — bi-weekly cadence (every N
* weeks).
*
* Walks the 8-step recurring path with "Alle X Wochen" picked on
* Step 5, then the interval input filled with 2 and Monday toggled
* on the weekday chips of Step 6. Emits
* `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO`.
*/
test.describe("medication wizard — bi-weekly", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates an every-2-weeks Monday medication end-to-end", async ({
page,
}) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Methotrexat",
dose: "10 mg",
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO",
rollingIntervalDays: null,
oneShot: false,
});
await openCreateWizard(page);
await fillStep1Name(page, { name: "Methotrexat" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "other");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "10" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "everyNWeeks");
await clickNext(page);
await expectStep(page, 6, 8);
await page.locator("#wizard-interval-weeks").fill("2");
const moChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "false") {
await moChip.click();
}
await clickNext(page);
await expectStep(page, 7, 8);
await clickNext(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/zwei Wochen/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Methotrexat",
oneShot: false,
schedules: [{ rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO" }],
});
await expectMedicationOnList(page, {
name: "Methotrexat",
withNextDueChip: true,
});
});
});
+179
View File
@@ -0,0 +1,179 @@
import { expect, test, type Route } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — compose-mode.
*
* Walks the create flow through to Step 8, then taps
* "Weiteren Zeitplan hinzufügen" to append a second weekly Wednesday
* schedule on the same medication. The list view surfaces both
* summary cards before save, and the POST body carries both
* schedules so the route can persist them in parallel.
*
* Use case the maintainer cares about: short-acting + long-acting
* insulin on a single medication. Today the wizard collapses to one
* schedule on save; compose-mode closes that data-loss gap.
*/
test.describe("medication wizard — compose-mode", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a 2-schedule medication end-to-end", async ({ page }) => {
await stubDashboardAnalytics(page);
const listEntry = {
id: STUB_MEDICATION_ID,
name: "Insulin",
dose: "10 IE",
category: "DIABETES",
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
id: "sch_e2e_1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
},
{
id: "sch_e2e_2",
windowStart: "20:00",
windowEnd: "21:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["20:00"],
rrule: "FREQ=WEEKLY;BYDAY=WE",
rollingIntervalDays: null,
reminderGraceMinutes: null,
},
],
};
const capture: { postBody: unknown } = { postBody: null };
void page.route("**/api/medications**", async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
if (url.pathname !== "/api/medications") {
await route.fallback();
return;
}
if (req.method() === "POST") {
capture.postBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { ...listEntry }, error: null }),
});
return;
}
if (req.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [listEntry], error: null }),
});
return;
}
await route.fallback();
});
await openCreateWizard(page);
// Steps 1-4 — medication-global.
await fillStep1Name(page, { name: "Insulin" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "diabetes");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "10" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
// Steps 5-7 — first schedule, daily cadence.
await expectStep(page, 5, 8);
await pickCadenceRow(page, "daily");
await expectStep(page, 5, 7);
await clickNext(page);
await expectStep(page, 6, 7);
await clickNext(page);
// Step 8 — list view with the first schedule rendered. Tap
// "Weiteren Zeitplan hinzufügen" to append a second draft.
await expectStep(page, 7, 7);
let cards = page.locator('[data-slot="wizard-schedule-card"]');
await expect(cards).toHaveCount(1);
await page.locator('[data-slot="wizard-schedule-add"]').click();
// Second schedule — weekdays on Wednesday.
await expectStep(page, 5);
await pickCadenceRow(page, "weekdays");
await clickNext(page);
await expectStep(page, 6);
const weChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="WE"]',
);
if ((await weChip.getAttribute("data-active")) === "false") {
await weChip.click();
}
// Default MO chip — toggle off so the rrule is WE-only.
const moChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "true") {
await moChip.click();
}
await clickNext(page);
await expectStep(page, 7);
await clickNext(page);
// Back on Step 8 — both summary cards should land before save.
await expectStep(page, 8);
cards = page.locator('[data-slot="wizard-schedule-card"]');
await expect(cards).toHaveCount(2);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Insulin",
oneShot: false,
schedules: [
{ rrule: "FREQ=DAILY" },
{ rrule: "FREQ=WEEKLY;BYDAY=WE" },
],
});
await expectMedicationOnList(page, {
name: "Insulin",
withNextDueChip: true,
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — daily cadence.
*
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* The DE label surface (Marc's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
test.describe("medication wizard — daily", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a daily medication end-to-end", async ({ page }) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Vitamin D",
dose: "1000 I.E.",
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
oneShot: false,
});
await openCreateWizard(page);
// Step 1 — name.
await fillStep1Name(page, { name: "Vitamin D" });
await clickNext(page);
// Step 2 — treatment class.
await expectStep(page, 2);
await pickTreatmentRow(page, "vitamin");
await clickNext(page);
// Step 3 — dose.
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "1000" });
await page.locator("#wizard-dose-unit").click();
await page.getByRole("option", { name: /I\.E\./ }).click();
await clickNext(page);
// Step 4 — course window (today by default).
await expectStep(page, 4);
await expect(
page.locator('[data-slot="course-window-starts"]'),
).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
await clickNext(page);
// Step 5 — cadence: "Täglich".
await expectStep(page, 5, 8);
await pickCadenceRow(page, "daily");
// After picking daily, the path compresses to 7 steps; step 5
// sits at slot 5 of 7.
await expectStep(page, 5, 7);
await clickNext(page);
// Step 7 — times of day (08:00 default chip).
await expectStep(page, 6, 7);
await expect(
page.locator('[data-slot="times-of-day-chip"][data-time="08:00"]'),
).toBeVisible();
await clickNext(page);
// Step 8 — summary + create.
await expectStep(page, 7, 7);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Jeden Tag/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Vitamin D",
oneShot: false,
schedules: [{ rrule: "FREQ=DAILY" }],
});
await expectMedicationOnList(page, {
name: "Vitamin D",
withNextDueChip: true,
});
});
});
+254
View File
@@ -0,0 +1,254 @@
import { expect, type Page, type Route } from "@playwright/test";
/**
* v1.5.4 — shared helpers for the modal medication-wizard specs.
*
* The six specs (daily / weekdays / biweekly / monthly / rolling /
* oneshot) walk the same dialog with different cadence-specific
* inputs. The shared pieces — POST + GET stubs, list-page entry
* check, dialog open + step-indicator assertions — live here so
* the per-cadence specs only carry the cadence-specific selectors.
*
* The path counter follows the user's path:
* one-shot = 5 steps (1, 2, 3, 4, 8)
* daily = 7 steps (1, 2, 3, 4, 5, 7, 8)
* recurring = 8 steps (1, 2, 3, 4, 5, 6, 7, 8)
*
* `expectStep(page, displayIndex, totalSteps)` pins the visible
* "Schritt X von Y" string the dialog header renders.
*/
export const STUB_MEDICATION_ID = "med_e2e_wizard";
interface StubbedMedication {
id: string;
name: string;
dose: string;
rrule: string | null;
rollingIntervalDays: number | null;
oneShot: boolean;
}
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
const capture: { postBody: unknown } = { postBody: null };
const listEntry = {
id: stubbed.id,
name: stubbed.name,
dose: stubbed.dose,
category: "OTHER",
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
startsOn: null,
endsOn: null,
oneShot: stubbed.oneShot,
schedules: [
{
id: "sch_e2e_1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["08:00"],
rrule: stubbed.rrule,
rollingIntervalDays: stubbed.rollingIntervalDays,
reminderGraceMinutes: null,
},
],
};
void page.route("**/api/medications**", async (route: Route) => {
const req = route.request();
const url = new URL(req.url());
if (url.pathname !== "/api/medications") {
await route.fallback();
return;
}
if (req.method() === "POST") {
capture.postBody = JSON.parse(req.postData() ?? "null");
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
data: { ...listEntry },
error: null,
}),
});
return;
}
if (req.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [listEntry],
error: null,
}),
});
return;
}
await route.fallback();
});
return capture;
}
export async function stubDashboardAnalytics(page: Page): Promise<void> {
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
}
/**
* Open the wizard dialog by hitting `/medications/new` (which
* redirects to `/medications?new=1` and opens the create dialog).
*/
export async function openCreateWizard(page: Page): Promise<void> {
// Pin the German locale for the wizard flow. The app default locale is
// English; these specs assert the "Schritt N von M" counter and other
// German label surfaces, which only render when the `healthlog-locale`
// cookie resolves to "de". Both the server layout and the client i18n
// provider read this exact cookie name, so setting it before the first
// navigation makes the dialog render German on first paint. Scoped here
// rather than in the shared auth state so English-asserting specs keep
// the default locale.
await page.context().addCookies([
{
name: "healthlog-locale",
value: "de",
url: page.url().startsWith("http")
? new URL(page.url()).origin
: "http://localhost:3000",
},
]);
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toBeVisible({ timeout: 10_000 });
}
/**
* Fill the Step 1 name input. The dialog's first step asks only for
* the medication name — the dose moved to Step 3.
*/
export async function fillStep1Name(
page: Page,
{ name }: { name: string },
): Promise<void> {
await expectStep(page, 1);
await page.locator("#wizard-name").fill(name);
}
/** Pick a Step 2 treatment-class row by its row identifier. */
export async function pickTreatmentRow(
page: Page,
row:
| "bloodPressure"
| "diabetes"
| "hormone"
| "glp1"
| "painRelief"
| "allergy"
| "vitamin"
| "supplement"
| "antibiotic"
| "other",
): Promise<void> {
await page
.locator(`[data-slot="wizard-class-row"][data-row="${row}"]`)
.click();
}
/** Fill the Step 3 dose amount input. */
export async function fillStep3Dose(
page: Page,
{ amount }: { amount: string },
): Promise<void> {
await page.locator("#wizard-dose-amount").fill(amount);
}
/** Pick a Step 5 cadence row by its row identifier. */
export async function pickCadenceRow(
page: Page,
row: "daily" | "weekdays" | "everyNWeeks" | "monthly" | "rolling" | "oneShot",
): Promise<void> {
await page
.locator(`[data-slot="wizard-cadence-row"][data-row="${row}"]`)
.click();
}
/** Click Next. Pinned by data-slot. */
export async function clickNext(page: Page): Promise<void> {
await page.locator('[data-slot="wizard-next"]').click();
}
/** Click Save (last step CTA on create + edit). */
export async function clickSave(page: Page): Promise<void> {
await page.locator('[data-slot="wizard-save"]').click();
}
/**
* Assert the wizard sits on a given visible step counter.
* `displayIndex` is the visible 1-based index; `totalSteps` is the
* path length for the active mode + cadence.
*/
export async function expectStep(
page: Page,
displayIndex: number,
totalSteps?: number,
): Promise<void> {
const pattern = totalSteps
? new RegExp(`Schritt ${displayIndex} von ${totalSteps}`, "i")
: new RegExp(`Schritt ${displayIndex} von \\d+`, "i");
await expect(page.getByText(pattern)).toBeVisible({ timeout: 5_000 });
}
/**
* Verify the post-submit URL settles on `/medications/<id>` (create
* path; the dialog navigates there on success).
*/
export async function expectRedirectToMedication(
page: Page,
id: string,
): Promise<void> {
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 10_000 })
.toBe(`/medications/${id}`);
}
/**
* Navigate to the list and assert the just-created medication
* appears. The recurring variants surface a "Nächste Einnahme:" chip;
* the one-shot variant does not.
*/
export async function expectMedicationOnList(
page: Page,
{ name, withNextDueChip }: { name: string; withNextDueChip: boolean },
): Promise<void> {
await page.goto("/medications", { waitUntil: "domcontentloaded" });
const main = page.locator("main");
await expect(main.getByText(name).first()).toBeVisible({ timeout: 10_000 });
if (withNextDueChip) {
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible();
}
}
+88
View File
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — monthly cadence.
*
* Walks the 8-step recurring path with "Monatlich" picked on Step 5
* and dayOfMonth = 15 set on Step 6. Emits
* `FREQ=MONTHLY;BYMONTHDAY=15`.
*/
test.describe("medication wizard — monthly", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a monthly-on-day-15 medication end-to-end", async ({
page,
}) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Vitamin B12",
dose: "1000 µg",
rrule: "FREQ=MONTHLY;BYMONTHDAY=15",
rollingIntervalDays: null,
oneShot: false,
});
await openCreateWizard(page);
await fillStep1Name(page, { name: "Vitamin B12" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "vitamin");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "1000" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "monthly");
await clickNext(page);
await expectStep(page, 6, 8);
await page.locator("#wizard-day-of-month").fill("15");
await clickNext(page);
await expectStep(page, 7, 8);
await clickNext(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Monatlich/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Vitamin B12",
oneShot: false,
schedules: [{ rrule: "FREQ=MONTHLY;BYMONTHDAY=15" }],
});
await expectMedicationOnList(page, {
name: "Vitamin B12",
withNextDueChip: true,
});
});
});
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — one-shot cadence.
*
* Picking "Einmalig" on Step 5 collapses the path onto the 5-step
* one-shot route ([1, 2, 3, 4, 8]). The wizard skips Steps 6 + 7
* because the dose date already lives in Step 4 (course window).
* Step 4's CourseWindowRow locks endsOn to startsOn for one-shot.
*
* The list-page "Nächste Einnahme:" chip is suppressed because the
* design-synthesis lifecycle deactivates one-shot medications once
* the single intake is logged. The spec only asserts the row name
* appears on the list.
*/
test.describe("medication wizard — one-shot", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a single-dose medication end-to-end", async ({ page }) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Grippeimpfung",
dose: "0.5 ml",
rrule: null,
rollingIntervalDays: null,
oneShot: true,
});
await openCreateWizard(page);
await fillStep1Name(page, { name: "Grippeimpfung" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "other");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "0.5" });
await page.locator("#wizard-dose-unit").click();
await page.getByRole("option", { name: /^ml$/i }).click();
await clickNext(page);
// Step 4 — course window. Set startsOn to a deterministic date so
// the post body assertion below is stable.
await expectStep(page, 4);
await page.locator('[data-slot="course-window-starts"]').fill("2026-10-15");
await clickNext(page);
// Step 5 — pick Einmalig. The path compresses to 5 steps the
// moment the row is picked; the visible counter follows.
await expectStep(page, 5, 8);
await pickCadenceRow(page, "oneShot");
// After the pick the path is [1, 2, 3, 4, 8]; the cadence pick
// step (raw step 5) sits between slots 4 and 5 of 5, so the
// counter pins on slot 4 of 5 until Next.
await expectStep(page, 4, 5);
await clickNext(page);
await expectStep(page, 5, 5);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Einmaldosis/i);
await expect(summary).toContainText(/2026-10-15/);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Grippeimpfung",
oneShot: true,
startsOn: "2026-10-15",
endsOn: "2026-10-15",
});
await expectMedicationOnList(page, {
name: "Grippeimpfung",
withNextDueChip: false,
});
});
});
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — rolling cadence.
*
* Walks the 8-step recurring path with "Flexibel ab letzter
* Einnahme" picked on Step 5 and rollingDays = 7 set on Step 6.
* Emits `rollingIntervalDays: 7` (no rrule). The cadence row also
* surfaces Marc's literal "Counter startet neu" copy directly below
* the row label — the assertion below pins the phrase so a future
* copy edit can't silently drop the rolling explainer.
*/
test.describe("medication wizard — rolling", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates an every-7-days-from-last-intake medication end-to-end", async ({
page,
}) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Mounjaro",
dose: "5 mg",
rrule: null,
rollingIntervalDays: 7,
oneShot: false,
});
await openCreateWizard(page);
await fillStep1Name(page, { name: "Mounjaro" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "glp1");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "5" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5, 8);
// The rolling row surfaces the Counter-Reset explainer right
// under the row label, not in a tooltip.
const rollingRow = page.locator(
'[data-slot="wizard-cadence-row"][data-row="rolling"]',
);
await expect(rollingRow).toContainText(/Counter/i);
await pickCadenceRow(page, "rolling");
await clickNext(page);
await expectStep(page, 6, 8);
await page.locator("#wizard-rolling-days").fill("7");
await clickNext(page);
await expectStep(page, 7, 8);
await clickNext(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/letzten Einnahme/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Mounjaro",
oneShot: false,
schedules: [{ rollingIntervalDays: 7 }],
});
await expectMedicationOnList(page, {
name: "Mounjaro",
withNextDueChip: true,
});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
import {
STUB_MEDICATION_ID,
clickNext,
clickSave,
expectMedicationOnList,
expectRedirectToMedication,
expectStep,
fillStep1Name,
fillStep3Dose,
mockMedicationsApi,
openCreateWizard,
pickCadenceRow,
pickTreatmentRow,
stubDashboardAnalytics,
} from "./medications-wizard-helpers";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — specific weekdays.
*
* Walks the 8-step recurring path through the modal dialog with
* "Bestimmte Wochentage" picked on Step 5, then Mo / Mi / Fr toggled
* on the weekday chips of Step 6. The emitted rrule is
* `FREQ=WEEKLY;BYDAY=MO,WE,FR`.
*/
test.describe("medication wizard — weekdays", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("creates a Mo/Mi/Fr medication end-to-end", async ({ page }) => {
await stubDashboardAnalytics(page);
const capture = mockMedicationsApi(page, {
id: STUB_MEDICATION_ID,
name: "Ibuprofen",
dose: "400 mg",
rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR",
rollingIntervalDays: null,
oneShot: false,
});
await openCreateWizard(page);
await fillStep1Name(page, { name: "Ibuprofen" });
await clickNext(page);
await expectStep(page, 2);
await pickTreatmentRow(page, "painRelief");
await clickNext(page);
await expectStep(page, 3);
await fillStep3Dose(page, { amount: "400" });
await clickNext(page);
await expectStep(page, 4);
await clickNext(page);
await expectStep(page, 5, 8);
await pickCadenceRow(page, "weekdays");
await clickNext(page);
await expectStep(page, 6, 8);
// The weekday default may already be [MO]; re-tick to make the
// selection deterministic for the assertion below.
const moChip = page.locator(
'[data-slot="wizard-weekday-chip"][data-token="MO"]',
);
if ((await moChip.getAttribute("data-active")) === "false") {
await moChip.click();
}
await page
.locator('[data-slot="wizard-weekday-chip"][data-token="WE"]')
.click();
await page
.locator('[data-slot="wizard-weekday-chip"][data-token="FR"]')
.click();
await clickNext(page);
await expectStep(page, 7, 8);
await clickNext(page);
await expectStep(page, 8, 8);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Wochentagen/i);
await clickSave(page);
await expectRedirectToMedication(page, STUB_MEDICATION_ID);
expect(capture.postBody).toMatchObject({
name: "Ibuprofen",
oneShot: false,
schedules: [{ rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR" }],
});
await expectMedicationOnList(page, {
name: "Ibuprofen",
withNextDueChip: true,
});
});
});
+9
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* Mobile-viewport smoke (Pixel 5 profile only). Asserts:
@@ -31,6 +35,11 @@ test.describe("mobile-viewport smoke", () => {
test("dashboard has no horizontal scroll, bottom-nav respects content, all CTAs ≥ 44 px tall", async ({
page,
}) => {
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a
// WEIGHT-only populated summary so the weight tile paints. Legacy
// mocks below stay for the reversible `=false` path.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The previous string glob `**/api/analytics`
// didn't match the query-string form so the IW1 slim-slice fetch
+19
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.15 phase-A3 fix #3 — onboarding card flicker on dashboard load.
@@ -102,6 +106,17 @@ test.describe("dashboard onboarding card flicker guard", () => {
}),
}),
);
// v1.7.2 — snapshot flag default-ON. The dashboard tile strip reads
// the snapshot cell; mock it with the populated WEIGHT summary
// (count 30 >= the 5-reading setup gate) and the same ~250 ms delay
// so the page's snapshot-driven tiles resolve on the same race
// window as the slowed analytics query the checklist reads. (The
// `GettingStartedChecklist` itself reads `/api/analytics?slice=...`
// unconditionally, so its flicker gate is unchanged.)
await mockDashboardSnapshot(page, {
summaries: WEIGHT_ONLY_SUMMARIES,
delayMs: 250,
});
// Same `/api/dashboard/widgets` stub as the incomplete-onboarding
// test below — keeps the dashboard from error-boundary-bailing
// when this query 500s in CI without a real session.
@@ -192,6 +207,10 @@ test.describe("dashboard onboarding card flicker guard", () => {
}),
}),
);
// v1.7.2 — snapshot flag default-ON. Empty snapshot summaries keep
// the tile strip blank (the user is still in setup) so the page
// renders the onboarding card path deterministically.
await mockDashboardSnapshot(page, { summaries: {} });
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
+35 -12
View File
@@ -41,18 +41,41 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
await page.waitForLoadState("networkidle");
await expect(page.getByLabel(/username/i)).toBeVisible();
const formInputs = await page
.locator(
'section input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]), section select',
)
.evaluateAll((els) =>
els.map((el) => ({
id: el.id || el.getAttribute("name") || "",
tag: el.tagName,
type: el.getAttribute("type") || "",
height: Math.round(el.getBoundingClientRect().height),
})),
);
const formInputs = (
await page
.locator(
'section input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]), section select',
)
.evaluateAll((els) =>
els.map((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
id: el.id || el.getAttribute("name") || "",
tag: el.tagName,
type: el.getAttribute("type") || "",
height: Math.round(rect.height),
width: Math.round(rect.width),
hidden:
el.classList.contains("sr-only") ||
style.display === "none" ||
style.visibility === "hidden",
};
}),
)
).filter(
// The avatar profile-photo upload uses the accessible
// visually-hidden <input type="file"> pattern: the input itself
// is sr-only / zero-size and the real 44 px touch target is the
// styled label/button that triggers it. The touch-target sweep
// must measure that visible affordance, not the hidden input, so
// exempt file inputs that are hidden or collapsed to zero size.
(inp) =>
!(
inp.type === "file" &&
(inp.hidden || inp.height === 0 || inp.width === 0)
),
);
expect(formInputs.length).toBeGreaterThan(0);
for (const inp of formInputs) {
+1 -1
View File
@@ -181,7 +181,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// already-onboarded ones, so for our pre-seeded completed e2e user
// the cookie is absent and the proxy passes the dashboard through.
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "healthlog_locale", "hl_onboarding"].includes(c.name),
["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name),
);
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
await ctx.dispose();
+172
View File
@@ -0,0 +1,172 @@
import type { Page } from "@playwright/test";
import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
import type { DataSummary } from "@/lib/analytics/trends";
import type {
DashboardSnapshot,
DashboardLayoutCatalogueEntry,
} from "@/lib/dashboard/snapshot";
/**
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
*
* The dashboard snapshot flag flipped default-ON in v1.7.2, so
* `src/app/page.tsx` reads every above-the-fold tile from the single
* `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT`
* is `"false"`. The e2e build sets no such var, so the snapshot path is
* live in CI. Specs that previously mocked only the legacy cells
* (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`)
* now hit the REAL snapshot route, which returns empty `summaries` for
* the seed user (zero measurements) and blanks the tile strip.
*
* `mockDashboardSnapshot` registers a `page.route` for the snapshot
* endpoint returning a correctly-typed `DashboardSnapshot` so the
* default-ON path paints deterministic tiles. Callers keep their legacy
* mocks alongside it — harmless, and they protect the reversible
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
*/
/** Fill a `DataSummary` from a sparse partial, defaulting the rest. */
function summary(partial: Partial<DataSummary>): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
...partial,
};
}
/**
* Four-metric populated tile set mirroring the legacy `/api/analytics`
* mock shared by `dashboard.spec.ts` / `charts-mobile.spec.ts` /
* `chart-overlay-controls.spec.ts`. Every summary has `count > 0` so the
* tile clears the data-floor gate in `src/app/page.tsx`.
*/
export const POPULATED_SUMMARIES: Record<string, DataSummary> = {
WEIGHT: summary({
latest: 78.5,
avg7: 78.2,
avg30: 77.9,
slope30: { slope: -0.05, direction: "down", confidence: 0.8 },
count: 30,
}),
BLOOD_PRESSURE_SYS: summary({
latest: 124,
avg7: 122,
avg30: 121,
slope30: { slope: 0.1, direction: "stable", confidence: 0.3 },
count: 30,
}),
BLOOD_PRESSURE_DIA: summary({
latest: 80,
avg7: 79,
avg30: 78,
slope30: { slope: 0.05, direction: "stable", confidence: 0.2 },
count: 30,
}),
PULSE: summary({
latest: 68,
avg7: 70,
avg30: 71,
slope30: { slope: -0.2, direction: "down", confidence: 0.6 },
count: 30,
}),
};
/** WEIGHT-only populated set for specs that only need one tile + chart. */
export const WEIGHT_ONLY_SUMMARIES: Record<string, DataSummary> = {
WEIGHT: POPULATED_SUMMARIES.WEIGHT,
};
function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] {
return DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, order) => ({
id,
visible: true,
order,
}));
}
export interface MockSnapshotOptions {
/**
* Tile summaries keyed by `MeasurementType`. Defaults to the
* four-metric populated set. Pass `{}` for an empty strip (e.g. the
* incomplete-onboarding branch).
*/
summaries?: Record<string, DataSummary>;
/** Artificial latency in ms before the route fulfils (race specs). */
delayMs?: number;
}
/** Assemble a fully-typed snapshot body from the requested tile data. */
export function buildMockSnapshot(
options: MockSnapshotOptions = {},
): DashboardSnapshot {
const summaries = options.summaries ?? POPULATED_SUMMARIES;
const now = new Date().toISOString();
const lastSeenByType: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
> = {};
for (const type of Object.keys(summaries)) {
lastSeenByType[type] = { lastSeenAt: now, daysAgo: 0 };
}
return {
user: {
username: "e2e-tester",
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: "1990-01-01T00:00:00.000Z",
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
tiles: {
summaries,
lastSeenByType,
mood: { summary: null, entries: [] },
},
extras: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
generatedAt: now,
};
}
/**
* Register a `page.route` interceptor for `GET /api/dashboard/snapshot`
* returning the API envelope `{ data: DashboardSnapshot, error: null }`.
*/
export async function mockDashboardSnapshot(
page: Page,
options: MockSnapshotOptions = {},
): Promise<void> {
const body = buildMockSnapshot(options);
await page.route(/\/api\/dashboard\/snapshot(\?|$)/, async (route) => {
if (options.delayMs) {
await new Promise((resolve) => setTimeout(resolve, options.delayMs));
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: body, error: null }),
});
});
}
+19
View File
@@ -0,0 +1,19 @@
/**
* @fileoverview Aggregates the project-local `healthlog/*` ESLint rules
* into a single plugin object for the flat config.
*
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
*/
"use strict";
const queryKeyFactory = require("./queryKey-factory.js");
const safeFetchRequired = require("./safe-fetch-required.js");
module.exports = {
rules: {
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
"safe-fetch-required": safeFetchRequired,
},
};
@@ -0,0 +1,138 @@
/**
* @fileoverview ESLint rule — outbound `fetch` must route through the
* `safeFetch` wrapper.
*
* v1.5.6 — `src/lib/safe-fetch.ts` is the documented egress entry point:
* it pins `redirect: "manual"` + an `AbortSignal.timeout` by default and
* gates the connect-time IP pin behind `requirePublicHost`. A raw
* `fetch(...)` call under `src/lib/` or `src/app/` bypasses every one of
* those defences, so this rule flags it at authoring time and in CI.
*
* Allowed:
* - `safeFetch(...)` — the wrapper itself.
* - calls inside `src/lib/safe-fetch.ts` — the wrapper's own `fetch`.
* - calls inside `src/lib/safe-fetch-dispatcher.ts` — the pinned dispatcher.
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
* mock or assert against `fetch` directly.
*
* The check is a syntactic `CallExpression` match against a bare
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
* `window.fetch(` member forms. `safeFetch` is a distinct identifier and
* never matches.
*
* Same-origin client fetches are exempt: a first argument that is a
* string literal (or template head) starting with `/` is a relative,
* same-origin request (`fetch("/api/…")`) that never leaves the origin
* and has no SSRF / redirect-leak / DNS-rebinding surface. The wrapper
* is for OUTBOUND egress to external hosts — absolute URLs or
* variable-sourced targets — which is exactly what stays flagged.
*
* @see src/lib/safe-fetch.ts
* @see src/lib/safe-fetch-dispatcher.ts
*/
"use strict";
// Files exempt from the rule — the wrapper internals and the test
// surface. Posix-style suffix matches against the absolute filename via
// `String#includes`, mirroring the queryKey-factory rule's convention.
const EXEMPT_FILES = [
"src/lib/safe-fetch.ts",
"src/lib/safe-fetch-dispatcher.ts",
];
// Only enforce inside the application source roots. Scripts, config, and
// generated code are out of scope.
const ENFORCED_ROOTS = ["src/lib/", "src/app/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const safeFetchRequiredRule = {
meta: {
type: "problem",
docs: {
description:
"Outbound fetch must route through the safeFetch wrapper (src/lib/safe-fetch.ts).",
},
schema: [],
messages: {
rawFetch:
"Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
if (
callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier" &&
callee.property.name === "fetch" &&
callee.object.type === "Identifier" &&
(callee.object.name === "globalThis" ||
callee.object.name === "window" ||
callee.object.name === "self")
) {
return true;
}
return false;
}
function isSameOriginRelative(arg) {
if (!arg) return false;
// A single leading `/` not followed by `/` or `\`. `//evil.com` is a
// protocol-relative absolute URL and `/\evil.com` normalises to the
// same under WHATWG parsing — both resolve off-origin, so neither is
// a same-origin path and the wrapper must still cover them.
const isRelativeHead = (head) => /^\/(?![/\\])/.test(head);
// `fetch("/api/…")`
if (arg.type === "Literal" && typeof arg.value === "string") {
return isRelativeHead(arg.value);
}
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
return isRelativeHead(arg.quasis[0].value.cooked ?? "");
}
return false;
}
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
// Exempt same-origin relative-path requests — they never leave
// the origin, so the wrapper's outbound defences do not apply.
if (isSameOriginRelative(node.arguments[0])) return;
context.report({ node, messageId: "rawFetch" });
},
};
},
};
module.exports = safeFetchRequiredRule;
+13 -2
View File
@@ -1,7 +1,7 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js";
import healthlogPlugin from "./eslint-plugins/healthlog/index.js";
const eslintConfig = defineConfig([
...nextVitals,
@@ -17,6 +17,10 @@ const eslintConfig = defineConfig([
// worktree on an older commit can't poison `pnpm lint` locally.
// CI never sees this path; the rule is for the dev environment.
".claude/**",
// The project-local ESLint rule plugin is CommonJS (require/module
// .exports) by ESLint convention; it is not application source and
// is not linted by the app's TypeScript ruleset.
"eslint-plugins/**",
]),
// v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
@@ -27,7 +31,14 @@ const eslintConfig = defineConfig([
// whitelist + rationale.
{
plugins: { healthlog: healthlogPlugin },
rules: { "healthlog/queryKey-factory": "error" },
rules: {
"healthlog/queryKey-factory": "error",
// v1.5.6 — every outbound fetch under src/lib + src/app must route
// through the safeFetch wrapper (manual-redirect + timeout, and the
// requirePublicHost connect-time pin). The wrapper internals and
// test files are exempt; see the rule file for the allowlist.
"healthlog/safe-fetch-required": "error",
},
},
]);
+919 -43
View File
File diff suppressed because it is too large. Load diff
+919 -43
View File
File diff suppressed because it is too large. Load diff
+919 -43
View File
File diff suppressed because it is too large. Load diff
+919 -43
View File
File diff suppressed because it is too large. Load diff
+919 -43
View File
File diff suppressed because it is too large. Load diff
+919 -43
View File
File diff suppressed because it is too large. Load diff
+11 -8
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.5.2",
"version": "1.7.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -53,7 +53,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "^5.4.0",
"@node-rs/argon2": "^2.0.2",
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.8.0",
@@ -65,9 +65,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.16.0",
"lucide-react": "^1.17.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next-themes": "^0.4.6",
@@ -79,9 +80,11 @@
"react-dom": "19.2.5",
"react-hook-form": "^7.76.1",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"sax": "^1.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"undici": "^8.3.0",
"web-push": "^3.6.7",
"yaml": "^2.8.4",
"zod": "^4.4.3",
@@ -93,7 +96,7 @@
"@next/bundle-analyzer": "^16.2.6",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^11.14.0",
"@testcontainers/postgresql": "^12.0.1",
"@types/node": "^25",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -103,14 +106,14 @@
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.14.1",
"knip": "^6.14.2",
"pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"pg": "^8.21.0",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.7.0",
"shadcn": "^4.9.0",
"tailwindcss": "^4",
"testcontainers": "^11.14.0",
"testcontainers": "^12.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.5"
+586 -359
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
-- v1.5 — richer medication-scheduling cadences.
--
-- Adds three medication-level fields (course window + one-shot flag)
-- and four schedule-level fields (times-of-day, reminder grace,
-- RRULE string, rolling-interval-days) alongside the legacy
-- `days_of_week` / `window_start` / `window_end` columns. Existing
-- rows are backfilled in-line so the new readers (v1.5.1 onward)
-- see a populated `rrule` and `times_of_day` for every legacy
-- schedule.
--
-- The migration is additive only; v1.5.x continues to read legacy
-- columns as the source of truth. v1.5.1 flips the readers to the
-- new fields with legacy as fallback; v1.6.0 drops the legacy
-- columns + the `parse/serializeScheduleRecurrence` helpers.
-- ── Medication course window + one-shot flag ───────────────────
ALTER TABLE "medications"
ADD COLUMN "starts_on" DATE,
ADD COLUMN "ends_on" DATE,
ADD COLUMN "one_shot" BOOLEAN NOT NULL DEFAULT FALSE;
-- ── MedicationSchedule v1.5 fields ─────────────────────────────
ALTER TABLE "medication_schedules"
ADD COLUMN "times_of_day" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "reminder_grace_minutes" INT,
ADD COLUMN "rrule" TEXT,
ADD COLUMN "rolling_interval_days" INT;
-- ── Backfill: legacy `days_of_week` → RRULE string + times_of_day ──
-- The encoding rules (per `src/lib/medication-schedule.ts`):
-- NULL / empty → daily
-- "1,3,5" → weekly Mon/Wed/Fri (0=Sun..6=Sat)
-- "i2;1,3,5" → every 2 weeks Mon/Wed/Fri (interval 1..4)
--
-- Weekday mapping HealthLog (Sun=0..Sat=6) → RFC 5545 BYDAY codes
-- (SU, MO, TU, WE, TH, FR, SA). The helper below converts the
-- comma-separated decimal list into the BYDAY substring.
-- Helper: HealthLog weekday number → RFC 5545 code.
CREATE OR REPLACE FUNCTION pg_temp.hl_weekday_to_byday(n int) RETURNS text AS $$
BEGIN
RETURN CASE n
WHEN 0 THEN 'SU'
WHEN 1 THEN 'MO'
WHEN 2 THEN 'TU'
WHEN 3 THEN 'WE'
WHEN 4 THEN 'TH'
WHEN 5 THEN 'FR'
WHEN 6 THEN 'SA'
ELSE NULL
END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Helper: comma-separated decimal list → comma-separated BYDAY codes
-- ("1,3,5" → "MO,WE,FR"). Filters out anything outside the 0..6 range
-- so a legacy row carrying garbage decodes to NULL (the v1.5.1 read-
-- flip then falls back to the legacy decoder, which has its own
-- defensive filter).
CREATE OR REPLACE FUNCTION pg_temp.hl_days_to_byday(days text) RETURNS text AS $$
DECLARE
result text;
BEGIN
IF days IS NULL OR length(trim(days)) = 0 THEN
RETURN NULL;
END IF;
SELECT string_agg(pg_temp.hl_weekday_to_byday(part::int), ',' ORDER BY part::int)
INTO result
FROM unnest(string_to_array(days, ',')) AS part
WHERE part ~ '^[0-6]$';
RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Backfill: each existing schedule row gets a populated `rrule` plus
-- a single-entry `times_of_day` derived from `window_start`. The
-- encoder branches on the three legacy shapes.
UPDATE "medication_schedules" SET
"times_of_day" = ARRAY["window_start"],
"rrule" = CASE
-- Daily (the convention for null / empty / "no valid weekdays")
WHEN "days_of_week" IS NULL
OR length(trim("days_of_week")) = 0
THEN 'FREQ=DAILY'
-- Multi-week with weekday subset ("i2;1,3,5")
WHEN "days_of_week" ~ '^i[1-4];[0-6](,[0-6])*$' THEN
'FREQ=WEEKLY;INTERVAL=' ||
substring("days_of_week" FROM 2 FOR 1) ||
';BYDAY=' ||
pg_temp.hl_days_to_byday(substring("days_of_week" FROM position(';' IN "days_of_week") + 1))
-- Multi-week, interval prefix only ("i2;")
WHEN "days_of_week" ~ '^i[1-4];$' THEN
'FREQ=WEEKLY;INTERVAL=' ||
substring("days_of_week" FROM 2 FOR 1)
-- Legacy weekday-list ("1,3,5")
WHEN "days_of_week" ~ '^[0-6](,[0-6])*$' THEN
'FREQ=WEEKLY;BYDAY=' || pg_temp.hl_days_to_byday("days_of_week")
-- Anything unexpected stays NULL so the v1.5.1 read-flip falls
-- back to the legacy decoder.
ELSE NULL
END
WHERE TRUE;
-- ── XOR constraint: rrule and rolling_interval_days are mutually
-- exclusive. Both NULL is allowed (legacy rows + future migrations).
ALTER TABLE "medication_schedules"
ADD CONSTRAINT "medication_schedules_rrule_xor_rolling"
CHECK ("rrule" IS NULL OR "rolling_interval_days" IS NULL);
-- ── Indexes ────────────────────────────────────────────────────
-- A targeted index on `rolling_interval_days IS NOT NULL` lets the
-- reminder worker quickly find every rolling-cadence schedule in
-- the system on the cron tick (rolling cadences are event-driven so
-- the worker needs to fan in over them on every intake).
CREATE INDEX "medication_schedules_rolling_idx"
ON "medication_schedules" ("rolling_interval_days")
WHERE "rolling_interval_days" IS NOT NULL;
-- Documentation comment on the legacy column so a future reader
-- understands the v1.5 → v1.6 migration plan.
COMMENT ON COLUMN "medication_schedules"."days_of_week" IS
'Legacy v1.4 cadence string encoding. Backfilled into `rrule` by the v1.5 migration; readers consult `rrule` first as of v1.5.1; column drops in v1.6.0.';
COMMENT ON COLUMN "medication_schedules"."window_start" IS
'Legacy v1.4 reminder-window start. `times_of_day[0]` is the v1.5 first-class equivalent; column kept through v1.5.x for backwards-compat.';
COMMENT ON COLUMN "medication_schedules"."window_end" IS
'Legacy v1.4 reminder-window end. `reminder_grace_minutes` is the v1.5 first-class equivalent; column kept through v1.5.x for backwards-compat.';
@@ -0,0 +1,24 @@
-- v1.5.4 — Clinical category bucket additions.
--
-- The medication's clinical category lives in a side-table
-- (`medication_categories`) with a TEXT column rather than a Prisma
-- enum, so adding a new bucket does not require an ALTER TYPE. The
-- migration is documentation-only — the new "DIABETES" and
-- "ANTIBIOTIC" values are enforced at the Zod layer
-- (`MEDICATION_CATEGORY_VALUES` in src/lib/validations/medication.ts)
-- and the side-table's `normalizeCategory` helper accepts them on
-- the same path as the pre-existing buckets.
--
-- The modal-wizard's Step 2 taxonomy adds Diabetes and Antibiotikum
-- as first-class rows. The mapping table (see
-- src/components/medications/wizard/wizard-payload.ts —
-- WIZARD_TREATMENT_MAPPING) writes "DIABETES" and "ANTIBIOTIC" to
-- the side-table for those rows instead of collapsing them into
-- "OTHER".
--
-- No SQL changes are needed — the index on `category` already covers
-- the new values, and existing rows retain their previous bucket.
-- Force a non-empty migration body so prisma records the new step
-- in the `_prisma_migrations` ledger.
SELECT 1 WHERE FALSE;
@@ -0,0 +1,29 @@
-- v1.5.5 — extend `measurement_type` enum for the six iOS-side
-- deferred identifiers.
--
-- Each HK identifier in this set used to land in
-- `HK_QUANTITY_TYPE_DEFERRED` in `src/lib/measurements/apple-health-mapping.ts`.
-- The batch ingest returned HTTP 200 with a per-entry
-- `skipped:"unmappable_identifier"`; the iOS client read that as
-- success and advanced its sync anchor, so any sample carrying one
-- of these identifiers was lost forever. The mapping now wires them
-- end-to-end:
--
-- RESPIRATORY_RATE ← HKQuantityTypeIdentifierRespiratoryRate
-- BODY_MASS_INDEX ← HKQuantityTypeIdentifierBodyMassIndex
-- LEAN_BODY_MASS ← HKQuantityTypeIdentifierLeanBodyMass
-- WALKING_HEART_RATE_AVERAGE ← HKQuantityTypeIdentifierWalkingHeartRateAverage
-- WALKING_ASYMMETRY ← HKQuantityTypeIdentifierWalkingAsymmetryPercentage
-- WALKING_DOUBLE_SUPPORT ← HKQuantityTypeIdentifierWalkingDoubleSupportPercentage
--
-- Pure additive — no existing row carries any of these values. The
-- `IF NOT EXISTS` guard keeps the migration idempotent and lets the
-- runner replay it on a partially-applied environment without
-- failing on the duplicate-enum-value error code.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESPIRATORY_RATE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_MASS_INDEX';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LEAN_BODY_MASS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_HEART_RATE_AVERAGE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_ASYMMETRY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_DOUBLE_SUPPORT';
@@ -0,0 +1,44 @@
-- v1.5.5 — per-user insights tile layout.
--
-- Mirrors `dashboard_widgets_json` for the `/insights` surface. iOS
-- v0.8.0 audit flagged that insights tile order + visibility lived in
-- the iOS client's local state only, so reordering a tile on one
-- device never propagated to the web Settings UI or to a second iOS
-- install signed into the same account. This column closes that
-- cross-device drift by giving the new `GET / PUT /api/insights/layout`
-- endpoint somewhere to persist the user's choice.
--
-- Shape:
-- {
-- "version": 1,
-- "tiles": [
-- { "id": "<tile-id>", "visible": <bool>, "order": <int 0..> },
-- …
-- ]
-- }
--
-- Default `NULL`, which the application resolver
-- (`src/lib/insights-layout.ts:resolveInsightsLayout`) reads as
-- "use `DEFAULT_INSIGHTS_LAYOUT`". The GET endpoint never lazy-writes
-- on read — the column only carries data once the user has explicitly
-- saved a layout, matching the dashboard-widgets contract.
--
-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 /
-- 0075 / 0078 / 0079 pattern so reruns are safe on environments where
-- the migration has already been applied.
--
-- Reversibility: down migration is
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_layout_json";
-- A roll-back loses the saved layout for every user who set one (they
-- fall back to the default order), but the application reads tolerate
-- a missing column / null row and the resolver returns the default
-- layout in either case, so a partial deploy where the schema is
-- rolled back ahead of the app code keeps working.
-- No additional index — reads at request time are per-user
-- (`WHERE id = $1`), matching the convention set by
-- `dashboard_widgets_json`, `thresholds_json`, `source_priority_json`,
-- and `doctor_report_prefs_json` on the same table (no GIN index on
-- any of them).
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "insights_layout_json" JSONB;
@@ -0,0 +1,24 @@
-- v1.5.5 — self-hosted avatar storage.
--
-- Replaces the Gravatar third-party leak. The previous /me payload
-- carried a `gravatarUrl` built from `SHA-256(email)` and pointed
-- every authenticated page-load at www.gravatar.com — Automattic
-- could correlate the digest against its known-email table on each
-- request. The new path stores the image bytes on the User row and
-- serves them from same-origin.
--
-- Three nullable columns; null on all three = the user has not
-- uploaded an avatar and the profile response returns
-- `avatarUrl: null`. The upload route guards the size + content-
-- type + dimensions before the row write, so the column-level
-- constraints intentionally stay loose (the validation is enforced
-- at the API layer where it can return a useful 4xx).
--
-- The bytes ride in pg_dump alongside the rest of the row, so the
-- standard backup path already covers them. No new filesystem
-- volume to mount, no new Docker compose change.
ALTER TABLE "users"
ADD COLUMN "avatar_bytes" BYTEA,
ADD COLUMN "avatar_content_type" TEXT,
ADD COLUMN "avatar_updated_at" TIMESTAMP(3);
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff. Show more