Commit Graph
692 Commits
Author SHA1 Message Date
Marc-André Bombeck 5b8a47b4ff fix(insights): strip the duplicate briefing paragraph and rebalance the Health Score column
The /insights hero strip already renders briefing.paragraph as its
subtitle directly above the Daily Briefing card; the card then mounted
the same paragraph a second time inside its CardContent before the
key-findings list. The user read the same string twice within 200 px.

Drop the leading paragraph render from <DailyBriefing>; the card now
opens directly on the structured key-findings list (the part the hero
subtitle cannot surface). The empty-state branch keeps its 'Generate
briefing' CTA so the populated → empty flow remains intact.

Rebalance the Health Score card so it reads as a co-equal column of
the hero rather than a small inset:
  - lg:w-[260px] → lg:w-[360px] xl:w-[400px]
  - score number text-4xl → text-5xl sm:text-6xl
  - progress bar h-1.5 → h-2
  - inline Ask-the-Coach button retires; the hero strip's existing
    action covers it. onAskCoach stays on the prop signature so callers
    don't break.

Fold in BL-P4-9 L2: disclaimer text-[10px] → text-[11px] (the 10 px
size was borderline against the 12 px mobile floor flagged in the
W10 design review).

Tests updated: daily-briefing test asserts the paragraph slot is
absent in the populated branch; health-score-card tests pin the new
no-button render across English and German locales.
2026-05-15 16:48:03 +02:00
Marc-André Bombeck 25952d7d05 feat(admin): surface the carrier chip under the auth provider on the login overview
Adds a `carrierShortLabel()` helper in `_shared.tsx` that folds the
verbose GeoLite2 `autonomous_system_organization` strings down to
the glanceable DACH short labels Marc asked for: "Telekom",
"Vodafone", "1&1", "O2". Unknown organisations fall through to the
raw string so the chip stays informative.

`LoginOverviewSection` now renders the short label as a smaller
chip below the existing auth-provider chip, gated on
`entry.carrier !== null` so private-IP and offline-miss rows keep
the legacy single-chip layout.

The CSV export grows a matching `carrier` column between `location`
and `provider`. The API selector pulls the new `asn` + `carrier`
fields. New translation keys (`admin.carrier`, `admin.carrierUnknown`)
are referenced in code; bucket B6 lands the JSON entries.
2026-05-15 16:47:36 +02:00
Marc-André Bombeck fe18a8810c feat(coach): replace the textarea hint footer with an info-icon tooltip
The composer rendered the keyboard-shortcut hint ("Enter to send,
Shift+Enter for new line") as a prose `<span>` directly under the
textarea. Combined with the disclaimer and the send button, the
footer ate ~140 px of vertical room for what is essentially a single-
sentence affordance.

Replace the `<span>` with an `Info` icon wrapped in a Radix `<Tooltip>`
sat left of the send button. The existing `composerHint` translation
surfaces both as the trigger's `aria-label` (so screen-readers still
hear it on focus) and as the tooltip body for sighted users on hover.
The slot `data-slot="coach-input-hint"` stays so consumers can still
target the affordance.
2026-05-15 16:47:25 +02:00
Marc-André Bombeck 4d88764e4f fix(coach): collapse the evidence disclosure to closed-by-default
The assistant bubble's evidence `<details>` mounted open whenever the
user had `coachPrefs.showEvidenceByDefault = true`, which surfaced the
raw measurement values (e.g. "Blutdruck letzte 30 Tage: 226") under
every reply. The disclosure is now always collapsed; the user expands
by click. The persisted pref stays in the schema for backward
compatibility but no UI reads or writes it, so the value is dead-code-
tolerated for one release and can be dropped in the next migration.

Drops the `evidenceDefaultOpen` prop wiring from `<MessageThread>` and
the corresponding `<Switch>` in the Coach settings sheet. Adjusts the
sheet test that previously asserted the toggle was rendered.
2026-05-15 16:45:55 +02:00
Marc-André Bombeck 053d678ac7 refactor(dashboard): promote GLP-1 schedule dates to a header pill row and drop the green seam
The faint dracula-green left-seam on the outer wrapper was decoration
without a semantic tie to the schedule rows beside it. The Syringe-icon
in the title row already carries the 'active therapy' signal at a
glance.

Replace the <dl> grid with a soft-green pill bar that groups Last- and
Next-Injection as one cohesive 'therapy schedule' unit. Each pill keeps
its data-slot anchor (glp1-tile-last / glp1-tile-next) so existing
tests stay in lockstep; new test asserts the data-slot='glp1-tile-schedule'
wrapper renders and the border-l-dracula-green/60 + border-l-2 classes
no longer appear on the outer wrapper.
2026-05-15 16:44:39 +02:00
Marc-André Bombeck 1d8b80698e feat(audit): wire ASN+carrier into the audit-log create/update path
Completes the v1.4.27 B3 audit-log work that landed in e8fb0a75. The
prior commit shipped the backfill helper but the schema + migration
+ audit.ts write-path were dropped during a concurrent staging
window. This commit puts them back.

`prisma/schema.prisma` grows two nullable columns on AuditLog (`asn`
+ `carrier`), the matching idempotent `IF NOT EXISTS`-guarded
migration lands at `0061_audit_log_carrier`, and the auditLog helper
resolves location + ASN row in parallel (single 3 s race, single
`prisma.auditLog.update` carries whichever fields the resolvers
filled).

Test coverage: five new cases on audit.test.ts cover all four
combinations of location/ASN hit-or-miss plus a row with no
carrier organisation.
2026-05-15 16:43:48 +02:00
Marc-André Bombeck c0ec1fecfb feat(dashboard): wire the GLP-1 drug-level pane behind a tab strip and range picker
The GLP-1 tile previously rendered a single weight chart — a downstream
proxy for the dose-response. The maintainer-flagged headline is the
estimated drug-level curve because it surfaces the sawtooth shape of
the weekly cycle directly.

