Commit Graph
1030 Commits
Author SHA1 Message Date
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 4c4fa645b1 docs(db): rewrite Migration 0057 PG11+ default-backfill comment
The original comment claimed that `DEFAULT 0` "only applies to *new*
inserts on PostgreSQL — existing rows are not backfilled". That's
wrong for PG11+: the fast-path `ADD COLUMN ... DEFAULT` materialises
the default for every existing row in O(1) time without rewriting the
table. The comment confused future readers about why the Wizard mount
treats `NULL` as 0 (defensive against the legacy ambiguity, not
because of any general PG behaviour).

Rewrite to match reality, and point at Migration 0060 as the follow-on
that flips the column to NOT NULL.

Fix-O / code-M8.
2026-05-14 22:36:33 +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 4d5c735d18 fix(e2e): use canonical healthlog-locale cookie in locale-switch spec 2026-05-14 22:22:44 +02:00
Marc-André Bombeck c75a68a88c docs(planning): W21 reconcile plan 2026-05-14 22:20:35 +02:00
Marc-André Bombeck f88c275df4 docs(planning): W21 i18n runtime findings 2026-05-14 22:14:26 +02:00
Marc-André Bombeck 65319317cd docs(planning): W21 dead-code findings 2026-05-14 22:08:16 +02:00
Marc-André Bombeck e8139e9f93 docs(planning): W21 simplifier findings 2026-05-14 22:05:18 +02:00
Marc-André Bombeck 5448d19fee docs(planning): W21 code-review findings 2026-05-14 22:03:26 +02:00
Marc-André Bombeck 0f4431cbf1 docs(planning): W21 senior-dev findings 2026-05-14 22:02:38 +02:00
Marc-André Bombeck 94f97fa26a docs(planning): W21 product-lead assessment 2026-05-14 22:01:42 +02:00
Marc-André Bombeck 472da13da7 docs(planning): W21 design findings 2026-05-14 22:01:28 +02:00
Marc-André Bombeck 230e7531b0 docs(planning): W21 security findings 2026-05-14 22:01:16 +02:00
Marc-André Bombeck 51f23ef318 docs(planning): W18 Low + apply-with-care cluster phase report 2026-05-14 21:53:23 +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 7d625343f9 docs(planning): note safeRequestProp widening follow-up in W15 phase report 2026-05-14 21:51:04 +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 4ab0d17808 docs(planning): W15 hygiene cluster phase report
Wave 5 hygiene wave summary covering the four atomic commits and
their verification matrix:
  fddb9754 — 380 dead i18n keys retired (25 false-positives kept)
  41c7425b — BASE_SYSTEM_PROMPT + INSIGHTS_SYSTEM_PROMPT removed
  61d156e7 — safeRequestProp narrow-catch + Tailwind @source base
  76dea94b — Cat-C comment path rewrite on /api/insights/targets
