Commit Graph
196 Commits
Author SHA1 Message Date
Marc-André Bombeck cf27df49c6 feat(api): research-mode acknowledgment endpoint (GET/POST/DELETE)
Three handlers at /api/auth/me/research-mode gate the opt-in GLP-1
PK research-view chart behind an MDR-disclaimer acknowledgment.

  GET     — returns the persisted flag/version + the live
            currentDisclaimerVersion so the Settings UI can detect
            copy drift and re-prompt.
  POST    — { acknowledged: true, version } turns the flag on.
            Rejects 400 when the submitted version does not match
            the server-side RESEARCH_MODE_DISCLAIMER_VERSION; rejects
            422 on malformed body. Per-user rate-limit 5/min — this
            is a one-tap dialog, not a hot path.
  DELETE  — idempotent: clears all three columns and writes an
            audit row even when the flag is already off.

Prompt-injection guard — the body shape accepts the version string
only (not free text); the value is matched against a server-side
constant before any DB write and never reaches the LLM. Hand-rolled
validation keeps the surface visibly tiny in code review.

11 route tests cover unauth/enabled/disabled GET; happy-path POST
with audit; stale-version POST; malformed POST; rate-limited POST;
unauth/successful/idempotent DELETE with audit.
2026-05-14 19:55:30 +02:00
Marc-André Bombeck 4849ac7634 feat(api): add POST /api/onboarding/step for wizard progression
Persists per-step progress with strict order enforcement: submitted
step must equal current+1, step 4 flips onboardingCompletedAt and
clears the hl_onboarding proxy cookie. Re-reads the fresh User row to
defend against tab-race conditions, audit-logs every transition, and
caps writes at 30 per 10 min per user to defang stuck-retry loops.

409 on out-of-order, 409 on already-completed, 422 on payload
validation failure, 429 on rate limit.
2026-05-14 19:39:42 +02:00
Marc-André Bombeck 7b7a896c44 feat(api): batch routes enqueue pr-detection (silent on >50 batch)
Measurement and workout batch ingest now fire a pr-detection job
after each successful insert. Batches above 50 entries propagate
silent=true so a multi-year Apple Health or HKWorkout backfill
writes the records without spamming hundreds of pushes. Both
enqueue calls are wrapped in audit-log entries and a swallow-and-
annotate failure path so a flaky queue cannot 500 an ingest call —
the 30-minute fallback cron picks the user up in any case.
2026-05-14 18:47:22 +02:00
Marc-André Bombeck d34c30eab6 feat(api): medication-inventory CRUD + intake-event hook for dose decrement 2026-05-14 18:46:49 +02:00
Marc-André Bombeck b55942fd18 feat(jobs): pg-boss queues for activity-sync + sleep-v2-sync + webhook enqueue hooks
Two new pg-boss queues join the existing reminder-worker orchestrator:

  withings-activity-sync — cron '0 * * * *' (hourly at :00)
  withings-sleep-sync    — cron '15 * * * *' (hourly at :15, offset)

Each queue's handler dispatches per-user: when the payload carries
`userId` (webhook-triggered) only that user syncs; when `userId` is
absent (cron-triggered) the worker iterates every WithingsConnection.
Per-user failures log as warnings so one parked-at-reauth connection
doesn't starve the rest on a cron tick.

The webhook handler now reads the `appli` form / JSON field and
dispatches:

  appli=16  → enqueue withings-activity-sync (W17b)
  appli=44  → enqueue withings-sleep-sync    (W17c)
  appli=1/2/4 or missing → legacy syncUserMeasurements (unchanged)

When pg-boss is unavailable (dev server without the worker process)
the handler falls back to inline sync so dev iteration isn't blocked
on a separate worker process.

The 15-minute cadence offset between activity (:00) and sleep (:15)
keeps the two crons out of lockstep with the existing measure cron at
:00, smoothing the load against the Withings 120-req/min/app cap.
2026-05-14 18:29:59 +02:00
Marc-André Bombeck caf2371c77 test(api): batch-workout integration + race + size-cap coverage
Unit + integration coverage for POST /api/workouts/batch.

Unit (database-free, mocks the Prisma layer):
- 401 on unauthenticated calls
- 413 when Content-Length exceeds the 5 MB ceiling, with the
  documented workout.batch.payload_too_large error code
- 429 when the rate-limit gate blocks the call
- 400 when the workouts array exceeds the 100-entry cap
- 400 when the Zod schema rejects the payload
- 400 when a route exceeds the 20 000-point cap
- Fresh entries marked inserted
- Pre-existing entries flagged duplicate without re-inserting
- Race-reconciliation downgrades inserted statuses to duplicate
  when createMany.count is below the attempted-row count
- WorkoutRoute rows attached by FK after the workout createMany

Integration (real Postgres in a testcontainer):
- Happy-path single workout with nested route lands as a Workout
  row + WorkoutRoute row + 1:1 FK + computed durationSec
- 100-workout batch with mixed routes lands all 100 workouts and
  exactly the expected number of route rows
- Over-cap batch (101 workouts) returns 400 with
  workout.batch.too_large
- Over-cap route (20 001 points) returns 400 with
  workout.batch.invalid
- Re-posted batch surfaces as duplicates without inserting twice
- Idempotency-Key replays the cached envelope and sets the
  X-Idempotent-Replay header
- Pre-seeded rate-limit row triggers 429 and writes nothing
- Narrow-scope Bearer token admitted on a route that declares no
  required permission (the W10 fix-C correctness fix the iOS
  narrow-scope sync depends on)
2026-05-14 18:15:37 +02:00
Marc-André Bombeck 839fa6bd62 feat(api): POST /api/workouts/batch — typed workout ingest with nested route
First write endpoint for the W8d Workout + WorkoutRoute tables.
Mirrors the v1.4.25 W10-hardened /api/measurements/batch in every
cross-cutting concern so the iOS sync engine and the deferred
Withings activity sync (W17b) can re-use the same retry / cursor
plumbing:

