CoachLaunchScope.metric was typed as a free-form string, which would
let any future caller pass a label the sources rail then silently
ignores (the rail's source-toggle contract only honours the
CoachScopeSource union — bp, weight, pulse, mood, compliance, plus
the v1.4.23 Apple Health additions). Narrow the field to that union
so the type system surfaces drift the moment a caller invents a new
metric name. Also pin a one-consumer note on the setOpen export
(only <LayoutCoachMount> forwards it to the sheet's onOpenChange
contract; keep the raw boolean setter for that callback shape).
Retire the entire inventory surface from the GLP-1 medication card:
the inline "pens left · weeks of supply" summary line, the disclosure
that wrapped the per-pen list, and the InventorySection component
file (no other consumer). The orphan i18n keys under
medications.inventory.* and the glp1Inventory* labels retire alongside
the help-text revision for glp1DosesPerUnitHelp. The iOS-consumed
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays byte-stable
in the response shape, and the server-side state-machine helpers
remain for that endpoint's computed weeksOfSupply.
Drop the InsightAdvisorCard component, its four integration tests
under src/components/insights/__tests__, the page-level mount on
/insights, the orphan i18n keys (advisorTitle, aiOverviewTitle), the
e2e advisor-card spec, the dashboard.spec.ts advisor assertion and
every related comment. The InsightsTabStrip regenerate icon stays
untouched as the sole power-user refresh affordance. The iOS-consumed
POST /api/insights/generate endpoint and its request/response shape
stay byte-stable.
Drop the entire weekly-report path: the /insights/report/[week] route,
the WeeklyReportView component, the hero strip's banner mount and
"Wochenbericht erstellen" action button, the ISO-week helpers, the
schema slot, the AI prompt rule + OUTPUT FORMAT block, the openapi
field, the i18n keys and every associated test. Coach now owns the
sole hero-row action. iOS contract is unaffected — no native consumer
reads any weekly-report endpoint or schema slot per the v1.4.28 iOS
contract inventory.
Drop the standalone GLP-1 dashboard tile and its data-fetch route.
The /api/dashboard/glp1 endpoint has no consumer outside the retired
tile (no native client reads it), so the route, the tile component,
its test, the e2e spec, the locale keys, the query-key entry and the
drift-guard assertion all retire together.
The medication detail page and its bidirectional contracts stay
untouched. An opt-in dashboard widget that surfaces the same level
chart will land later in this cycle, anchored on the existing
dashboard-layout toggle list.
The seven `*-status.ts` routes awaited `provider.generateCompletion`
with no client timeout. When the upstream stalled, React-Query's
three default retries plus exponential backoff stacked on top of
the upstream latency and the InsightStatusCard spun for up to 90 s
before the user saw the cached fallback or a generic failure. This
is the downstream half of FB-D2 ("pulse insights loads forever").
Add a small `withTimeout` helper that races the provider call
against a 20 s budget and resolves with a typed `{ timedOut, value }`
envelope. Every status route returns the no-key fallback text in a
cached-style response shape on timeout; the audit-log cache is NOT
written so a transient upstream stall cannot poison tomorrow's
hit. `retry: 0` on `useInsightStatus` stops the client from
re-firing the same expensive call after the deterministic fallback
lands.
Cap the per-route Prisma reads at the same time — `take: 365` for
PULSE / WEIGHT, `take: 1095` for the BP three-channel surfaces,
`take: 90` for MoodEntry, `take: 5000` for the general snapshot.
The downstream `applyPayloadBudget` trims further; without the cap
power users pulled tens of thousands of rows on every refresh.
iOS contract — `/api/insights/*-status` routes are web-only (iOS
runs its own on-device summaries against `/api/measurements/series`).
No public-API shape changes; pure server-internal hardening.
The chart-data query in health-chart.tsx walked the full measurement
history with a `while (true)` paginated loop on every visit. For
Apple-Health-rich accounts (pulse every minute, months of backfill)
this meant tens of thousands of rows per type per page navigation,
which is the root cause behind FB-D2's "pulse insights loads forever"
report.
Derive `from` / `to` from the active range tab (plus the
comparison-baseline shift when overlay is on), pass both to
/api/measurements, and drop the unbounded paginator. The query key
re-keys on the window so a range-tab change refetches the right
slice. staleTime 60s + gcTime 5min hold the bounded payload across
tab navigation between insights sub-pages.
New `aggregate` query param + range-aggregation helper let the GET
handler downsample server-side for ranges over 90 d (one row per
day) and over 365 d (one row per ISO week). The per-request `limit`
ceiling lifts from 500 to 5000 — still bounded, but enough headroom
for an explicit from/to window without re-introducing pagination.
iOS contract — GET /api/measurements: additive. The `from`, `to`,
`aggregate` params are all optional; callers that omit them (the
iOS client, the existing measurements page) see the unchanged
response shape and the historical 500-row default cap. The
aggregated branch is only entered when both from + to are present
AND the window is wide enough for the threshold ladder to engage.
The runtime gate for the resilient GeoLite2 fallback. On the first
`lookupIpLocation` or `lookupIpAsn` call that has to leave the offline
tier, the resolver checks `offlineGeoReady()` — false if the geo asset
directory carries the `.empty` marker or the City MMDB is absent — and
fires `dispatchLocalisedNotification` to every admin user, exactly
once per process lifetime, with a body that points the maintainer at
the GitHub Actions secrets page where `MAXMIND_LICENSE_KEY` belongs.
Adds the two translation keys
`notifications.admin.offlineGeoUnavailableTitle` /
`offlineGeoUnavailableBody` to every locale bundle, locks them down in
the drift-guard, and covers the latch, the marker, the no-admin path
and the ASN fallback with 8 new vitest cases under
`geo-offline-detection.test.ts`.
Drop fourteen exported helpers and constants with zero call sites:
countMessages, buildGeneratePrompts, _resetSafetyContractsCacheForTests,
forEachLocaleAndRule, getPulseRange, trendLinePoints,
CHART_MINI_HEIGHT_PX, formatDateWithWeekday,
resolveGeneralStatusLocale, isSubPageSlug, recordHeartbeat,
EVENT_TYPE_LABELS, cleanupExpiredRateLimits, and sleepStageEnum.
Also retire the v1.4.20 legacy insights prompt scaffold —
getInsightsSystemPrompt, InsightsOutput, and the two dead
INSIGHTS_SYSTEM_PROMPT_EN/DE constants — plus the matching vi.mock
key in the generate route test that no longer maps to a live export.
Drop the empty __testables block in glp1-snapshot.ts, the legacy
helper reference in the insight-generator JSDoc, and the two stale
v1.4.16 InsightsPageHero comments in hero-strip + its test file.
Consume the MedicationComplianceChart compareBaseline prop via an
explicit void so the destructure documents the intentional accept-and-
defer pattern instead of looking like a missed wire-up.
Every symbol verified zero-caller via grep across src/, scripts/, and
__tests__/ before drop.
Promote the Coach drawer mount from the mother /insights page up to
/insights/layout.tsx so every routed sub-page can reach it. A new
CoachLaunchProvider exposes useCoachLaunch() for the hero strip,
suggested-prompt chips, and the seven sub-pages; the layout renders
the drawer beside the provider so navigation across Insights surfaces
no longer unmounts the conversation.
Each sub-page (blutdruck, gewicht, puls, stimmung, medikamente, bmi,
schlaf) mounts a new CoachLaunchButton that pins to the bottom on
narrow viewports and renders as an inline header action on lg+.
Sweeps the leftover export surface across four modules surfaced by
the v1.4.26 backlog dead-M5 / dead-M7 / simp-M6 / simp-M7 / BL-P4-5
items:
* src/lib/medications/glp1-knowledge.ts — eight constituent type
exports (`Glp1Route`, `Glp1DrugClass`, `Glp1Pharmacology`,
`Glp1Storage`, `Glp1Pen`, `Glp1AdverseReactions`, `Glp1Indications`,
`Glp1SourceCitations`) drop the `export` keyword. All eight are
used only as constituents of the still-exported `Glp1DrugRecord`
aggregate and have no external import sites; narrowing the module
surface stops a future tree-shaker from re-pulling them.
* src/lib/medications/scheduling/cadence.ts — `ExpectedDose` drops
its `export` keyword (no external readers).
* src/lib/medications/scheduling/cadence.ts — `pairDoses` drops the
redundant second `.sort()`. Sorting by `windowStart` and by the
centre `(start+end)/2` produce the same order for the chronologically
separable windows pairDoses actually sees, so the O(n log n) tail
that re-sorted the result was wasted work.
* src/lib/medications/titration/ladder.ts — `escalationDue`'s
JSDoc gets the parameter-role documentation simp-M7 called out.
The `drugId` parameter renames to `drugIdForCeiling` so the
"next-step exists" lookup role is obvious at the call site.
* src/lib/ai/coach/glp1-snapshot.ts — `__testables.WEEKDAY_KEYS`
and the underlying private constant retire (zero callers).
Drops the hand-maintained 14-key listing on `metricPriorityObjectSchema`
and the parallel 14-key block on the top-level `sourcePrioritySchema`
flat shape; both surfaces now derive their property set from the
`SOURCE_PRIORITY_METRIC_KEYS` constant via `Object.fromEntries`. The
two listings had already drifted twice during v1.4.25 polishing
passes when a new metric class was added. Adding a metric class is
now a single-line constant edit instead of three matching listings
the lint chain doesn't enforce.
Lifts the per-locale message map and the dotted-key resolver out of
`src/lib/i18n/context.tsx` and `src/lib/i18n/server-translator.ts`
into a new `src/lib/i18n/shared-resolve.ts`. The two surfaces had
each carried their own private copy of the same data structure and
the same six-step path resolver; the maps drifted twice when a new
locale was added during v1.4.18 and v1.4.25 polishing passes.
Pinning both call-sites to a shared module keeps client and server
fallback semantics in lockstep and shrinks the surface a future
locale addition has to touch from two files to one.
Adds a year-in-review window between last90days and allTime so the
Coach drawer can ground a long-horizon question (seasonal mood
patterns, year-over-year weight trajectory) without falling back to
the unbounded `allTime` window. The new enum value flows through the
scope schema, the snapshot builder's window-to-day-count mapping,
the provenance window-set seeding so the source-chips render the
year-in-review chip, and the Coach source-chips Record.
Extends the snapshot test with a `lastYear`-specific assertion that
pins the scope window and the provenance envelope.
The W7d narrowing already pinned the catch to the V8 private-field
TypeError shape, but a real read regression — say, a header parser
crashing on a malformed value — still slipped through silently into
the fallback path because the narrow-catch swallowed every tolerated
error without trace. Logs every fallback through console.warn in
non-test environments so the dev console and run log surface a
regression while the production path keeps the same defensive
defaults. The /api/insights/targets stray-brace comment typo and the
BASE_SYSTEM_PROMPT / INSIGHTS_SYSTEM_PROMPT bare exports flagged by
the original BL-P1-2 / BL-P1-4 items were already retired earlier
in the v1.4.27 cycle.
Lands every new translation key referenced by the v1.4.27 buckets B1,
B3, B4 and B5 across all six locale bundles (38 keys per locale):
- dashboard.glp1.tabLevel / tabWeight / tabsAria / rangeStripLabel /
levelUnavailable / weightUnavailable (B1 — drug-level vs weight tab
strip on the GLP-1 dashboard tile).
- admin.carrier / admin.carrierUnknown (B3 — IP-address carrier chip
on the admin login overview).
- insights.emptyState.{bloodPressure,weight,pulse,bmi,mood,medication,sleep}.{title,description,cta}
(B4 — per-metric empty-state cards on the insights sub-pages).
- notifications.admin.{deployFailedTitle,deployFailedBody,testNotificationTitle,testNotificationBody,reminderCheckMissedTitle,reminderCheckMissedBody,reminderCheckOverdueTitle,reminderCheckOverdueBody}
(B5 — admin notification messages routed through the localised
dispatch helper).
- notifications.user.telegramTestBody (B5 — user-facing Telegram test
message localised to User.locale).
Also retires the now-dead charts.moodLabel1..5 chart-axis label keys:
the mood chart and the mood list now consume the same MOOD_LABEL_KEYS
map from a new src/lib/mood/labels.ts so the chart-axis copy stays in
lockstep with the list (BL-P6-11). Three distinct copy sets for the
same five mood states drifted twice during v1.4.18 / v1.4.25
polishing; the shared resolver locks them together going forward.
New drift-guard test at src/__tests__/i18n-drift-guard.test.ts pins
the GLP-1 tab, admin carrier, insights empty-state, admin/user
notification, and personal-record key sets so a contributor who only
edits one bundle gets a precise failure instead of a 200-line parity
diff. The locale-integrity test continues to assert global parity;
the drift-guard layers on top with call-site-specific coverage.
Adds a new `format` namespace across every locale bundle that
documents the locale's native date / time pattern ordering as
template strings (`{day}.{month}.{year}`, `{month}/{day}/{year}`, …).
DE keeps DD.MM.YYYY with dot separators, EN keeps MM/DD/YYYY with
slashes, FR/ES/IT adopt DD/MM/YYYY with slashes (the previous
EN-shaped fallback was visibly wrong on French weekly-report PDFs),
PL adopts DD.MM.YYYY with dots.
The runtime `makeFormatters()` helper still routes through `Intl.*`;
the keys exist so PDF / CSV / email surfaces that render outside a
React context can still pick the right ordering hint.
A new smoke test at
`src/lib/i18n/__tests__/format-locale-order.test.ts` pins the
ordering per locale so a copy-paste regression can't silently flip
DD/MM to MM/DD on a non-English bundle.
The dashboard already gates each tile through the existing
hasWeight / hasBp / hasPulse / hasBodyFat / hasMood / hasSleep /
hasSteps flags. Those flags read summaries[METRIC].count > 0, which
is exactly the contract hasMetricData() codifies for the routed
Insights surfaces. Both paths now point at each other in code
comments; no behaviour change.
Brand-new accounts (no manual logs, no Apple Health upload yet) used to
land on chart skeletons over blank target bands when they clicked any
metric tab. Apple Health hides those surfaces entirely until the first
observation lands — match the pattern.
Introduce `src/lib/insights/metric-availability.ts` as the single
decision point. `hasMetricData(metric, inputs)` reads `summaries[METRIC].count`
for sensor metrics (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_* / SLEEP_DURATION
/ VO2_MAX / STEPS / ACTIVE_ENERGY), the `hasMood` / `hasMedication`
booleans for event-driven metrics, and BMI inherits from WEIGHT.
Wire three render gates:
- `<InsightsTabStrip>` filters its pills via the new `availability`
prop. Backward compatible: undefined `availability` keeps every pill.
- `<InsightsLayoutShell>` fetches `/api/analytics` +
`/api/insights/comprehensive` once per route and threads the inputs
into the strip; both queries share their cache keys with sub-page
consumers so the payload lands single-fetch.
- Each `src/app/insights/{slug}/page.tsx` early-returns an
`<EmptyState>` with the right CTA — `/measurements/new` for sensor
metrics, `/mood` for Mood, `/medications` for Medication,
`/settings/data-sources` for Sleep.
Auto-light-up: once the iOS native client uploads its first measurement
the same gate flips on the next refetch — no per-metric feature flag.
References the new `insights.emptyState.<metric>.{title,description,cta}`
translation keys (B6 lands them in messages/*.json):
- insights.emptyState.bloodPressure.{title,description,cta}
- insights.emptyState.weight.{title,description,cta}
- insights.emptyState.pulse.{title,description,cta}
- insights.emptyState.mood.{title,description,cta}
- insights.emptyState.medication.{title,description,cta}
- insights.emptyState.bmi.{title,description,cta}
- insights.emptyState.sleep.{title,description,cta}
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.
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.
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.
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.
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.*
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
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.
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.
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.
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
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).
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
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).
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).