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.
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
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.
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.
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.
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.
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
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).
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.
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'.
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.
W19f reads the EMA titration schedules already pinned in the
glp1-knowledge layer (titrationStepsMg + titrationIntervalWeeks) and
normalises them into a TitrationStep contract the upcoming API
route and detail-page section will share:
- getLadder(drugId) / ladderFromRecord(record)
- findCurrentStep(drugId, latestDoseMg) — snaps within ±10 %, null
outside the tolerance window so non-standard doses don't get
falsely highlighted
- nextStep(drugId, currentStep) — null at the ceiling
- weeksOnCurrentStep(drugId, currentStep, doseChanges, asOf) —
counts elapsed whole-weeks since the latest matching dose-change
row
- escalationDue(drugId, currentStep, weeksOnStep) — observational
flag (titrationIntervalWeeks reached AND next step exists)
The escalationDue helper is a *display* flag, not advice — the UI
copy stays observational (MDR boundary, parallel to W19c ground
rules). No DB access; the route owns prisma, this module owns math.
Same shape as the W19d / W19e pure modules.
22 cases pass covering every EMA-approved drug's ladder, tolerance
boundaries, the ceiling edge, weeks-on-step for empty / matching /
future histories, and the escalationDue toggle.
Adds the /api/medications/[id]/side-effects collection + per-row
endpoints (GET / POST + DELETE), the SideEffectsSection picker UI,
and i18n across the six locales. Mounted on the medication detail
page between the drug-level chart and the intake history; only the
GLP-1 treatmentClass renders the section.
The picker is category → entry chip → 5-Likert severity with an
optional 280-char note. The server derives the category from the
entry server-side — a client-supplied mismatch returns 422, so a
NAUSEA row tagged as INJECTION_SITE never lands in the DB.
POST is rate-limited 30/min/user (matching the inventory route);
DELETE is allowed at any time because the rows are user-owned and
non-clinical, with the audit trail preserving entry + severity +
occurredAt so a forensic reconstruction stays possible.
DE + EN copy hand-curated; FR / ES / IT / PL drafted from EN with
EMA-EPAR-§4.8 terminology. 39 new tests cover the route contract
(auth, ownership, validation, category mismatch, rate-limit) and
the section's static-render shape (empty state, category badge,
severity ladder, entry label, locale switch).
Single source of truth for the GLP-1 side-effect taxonomy — entry →
category mapping plus the reverse index, ordered category list, and
a 1-5 Likert → semantic-label helper. The DB stores `category`
denormalised for cheap range scans; this module is the authority and
the API write path uses `categoryForEntry()` instead of trusting a
client-supplied category.
Severity helpers return locale-agnostic ladder keys (`mild` …
`verySevere`); the UI maps each to a translated label so the picker
stays purely structural. Tests pin every contract the API validator
and picker depend on: 21 entries × 5 categories with the EMA-derived
counts (GI 5, METABOLIC 4, INJECTION_SITE 4, COGNITIVE 4,
GLP1_SPECIFIC 4); every entry maps to exactly one category;
severity-ladder index is monotonic.
Adds the Research Mode card to Settings → Advanced alongside the
existing danger zone. Toggle ON opens the acknowledgment dialog (the
dialog owns the POST so the user reads the disclaimer first); OFF
fires DELETE directly. An amber re-prompt banner appears above the
toggle whenever the server's currentDisclaimerVersion drifts ahead of
the acknowledgedVersion, surfacing a Re-acknowledge CTA that re-opens
the dialog without flipping the toggle. The Advanced section
description is updated across all six locales to reflect that the
section now hosts both controls.
Recharts AreaChart over a 21-day window, anchored at now. Wires the
W19c-Backend pure PK module: drug id resolved from the medication
brand via the catalog, doses derived from non-skipped intake events
matched against the dose-change history. Y-axis is unit-less per
research §2.3 — no tick labels, the 'Estimated level (relative)'
caption sits above the frame. The chart gates on enabled +
acknowledgedVersion === currentDisclaimerVersion (defence-in-depth
alongside the server 400) and renders distinct placeholders for
each failure mode. Mounted on /medications/[id]/history when
treatmentClass === GLP1.
Opt-in modal that gates the GLP-1 drug-level chart behind an MDR-aware
disclaimer. Reads the live disclaimer version from the server and posts
it back on acknowledge so a server-side bump forces re-acknowledgment.
Sections cover what the chart is, what it isn't, why population PK is
an estimate, the EU 2017/745 + MDCG 2021-24 boundary, and EMA + ASCPT
psp4.13099 citations. Stale-version 400 surfaces a localised error
toast and keeps the dialog open.
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.
Pure module — no I/O, no Date.now(), reads only the static catalog
in glp1-knowledge.ts. Implements Bateman first-order absorption +
first-order elimination per research §2.2, exposes:
computeOneCompartment — array of {tHours, concentration} samples
across a configurable window for the
opt-in research-view AreaChart.
shotPhaseAt — qualitative rising/peak/fading/none chip
per research §2.4.
Two-compartment fidelity is deliberately out of scope. The
journal-of-record (Schneck & Urva 2024, psp4.13099) confirms a
two-compartment structure as the high-fidelity model, but importing
it would invite numeric concentration readouts that cross the EU MDR
Class I threshold (research §11, §12.4). The one-compartment closed
form is sufficient for the qualitative chip and the unit-less
research chart; the rigorous variant waits for v1.5 / R8 alongside
the medical-device review.
RESEARCH_MODE_DISCLAIMER_VERSION (2026-05-14.1) is exported from
this module so the API endpoint records exactly which copy version
the user has acknowledged, and the Settings UI re-prompts on
version drift.
15 tests cover: per-drug smoke, zero-before-dose, empty input,
linear superposition, window/step overrides, sawtooth shape, phase
classification at fresh/mid/tail timepoints.
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.
Adds the dynamic step segment (steps 0-4) plus a pass-through layout
that leaves the legacy /onboarding root page untouched. Each step page
renders the OnboardingShell with a placeholder body the Content agent
will replace.
Server-side gating enforced:
- unauthenticated → /auth/login
- onboardingCompletedAt != null → /
- out-of-order forward jumps → user's current onboardingStep
Backwards navigation stays allowed so the shell's Back button works.
Server component that paints the consistent step frame (logo, four-dot
progress strip, back/skip/next CTA row, safe-area-bottom padding) for
the rebuilt /onboarding/[step] flow. Step pages render their step-
specific body into the children slot; the shell owns every surrounding
pixel.
Translator falls back to the raw i18n key when keys are missing, so the
shell renders cleanly before the full onboarding.* key surface lands.
Adds the integration suite for the detection worker against a real
Postgres testcontainer. Covers measurement-driven PRs (steps,
RHR, VO2 max), idempotency on re-run, silent-flag propagation,
and the drift guard that every PR-trackable metric type lands in
the scan loop.
Also patches a pre-existing schema gap: the
(userId, metricType, metricSlot, achievedAt) unique index on
personal_records is NULLS-DISTINCT in Postgres, so the index alone
does not dedup the measurement-driven rows (metricSlot = NULL).
The worker now does an application-level pre-flight findFirst on
the same composite key before each insert, keeping both the
workout path (index covers it) and the measurement path
(application-level guard) idempotent without a Migration.
Turbopack rewrites `__dirname` to a synthetic `/ROOT/...` token at
build time, so `readFileSync(join(__dirname, 'safety-contracts.fr.yaml'))`
crashed during page-data collection in the Docker build with an
ENOENT. The chunk error fixed in the previous commit was hiding the
issue.
The path now resolves through `process.cwd() + 'src/lib/ai/prompts'`,
which stays stable across `next build`, `next start`, and the test
runner. `outputFileTracingIncludes` in `next.config.ts` pulls the YAML
siblings into the standalone runtime image so the read succeeds at
deploy time.
Adds a small text-only PR pill that consumers can compose next to
a metric label. The badge pulls /api/personal-records?metricType=
and renders only when the most recent record landed in the last
30 days — older PRs are still visible on the dedicated /insights
PR list but the dashboard surface keeps a fresh-only filter so the
pill is a noticeable signal rather than a permanent fixture.
i18n keys insights.personalRecord.badge and insights.personalRecord.tooltip
ship in all six locales. The badge component is locale-aware
through the standard useTranslations / useFormatters hooks and
exposes a withTooltip prop so standalone uses (doctor report,
correlation cards) can skip the Tooltip provider.
`@/lib/validations/source-priority` imported `annotate` from
`@/lib/logging/context`, which pulls `node:async_hooks` via
`AsyncLocalStorage`. Any client component touching a type-only export
(e.g. `DEFAULT_SOURCE_PRIORITY` in `components/settings/sources-section`)
dragged async_hooks into the browser bundle and broke the Turbopack
build.
The validations module now exposes `registerSourcePriorityParseObserver`
with a no-op default. `@/lib/logging/context` registers the real
`annotate` callback on first server-side import, so the breadcrumb
still surfaces in ops dashboards while the client bundle stays clean.
`@/lib/tz/resolver` imported `prisma` at module top so any client
component that pulled `detectBrowserTimezone`, `listSupportedTimezones`,
`formatInUserTz`, `userDayKey`, or `DEFAULT_TIMEZONE` dragged
`node:module` into the browser bundle and broke the Turbopack build
(register page, timezone picker, admin general/login sections,
`@/lib/export`).
Pure helpers now live in `@/lib/tz/format` (no Prisma). The resolver
re-exports them so every server-side caller keeps its
`@/lib/tz/resolver` import path; the four client modules + the
export helper now import from `./format` directly.
The drift guard read `.planning/research/glp1-feature-inspiration.md`
unconditionally and threw an ENOENT at collection time on CI, where
`.planning/` is local-only. The hard-pin block (TS module vs cited
EMA EPAR / psp4.13099 values) is the real production guarantee and
keeps running everywhere; the soft-pin blocks that grep the research
markdown now `describe.skipIf` when the file is missing.
The fallback for missing `attribution.windowEndAt` was `new Date()`,
which made consecutive `computeHealthScore()` calls drift by a
millisecond on loaded CI runners and turned the determinism guard into
a flake. The fallback now picks the most recent input date across the
weight and mood series (Unix epoch when neither has entries) so the
result is byte-identical across calls. Production routes already
supply their own `windowEndAt` and stay unaffected.
Adds a focused test asserting per-component `asOf` stability across
back-to-back invocations.
Adds the PERSONAL_RECORD event type to the dispatcher vocabulary
and flips its default policy to opt-in. Without an explicit
preference row the dispatcher now suppresses PR pushes — so a
multi-year Apple Health backfill writes the records but does not
saturate the lock-screen on first sync. Other events keep the
opt-out default they always had.
The /notifications matrix renders the new event automatically
because it iterates EVENT_TYPES; the per-event default helper
keeps the toggle in the right initial state across all six
locales (EN, DE, FR, ES, IT, PL).
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.
Adds the pr-detection queue and its handler to the reminder worker.
Ingest paths enqueue per-user jobs via enqueuePrDetection(); a
30-minute fallback cron iterates every user as a safety net for
ingest paths that skip the hook. Local concurrency is 5 so a
backfill spike drains without crowding the reminder check or the
daily insights workload running on the same node.
The payload carries a silent flag — ingest hooks set it on bulk
historical batches so a multi-year Apple Health backfill writes
the PR rows without firing hundreds of push notifications.
Scans Measurement and Workout history per user and writes
PersonalRecord rows for every all-time best. Direction comes from
the W8d helper (MAX = higher wins, MIN = lower wins, null = never
a PR). A seven-sample warm-up gate per metric keeps a one-off
out-of-range sensor reading from locking in as the baseline.
Workout slots cover longest run / longest distance / fastest 5 km
for running and cycling. Ties write a row but mark it as
push-suppressed so the user matched (not beat) their best. The
unique index plus skipDuplicates makes the handler idempotent.
Pins the load-bearing CSS class names introduced in W20a so a future
refactor that drops the heading-row height lock or the value-row
baseline alignment fails CI before reaching the dashboard.
- Heading-row contract: `h-5` + `whitespace-nowrap` + `truncate`
on the label slot. Defensive — the live label is locale-abbreviated
upstream, but a missing short-key fallback or an unexpectedly long
fallback string should still not wrap to a second line.
- Value-row contract: `items-baseline` + `text-3xl` + `leading-none`
across all five top-row tiles (Weight, BP, Pulse, Body Fat, Mood) so
the digits land at the same y-coordinate inside the grid.
- Arrow-slot contract: always rendered, end-anchored via `ml-auto`,
fixed 16×16 frame; renders a muted "—" placeholder when slope30
is null so the slot width is deterministic across tiles.
- i18n integrity: every `dashboard.*Short` key resolves to a
non-empty string in en / de / fr / es / it / pl. Complements
the broader i18n-locale-integrity test with a precise diagnostic
for the dashboard tile namespace.
Locks the TrendCard layout so the top-row Dashboard tiles align across
the strip:
- Heading row gets a fixed `h-5` so a one-line label and a (defensive)
truncate behave identically. The label class adds `whitespace-nowrap`
+ `truncate` so even an unexpectedly long string never wraps to a
second line — pairs with the locale-abbreviated keys wired in the
previous commit.
- Value row drops `flex-wrap` and pins `items-baseline` so the
headline number, unit, and trend arrow all sit on the same y-baseline.
At the same font-size (`text-3xl leading-none`) every tile's digits
now land at the identical height (Weight 80 kg used to sit above BP
122 mmHg when one tile's heading wrapped).
- Trend arrow moves to the end of the value row (after the unit) per
Marc's brief, with a deterministic 16×16 slot. When the metric has no
slope yet, the slot renders a muted placeholder dash so the layout
width stays consistent across tiles.
Marc verbatim: "rechts neben der Zahl, also nachdem die zum Beispiel
Kilo oder so kommt, der Pfeil kommt, wie gerade der Trend ist".
Verified at Pixel-5 (393 px, 2-col grid ≈ 180 px per tile) and
iPad-portrait (5-col ≈ 140 px) — heading row stays single-line, value
row baseline stays flat across the strip.