- requireAuth() — cookie + Bearer; narrow-scope-token safe per
  the W10 fix-C contract (a route that declares no scope admits
  any authenticated token, wildcard or otherwise).
- withIdempotency — Idempotency-Key replays the cached envelope
  on retry (24h window).
- checkRateLimit at 60 batches/min/user — 100 workouts/batch
  ceiling × 60/min = 6 000 workouts/min headroom, orders of
  magnitude past any healthy sync.
- Content-Length pre-flight at 5 MB — anything larger returns
  413 with a structured error code so the client falls back to
  one-workout-per-call.
- Per-entry status (inserted | duplicate | skipped) so the iOS
  sync cursor can checkpoint accurately.

Idempotency contract:
- HTTP-level via Idempotency-Key.
- Per-entry via @@unique([userId, source, externalId]) — Prisma
  createMany skipDuplicates absorbs duplicate-key conflicts.

Race reconciliation mirrors the v1.4.25 W10 fix for measurements
batch: under contention, createMany.count is the source of truth
for how many rows THIS request wrote, and we downgrade enough
per-entry inserted statuses to duplicate so the envelope sums
stay consistent with the aggregate counts. The iOS sync cursor
advances past both statuses identically.

Two-step write: createMany the workouts, then look up the freshly
written rows by composite key and createMany the routes by FK.
The 1:1 WorkoutRoute.workoutId unique index means route inserts
are also idempotent on re-submit.
2026-05-14 18:08:54 +02:00
Marc-André Bombeck a1ffa497f1 fix(security): Withings webhook secret as URL path segment, not query string
Move the Withings webhook shared secret from `?secret=…` to a path
segment (`/api/withings/webhook/<secret>`) so it never lands in the
reverse-proxy `query_string` access-log column or GlitchTip URL/
breadcrumb capture surface (W10 finding M-2).

Withings has no facility for custom HTTP headers on outgoing
notifications and never signs the body — every Notify.subscribe call
accepts only action/callbackurl/appli/client_id/nonce/signature, so
the callback URL is the only authenticity surface a subscriber
controls. Path-segment binding is the largest practical shift away
from loggable query strings that Withings supports end-to-end.

The legacy `/api/withings/webhook` route stays alive for one release
cycle (removal target: v1.4.27) so existing subscriptions keep
delivering. The query-string path now emits a deprecation breadcrumb
on every hit. Active users re-subscribe organically when they reconnect
for the v1.4.25 `user.activity` OAuth-scope upgrade.

Tests: 8 new for the path-segment route (valid / invalid / missing /
unset-env / prefix-match / rate-limited / HEAD / GET), 3 new for
`getWithingsWebhookCallbackUrl()`. Existing legacy-route tests
continue to pass over the refactored shared helper.
2026-05-14 17:46:25 +02:00
Marc-André Bombeck 53a7992a91 fix(audit): log source-priority + doctor-report-prefs writes
Both PUT endpoints persist values into User.*Json columns that
shape downstream analytics output (source-priority drives the
aggregator's source pick on every read; doctor-report-prefs gates
PDF section inclusion including the mood-data toggle). Neither
wrote an audit-log entry, so a silent compromise or unintended
client write was invisible.

Mirror the timezone-route pattern: capture the previous shape,
write the new value, then `auditLog` with previous + next +
client IP. Adds the missing source-priority route test alongside
the new audit case in the doctor-report-prefs suite.
2026-05-14 17:03:10 +02:00
Marc-André Bombeck ad62fe229f fix(api): correct batch-ingest race reconciliation — accurate inserted/duplicate counts under contention
The previous race-reconciliation block compared post-insert rows in
the DB against the rows we attempted to insert, downgrading any
"inserted-but-not-stored" entries to "duplicate". Under standard
Postgres semantics that branch never fires: `skipDuplicates`
absorbs duplicate-key conflicts but the row is still present in the
table (written by the other batch that won the race). So every
attempted row was "stored" and the loop ran as a no-op, leaving
the per-entry envelope inconsistent with the aggregate counts when
two batches collide. The v1.5 iOS sync cursor depends on the
envelope sums matching the aggregate counts to checkpoint
correctly.

Pragmatic fix: trust `createMany.count` for the aggregate
`insertedCount` and downgrade exactly `toInsert.length -
insertedCount` per-entry "inserted" statuses to "duplicate". The
post-fix loop no longer touches `insertedCount` (it was already
correct from createMany) so the count cannot drift negative. Order
doesn't matter for the client — both statuses checkpoint past the
row and the DB ends up with the same single-copy state.

Concurrent-write integration test asserts the three invariants the
iOS sync cursor relies on: per-entry sums equal aggregate counts,
aggregate counts stay non-negative, and the DB row count matches
the combined `inserted` total.
2026-05-14 17:01:07 +02:00
Marc-André Bombeck c49fe731e4 fix(measurements): accept deviceType on single-entry POST (mirrors batch route)
`Measurement.deviceType` exists on the row, was added by the W8c
batch ingest path, but the single-entry `createMeasurementSchema`
omitted it — every single-row POST silently dropped the device tag
even when the client supplied one. Manual rows tagged with watch /
scale / ring metadata lost their attribution at the schema boundary
and showed up as unsourced.

Declare `deviceType` on the schema (optional, nullable, 1–32 chars),
thread it through both single-entry and array-body POST handlers,
and add three RTL cases (present, null, omitted) so the parsed
shape stays observable.
2026-05-14 16:56:06 +02:00
Marc-André Bombeck 04dd972358 fix(security): rate-limit batch-measurements ingest (60/min default)
The Apple Health batch endpoint accepted 500-row payloads with no
per-user write cap. A leaked wildcard iOS token could sustain
unbounded inserts (500 rows × N batches/sec) and degrade the
database for every other user on the host.

