Commit Graph
2586 Commits
Author SHA1 Message Date
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>
v1.5.4
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>
v1.5.3
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