Add a two-tab segmented control (Drug-Level default / Weight) inside the
chart pane and a 7d / 30d / 90d / All range strip above. The Drug-Level
tab mounts <DrugLevelChart compact …>, which now drops its outer card
wrapper and renders at 160 px instead of 240 px when 'compact' is set,
so the dashboard tile owns the card surface. Range selections thread
through a new windowHoursBefore prop on DrugLevelChart; the sample step
scales 6/12/24/48 h with window length so 'All' mode does not paint
thousands of points. The standalone surface at /medications/[id]/history
keeps its full 21-day default and full-card chrome — both consumers
share one implementation.

New translation keys referenced (B6 sweep picks up):
- dashboard.glp1.tabLevel
- dashboard.glp1.tabWeight
- dashboard.glp1.tabsAria
- dashboard.glp1.rangeStripLabel
- dashboard.glp1.levelUnavailable
- dashboard.glp1.weightUnavailable

Test coverage extended: glp1-tile test asserts tab strip + range strip
DOM, drug-level default selection, level-unavailable hint when the
medication has no id; the existing DrugLevelChart test suite continues
to pin the standalone-surface contract.
2026-05-15 16:43:24 +02:00
Marc-André Bombeck e8fb0a7521 feat(audit): persist ASN and carrier on login audit rows
Adds two nullable columns to AuditLog (`asn Int?` + `carrier String?`)
via an additive `IF NOT EXISTS`-guarded migration. The audit-log
write path now resolves the location and the ASN row in parallel and
writes whichever fields the resolvers could fill in a single update.

A new `runGeoBackfill` helper walks rows where `location IS NULL`
AND `ipAddress IS NOT NULL` AND `createdAt > now() - 30 days`,
capped at 5 000 rows per pass. Useful for picking up rows that
landed during an `ipwho.is` outage or before the GeoLite2 MMDBs
were bundled into the image.
2026-05-15 16:43:03 +02:00
Marc-André Bombeck 3e930e68f6 feat(notifications): localise admin and user Telegram messages to the recipient locale
Several call-sites composed notification bodies in English or hardcoded
German without consulting User.locale. Swap each surface to a
translator-aware path:

- src/app/api/internal/deploy-webhook/route.ts uses
  dispatchLocalisedNotification per admin, reading
  notifications.admin.deployFailedTitle and
  notifications.admin.deployFailedBody with param substitution.

- src/app/api/admin/notifications/test/route.ts resolves the admin
  locale once at the top of the handler, then composes the test
  notification via getServerTranslator. This route talks to the
  senders directly because it needs per-channel success/error
  feedback for the UI, so it stays off the dispatcher.

- src/app/api/settings/telegram/test/route.ts has the same shape for
  the per-user path; it reads the user locale and translates
  notifications.user.telegramTestBody before sending.

- src/app/api/admin/notifications/reminder-check/route.ts uses
  dispatchLocalisedNotification with per-row recipient locale, so the
  affected user (not the admin running the check) sees the message
  in their language.

Reminder-worker is already localised via getPhaseMessage; no change.
src/lib/integrations/status.ts is out of scope and left for a later
symmetry-sweep.

Tests:
- src/app/api/settings/__tests__/telegram-test-locale.test.ts covers
  de, fr, null, unsupported locale paths plus rate-limit,
  missing-token, and send-failed branches.
- src/app/api/internal/deploy-webhook/__tests__/route.test.ts swaps
  its admin-alert assertion from dispatchNotification to
  dispatchLocalisedNotification.

New translation keys (B6 picks up the JSON edits):
- notifications.admin.deployFailedTitle
- notifications.admin.deployFailedBody
- notifications.admin.testNotificationTitle
- notifications.admin.testNotificationBody
- notifications.admin.reminderCheckMissedTitle
- notifications.admin.reminderCheckMissedBody
- notifications.admin.reminderCheckOverdueTitle
- notifications.admin.reminderCheckOverdueBody
- notifications.user.telegramTestBody
2026-05-15 16:42:24 +02:00
Marc-André Bombeck 9afb31bd15 chore(dashboard): retire the standalone InsightsCardPreview surface
The dashboard preview duplicated the much-richer /insights advisor surface
that already lives one click away (Daily Briefing, hero strip, correlation
row, full advisor, weekly-report banner). The preview also competed
visually with the GLP-1 tile for the same vertical real estate above the
chart row.

Drop the import + render + isChartVisible gate in src/app/page.tsx, drop
the insightsPreview widget entry from src/lib/dashboard-layout.ts, drop
the matching row in src/components/settings/dashboard-layout-section.tsx,
and delete src/components/insights/insights-card.tsx and its test file.
The advisor query hook stays on /insights — the dashboard no longer
mounts it. Update the dashboard-layout contract test to assert the
widget id is absent.

F7 weekly-report scan: ran grep -rinE 'Wochenreport|weekly report|/insights/report' across src/ and messages/. All weekly-report click affordances on /insights are correctly wired (HeroStrip Generate button → currentWeekHref, WeeklyReportBanner → /insights/report/[week], InsightAdvisorCard banner → /insights/report/[week]). The /insights/report/[week] route mounts WeeklyReportView and renders the full surface. No dead click affordance was located inside the 30-minute scan budget. The retired InsightsCardPreview was the only dashboard-anchored insight card with a CTA — its removal in this commit is the most likely reason the maintainer perceived a dead click. F7 deferred to v1.4.28 with a maintainer-screenshot ask; recorded in .planning/v1428-backlog.md.