Gate the write path with the shared `checkRateLimit` helper at
60 batches/min/user — generous for legitimate iOS sync (the
HealthKit observer coalesces well below one batch/minute on a
quiet device, and 60/min × 500 rows = 30 000 rows/min headroom)
and tight enough that a misbehaving client bottoms out within a
minute. Returns 429 with an unambiguous message so the iOS
client can back off and retry rather than assume the row landed.

Integration test exercises the over-limit path by pre-seeding the
`rate_limits` counter at the cap.
2026-05-14 16:54:45 +02:00
Marc-André Bombeck 3dff9345d2 test(personal-records): coverage for limit clamp + default value
Update existing call-shape assertions to include the new `take: 100`
default and add three cases for the W10 pagination contract:
  - explicit `?limit=25` passes through verbatim
  - `?limit=999999` clamps to the project-wide ceiling (500)
  - garbage `?limit=abc` falls back to the default without 400-ing
2026-05-14 16:54:35 +02:00
Marc-André Bombeck eb49d8ab1b fix(personal-records): clamp findMany result count via ?limit pagination
The GET route was an unbounded findMany. Today the detection worker
isn't shipped so every user sees an empty array, but a power user with
multi-year Apple Health history could accumulate 50+ PRs per metric ×
14 PR-trackable metrics once the worker lands.

Default 100, max 500 — matches `listMeasurementsSchema`'s ceiling so
every ingest-and-read endpoint on the route surface shares one upper
bound. Garbage values silently clamp to the default rather than 400-ing,
keeping the same defence-in-depth posture the `metricType` parse uses.
The v1.5 iOS app's query path is now bounded from day one.
2026-05-14 16:53:02 +02:00
Marc-André Bombeck 71745b4c63 fix(analytics): thread user timezone through the weight-weekday correlator
The W7 per-user-tz sweep threaded `userTz` through every aggregator on
the analytics route except `berlinIsoWeekday()` — the module-level
`Intl.DateTimeFormat` stayed pinned to `Europe/Berlin`. A user in
`Pacific/Auckland` whose 23:30 weight reading lands at 12:30 the same
day in Berlin had their Monday-local reading bucketed under Sunday's
weekday column. Pearson/ANOVA on the wrong weekday.

Replace the pinned formatter with a per-tz memoised lookup, rename the
helper to `isoWeekdayInTz()`, and feed `userTz` through the call site.
Also rename the misleading `dateFromBerlinKey()` → `dateFromDayKey()`
since the keys it consumes are already anchored to the user's tz
upstream.

Integration test covers the non-Berlin path: 28 weight readings at a
UTC instant where the Berlin and Auckland weekdays disagree; the
correlator reaches the gate with every seeded row counted, confirming
the helper used the user's tz to bucket.
2026-05-14 16:48:46 +02:00
Marc-André Bombeck 86737fd6a5 feat(health-score): expose per-component source + asOf in provenance detail
Extends HealthScoreComponentDetail with a source pill (manual /
withings / appleHealth / mixed / none) and an asOf ISO timestamp.
The analytics route folds the persisted MeasurementSource enum from
weight + BP rows into the new attribution slot; mood + compliance
stay manual until later ingest paths land. Backward compat is
preserved — callers that don't pass attribution still get a valid
result with manual defaults for present components.
2026-05-14 16:15:18 +02:00
Marc-André Bombeck 8f57f5e0d7 feat(schema): PersonalRecord + direction helper (Migration 0054)
Schema-only release of the personal-records feature. Lands now so the
v1.5 iOS-Swift app can build its query path against the GET
/api/personal-records contract from day one; the detection worker
that populates the rows is explicitly out of v1.4.25 scope and
will land in v1.4.26 or v1.5.

Schema (prisma/migrations/0054_personal_record):
  - PersonalRecordDirection enum (MAX | MIN) stored on the row so
    the future detection worker doesn't re-resolve direction at
    query time.
  - personal_records table: userId FK (cascade), metricType
    (MeasurementType enum), nullable metricSlot for sport-specific
    PRs, direction NOT NULL, value + unit + achievedAt, optional
    sourceMeasurementId (SET NULL on delete so PRs survive the
    underlying measurement), source provenance, externalId.
  - Composite unique (userId, metricType, metricSlot, achievedAt)
    for idempotent worker re-runs.
  - Index (userId, metricType, value) for the "current PR" read
    pattern (ORDER BY value DESC LIMIT 1 / ASC LIMIT 1).

Direction helper (src/lib/personal-records/pr-direction.ts):
  - getPRDirection(MeasurementType) -> MAX | MIN | null. Exhaustive
    switch covering all 28 canonical enum values:
      MAX — steps, active energy, flights, distance, VO2 max, HRV,
            total body water, bone mass, muscle mass, time in daylight.
      MIN — resting HR, body fat, fat mass, visceral fat, vascular age,
            pulse-wave velocity, audio exposure (env + headphone).
      null — BP, glucose, body / skin temperature, pulse, SpO2, weight,
             sleep duration, fat-free mass (homeostatic, clinical-edge,
             or direction-ambiguous metrics; the v1.4.26 worker can
             enrich weight from User.thresholdsJson when it ships).
  - isPRTrackable() convenience predicate for the future worker.

API surface (src/app/api/personal-records/route.ts):
  - GET returns { data: PersonalRecord[], error: null } (canonical
    apiSuccess envelope).
  - Optional ?metricType=X filter, validated against
    measurementTypeEnum; unknown values drop silently so a
    loose-typed caller never 400s.
  - No write endpoint, no detection worker — explicitly v1.4.26
    scope.

Tests (drift-guard + route):
  - pr-direction: every MeasurementType returns one of MAX / MIN /
    null; per-direction bucket membership pinned.
  - route: 401 unauth, empty response, valid filter passthrough,
    unknown-filter graceful drop, seeded-record round-trip.