2026-05-14 21:48:11 +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 781e6e180d docs(planning): W20-rest P6 polish phase report
Shipped 3 of 11 P6 polish items in this wave under touch-disjoint
constraints (W15 owns messages/*.json + system prompts, W18 owns
charts core / errors / audit-log / deviceType / drift-guard / CSS
anti-patterns / S1-S12):
  - P6-1 Pearson incomplete-beta replacement
  - P6-3 Coolify auto-deploy maintainer toggle
  - P6-8 GH translation-feedback issue template

Deferred to v1.4.26 (8 items + rationale):
  - P6-2 sentinel HK envelopes (no parser yet)
  - P6-4 Coach lastYear window (spec ambiguity + 9 surfaces)
  - P6-5 Coach prefill on row tap (Wave 4b stable footprint)
  - P6-6 per-night sleep-stage chart (W18 surface + new endpoint)
  - P6-7 locale-native date formats (i18n only — W15)
  - P6-9 hand-review FR/ES/IT/PL prose (i18n only — W15)
  - P6-10 Cat-B endpoint triage (per-endpoint maintainer go/no-go;
    7 recommendations enumerated in the report)
  - P6-11 mood verbal labels (already shipped in v1.4.25 polish pass;
    no remaining surfaces)
2026-05-14 21:46:41 +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
Marc-André Bombeck 7b766bedab fix(a11y): wire aria-controls + aria-expanded on GLP-1 dose-history details
The native <details>/<summary> element conveys state via the open
attribute, but the rest of the app's accordions also surface
aria-controls + aria-expanded so SR users hear the same contract on
both. The disclosure now references the inner panel by ID and the
expanded state mirrors the React-side toggle. Closes W10 design L3.
2026-05-14 21:45:38 +02:00
Marc-André Bombeck 766b64e44c feat(github): translation-feedback issue template
The maintainership banner that surfaces on AI-initial locales (FR / ES
/ IT / PL) links to issues/new?template=translation.md, but the
referenced template was never added to the repo. GitHub fell back to
a blank issue body and the funnel produced unstructured reports that
were hard to triage.

The new translation.yml form asks for locale, the route/component
where the wording appeared, current vs. suggested phrasing, and the
kind of issue (grammar, register, terminology, …). The banner URL
moves to template=translation.yml to match the filename.

No test churn — the banner component test does not pin the URL.
2026-05-14 21:44:29 +02:00
Marc-André Bombeck f4606fb1cf fix(charts): replace hsl(var(--token)) with bare var(--token) refs
The Dracula tokens are stored as hex values; wrapping them in
hsl() yielded an invalid CSS color string that the browser fell
back from to default. Mirrors the W10 reconcile-A C1 fix already
applied to the sleep-stage chart. Three call sites remained:
mood-chart CartesianGrid stroke, scatter-correlation CartesianGrid
stroke, and the scatter-correlation tooltip background + border.
2026-05-14 21:43:57 +02:00
Marc-André Bombeck c2e5c8602f test(insights): cover detectGlp1Plateau direct branches with mocked prisma
Existing coverage only exercised the deterministic prompt formatter.
The Prisma-driven branches around window arithmetic, meds[0] pick,
and threshold comparison were untested — the previous regressions
flagged by W10 (security sanitise + timezone) could have slipped.
Ten new tests pin the seven return paths plus the multi-medication
first-pick contract and the drug-name first-word display token.
2026-05-14 21:42:54 +02:00
Marc-André Bombeck e23dd28619 ci(coolify): explicit maintainer toggle for auto-deploy gate
Three releases in a row shipped via the host-side SSH retag fallback
because the COOLIFY_WEBHOOK / COOLIFY_TOKEN secrets were absent, the
deploy step warned and exited 0, and the workflow stayed green. The
silent miss meant the gap surfaced only at deploy time when /api/version
refused to advance.

The vars.COOLIFY_AUTO_DEPLOY repository variable now disambiguates
intent:
  on  — fail the step when secrets are absent (catches the gap loudly
        on the next release).
  off — unconditional skip with a notice (host-side SSH is the chosen
        path).
  unset / other — legacy warn-and-skip behaviour.

Variable lives under Settings → Secrets and variables → Actions →
Variables (not Secrets — it is intentionally readable). The warn-only
default keeps existing repos working; flipping the toggle to 'on'
turns the silent miss into a noisy error.
2026-05-14 21:41:58 +02:00
Marc-André Bombeck 41c7425b55 chore(ai): drop dead BASE_SYSTEM_PROMPT and INSIGHTS_SYSTEM_PROMPT constants
Both constants were @deprecated since v1.4.20 with zero callers
anywhere in the source tree. The Coach surface migrated to
`getBaseSystemPrompt(locale)` + the per-locale safety-contract matrix
(W14c); the Insights surface uses `getInsightsSystemPrompt(locale)`
plus per-page-type prompts introduced in the W3/W6 redesign. The
backwards-compatibility re-exports outlived their purpose.

Removed:
  src/lib/ai/prompts/base-system.ts — `BASE_SYSTEM_PROMPT`
  src/lib/insights/prompt.ts        — `INSIGHTS_SYSTEM_PROMPT`

Verification:
  grep -rn 'BASE_SYSTEM_PROMPT[^_]\|INSIGHTS_SYSTEM_PROMPT[^_]' src
                                  → zero hits
  pnpm typecheck                  → clean
  pnpm lint                       → clean
  pnpm vitest run src/lib/ai src/lib/insights → 1205 / 1205 pass
2026-05-14 21:41:13 +02:00
Marc-André Bombeck c80e0a089d fix(insights): exact Student's-t p-value via incomplete beta
Pearson's two-sided p-value previously relied on the standard-normal
approximation of the t-distribution tail. The approximation is generous
in the wrong direction at low df: at df=12 a true p of 0.04 reports
back as p of 0.025 and the card sneaks past the p<0.05 surfacing gate.
v1.4.23 H6 sidestepped the issue by raising MIN_PAIRED_N from 14 to
20, but the gate was always defence-in-depth rather than correctness.

Auto-discovery work planned for v1.5/v1.6 widens the metric pair grid
past the three pre-defined cards, so the false-positive cost of the
normal approximation starts to compound. The fix lands the rigorous
path now: a regularised-incomplete-beta evaluation of the Student's-t
survival, via the Numerical Recipes Lentz continued-fraction
recurrence, with a Lanczos lnGamma prefactor. The two-sided p is
I_x(df/2, 1/2) for x = df / (df + t^2); the symmetric-tail identity
I_x(a,b) = 1 - I_{1-x}(b,a) keeps the recurrence in its convergent
half-plane.

Reference values agree with R's pt() to ~1e-4 across the surfacing
range. Three new tests pin: r=0.5 df=18 lands in [0.005, 0.06], r=0
df=18 returns p=1 via the symmetric-tail branch, r near 1 df=18
returns a strictly positive p (the old normalCdf call underflowed to
0). The existing noise-fixture test had to be regenerated against a
truly orthogonal LCG-derived stream — the previous fixture produced
r=-0.49 which the exact survival correctly flags as significant
(the normal approx was loose enough to mask it).
2026-05-14 21:40:44 +02:00
Marc-André Bombeck fddb975432 chore(i18n): drop 380 dead translation keys flagged in W10 runtime probe
W10's i18n-runtime-gaps probe inventoried 405 unreferenced catalog keys
across both locale files. A second-pass verification against the source
tree (literal `"key"` / `'key'` / backtick exact-match) confirmed 380 of
them as truly orphan and 25 as live references the static analyser had
missed — typically built through template literals or wrapped in
non-string contexts.

This commit removes the 380 verified-dead keys from all six locale
files (en/de/fr/es/it/pl) and prunes the now-empty
`classifications.{bp,pulse,bodyFat}.*` placeholder allow-list entries
in `i18n-locale-integrity.test.ts` so the parity test passes against
the smaller surface.

Top namespaces retired:
  settings.*            (114 keys) — old setup screens, audit-log
                                     leaf labels, ai providerChain
                                     scaffolding
  classifications.*     (69 keys)  — alerts.* group + legacy per-metric
                                     leaf labels; live classification
                                     surfaces use targets.* now
  admin.*               (38 keys)  — superseded by admin/[section] shell
  onboarding.*          (21 keys)  — v1.4.22 onboarding rewrite leftovers
  medications.*         (18 keys)  — v1.4.18 redesign orphans
  dashboard.*           (15 keys)  — v1.4.20 insights redesign leftovers
  bugreport.*           (12 keys)  — simplified bug-report screen
  common.*              (12 keys)  — generic strings no longer referenced
  charts.*              (15 keys)  — v1.4.20 chart-label slugs
  auth.*                (10 keys)  — auth UI now uses inline copy
  notifications.*       (10 keys)  — toast variants no longer fired
  targets.*             (9 keys)   — replaced by targets.{label,status}.*
  measurements.*        (7 keys)
  comparison.*          (6 keys)
  mood.*                (5 keys)
  thresholds.*          (4 keys)
  achievements.*        (4 keys)
  format.*              (3 keys)
  gettingStarted.*      (2 keys)
  nav.*                 (1 key)

Verification:
  pnpm typecheck                  → clean
  pnpm lint                       → clean
  pnpm vitest run                 → 3731 passed, 1 skipped
  i18n-locale-integrity           → 26 / 26 (locales still parity-clean)
2026-05-14 21:40:13 +02:00
Marc-André Bombeck f16d4b3f0e fix(api): rename coach.batch.too_large to measurement.batch.too_large
The batch ingest route lives under /api/measurements, not the Coach.
Aligns the errorCode with the sibling DELETE route, which already
emits measurement.delete.too_large.
2026-05-14 21:37:53 +02:00
Marc-André Bombeck ab54d86bf8 docs(planning): W19f titration ladder + Wave 4b closeout phase report
Closes Wave 4b. The GLP-1 medication detail page section stack is
now complete: DrugLevelChart (W19c) -> SideEffectsSection (W19d) ->
SchedulingSection (W19e) -> TitrationSection (W19f) ->
IntakeHistoryList (existing).

40 new tests across the three commits (22 ladder helper, 8 route,
10 component). Migration 0060 stays unassigned.
2026-05-14 21:28:27 +02:00
Marc-André Bombeck 86dbff0e6e feat(medications): EMA titration-ladder display on GLP-1 detail page
Adds the read-only TitrationSection to the medication detail page,
mounted between SchedulingSection (W19e) and IntakeHistoryList.
Same chrome as the W19d / W19e panels so the Wave-4b stack reads
as one visual group.

Layout:
  - Header with drug INN
  - Vertical step list on mobile, horizontal track from sm; each
    step shows dose, typical-weeks badge, and a current/past/upcoming
    state attribute the design system can hook (CSS-only, no JS state)
  - Current step highlighted with a primary-color ring + 'You are
    here' caption
  - Optional dwell hint + next-step caption — observational copy
    only (no 'should/must step up' anywhere; verified by test)
  - Ceiling message when the user is on the top step
  - Non-standard dose caption when the latest dose doesn't snap to
    a step inside the ±10 % tolerance
  - Disclaimer + external EMA-source link on every render

i18n (medications.titration.*) added in all six locales after the
medications.scheduling block per the W19e handoff for stable JSON
ordering. EN + DE hand-curated Marc-Voice; FR / ES / IT / PL drafted
from EN. Parity test (i18n-locale-integrity) passes.

10 component tests cover surface render, step-card count, single
'you are here', next-step caption, ceiling message, non-standard
dose, disclaimer + EMA link, empty ladder, observational-copy
discipline, and DE locale switch.

The escalationDueHint copy is strictly reference (MDR boundary,
parallel to W19c safety ground rules); the test pins the negative
assertion — no 'should step up', no 'must step up', no
'recommend step up'.
2026-05-14 21:27:29 +02:00
Marc-André Bombeck b4ed2eda9e feat(api): GET titration-ladder + current-step for GLP-1 medications
Returns the EMA-reference ladder for the medication's drug, the
user's matched current step (snapped within ±10 % of their latest
dose), elapsed whole-weeks on that step, the immediate-next step,
and the observational escalation-due flag.

  GET /api/medications/[id]/titration
    -> { drugId, drugInn, ladder, currentStep, currentStepIndex,
         weeksOnCurrentStep, nextStep, escalationDue, sourceEMA }

Defence-in-depth gating:
  - requireAuth() + medication ownership check (404 on mismatch);
  - 404 when treatmentClass !== 'GLP1' (the UI already gates here);
  - 404 when the medication name doesn't map to a catalog brand.

Reads the user's latest dose from the MedicationDoseChange stream
(history of record); falls back to parsing the legacy free-text
medication.dose only when the stream is empty.

Pure-read; no writes; annotate() instrumentation only. Same shape
as the W19e cadence route — math lives in the pure helpers, prisma
lives in the route.

8 cases pass covering 401, 404 paths (non-owner, non-GLP-1,
unknown brand), the happy path on a 5 mg Mounjaro user, the
non-standard-dose null path, the ladder-ceiling no-next-step path,
and the escalationDue=true boundary past EMA dwell-time.
2026-05-14 21:23:14 +02:00