i18n keys still referenced after this change (B6 sweep will reconcile):
none from this commit. Keys 'dashboard.insightsPreview' and 'insights.aiInsights' are now unreferenced in code and become candidates for B6's dead-key sweep.
2026-05-15 16:38:22 +02:00
Marc-André Bombeck 50150e35ef feat(geo): bundle GeoLite2-City and add an offline-first lookup path
Rewrites src/lib/geo.ts so lookupIpLocation reads from the bundled
MaxMind GeoLite2-City MMDB first and falls back to the existing
ipwho.is path only for IPs the offline DB cannot resolve. Adds a
sibling lookupIpAsn that returns { asn, carrier } from the
GeoLite2-ASN MMDB so the admin login-overview can grow a carrier
chip alongside the auth-provider chip.

The MMDB files are too large to vendor in git. scripts/fetch-geolite2.sh
downloads them at build time using a MaxMind license key; the
Dockerfile copies assets/geolite2/ into /opt/geolite2/. When the
license key is absent or the files are missing the image still
builds and the resolver falls back to ipwho.is (v1.4.26 behaviour).

GeoLite2 is CC BY-SA 4.0 — attribution lives on the new public
/about page and in docs/audit/v1427-summary.md.
2026-05-15 16:36:55 +02:00
Marc-André Bombeck 2c9d17c333 feat(notifications): add a translator-aware dispatch helper
The base dispatchNotification is locale-agnostic by design: it
forwards title and message to every sender verbatim. Several
call-sites (admin alerts, deploy webhook, Telegram test routes,
reminder-check) compose bodies in English without consulting
User.locale, leaving recipients with mismatched copy.

Introduce dispatchLocalisedNotification next to the base dispatcher.
The helper resolves the recipient's User.locale (falls back to the
project default when missing, unsupported, or unreadable), calls
getServerTranslator(locale).t on both the title and message keys
with optional params, and delegates to dispatchNotification with the
composed strings. Missing translation keys fall back to the raw key
and log a wide-event warning so ops can spot bundle drift.

Locale resolution is intentionally restricted to the persisted
User.locale column. The helper runs from background jobs and webhook
handlers where the request context may not belong to the recipient
(an admin alert resolves the admin's locale, not the affected user's),
so cookies and Accept-Language are out of scope.

Forward-compat scaffolding: an optional channel selector
('telegram' | 'email') is accepted and forwarded as
metadata.preferredChannel so call-sites can declare intent today;
the dispatcher continues to fan across every enabled channel until a
follow-up wires routing.

Tests:
- notifications/__tests__/admin-locale.test.ts drives the helper
  through de / fr / null / unsupported / missing-row locale paths,
  the missing-translation-key fallback, the event-type and metadata
  pass-through, and a Prisma-throws path that must not propagate.

Bundle keys referenced by this commit (B6 will land the JSON edits):
- notifications.admin.*
- notifications.user.*
2026-05-15 16:36:19 +02:00
Marc-André Bombeck 9c8660a4e0 feat(settings): replace single-spinner loading with skeleton rows on Thresholds and Sources
Replaces the centred `<Loader2 /> Loading` line in
`<ThresholdsEditorSection>` and `<SourcesSection>` with a skeleton
list whose row count matches the loaded UI (14 rows each — one per
`METRIC_ORDER` entry and one per `SOURCE_PRIORITY_METRIC_KEYS` entry
respectively). The skeleton rows reuse the live UI's container
classes (`border-border space-y-3 rounded-lg border p-4` on
Thresholds; `border-border bg-background/30 space-y-2 rounded-md
border p-3` on Sources) so the post-fetch swap lands in place. The
`<Skeleton>` primitive's `motion-reduce:animate-none` covers the
reduced-motion preference. Combined with the `min-h` shell reserve
from the previous commit, this removes the page-height jump that
landed under the sticky sidebar when these two sections finished
loading.
2026-05-15 16:31:42 +02:00
Marc-André Bombeck e060ab33a2 fix(integrations): silence the Withings scope-skip path from the 3-strike alert ladder
The v1.4.26 scope-skip short-circuit in `sync-activity.ts` and
`sync-sleep.ts` called `recordSyncFailure` for a deliberate no-op,
which incremented `IntegrationStatus.consecutiveFailures` and tripped
the 3-strike admin Telegram alert for every legacy connection. Webhook
deliveries (appli=16 / appli=44) could run the counter from 0 to 3 in
seconds on a freshly-deployed v1.4.26 install, and pre-existing
counters that survived the deploy (e.g. an 87-strike row from the
pre-fix transient burst) paged immediately on the first scope-skip
after the 24h re-alert window closed.

Extract a new `parkIntegrationAtReauth` helper next to
`recordSyncFailure` that sets the row to `error_reauth` without
incrementing the counter, without writing an `integrations.sync.failed`
audit row, and without entering the threshold ladder. A standalone
`integrations.reauth_required` audit row is written on the first park
so the operations trail still shows the event; re-parking the same
scope-skip is idempotent (no extra audit write). Swap the two
deliberate scope-skip call-sites in `withings/sync-activity.ts` and
`withings/sync-sleep.ts` to the new helper.

The defence-in-depth 403 catch-block in each sync routine stays on
`recordSyncFailure` — a 403 reaching the catch IS genuinely unexpected
once the scope-skip lands above, so the alert ladder should still fire
on that asymmetric pair. BL-P3-2 parity verified between activity and
sleep.

Tests extended:
- `integrations/__tests__/admin-alert.test.ts` adds a
  `parkIntegrationAtReauth` block asserting state=error_reauth without
  counter increment, no `dispatchNotification`, and audit-log
  idempotency on a second call.
- `withings/__tests__/sync-activity.test.ts` +
  `withings/__tests__/sync-sleep.test.ts` swap their scope-skip
  assertions to `parkIntegrationAtReauth` and keep the 403 catch-block
  on `recordSyncFailure` to prove the defence-in-depth alert path
  survives.
2026-05-15 16:30:56 +02:00
Marc-André Bombeck 3ed78a032b fix(charts): align MoodChart height with the rest of the trend strip
MoodChart rendered at 280 px while every sibling chart on the dashboard
card row (weight, BP, pulse, body fat, sleep, steps, medication
compliance) renders at 240 px. The 40-px outlier broke vertical rhythm
when scrolling past the trend strip — the row swelled when it hit the
mood card.