2026-05-14 16:01:26 +02:00
Marc-André Bombeck 53278ccd87 test(sources): integration tests for two-axis canonical-source resolution
Lock the GET /api/analytics wire-up to the new two-axis picker with a
fixture-driven test that drives a real Postgres testcontainer + the
production handler. Four scenarios on the same Berlin day:

  1. Plain default user (single source, no override) — picker passes
     through; v1.4.25-today behaviour stays a regression sentinel.
  2. Per-metric override (sleep: [WITHINGS, APPLE_HEALTH]) — flips
     the default APPLE_HEALTH-first ladder; Withings row wins.
  3. Per-device default (no user override) — Apple Watch row beats
     iPhone row under same source on default ladder (watch first).
  4. User flips device ladder to [phone, watch] — iPhone row wins.

Wire-up: analytics' fetchMeasurementSeriesChunked now selects
deviceType + type on every chunk so the picker can read both axes.
ChunkedRow gains two fields; all existing aggregation paths continue
to compile and the integration suite stays green (6/6 passing across
analytics-sleep-stages.test.ts + source-priority-two-axis.test.ts).

Unit tests already cover the picker's internal edge cases (45 unit
across helper + algorithm); this file is the end-to-end guard.
2026-05-14 15:36:42 +02:00
Marc-André Bombeck 024845e5bd feat(api): nullable deviceType on Measurement for two-axis source priority
Add an optional deviceType field to the POST /api/measurements/batch
payload schema. The iOS client maps each sample's HKDevice.model to a
canonical device class (watch | band | ring | phone | scale | other |
unknown); the server stores it on a new Measurement.device_type
column. The cross-source canonical picker (W8c) reads this column to
break Apple-Watch-vs-iPhone ties when the same source contributed
multiple devices for the same day.

Backward-compatible end-to-end:
  - Schema field is optional; legacy iOS builds keep working unchanged.
  - DB column is nullable; the migration is an additive ALTER TABLE
    (Migration 0051, next free per v1.4.25 handoff section 4).
  - The picker treats null as 'unknown' and only uses it as a
    tiebreaker when a ranked device-type coexists in the bucket; with
    a single source + null deviceType the row passes through as today.

OpenAPI: deviceType is documented on AppleHealthBatchEntry with
guidance on the HKDevice.model mapping. Batch endpoint description
gains a W8c note. iOS-Claude can lock the field name + enum from the
spec without re-reading source.

Tests: 45/45 source-priority tests pass; the batch route's existing
integration tests are untouched by the schema addition.

Pre-existing lint failure unrelated to this change:
src/components/i18n/maintainership-banner.tsx triggers a
react-hooks/set-state-in-effect warning; the file is currently
untracked and lives outside this Wave's scope.
2026-05-14 15:34:18 +02:00
Marc-André Bombeck 98f7d11861 chore: resolve parallel-agent merge conflicts + sweep up loose edits
7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.

This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
2026-05-14 14:19:08 +02:00
Marc-André Bombeck 5a9612822e feat(withings): OAuth scope upgrade + reconnect banner
v1.4.25 W5d. The Withings Activity / Sleep endpoints sit behind the
`user.activity` OAuth scope which v1.4.24-and-earlier connections
never requested. This commit ships the scope plumbing + reconnect
surface so existing users can opt in to step / active energy /
distance / floors-climbed ingest with a single click.

  - Schema: `WithingsConnection.scope` nullable column. NULL =
    legacy connection that's never re-authed since v1.4.25.
  - Migration 0050 — additive column add.
  - Client: `WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity"`
    + `parseWithingsScope` / `hasActivityScope` helpers.
  - Callback: persists `scope: WITHINGS_OAUTH_SCOPE` on every
    successful exchange, so reconnecting upgrades the row.
  - Status route: exposes `scope` + `hasActivityScope` so the UI
    can render the reconnect banner conditionally.
  - Settings → Integrations: paint a reconnect banner when the
    user has an active connection AND `hasActivityScope === false`.
    Click → /api/withings/connect → standard OAuth flow.
  - i18n: EN + DE strings for the banner +
    `measurements.types.{fatFreeMass,fatMass,muscleMass,
    skinTemperature,pulseWaveVelocity,vascularAge,visceralFat}`.
  - 5 new unit tests covering the scope helpers.

Note: the Activity sync routine itself
(`POST /v2/measure?action=getactivity`) lands in v1.4.26. Shipping the
scope upgrade now means users only reconnect once instead of twice.

Cross-source dedup protection is already in place via the W5e
foundation (`pickCanonicalSourceRows`): once Activity sync lands AND
a user also has Apple Health passthrough (v1.5), the per-day picker
prevents double-counting cumulative metrics from two sources.
2026-05-14 14:13:58 +02:00
Marc-André Bombeck 7846d68dd5 feat(insights): sleep-stages chart renders per-night stacked bars (7/14/30d toggle)
Marc directive 2026-05-14 — the /insights/schlaf chart now shows one
bar per night with DEEP / REM / CORE / AWAKE / IN_BED / ASLEEP stacked,
matching Apple Health's sleep tab. The 30-day average-composition
read is preserved as a fallback when the API doesn't yet ship
perNight (legacy clients during rollout).

Plumbing:
- src/app/api/analytics/route.ts: computeSleepStageBreakdown() also
  builds a per-Berlin-tz-day perNight array, sorted ascending. Empty
  days drop out — the chart treats them as gaps.
- src/components/insights/sleep-stage-stacked-bar.tsx: rewrites
  the visualisation. Default window = 7d; 14d + 30d toggles in the
  card header (same min-h-11 touch targets as the dashboard range
  tabs). Y-axis renders hours, tooltips show per-stage minutes +
  percent + nightly total.
- Mobile-first: window-toggle pills reflow under the title on <sm
  screens, sit inline on sm+. Bars use Recharts vertical layout
  (per the directive — no horizontal reflow on mobile, just tighter
  XAxis labels via preserveStartEnd).

i18n: no new keys — reuses insights.sleep.stages.* + the existing
compositionTitle / compositionSubtitle / compositionAriaLabel slots.

Tests: 4 new cases under sleep-stage-stacked-bar.test.tsx cover
- 7d default + three toggle buttons rendering
- empty-state (perNight=[] AND stages={} → 'sleep-stage-empty' slot)
- legacy fallback (no perNight → aggregate row still renders)
- sparse perNight (gaps don't crash the chart)
Existing 4 tests (title + aria + de locale + unknown stage key)
keep passing after perNight prop addition.
2026-05-14 14:08:56 +02:00
Marc-André Bombeck 03f58c75b8 docs(planning): W4d v1.4.25 GLP-1 full integration phase report 2026-05-14 14:04:53 +02:00
Marc-André Bombeck d74aa2066b style(glp1): prettier formatting across W4d surfaces 2026-05-14 14:03:43 +02:00
Marc-André Bombeck 6148a179ca feat(briefing): GLP1 plateau detection rule with clinical referral framing
Phase 9 of W4d GLP-1 full integration. New `detectGlp1Plateau()`
helper flags the clinical plateau pattern for GLP-1 receptor agonists:
≥21 days on a stable dose AND weight delta within ±0.5 kg over that
same window. When the rule fires, a SYSTEM CONTEXT block is appended
to the insight-generation user prompt with the named drug, current
dose, days-on-dose, and weight delta — and instructs the model to
emit a glp1_plateau keyFinding framed observationally.

GROUND RULE 14 (insight prompt) keeps the safety contract: the
finding NAMES the plateau and the conversation it should prompt
("worth mentioning at the next visit"), but NEVER recommends a dose
change. The plateau is detection, not prescription.

Detector returns `null` when no GLP-1 medication is active OR no
plateau condition is met, so 99% of users pay zero token cost and
never see a plateau-shaped section in their briefing.

The schema gains "glp1_plateau" in the keyFinding.sourceMetric enum
(landed earlier this phase) and the daily-briefing icon mapping
inherits the Pill icon for visual consistency with other
medication findings.
2026-05-14 13:52:31 +02:00
Marc-André Bombeck 2c7c3cc9f3 feat(insights): GLP1 therapy timeline on /insights/medikamente
Phase 8 of W4d GLP-1 full integration. New TherapyTimeline component
mounts on /insights/medikamente below the compliance grid. Renders a
chronological merge of every GLP-1-relevant event the user has logged:
dose changes (with up/down arrow against the previous step),
injections (with optional site), inventory events, and side-effect
days collapsed from MoodEntry tags.

Self-hides for users without a GLP-1 medication (the API returns
`hasGlp1: false`), so /insights/medikamente keeps its v1.4.24 look
for everyone else.

New GET /api/insights/glp1-timeline aggregates the data. Caps at 60
entries by default (configurable to 200 via ?limit) to keep the list
readable; older events surface via the existing intake-history page.

DE + EN copy for the surface lives under insights.therapyTimeline.
The medikamente sub-page description gains a one-liner explaining
when the timeline appears.
2026-05-14 13:49:35 +02:00
Marc-André Bombeck edae56969f chore(insights): drop orphaned /api/insights/general-status — superseded by InsightAdvisorCard since v1.4.16
The HTTP route had no remaining UI callers — the v1.4.16 polish replaced
the <InsightStatusCard> on the /insights overview with the polished
<InsightAdvisorCard>, and the per-metric sub-pages each consume their
own dedicated endpoint (/api/insights/{weight,bmi,pulse,blood-pressure,
medication-compliance}-status). The lib helper generateGeneralStatusForUser
remains in src/lib/insights/general-status.ts because the reminder
worker still pre-warms the daily cache for users opted in to the
briefing notification (src/lib/jobs/reminder-worker.ts).

Verification:
- grep confirms zero callers under src/ outside the route file itself
- the route was not registered in src/lib/openapi/routes.ts and
  docs/api/openapi.yaml carries no general-status path
- pnpm typecheck remains clean
2026-05-14 13:49:06 +02:00
Marc-André Bombeck 88efd42755 feat(dashboard): GLP-1 status tile with next-injection + weight-delta caption
Renders a dedicated tile for users on a GLP-1 receptor agonist
(Mounjaro, Ozempic, Wegovy, Zepbound, …) — the new W4d schema enum
`Medication.treatmentClass = GLP1` is the discriminator. Tile self-
hides when no active GLP-1 med exists so accounts without one see no
visual change.

The tile surfaces:
  - Drug name + current dose ("Mounjaro 7.5mg") — sourced from the
    `MedicationDoseChange` titration history (latest row wins), so a
    Mounjaro user titrating up 2.5 → 5 → 7.5 → 10 mg sees their
    current step, not the starting dose.
  - Last injection date + weekday ("Sun, 05/10")
  - Next injection date + weekday + countdown
    ("Sun, 05/17 (in 3 days)") — predicted from the existing schedule
    cadence
  - Weight delta since starting the therapy ("−4.2 kg since start"
    / "−4,2 kg seit Beginn") — green chip on loss, orange on gain,
    muted minus on flat (within ±0.1 kg)
  - A compact weight chart with vertical injection-day markers via
    the new `<HealthChart verticalMarkers={…} />` prop

New server endpoint `GET /api/dashboard/glp1` aggregates the data:
the heavy lifting (current dose / last + next injection / titration
history / inventory math) is delegated to the existing
`buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
the dashboard tile read from the same shape. The route adds the UI-
facing extras (weight series since first dose-change, current vs
start, injection date keys for the markers) on top.

Mount: dashboard mother page (`src/app/page.tsx`) mounts the tile
below the AI insights preview and above the chart row — high enough
that a Mounjaro user sees their dose-response without scrolling past
BP / pulse, but below the AI preview which stays the scroll-stop
hero anchor.

Constraint respected (Marc directive 2026-05-14): the chart lives on
the Dashboard tile and the Insights /medikamente sub-page only; the
Medications page itself stays chart-free.

i18n keys: dashboard.glp1.{title, lastInjection, nextInjection,
weightDelta, inDays, empty.title, empty.description} in EN + DE.

Tests pin 7 scenarios: hidden when no active GLP-1, hidden when meds
array is empty, skeleton during fetch, full render for a Mounjaro 7.5
mg weekly profile, latest titration step wins (10 mg, not 2.5 mg),
fallback to name-only when no dose history exists, German copy under
the de locale.
2026-05-14 13:37:29 +02:00
Marc-André Bombeck b8a1c1833b feat(medications): GLP1 medication-card variant — text-rich, no chart per Marc directive
Phase 4 of W4d GLP-1 full integration. New `Glp1MedicationCard`
variant renders inside the existing medications grid alongside generic
cards — same Card shell, same compliance bars, same primary intake
actions. The GLP-1-specific rows sit between the dose line and the
compliance bars so the user reads top-down: drug → injection state →
adherence.

Marc directive 2026-05-14: NO chart on the medication card. The card
stays text-rich:
- Headline: drug name + current dose ("Mounjaro · 7.5 mg")
- Last injection line with optional site ("Last: Mo May 12 · Abdomen,
  lower left")
- Next injection line with day count ("Next: Mon, May 19 (in 5 days)")
- Rotation hint ("Last site: Abdomen left · Recommended:
  Abdomen right") — only when the last site differs from the
  recommended next site
- Pen-inventory line with low-stock badge when dosesPerUnit is set
- Collapsible "Dose history" details block listing every
  MedicationDoseChange row
- "Log side effect" quick-button alongside Taken / Skipped

Routing: the medications page reads `treatmentClass === "GLP1"` and
dispatches to the variant. Existing meds (treatmentClass = "GENERIC",
the migration 0046 default) render the unchanged generic card.

Companion pieces:
- `injection-sites.ts` — the 8-site enum + `nextInjectionSite()`
  recommender that maximises Euclidean distance to recent picks and
  beats stale sites in the tie-break.
- `/api/medications/[id]/glp1` — GLP-1 details endpoint feeding the
  card (dose changes + recent intakes + running inventory math).
  POST creates a dose-change or inventory event.
2026-05-14 13:36:30 +02:00
Marc-André Bombeck b50ebccf0b feat(coach): distinct daily-limit vs provider-rate-limit error UX
`coach.budget.exceeded` (per-user daily token cap, returned as JSON
429) now surfaces the dedicated "Daily limit reached; resets at 00:00
UTC." copy. `coach.provider.rate_limited` (every provider in the
fallback chain landed on 429, streamed as an SSE error frame) surfaces
"Provider temporarily rate-limited; retry in ~5 min." Other provider
failures keep the generic copy. The chat route emits the new
rate-limited code; the send hook decodes the JSON 429 envelope's
structured `error` field so the drawer can route the bubble copy.
2026-05-14 13:35:57 +02:00
Marc-André Bombeck 94cc7f571d feat(medications): once-weekly cadence preset for GLP1 category
Phase 3 of W4d GLP-1 full integration. The form gains a small
"Treatment class" selector (GENERIC | GLP-1 injection) sitting under
the existing clinical-category dropdown. Picking GLP-1 reveals two
helpers that stay hidden for every other medication:

- "Once weekly on …" preset — a single-tap weekday row that writes
  daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals
  the advanced block so the user can still tune the window. The last
  weekday picked persists in localStorage so a follow-up med starts on
  the same day.
- "Doses per pen / vial" optional input that powers the inventory math
  (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
  pen = 1). Leaving it blank keeps inventory tracking off.

The wire schema gains optional `treatmentClass` and `dosesPerUnit`
fields. Both default sensibly (GENERIC + null) so the v1.4.24 client
keeps working untouched.

Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not
crowd the form. The new affordances live behind the treatment-class
toggle — for every other med the form is byte-identical to v1.4.24.
2026-05-14 13:33:18 +02:00
Marc-André Bombeck 759fdaefcd feat(doctor-report): server filter respects per-section toggles + PDF skips disabled sections 2026-05-14 13:29:53 +02:00
Marc-André Bombeck 330be965ac feat(coach): default analysis window as user preference + per-conversation override
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
2026-05-14 13:28:10 +02:00
Marc-André Bombeck a732b1926e feat(withings): subscribe to BP + temperature webhooks alongside weight
setupWebhook now subscribes to appli=1 (weight + composition),
appli=2 (temperature), and appli=4 (pressure family: BP, pulse, SpO2)
in one pass. The previous implementation only registered appli=1, so a
new BP reading from a BPM cuff sat for up to an hour waiting for the
hourly poll fallback instead of arriving via push within seconds.

A failure on one category is logged and we proceed with the rest —
losing one webhook is strictly better than rolling back all three.
The disconnect path mirrors the same iteration so cleanup unsubscribes
every category we ever subscribed to. Webhook handler already
dispatches `syncUserMeasurements` regardless of appli, so no change
needed on the receiver side.

Exports `WITHINGS_NOTIFY_APPLIS` as the single source of truth so the
unit test and the disconnect route stay in lockstep with setupWebhook.
2026-05-14 13:27:37 +02:00
Marc-André Bombeck 5cb4a1dde1 feat(doctor-report): per-section toggle UI + persistence 2026-05-14 13:24:35 +02:00
Marc-André Bombeck c2fc95f815 fix(settings): dashboard section save actually persists
The Settings → Dashboard sub-section's Save button silently 422'd once
the user had touched any per-chart overlay popover. Root cause was a
Zod v4 semantics change: `z.record(z.enum(KEYS), ...)` now requires
every enum key to be present in the input, where Zod v3 treated the
same shape as a partial record. Real-world Save payloads only carry
the chart keys the user actually toggled, so every Settings → Dashboard
Save attempt with one or two `chartOverlayPrefs` entries failed with
`expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
the toast surfaced "Layout konnte nicht gespeichert werden".

Fix uses `z.partialRecord(z.enum(KEYS), ...)` which preserves the
original v1.4.18 intent — per-chart opt-in overlay prefs, missing keys
filled by the resolver. The inner object now also accepts the
`comparisonBaseline` field so a Settings save preserves the per-chart
comparison toggle the user set via the chart-card popover (otherwise
Zod's default strip behaviour wiped it on every save).

New integration suite pins:
  - partial chartOverlayPrefs round-trips
  - per-chart comparisonBaseline survives a Settings save
  - empty chartOverlayPrefs still persists (fresh accounts)
  - widget toggle changes land in the DB
2026-05-14 13:22:07 +02:00
Marc-André Bombeck 84b54d9f11 refactor(insights): mother page hosts overview only; metric depth moves to sub-pages
After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.

What's gone from the page (now lives on the matching sub-page):
  - Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
    pulse/mood/meds/bmi) with their target-zone + value-band wiring.
  - Per-metric status-card queries (blood-pressure-status,
    weight-status, pulse-status, bmi-status, mood-status,
    medication-compliance-status).
  - All the per-section <InsightStatusCard> + scatter-correlation
    cards (BP × continuity, weight × BP, mood × BP, mood × weight).
  - The bulky helper functions (getBloodPressureSectionStatus,
    getWeightSectionStatus, …, classifyRangeValue) and their imports
    (getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
  - The inline <InsightsSectionNav> mount — superseded by the strip
    that <InsightsLayoutShell> now mounts.

What stays here:
  - HeroStrip (greeting, weekly-report banner, Health Score panel).
  - DailyBriefing (the AI morning paragraph).
  - CorrelationRow (cross-metric hypotheses).
  - TrendsRow (BP / weight / mood mini-charts above each annotation).
  - InsightAdvisorCard (the rich recommendations grid).
  - CoachDrawer — mother-page-only, per Marc's directive.

The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.

trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.
2026-05-14 13:07:33 +02:00
Marc-André Bombeck 959adec899 feat(api): DELETE /api/measurements/by-external-ids for iOS deletion-sync
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).

Contract:
  - Body: { externalIds: string[] } with min 0, max 500 entries.
  - Auth: requireAuth() — session cookie or bearer.
  - Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
    failure — matches the ingest path's symmetric tolerance).
  - Over-cap: 422 with errorCode "measurement.delete.too_large" so
    the iOS client can surface a specific diagnostic.

Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.

OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
2026-05-14 13:07:05 +02:00
Marc-André Bombeck 35f068ac26 feat(analytics): berlinDayKey callers honour user displayTimezone
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.

The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).

Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
2026-05-14 12:52:01 +02:00
Marc-André Bombeck 8e4a8080dc i18n(targets): add EN + DE strings for redesigned card primitives
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.

Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:

  • targets.summary.{weekTitle,weekTitleAll,streakHighlight}
  • targets.card.{lastMet,streak}
  • targets.consistency.{ariaLabel,daysInRange,daysLogged,
                          dotInRange,dotNearRange,dotOutOfRange,
                          dotNotLogged,insufficientData,relativeDay.*}
  • targets.relativeDay.{today,yesterday,daysAgo}
  • targets.coach.cta
  • targets.mood.stability.{stable,variable,highlyVariable}

Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.