Consume CHART_HEIGHT_PX from the shared constants module so the empty
state and the populated chart now stride with the rest of the row. Bump
the populated-chart override from 280 to 240 in the same edit.
2026-05-15 16:30:18 +02:00
Marc-André Bombeck b280253033 fix(settings): reserve a minimum main-column height in the settings and admin shells
Adds `min-h-[calc(100dvh-12rem)]` to the `<main>` element in both
`<SettingsShell>` and `<AdminShell>`. The sticky 220 px sidebar is
anchored to the page scroll; previously the grid's row height tracked
the section's content height, so swapping a short spinner for a long
list (Thresholds: 14 rows; Sources: 14 metric tiles) jumped the page
height under the sidebar and shifted the active link off-screen. The
12 rem reserve covers the global header (5 rem) + section header
(~5 rem) + a small buffer, so the spinner-to-content transition no
longer changes the viewport height. Short pages (`/settings/about`)
still render short because `min-h` does not clamp tall content.
2026-05-15 16:29:52 +02:00
Marc-André Bombeck d567e45423 feat(charts): extract shared chart-height and range-preset constants
New module src/lib/charts/constants.ts exports CHART_HEIGHT_PX (240),
CHART_MINI_HEIGHT_PX (160), and CHART_RANGE_PRESETS (7d / 30d / 90d / All).
The constants give the dashboard chart strip and the GLP-1 tile one
source of truth so future retuning lands in one place.
2026-05-15 16:27:16 +02:00
Marc-André Bombeck 2e4edbf2d0 fix(settings): raise the TimezonePicker inner gap to match the form rhythm
The picker row sits between two `space-y-4` (16 px) form rows, so the
old `gap-2` (8 px) between the select and the detect button read as a
tighter rhythm than the rest of the profile form. `gap-3` (12 px) keeps
the detect button visually attached to the select (it is a hugged
action) while no longer breaking the cadence the surrounding rows set.
2026-05-15 16:27:01 +02:00
Marc-André Bombeck a04e119cac fix(settings): pair date-of-birth with language in one grid row
Moves the language select into the same `grid sm:grid-cols-2` row as
date of birth and drops the standalone language wrapper plus its
`sm:max-w-xs` clamp. The profile form now carries a uniform two-cell
rhythm from top to bottom instead of alternating between paired and
single-cell rows. Updates the mobile-consistency e2e assertion to match
the new contract: dob + language MUST share a grid row.
2026-05-15 16:26:26 +02:00
Marc-André Bombeck ae244cab12 chore: merge v1.4.26 into develop
# Conflicts:
#	CHANGELOG.md
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	package.json
#	src/lib/withings/__tests__/sync-activity.test.ts
#	src/lib/withings/__tests__/sync-sleep.test.ts
#	src/lib/withings/sync-activity.ts
#	src/lib/withings/sync-sleep.ts
2026-05-15 07:38:29 +02:00
Marc-André Bombeck d98e85cd03 chore(release): v1.4.26 — privacy policy for App Store submission
Public privacy-policy page at /privacy enumerates every Apple
HealthKit identifier the iOS app reads, names every third-party
sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks
Apple BLOCKER 2 of the iOS App Store Connect submission by giving
the registered Privacy URL a real page to point at.
2026-05-15 07:35:05 +02:00
Marc-André Bombeck fec02aa573 fix(withings): skip activity + sleep sync when OAuth scope lacks user.activity
Legacy v1.4.24-and-earlier Withings connections sit on `user.metrics`
only. W17b/c added `syncUserActivity` + `syncUserSleep` against the
scope-gated `/v2/measure?action=getactivity` and `/v2/sleep?action=get`
endpoints without a scope guard, so every cron tick for a legacy user
hit a guaranteed 403. The catch-block classified the 403 as transient
(the refresh token still worked; only the resource call failed), pg-boss
retried, and the 3-strike admin alert paged Telegram for every affected
user.

Both routines now short-circuit at entry: read `WithingsConnection.scope`,
call `hasActivityScope` from `src/lib/withings/client.ts`, and on miss
park at `error_reauth` via `recordSyncFailure({ kind: "reauth_required",
errorCode: "scope_missing" })` and return 0. The Settings reconnect
banner already in place tells the user what to do; subsequent cron ticks
short-circuit cheaply on `isReauthRequired`.

Defence-in-depth: a 403 reaching the catch-block (race where scope was
revoked between OAuth and the next sync without invalidating the refresh
token) now classifies as `reauth_required` instead of `transient`, so
pg-boss stops retrying and the 3-strike alert stays quiet.

Tests: +4 scope-skip / 403-classification cases per routine (8 total),
defaulting the existing field-mapping + idempotency tests to a
scope-is-fine connection so they stay focused on the write path.
2026-05-15 07:32:35 +02:00
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00
Marc-André Bombeck df719caa2f fix(medications): derive side-effect category server-side + drift-guard test
simp-M1 — the side-effect category and entry tuples in the Zod
validators no longer restate the Prisma enum keys verbatim. Both
arrays now derive from MedicationSideEffectCategory and
MedicationSideEffectEntry via z.nativeEnum, so adding a new entry
only requires touching the Prisma schema + the taxonomy mapping.

code-M1 — drift-guard test asserts the three sources (Prisma enums,
taxonomy module, validators) cover the same keysets. Catching drift
at test-time prevents the silent 422-vs-DB-accept mismatch that bit
the previous shape.
2026-05-14 23:11:06 +02:00
Marc-André Bombeck 8399c47874 refactor(api): adopt assertMedicationOwnership + rate-limit option-bag across 9 medication routes
Hoist the ownership-check boilerplate that nine medication sub-routes
were each carrying inline into the shared route-guard helper, and align
the three W19 routes that hand-wrote the rate-limit header loop on the
new apiError option-bag form.