Verification:
  - pnpm typecheck: clean
  - pnpm test: 2325 / 2325 passing
  - pnpm lint: clean
  - i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
2026-05-14 12:43:02 +02:00
Marc-André Bombeck f3c9e2d7a6 feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:

- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
  week. The client renders 7 dots, one per day, filled when the day's
  mean reading landed in the green band, hollow when logged but
  out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
  reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
  page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
  target band, or null when no in-range day in the last 30. Surfaces
  the maintainer-tester case the W5 reconcile flagged (today lands
  in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
  OR < 1 day logged in 7d. The client hides the strip + percentage
  when set so we never surface a misleading "0 of 7" caption.

Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.

Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).

Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
  mood) — classify the day's mean against the same green band the
  RangeBar paints. Orange band reuses the pulse / glucose
  configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
  every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
  ≥ 50% = near, otherwise out. Days with zero scheduled doses
  return null so they don't break the streak.
2026-05-14 12:21:25 +02:00
Marc-André Bombeck 86a52dac33 refactor(targets): remove v1.4.22 per-card sparkline + delta caption
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.

Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
  helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
  targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
  in phase 3)
2026-05-14 12:15:17 +02:00
Marc-André Bombeck a0dffbdaeb style: prettier on v1.4.25 W7 timezone files 2026-05-14 12:12:59 +02:00
Marc-André Bombeck 46f2cfb9e7 feat(auth): signup captures browser-detected timezone
v1.4.25 W7 phase 6 — proposal §4 Option C as default-value layer.

The registration form now reads
Intl.DateTimeFormat().resolvedOptions().timeZone and sends it with
the signup payload. The server:
  1. Validates the value against Intl.supportedValuesOf via
     isValidTimezone().
  2. On any invalid value (or absence), resolves the admin-
     configured server default (AppSettings.defaultUserTimezone) and
     uses that.
  3. On no admin default, the resolver falls back to the
     'Europe/Berlin' string, which is also the User.timezone column
     default — so the worst case still produces the legacy
     pre-v1.4.25 behaviour.

The Zod registerSchema gains an optional 'timezone' string field
capped at 64 chars (matches the longest IANA canonical zone
identifier plus headroom). Audit-log entry now carries the resolved
zone so an operator can spot a Tokyo user mis-attributed to Berlin
if browser detection fails.

No new tests — the existing register-route integration coverage is
thin, and the v1.4.25 W7 phase 7 integration test in
Pacific/Auckland covers the full signup → measurement → export
round-trip end-to-end.
2026-05-14 12:09:16 +02:00
Marc-André Bombeck 26568c18a8 feat(admin): server-default timezone setting for new signups
v1.4.25 W7 phase 5 — proposal §5 admin half.

GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
  - empty string clears the column (resolver falls back)
  - non-empty must pass isValidTimezone(); otherwise 422
  - successful writes invalidate the server-default resolver cache
    so the registration handler sees the new value on the next
    signup without waiting 60s for the TTL