Routes touched:
- titration: shared guard + replaced parseDoseString with the shared
  parseDoseMgOrNull + replaced the inline GLP1_DRUGS reverse-lookup
  with findDrugIdByBrand.
- side-effects: shared guard + option-bag headers + dropped the
  client-supplied 'category' field per code-M6 (server derives via
  categoryForEntry).
- inventory: shared guard + option-bag headers.
- cadence: shared guard layered on top of the W21 Fix-O timezone wiring.
- intake (POST + GET): shared guard.
- compliance: shared guard.
- phase-config (GET + PUT + DELETE): shared guard.
- api-endpoint (GET + PUT): shared guard.
- glp1: shared guard replacing Fix-K's inline copy + option-bag headers.

apiError now accepts { headers: Record<string,string> } in its third
argument bag; the headers flow through NextResponse and are stripped
from the meta envelope so the existing { errorCode, ... } meta usage
keeps its shape.
2026-05-14 23:09:50 +02:00
Marc-André Bombeck 2b712429c0 refactor(ui): adopt shared readError + ResearchModeStatus + design-system Select
- advanced-section: drops the local ResearchModeStatus interface in
  favour of the shared DTO, replaces the nested ternary that built the
  toggle's status caption with a researchModeStatusLabel helper
  threaded through the shared gate-state classifier.
- WelcomeCarousel / GoalsChipPicker / SourceCardGrid / BaselineForm:
  import the shared readError helper instead of the four hand-rolled
  copies — the request-failed fallback shape now evolves in one place.
- BaselineForm: swap the gender native <select> for the
  shadcn-radix Select component so the picker matches the rest of the
  form library (focus rings, keyboard nav, theme tokens).
2026-05-14 23:04:37 +02:00
Marc-André Bombeck d6456a30eb refactor(medications): extract MedicationDetailSection wrapper across 4 components
Lock the shared chrome (border + header row + dotted divider + body
band) into one wrapper so the three Wave-4b panels (Titration,
Scheduling, SideEffects) stop drifting on padding, border opacity,
and aria-wiring rules.

- src/components/medications/medication-detail-section.tsx (new) —
  composes the section shell with a stable titleId / headerExtras
  contract.
- TitrationSection — drops the hand-rolled templateFill helper (the
  i18n t() function already accepts the params bag); adopts the
  wrapper.
- SchedulingSection — adopts the wrapper; grows cadence-timeline
  cells to a 44 px tap-target per WCAG 2.5.5 while keeping the 12 px
  visible cell.
- SideEffectsSection — adopts the wrapper; swaps the native <select>
  for the shadcn-radix Select so the picker matches the rest of the
  form library.
- inventory-section — drops the local InventoryState string-union in
  favour of the Prisma MedicationInventoryState enum; replaces the
  reimplemented daysRemainingInUse with the widened pure helper.
- DrugLevelChart — adopts shared ResearchModeStatus,
  findDrugIdByBrand, and parseDoseMg (the local parseDoseMg is
  re-exported for test backwards-compat).
- inventory state-machine — widens daysRemainingInUse to accept
  either the full InventoryItemView or a thin { firstUseAt } shape.
2026-05-14 23:02:28 +02:00
Marc-André Bombeck c8ee4cb9fc feat(medications): shared helpers — readError + route-guards + dose-string + research-mode-types
Hoist five duplicated helpers into single source modules so each
contract evolves in exactly one place:

- src/lib/api/read-error.ts — replaces the four hand-rolled copies
  in the onboarding flow (Welcome / Goals / Source / Baseline).
- src/lib/medications/research-mode-types.ts — shared ResearchModeStatus
  DTO + tri-state gate helper consumed by both the DrugLevelChart
  and the Settings advanced section.
- src/lib/medications/route-guards.ts — assertMedicationOwnership
  for the eleven medication sub-routes, preserving the 404 leak
  shape across the privacy boundary.
- src/lib/medications/dose-string.ts — unified parseDoseMg + null
  variant replacing the DrugLevelChart and titration-route parsers.
- src/lib/medications/glp1-knowledge.ts — adds findDrugIdByBrand
  reverse lookup so the brand-to-id pattern stops being open-coded
  at every chart render.

28 new unit tests across the five helpers.
2026-05-14 22:54:25 +02:00
Marc-André Bombeck 5f5b8dfb49 chore(medications): delete unused research-mode-staleness module + add PR worker null-slot dup regression
`research-mode-staleness.ts` shipped in W19c-Safety as a sibling helper
for a future 90-day re-acknowledgment gate on the GLP-1 drug-level
chart. The wiring step never landed — the helper has zero production
importers (only its co-located test consumed it). The version-bump
re-prompt path in `/api/auth/me/research-mode/route.ts` already
covers the regulatory contract (a copy or EMA-EPAR ratchet forces
the dialog), so the calendar-staleness gate is intentionally deferred
to v1.4.26 and the unused module ships off-codebase rather than
sitting orphaned through the release.

Also pin the PR-detection worker's null-slot dedup contract against
a back-to-back invocation regression. The personal_records unique
index uses NULLS NOT DISTINCT on `(userId, metricType, metricSlot,
achievedAt)` — measurement-type PRs (slot = NULL) must coalesce
across consecutive worker invocations even when the candidate row
sits at the exact same achievedAt. A future refactor that drops the
NULLS-NOT-DISTINCT semantics would silently double-insert; this test
fails fast if that happens.

Fix-O / code-M5 + dead-H4 + senior-M3 + L2.
2026-05-14 22:43:53 +02:00
Marc-André Bombeck 2a65f2b179 feat(medications): thread timeZone into cadence + compliance helpers
The W19e pure cadence + compliance helpers used `Date.setHours`,
`getDay`, and `setHours(0, 0, 0, 0)` for every local-day boundary,
window-time application, and streak day-key. Those methods read the
host process's system time — every medication detail page on the
cadence route returned values relative to whatever zone the server
ran in (Europe/Berlin in prod, often UTC in CI), not the user's. A
Tokyo user reading the same data saw their 08:00 window land at
16:00 wall-clock and the day-of-week filter ran off the Berlin
weekday.