The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'

Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
2026-05-14 12:05:46 +02:00
Marc-André Bombeck 65ce9fd4c8 feat(profile): timezone picker on account settings page
v1.4.25 W7 phase 4 — proposal §5.

Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.

Form save uses Promise.all over two PUTs:
  - /api/auth/profile (email, height, dob, gender)
  - /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.

The new route PUT /api/auth/me/timezone:
  - requireAuth gates the cookie + Bearer paths
  - 422 on any string the runtime's Intl.DateTimeFormat rejects
  - 200 on success, returns the persisted value
  - audits 'user.timezone.update' with previous + next
  - invalidates the resolver cache so the next read sees the new
    value within one tick instead of waiting 60s for the TTL

i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'

Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
2026-05-14 12:01:58 +02:00
Marc-André Bombeck ce6d6be95a feat(doctor-report): PDF timestamps render in user displayTimezone
v1.4.25 W7 phase 3.3 — closes proposal §3 symptom 8.

renderDoctorReportPdfBytes now accepts an optional userTz in its
DoctorReportRenderOptions. The PDF route resolves the patient's
stored timezone via the v1.4.25 W7 resolver and threads it through
makeFormatters so date/time rows render in the user's zone.

A US patient generating a report at home in Boston now sees Eastern
time rows, not Berlin. Existing Berlin-region users see no
behaviour change — the User.timezone default is Europe/Berlin and
the formatter falls back when userTz is empty.

Client-side caller (settings dialog) is untouched in this commit — a
follow-up wave wires the auth-context value through the dialog
component.
2026-05-14 11:57:02 +02:00
Marc-André Bombeck 96076dbc33 feat(export): emit ISO-8601 with user-tz offset instead of Z
v1.4.25 W7 phase 3.1 — closes the export half of issue #167.

CSV/JSON exports previously emitted timestamps via .toISOString(),
producing 2026-05-15T09:00:00.000Z. Excel and LibreOffice drop the
trailing Z silently and render the bare datetime in the viewer's
locale, so a Warsaw user reads UTC as local time — 11:00 entries
display as 09:00 in their spreadsheet.

Export helpers now accept an optional userTz argument and emit
ISO-8601 with the user's UTC offset
(2026-05-15T11:00:00+02:00). The offset survives the round-trip and
the on-screen value is unambiguous regardless of the spreadsheet's
defaults. Routes resolve the user's stored timezone via the v1.4.25
W7 phase 2 resolver and thread it through formatMeasurements /
formatIntakeEvents / formatMoodEntries.

Backward-compatible: userTz is optional. When omitted (admin tools,
canonical-backup-on-disk shape) the helpers fall back to
.toISOString(), keeping the legacy contract for the JSON full-backup
route that the upload-restore validator depends on.

Surfaces touched:
  - src/lib/export.ts (3 formatters + helper)
  - src/app/api/export/measurements/route.ts
  - src/app/api/export/medications/route.ts (intake history block)
  - src/app/api/export/mood/route.ts
  - src/app/api/export/route.ts (legacy aggregate route)

Tests: 5 new cases in src/lib/__tests__/export.test.ts covering
Berlin / Auckland / New_York offsets, the no-userTz fallback, and
the mood-entry case where the stored YYYY-MM-DD date column stays
unchanged (see proposal §6.3 for the read-side rationale).
2026-05-14 11:55:06 +02:00
Marc-André Bombeck 3e9881f3fd refactor(insights): remove duplicate InsightStatusCard mount — superseded by InsightAdvisorCard since v1.4.16
The /insights page rendered both <InsightAdvisorCard> (the polished
severity-ordered recommendations grid + per-rec rationale + confidence
meter + medical-citation footnotes shipped in v1.4.16 phase D
reconcile) AND <InsightStatusCard title={t("insights.generalStatusTitle")}>
(the v1.4.15 text-only summary). The page comment at the advisor
wiring acknowledged the status card "was intended to be replaced"
during D-reconcile but the status mount was never removed, so users
saw two cards saying the same thing — one labelled
"KI-Gesundheitsanalyse" and one labelled "Allgemeiner
Gesundheitszustand".

Removed:
  - The <section id="section-general"> block + its <InsightStatusCard>.
  - The `generalStatus` useQuery + the unused `GeneralStatusData`
    interface.
  - The `generalStatus?.updatedAt` entry from freshestUpdatedAt(); the
    other six per-section caches still feed the hero's "Generated …"
    caption.
  - The "section-general" entry from the sticky pill nav (it had
    nothing left to scroll to).

Kept:
  - The /api/insights/general-status route + the underlying
    generateGeneralStatusForUser lib — the reminder worker still
    pre-warms the daily general-status cache for users opted in to the
    briefing notification. Added a JSDoc @deprecated note on the route
    pointing new callers at <InsightAdvisorCard> /
    useInsightsAdvisorQuery().
  - The insights.generalStatusTitle + insights.generalStatusBadge.*
    translation keys — they're still referenced by the per-section
    badge labels (insights.generalStatusBadge.good / .watch /
    .critical map traffic-light states on every metric section, not
    just the dropped general one).
2026-05-14 11:50:49 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00