Thread an optional IANA `timeZone` argument through
`expandScheduleSlots`, `pairDoses`, `buildCadenceTimeline`,
`computeNextDose`, `missedDoses`, and `complianceChips`. Every local
operation now resolves through `Intl.DateTimeFormat`. When the
argument is omitted the helpers fall back to system-local — every
existing unit test (system-local by construction) keeps its
assertions green. The cadence route resolves the user's zone via
`resolveUserTimezone(user.id)` and forwards.

Regression tests pin the 08:00 wall-clock contract across Berlin /
Tokyo / Los Angeles and check that a "Mondays only" schedule lands
on each zone's local Monday.

Fix-O / code-H2.
2026-05-14 22:42:37 +02:00
Marc-André Bombeck 701fd5f251 fix(withings): anchor activity sync at noon UTC for tz-safe bucketing
Withings reports per-day aggregates with `date` as `YYYY-MM-DD`. The
v1.4.25 W17b writer anchored `measuredAt` at the day's 23:59:59 UTC so
the row would sort at the end of the day in chronological reads. That
shape mis-bucketed positive-offset users: a row from `date=2026-05-12`
arrived as 2026-05-13 08:59:59 in Tokyo, off-by-one for every
dashboard tile that reads the row in user-local time.

Anchor at noon UTC (`12:00:00Z`) instead — the standard "calendar-day
with no clock" representation per RFC 3339 §5.6. The instant lands
inside the local calendar day for every supported user timezone in
the practical [-11, +12) range (Honolulu through Tokyo), so the
analytics layer day-keys the row to the same calendar day every user
reads it in.

Regression test covers the four canonical user zones; the existing
end-of-day assertion is updated.

Fix-O / senior-M2.
2026-05-14 22:38:01 +02:00
Marc-André Bombeck 3f7a8ce6a6 feat(db): Migration 0060 backfills onboardingStep + flips to NOT NULL
Migration 0057 shipped `onboarding_step INTEGER DEFAULT 0` as nullable so
the additive DDL avoided a long table rewrite during the W14b deploy.
The wizard mount code and the POST route then carried defensive
null-handling branches (read-side `?? 0`, write-side `OR step IS NULL`)
to keep legacy pre-W14b rows working. That left a tri-state surface the
route had to widen on every step-0 advance.

Backfill every NULL row to 0 (the same value the read-path already
substitutes), then `ALTER COLUMN ... SET NOT NULL`. schema.prisma drops
the `?` so the Prisma type-surface mirrors the DB. The conditional
update WHERE in `/api/onboarding/step` collapses to the strict-equality
form on every step — the legacy null branch becomes unreachable code
and is removed.

Fix-O / senior-M1.
2026-05-14 22:35:57 +02:00
Marc-André Bombeck a238ae48d1 fix(design): pin acknowledgment dialog footer + i18n back-button + drop GoalsChipPicker storage block
Three small surface fixes consolidated because each is a one-touch
change on a separate file:

- `ResearchModeAcknowledgmentDialog`: the entire `DialogContent`
  scrolled at `max-h-[90vh] overflow-y-auto`, which pushed the
  Acknowledge / Cancel CTAs below the fold on phone-class viewports.
  Move scrolling onto the body section only so the header and footer
  stay pinned.
- Medication history page back-button rendered the literal "Zurück"
  instead of going through `t()`. Wire the new `medications.back`
  key across all six locale bundles.
- `GoalsChipPicker` shipped a localStorage persistence block scoped
  by user id, but no consumer reads the same key. Drop the storage
  layer (and the unused `goalsStorageKey` /
  `ONBOARDING_GOALS_STORAGE_PREFIX` exports) until v1.4.26 lands the
  `User.onboardingGoals` column the picker is meant to feed.
2026-05-14 22:30:58 +02:00
Marc-André Bombeck 7c9b4b6563 fix(onboarding): conditional step write + 409 on concurrent advance
Two parallel tabs both reading `onboardingStep = 2` and both POSTing
`step: 3` would both succeed because the second write didn't re-check
the precondition — the wizard could double-advance and skip a step.
Move the write onto `updateMany` with the precondition embedded in
the WHERE clause: exactly one update lands, the loser sees count = 0
and returns 409 so the client can refresh and retry.

The WHERE clause covers `onboardingCompletedAt: null` so the same
guard catches a race with `POST /api/onboarding/complete`. The
`current === 0` branch widens the match to `step IS NULL OR step = 0`
so legacy pre-W14b accounts still advance from step 1.

Fix-M / code-M7.
2026-05-14 22:30:02 +02:00
Marc-André Bombeck 35cb488b6d fix(design): lift personal-record badge to WCAG AA contrast
The PR pill painted `text-dracula-green` (#50fa7b) on a
`bg-dracula-green/10` chip background. In dark mode that measured
around 2.6:1 — well under the 4.5:1 minimum WCAG 2.1 AA requires
for small text. Switch to the semantic `--success` token, which
resolves to a darker forest green on the light card (#14720a) and
to Dracula's neon green on the dark card, and lift the chip tint
from /10 to /15 for a sturdier edge. Both modes now clear AA.
2026-05-14 22:28:19 +02:00
Marc-André Bombeck 9ef1dd6e02 feat(api): legacy Withings webhook counter + migration URL in warning
The legacy ?secret=... route stays alive through v1.4.27 so existing
Withings subscriptions keep delivering while users re-subscribe. The
release-gate cannot tell when "legacy form usage has trended to zero"
because the route only emits a fire-and-forget Wide Event warning and
the warning text omits the destination URL anyone watching the access
log would need.

This pass adds:
- An in-memory `withings.webhook.legacy_form_total` counter scoped to
  the process (the right granularity for "since cold-start" usage).
- A `getLegacyFormTotal()` accessor + a test-only reset helper so the
  counter is observable without a backchannel.
- A `counters.withingsWebhookLegacyFormTotal` field on the existing
  `/api/admin/status` payload — the natural surface for ops counters
  alongside the user / measurement / worker blocks.
- The warning text now spells out `/api/withings/webhook/[token]` and
  the "re-subscribe with the path-segment token form" instruction.
- Two new assertions on the webhook test (counter increments on the
  query path, stays at zero on the header path, warning contains the
  migration URL) and one new assertion on the admin-status test.
2026-05-14 22:28:08 +02:00
Marc-André Bombeck 6f3b901c3e fix(workout): reject endedAt <= startedAt + zero-duration PR guard
A workout with `endedAt <= startedAt` produced a non-positive
`durationSec` that downstream consumers treated as a real zero —
worst case a fastest-5km PR of zero seconds, which would lock in
until manual cleanup. Add a `.superRefine` clause on
`createWorkoutSchema` so the ingest gate refuses the row.

The PR detector stays defensive against any pre-gate row or future
code path that bypasses the schema: MIN-direction slots now always
filter `durationSec > 0` regardless of which field carries the PR
value. A MIN slot sorts ascending, so the smallest value wins — a
zero-duration row would always be picked first.

Fix-M / code-M2.
2026-05-14 22:27:32 +02:00
Marc-André Bombeck 733fc2cec7 fix(design): align range-bar zone backgrounds with Dracula tokens
`<RangeBar>` painted its red / orange / green zones in raw Tailwind
palette (`bg-red-500/8`, `bg-yellow-500/12`, `bg-green-500/20`),
while the marker dot already drew from `var(--dracula-red|orange|green)`.
That split meant the zone backgrounds drifted out of the theme on
dark-mode and never tracked the rest of the token surface (PR
badges, alerts, status pills). Swap the zone tints to the matching
Dracula tokens and pin the absence of the legacy raw palette in the
existing extraction-contract test.
2026-05-14 22:27:30 +02:00
Marc-André Bombeck 4a7195a1bc fix(onboarding): 44px touch targets across wizard CTAs + carousel pager
Bring every back / skip / next / chevron / pager button on the
v1.4.25 onboarding wizard up to the 44x44 px WCAG 2.5.8 floor. The
previous `size="sm"` buttons measured 32 px tall, the chevron icons
were 32 px square, and each dot pager target was 10 px square — all
well under the target-size minimum for thumb-driven flows.

Carousel slides now derive their accessible name from the slide
title via `aria-labelledby`, so the polite live region (which
mirrors "Slide N of M") is the single source for position
announcements. The previous markup announced position twice on every
swipe: once from the slide's own `aria-label`, again from the
live region.
2026-05-14 22:26:46 +02:00
Marc-André Bombeck a400e5407b feat(api): tighten POST /api/medications/[id]/glp1 with Zod + audit + rate-limit
The GLP-1 convenience route shipped in W4d skipped the Zod / audit /
rate-limit triple the sibling inventory + side-effect POSTs ship with.
Hand-rolled `typeof === "number"` guards let NaN, Infinity, negative
doses, multi-megabyte notes, and 1970-era timestamps reach Prisma.

This pass adds:
- `glp1DoseChangePostSchema` + `glp1InventoryPostSchema` + the XOR
  `glp1PostBodySchema` in `src/lib/validations/medication.ts`.
- A 500-char cap on `note`, 200-char cap on `reason`, 100 mg cap on
  `doseValue`, integer cap on `delta`, and a +/-5-year window on
  `effectiveFrom`.
- The 30/min/user rate-limit copied from the sibling routes, keyed on
  `medication-glp1:post:<userId>`.
- `auditLog("medication.glp1.update", ...)` for every mutation, tagged
  with the affected row id and the dose / delta values.
- A test fixture mirroring the inventory + side-effect tests with
  thirteen assertions covering the new 422 paths, the rate-limit
  header, and both audit branches.
2026-05-14 22:26:14 +02:00
Marc-André Bombeck 1d5dcfb8b1 perf(inventory): bulk-update expired in-use items via updateMany
The daily cron sweep loaded every stale IN_USE row and issued one
`prisma.update` per row, recomputing the state in JS only to learn
what the selector already proved (state=IN_USE AND expiresAt < now ⇒
the row must transition to EXPIRED, because IN_USE implies
dosesRemaining > 0 and the expiresAt filter shows the deadline lapsed).
Collapse the round-trip onto a single `updateMany` so the cron's cost
is O(1) round-trips per user instead of O(stale rows).

Fix-M / code-H3.
2026-05-14 22:26:01 +02:00
Marc-André Bombeck 1b5906a504 fix(inventory): re-run state machine on markAsFirstUseAt PATCH
A back-dated first-use stamp whose 30-day in-use window has already
lapsed must land at EXPIRED, not IN_USE. The hand-rolled state
transitions in the PATCH route composed the next-row view but never
re-evaluated through the canonical state machine, so the route could
emit an IN_USE row whose expiresAt was already in the past. Wire
`computeInventoryState` over the composed view so every PATCH lands
on the same decision tree the intake hook and the daily expire cron
already share.

Test: ACTIVE pen + markAsFirstUseAt 45 days ago ⇒ EXPIRED.

Fix-M / code-H1.
2026-05-14 22:24:54 +02:00
Marc-André Bombeck 44e8229ff3 fix(logging): redact http.path + http.route in WideEventBuilder.setHttp
Closes the WITHINGS_WEBHOOK_SECRET leak (sec-C1): the api-handler
seeds http.path and http.route from url.pathname, which carries the
shared secret for /api/withings/webhook/<secret> requests. Routing
both fields through redactSecrets() at setHttp() time keeps the
secret out of stdout, the in-memory ring buffer, and Loki egress.
2026-05-14 22:24:42 +02:00
Marc-André Bombeck 8da6a4ae69 feat(logging): path-segment secret redaction via PATH_SECRET_PATHS registry
Adds a registry of URL path prefixes whose trailing segment is a
shared secret, seeded with the /api/withings/webhook/ entrypoint.
The redactSecrets() egress filter now rewrites those segments to
[REDACTED] before stdout, in-memory ring buffer, and Loki egress.
2026-05-14 22:23:49 +02:00
Marc-André Bombeck 3b5f06d6b3 refactor(insights): extract useInsightStatus hook for sub-page status queries
Five insights sub-pages (blutdruck / bmi / gewicht / puls / stimmung)
each declared the same 13-line useQuery block plus an identical
StatusData interface. The hook routes through the existing queryKeys
factory so the cache keys stop drifting on the next typo. Medikamente
keeps its bespoke query because the compliance-status payload carries
a different shape (per-medication breakdown). Closes W10 simplifier
S12.
2026-05-14 21:51:26 +02:00
Marc-André Bombeck fcda115123 fix(api-handler): widen safeRequestProp narrow-catch to tolerate undefined requests
Follow-up to commit `61d156e7`. The narrow catch initially recognised
only the V8 private-field TypeError wordings, but unit tests routinely
invoke route handlers as `GET()` with no arguments — the wrapper then
reads `args[0]` as `undefined` and the read callback raises
`TypeError: Cannot read properties of undefined (reading 'url')`,
which is not a private-field error.

The classifier is renamed to `isTolerableRequestProbeError` and now
also matches the "cannot read properties of undefined / null"
TypeError shape (both modern V8 wording and the older
`Cannot read property 'X' of undefined` form). Any other TypeError
still rethrows so production regressions stay visible.

This restores the no-arg-direct-invoke contract that
`src/app/api/auth/me/source-priority/__tests__/route.test.ts` and
similar vitest suites rely on (`GET()` with no request to assert
the auth-gate path returns 401).

Verification:
  pnpm typecheck                                       → clean
  pnpm vitest run api-handler-safe-request-prop        → 11 / 11 pass
  pnpm vitest run src/app/api/auth/me/source-priority  → 6 / 6 pass
  pnpm vitest run (full unit)                          → 3751 / 3751
2026-05-14 21:50:29 +02:00
Marc-André Bombeck bbc0afd555 refactor(analytics): export ContributingSource so its callers stop spelling the union
The literal "manual" | "withings" | "appleHealth" was spelled in
six places across health-score.ts and the analytics route, plus once
in the card. The lib helper and the route now read from a single
ContributingSource alias; the card keeps its local copy on purpose so
the client bundle stays free of analytics imports. Closes W10
simplifier S9 (lib + route scope).
2026-05-14 21:48:24 +02:00
Marc-André Bombeck 76dea94bae chore: Cat-C typo and naming polish from W10 dead-code probe
W10's dead-code candidates report (§C2) flagged a half-quoted reference
inside `src/app/api/insights/targets/route.ts:807`. The comment claimed
the AI prompts lived at `src/lib/insights/prompts/{base-system,
general-status}.ts`, but those files actually sit under
`src/lib/ai/prompts/` and the brace-expansion abbreviation was both
wrong-pathed and easy to misread as a stray-brace typo.

The comment is rewritten in full path form so future readers can grep
the referenced files directly and so the source citation matches the
real location.

Verification:
  pnpm typecheck                         → clean
  pnpm lint                              → clean
  grep -rn 'general-status}' src         → zero hits
2026-05-14 21:46:48 +02:00
Marc-André Bombeck 030896755c refactor(settings): collapse moveSource + moveDeviceType into reorderLadder helper
The two reorder helpers were 21-line near-duplicates that drifted apart
twice during W8c before being realigned. Extracted the shared swap
into a generic reorderLadder<T>() helper so both callers reduce to
read-ladder, call helper, write back. Closes W10 simplifier S3.
2026-05-14 21:46:38 +02:00
Marc-André Bombeck 61d156e71b fix(api-handler): narrow safeRequestProp catch + harden @source path resolution
Two carry-overs from the W7d dev-server repair, flagged for v1.4.26 in
the post-W7d follow-up list.

1. `safeRequestProp` previously caught every exception from the read
   callback so logging instrumentation never crashed the wrapped
   handler. That was overly broad — a regression in a header parser
   or a `null` access inside `read` would silently fall back to the
   default and hide the bug. The catch is now narrowed to the V8
   private-field `TypeError` that Next 16 raises when the request is
   a synthetic placeholder on force-static handlers; every other
   error rethrows. Three message-shape variants are recognised
   (`private member`, `private field`, `private name`) to cover
   current V8 + Bun + older runtimes.

2. `src/app/globals.css` now imports Tailwind with an explicit
   `source("../..")` base. Tailwind v4 auto-detection otherwise walks
   up from the CSS file's resolver root, which can land at an
   unexpected level depending on how the build tool reaches the
   stylesheet. Pinning the base mirrors the cwd-anchored fix the
   safety-contracts loader landed in commit `dd44a0d` and stops the
   planning / docs directories from leaking class-shaped fragments
   into the generated CSS regardless of where the build is invoked
   from.

The narrow-catch helper is exposed through a `__testables` export so
its contract can be unit-tested directly. The new test file covers:
  - the three private-field message variants classify as match
  - generic TypeErrors, RangeErrors, and bare Errors classify as
    non-match (must rethrow)
  - safeRequestProp returns the read result on a real request
  - safeRequestProp returns the fallback on a private-field TypeError
  - safeRequestProp rethrows any other exception

Verification:
  pnpm typecheck                           → clean
  pnpm lint                                → clean
  pnpm vitest run api-handler-safe-request-prop → 9 / 9 pass
2026-05-14 21:45:56 +02:00