The per-channel "Send test" button on the Settings notification card hit
`/api/notifications/apns/test`, which forwarded `eventType: "SYSTEM_ALERT"`
to the dispatcher. The dispatcher's `isTimeSensitive` gate keys off
MEDICATION_REMINDER specifically, so the test push went out at the default
`active` interruption-level. iOS then summarises those into the Notification
Center rather than presenting a lock-screen banner, which leaves the user
with no signal whether a real dose reminder — which DOES use the
time-sensitive path — will surface visibly.
Switching the test event-type to MEDICATION_REMINDER exercises the same
dispatcher branch the production reminder cron runs: time-sensitive
interruption-level, priority 10, the MEDICATION_REMINDER action category,
collapse-id. A successful self-test now demonstrates the exact lock-screen
behaviour the user can expect from a real dose reminder; an unsuccessful
self-test (sound-only, Notification-Center-only) points the user at their
own iOS Settings → HealthLog → "Allow Time Sensitive Notifications" toggle
or active Focus mode rather than appearing to be a server-side bug.
Title and body strings remain the existing "Test notification" copy so the
push cannot be confused with an actual scheduled dose.
W-POLISH's helper extraction and W-OBSERV-PII's redactor landed
through separate worktrees and collided at cherry-pick time on the
dashboard widget + measurement series routes. Resolution routes
the request body / query map through `redactSensitiveFields` first,
then passes the redacted shape to `buildPayloadDiagnostic` — the
helper owns the JSON.stringify + 256-char truncate, the redactor
owns the denylist sweep. Composable; both modules stay
single-purpose.
Also regenerates docs/api/openapi.yaml after both worktree merges
applied (the cherry-pick resolution kept ours, which was stale).
The atomic-delete landing of v1.4.48 collapsed five historical delete
sites into one, but the post-delete redirect target ?reason=state
now covers three semantically different conditions (CSRF leg 1 mismatch,
P2025 replay, post-delete validity fail). Operators reading the
audit trail cannot tell them apart. This release splits into four
distinct reason tags (csrf1, replay, expired, cross_user) and emits
a wide-event annotation carrying the same.
The delete-catch warning template now interpolates err.name rather
than ${err}, defensively guarding against Prisma error messages
echoing offending values into the wide-event log.
Closes GH #206. Server-side APNs reminder for a dose now skips
when the user's notification_prefs.medication.clientManaged is
true — set by the iOS client once it confirms local SpeziScheduler
banners are wired. Default false so existing users see no change
until the iOS client opts them in. PATCH + GET endpoints land at
/api/auth/me/notification-prefs with deep-merge semantics so
future categories can slot in next to `medication` without
overwriting siblings. Cron emits a wide-event annotation per
suppressed dose so the operator can audit the skip path.
The /api/admin/notifications/diagnostic endpoint (v1.4.48) carried
a recentPushAttempts shape but returned an empty array because no
persistent table backed the field. This release adds the table,
wires fire-and-forget writes from every sender (APNS, web-push,
Telegram, NTFY), and swaps the diagnostic endpoint's stub for a
real query that returns the last 20 attempts ordered by recency.
A daily prune at 03:35 Europe/Berlin retires rows older than 90
days; retention matches the mood-reminder dispatch ledger so both
behavioural footprints follow the same one-billing-cycle window.
The v1.4.48 received_shape_excerpt + zod_issues surface is safe
for the current widget + series payloads (enum / boolean / int only)
but the same pattern is heading for routes that accept free-text.
Pre-empt the leak: introduce a shared redactSensitiveFields helper
with a SENSITIVE_KEY_PATTERNS denylist (password / token / secret /
apiKey / authorization / csrfState / nonce). The widget + series
routes now route their body through it before the 256-char truncate.
Audit-log sites that echoed Zod issues[].message into details now
strip the message string to {path, code} pair — message could carry
the offending user input verbatim for some Zod error codes.
- MoodReminderCard auto-clear parity with DisableCoachCard (3 s
setTimeout + useRef cleanup) — closes the v1.4.48 docstring
parity gap
- mergeReorderIntoLayout dev-only console.warn on silent orphan
drop — defence-in-depth for the upcoming per-tile Suspense
refactor
- buildPayloadDiagnostic helper extracted from widget + series
routes — single source of truth for the iOS payload-diff
wide-event meta
- disableCoachBody + disableCoachData collapsed in OpenAPI routes
- IntegrationStatusNullProbeResult export dropped (local-only type)
- newerAvailable + linksHeading native translations for es / fr /
it / pl
The yaml@2 emitter could not stringify .meta()-tagged sub-schemas
inside z.array(...) under sortMapEntries: true — alphabetic sorting
moved alias references ahead of their anchor definitions, tripping
verifyAliasOrder with "Unresolved alias: a1" during stringify. The
fix drops sortMapEntries from both generate-openapi and check-openapi
so anchors stay emitted before their aliases; output stability is
preserved by upstream determinism (Zod property declaration order +
single-object path table). The committed openapi.yaml is regenerated
in declaration order — large diff, functionally identical.
With the emitter stable, the v1.4.48 H-APNs-1 admin diagnostic
endpoint now appears in docs/api/openapi.yaml alongside its component
schemas: AdminDiagnosticDevice (APNs token masked to prefix +
suffix), AdminDiagnosticChannel (enabled + configPresent), and
AdminDiagnosticPushAttempt (reserved for the v1.4.48+ PushAttempt
table).
The v1.4.48 work is renamed in-place because the v1.4.49 backlog
expanded to include the items v1.4.48 deferred — server-side
suppression of MEDICATION_REMINDER APNs (issue #206) under an
opt-in clientManaged flag, push_attempts persistence backing
the diagnostic endpoint, OpenAPI registration for that endpoint,
and a sweep of v1.4.48 QA forward-findings (Withings reason-tag,
observability PII denylist, MoodReminderCard parity, simplifier
dead-code, sub-locale copy gaps).
Ships the deferred Mediums and Lows from the v1.4.47 audit round.
Greens up the workout-batch integration suite that had been red on
main for three releases. Extends the v1.4.47.1 90-day outer-WHERE
cap to the cold-mount /api/analytics fallback so the cold slim
slice drops from multi-second to sub-second on tenants with large
measurement partitions. Adds the admin notifications-diagnostic
endpoint that the v1.4.47 APNs investigation needed but did not
have.
Same commit closes H1 — the v1.4.45 and v1.4.46 CHANGELOG sections
are scrubbed of internal release-process taxonomy. Code comments
in summaries-slice no longer reference specific tenant row counts;
the attempt-number runbook references migration 0077 instead of
the stale 0076.
Deferred:
- Server-side suppression of MEDICATION_REMINDER APNs for iOS
v0.6.0.7+ clients (issue #206) — waits for iOS v0.6.0.8 to
ship the notification-permission re-trigger fix (healthlog-iOS#10).
- /api/admin/notifications/diagnostic OpenAPI entry — admin-only,
internal consumer today; v1.4.49 lands the iOS-side companion.
- analytics/route.test.ts slim-summaries fixture: mock 4 $queryRaw
calls (coverage + allTime + windowed + latest) to match the M0
cold-fallback split shape
- dashboard-layout-section hint assertion: anchor on the new L9
shortened German copy ("Pfeiltasten Hoch/Runter") instead of the
pre-v1.4.48 phrase
The env-var is baked into the Next.js bundle at build time via
docker-publish; the Coolify runtime entry was misleading. Document
the "DO NOT SET" rule in the Coolify section of the reverse-proxy
guide so operators don't try to fix the stale entry. The same
build-time semantics apply to NEXT_PUBLIC_APP_BUILD_SHA and
NEXT_PUBLIC_APP_BUILT_AT; both are now called out alongside.
Closes H0 in v1.4.48 backlog.
Post-v1.4.47 the legacy column was dropped; rows that still carry
consecutiveFailuresByKind: null alert two strikes later than they
should because max([1]) = 1 vs max(N+1, 1) = N+1 >= threshold.
The probe is fire-and-forget at startup, surfaces a single Wide
Event warning with the row count, idempotent across reboots.
Closes M1 in v1.4.48 backlog.
- Auto-clear status banner after 3 s, matching MoodReminderCard
- Extract use-query-client-safe helper, drop eslint-disable in
use-disable-coach (and use-feature-flags if shape matches)
- Migrate PATCH /api/auth/me/disable-coach body validation to Zod
+ returnAllZodIssues for uniform 422 envelope
- Drop disable-coach-card fetch-stub test; the Switch-click
behavioural assertion covers the contract
Closes M2 + M4 + L10 + L11 in v1.4.48 backlog.
The connect endpoint minted a fresh OAuth state row per call with
no guard. Add checkRateLimit (10 calls / 60 s per user) mirroring
disable-coach. Wrap the row create in try/catch — a DB-glitch now
redirects to /settings/integrations?withings=error&reason=connect
instead of bubbling a 500. Closes L5 + L14 in v1.4.48 backlog.
Both dashboard.widgets.validation-failed and
measurements.series.validation-failed currently emit only the Zod
error count. Extend the wide-event meta with received_keys, a
truncated received_shape_excerpt (256 chars max), and zod_issues so
a single log line carries both the iOS-sent shape and the rejection
reason. Closes H-iOS-1 + H-iOS-2 in v1.4.48 backlog.
The callback previously read the ledger row then deleted it in two
round-trips, contradicting its own docstring's atomicity promise.
Concurrent callbacks with the same nonce could both pass findUnique
before either issued delete. Replace with delete-first + catch P2025
for the replay branch — atomic at the Postgres level. Silent
.catch(() => {}) on delete failures now surfaces via Wide-Event
warning. Closes M3 + M7 in v1.4.48 backlog.
The W1 column-drop migration was renumbered 0076 -> 0077 during the
cherry-pick reconcile and Withings OAuth state took the 0076 slot.
12 in-code references still pointed at 0076. consecutiveFailures
sites now read 0077; disable-coach sites read 0078. Comment-only
sweep, no behavioural change. Closes M5 in v1.4.48 backlog.
The narrows and aggregates queries in summaries-slice computed
windowed avg/slope/r2 against the full measurements partition,
scanning ~450 k rows for an active user and discarding 95 %
inside FILTER clauses. The outer WHERE now matches the inner
window cap. Expected: 9 s → 0.5–1 s. Closes M0 in v1.4.48 backlog.
The rollup-fresh `narrows` query already shipped the outer cap in
v1.4.47.1. This commit extends the same fix to the cold-fallback
path by splitting the heavy single-statement aggregate into two
parallel queries:
- `allTime` — keeps the full-partition scan for COUNT / MIN /
MAX / AVG so all-time counts stay correct.
- `windowed` — carries the 7/30/90-day avg / slope / r² columns
and takes the new outer 90-day cap. Every row excluded by the
cap was already aggregating to NULL inside its FILTER, so the
output is bit-identical.
Tests in `summaries-slice.test.ts` are updated to mock both query
shapes; a new "90-day outer measured_at cap" describe block pins
the SQL contract so a future refactor that drops the cap fails
loudly.
The 'dedupes a re-posted batch' and 'two batches with one overlapping
externalId' tests carried pre-v1.4.42 expectations. The new
dedupeWorkoutBatch helper treats matching externalIds across sources
as one logical row. Closes M-CHRONIC-RED in v1.4.48 backlog — the
integration suite was red on main since v1.4.45.
Surfaces device APNs token presence (masked), notification channel
config presence, and recent push attempts for the calling admin's
account. Closes H-APNs-1 in v1.4.48 backlog — fills the DB-shell
gap that bottlenecked the v1.4.47 APNs investigation.
- target-card.tsx added to coach-user-disable fixture (per-card CTA hides)
- grep-discovery test mirrors coach-cascade for user.disableCoach +
useDisableCoach — new sites must be added to the allowlist explicitly
- textarea forwardRef Symbol assertion deleted (React-internal API)
- vi.useRealTimers() moved to afterEach in medications/intake and
dashboard/summary route tests; a failed assertion can no longer
leak fake timers across suites
Closes L2 + L12 + L13 + H2 in v1.4.48 backlog.
On BadEnvironmentKeyInToken, retry once with the opposite gateway. On success, persist Device.apnsEnvironment correction. Two new unit tests cover retry+persist and no-retry-on-BadDeviceToken.
v1.4.47.2/.3 confirmed the production APNS_KEY env-var is corrupted along the docker-compose env_file pipeline. Add APNS_KEY_B64 — operator base64-encodes the .p8 file and stores that ASCII-safe blob; app decodes back to PEM. No \n escape gymnastics. Precedence: B64 > KEY > FILE.
v1.4.47.2 normalisation didn't recover the production PEM. Emit a structured diagnostic on the existing parse-failure branch (counts + sha256_prefix, no secret bytes) so the operator can compare the in-container shape against the source .p8.
Coolify runtime logs surfaced
"secretOrPrivateKey must be an asymmetric key when using ES256"
on every push attempt. The channel auto-paused after 3 consecutive
failures.
Root cause: the .p8 PEM stored in the Coolify env-var arrived at
process.env.APNS_KEY without parseable newlines around the BEGIN/END
markers (env_file round-trip artefact). openssl pkey accepts it,
jsonwebtoken@9 used by node-apn is strict.
Normalise the PEM after the 12-factor \n unescape: strip whitespace
between markers, force 64-char line wrap, rebuild canonical PEM.
Idempotent on already-correct inputs. Verify via crypto.createPrivateKey
before handing to node-apn.
The `narrows` query inside `computeFromRollups` scanned the user's
full `measurements` partition without an outer `measured_at` cap
even though every aggregate FILTER inside the SELECT restricted to
7/30/90 days. On a ~450k-row tenant the cold slim slice paid ~9s
TTFB to read rows that were always aggregated to NULL.
Adding `AND m."measured_at" >= NOW() - INTERVAL '90 days'` to the
outer WHERE turns the read into an index range scan on
`(user_id, type, measured_at)` and returns bit-identical output.
Expected: ~9s -> ~0.5-1s cold, sub-50ms warm.
No schema change, no migration, no env-var change. The
`computeFromLiveAggregate` split for tenants without rollup
coverage is queued for v1.4.48.
Privacy-leaning users want the Coach affordance gone even when the
operator has enabled the global `assistant.coach` feature flag. The
new per-user `user.disable_coach` boolean is a hard client-side gate
that sits BELOW the existing operator flag — both must agree to
render the affordance.
The toggle ships as a Switch in Settings → Insights ("Hide Coach" /
"Coach ausblenden") and persists via `PATCH /api/auth/me/disable-coach`.
Every Coach mount point on the web (`<LayoutCoachFab>`,
`<LayoutCoachMount>`, the inline `<CoachLaunchButton>` pill, the
`<SuggestedPrompts>` chip strip, the `<HeroStrip>` action row, the
`/targets` page CTAs) reads the new value through a small
SSR-safe `useDisableCoach` hook so the long-tail snapshot tests
keep rendering without a QueryClientProvider.
Default is `false` — the upgrade from v1.4.46 is invisible to anyone
who hasn't toggled the new switch. The /api/auth/me payload tolerates
an undefined field for partial-deploy rollback safety. A dedicated
invariant fixture pins every Coach surface against both the
operator-level and the per-user gates; the existing cascade test
gains a `generated/` skip so Prisma's `inlineSchema` doc-comment
copy doesn't trip the orphan check.
Bilingual + es/fr/it/pl translations land in the same commit.
WelcomeCarousel and TourLauncher both auto-launched when their gating
flags were null, stacking ~90 seconds of forced onboarding on top of a
brand-new user before they could touch the dashboard. The tour now
auto-launches only ≥ 24 h after the wizard finishes (new pure
`shouldAutoLaunchTour` helper); first-day visits belong to the welcome
carousel alone. Users who *want* the tour on day one can trigger it
from a new "Replay the tour" / "Tour ansehen" button in Settings →
About — same persistence + window-event flow as the existing Settings
→ Account restart button. A per-user `tour-force-launch` sessionStorage
marker carries the manual intent across the navigation back to the
dashboard so the 24 h gate stays out of the way of an explicit click.
Five test surfaces updated to match the v1.4.47 W1 drop:
- `src/lib/integrations/__tests__/status.test.ts`
* `recordSyncSuccess`, `markDisconnected`, `markReconnected`,
`resumeIntegrationFromPark` upsert assertions drop the
`consecutiveFailures: 0` field.
* Alert-ladder tests now seed the existing-row mock with a bucket
payload at N-1 so the in-memory increment lands at N — the
writer no longer consults the upsert return's
`consecutiveFailures` for the threshold check.
* The "back-fills from legacy single counter" case is replaced
with a "seeds a fresh zero envelope when the row has no bucket
payload yet" case (the back-fill path is gone).
* New `down-script restores legacy consecutiveFailures via
Math.max` block locks in the migration 0076 rollback recipe at
the JS level: an in-memory `projectLegacyCounter` mirrors the
SQL `GREATEST(transient, reauth_required, persistent)`
projection and is asserted against zero, null, the 28-deep
production-shape persistent streak, and parity with the live
alert ladder's `Math.max(...Object.values(buckets))`.
* Stale `consecutiveFailures: N` properties on Prisma mock returns
are stripped (the writer no longer reads them).
- `src/lib/integrations/__tests__/admin-alert.test.ts`
* `parkIntegrationAtReauth` "creates a fresh row at counter=0"
now asserts the zero bucket envelope rather than the dropped
`consecutiveFailures: 0` literal.
* The "not.toHaveProperty consecutiveFailures" assertion gains
a sibling for `consecutiveFailuresByKind` — the bucket is the
live counter now, and `parkIntegrationAtReauth` must still leave
it untouched on a re-park.
- `src/components/settings/__tests__/integrations-section.test.tsx`
* The six fixture envelopes drop the now-removed
`consecutiveFailures: N` field (the view-model dropped the
property in this release).
- `tests/integration/integration-status.test.ts`
* Five `row.consecutiveFailures` assertions are migrated to
`row.consecutiveFailuresByKind` matchers carrying the same
counter value bucketed by failure kind.
* The parked-state fixture's `integrationStatus.create` call no
longer sets the dropped column.
Now that migration 0076 has dropped the `consecutive_failures` column,
the status writer no longer needs to back-fill from the legacy
integer, increment two parallel counters, or read the column in the
alert-ladder reducer.
Concrete changes:
- `IntegrationStatusSnapshot` drops the `consecutiveFailures` field
(the per-kind bucket payload remains as the sole counter).
- `recordSyncFailure` removes the back-fill branch and the
`consecutiveFailures` field from both `create` and `update` upsert
payloads. The audit row's `attemptNumber` now sums the buckets so
operators see the same running total they saw before the drop.
- The alert-ladder reader becomes
`Math.max(...Object.values(buckets))` — no parallel read of the
legacy column.
- `markReauthRequired` switches its create-side seed from
`consecutiveFailures: 1` to a bucketed
`{ transient: 0, reauth_required: 1, persistent: 0 }` so first-time
out-of-band reauth detections still seed the bucket the alert
ladder reads.
- `recordSyncSuccess`, `resumeIntegrationFromPark`, `markDisconnected`,
`markReconnected`, `parkIntegrationAtReauth` drop the legacy column
from their update payloads.
- The `/api/integrations/status` view-model + the settings
integrations-section view-model drop `consecutiveFailures: number`
from their TypeScript shapes (it was declared but never read in
the UI — only the bucket payload was consumed).
v1.4.43 W14 introduced consecutive_failures_by_kind as the bucketed
per-FailureKind counter and kept the single-column consecutive_failures
integer for one release as a fallback for v1.4.43 readers mid-deploy.
v1.4.45 W10 closed the back-fill write gap so the bucket and the
legacy column post-write at the same N+1 value, and the >24h release
gap since v1.4.43 means every active integration in production has
written at least one failure or success, so every row's
consecutive_failures_by_kind is populated.
The legacy integer is now redundant: the alert ladder reads
Math.max over the bucket values and no other call site consults the
column. Migration 0076 drops it with an IF EXISTS guard. The
reversibility recipe is documented inline so a rollback can rebuild
the column from GREATEST(transient, reauth_required, persistent) over
the existing JSON payload.
The legacy OAuth state shape `${user.id}:${random16}` (v1.4.42-) put
the user id on both the URL state param and the `withings_state`
cookie. httpOnly + Secure + sameSite:lax shut the XSS exfiltration
surface, but the user id still leaked into any request-log entry
where the cookie is not redacted and into network-traffic captures
of the redirect. Cosmetic today; a future refactor flipping the
cookie to non-httpOnly would silently expose it.
The v1.4.43 security audit (L-1) recommended switching the state to
a fully-random nonce backed by a short-lived `(nonce -> userId)`
ledger row, with the callback resolving the user via the row rather
than parsing it out of the cookie. This change lands the migration
end-to-end:
- New `WithingsOAuthState` table (migration `0076`): nonce PK, userId
FK with cascade, createdAt default-now, expiresAt + index for the
cleanup sweep.
- `withings/connect` mints a 22-char base64url nonce
(`randomBytes(16).toString("base64url")` — 128 bits, OAuth 2.0
§10.10 recommended threshold), persists the row with a 10-minute
TTL, and sets the cookie to JUST the nonce (no user id).
- `withings/callback` verifies the cookie/URL pair byte-for-byte
(timing-safe), then looks up the row, asserts `expiresAt > now()`
AND `row.userId === session.user.id` (defence-in-depth — the audit
rec was to resolve user identity from the row; cross-checking it
against the session covers a stolen-cookie + cross-user scenario),
and deletes the row on every exit branch (single-use semantics, so
a replay of the same nonce fails the second time).
- Daily cleanup cron at 03:20 Europe/Berlin slots between the
audit-log cleanup (:15) and the mood-reminder cleanup (:25); the
helper module mirrors the audit-log-cleanup shape so the worker
contract is symmetric.
Tests:
- 3 unit cases pin the cleanup helper contract (deleteMany shape,
default-now branch, idempotent re-run).
- 2 unit cases pin the mint helper + constants (22-char base64url,
10-minute TTL, cookie name preserved across the deploy window).
- 5 integration cases pin the connect -> callback ledger lifecycle
end-to-end against real Postgres: row mint, no-credentials short-
circuit, single-use consumption + replay rejection, expired-row
rejection, cross-user mismatch rejection.
- The existing `withings-oauth.test.ts` integration cases updated to
reflect the new state shape; the legacy `${user.id}:${random32}`
assertion was the only test relying on the old contract.
Files: 12. Tests: 5076 -> 5108 (+32 unit; integration adds 5 cases
plus 2 case updates on the existing file).
The dashboard layout section already let users reorder widgets via ↑/↓
arrow buttons; the v1.4.43 QoL audit (M1) flagged that modern parity
with Apple Health and Withings web expects drag-to-reorder as the
mouse/touch surface. Adds a GripVertical drag handle on each row wired
to @dnd-kit/sortable. The keyboard fallback stays — arrow buttons are
unchanged and the new handle is also keyboard-operable via dnd-kit's
KeyboardSensor (Tab + Space + arrows).
Both surfaces persist through the same draft → PUT path the arrow
buttons already used, so save / cancel / reset semantics carry over
without changes. Pure reorder logic is extracted into a shared
`reorderWidgets` helper so the mutation payload contract is pinned by
unit tests without spinning up a DndContext.
Bundle delta: ~29 KB gzipped (core 22 + sortable 5 + utilities 2),
well within the 50 KB budget called out in the audit.
All six locales gain `dragHandle` (button label) and `dragHandleHint`
(shared `aria-describedby` paragraph) keys.
Six textarea call-sites had been copy-pasting the same className with
the text-base sm:text-sm iOS-zoom defence; the audit flagged it as a
drift risk. New shadcn-style primitive in src/components/ui/textarea.tsx
mirrors <Input>'s shape and bakes in the defence, the WCAG 2.5.5 tap-
target floor (min-h-11 sm:min-h-9), autoCapitalize="sentences" /
spellCheck defaults for prose, and the autoComplete="off" + password-
manager ignore posture from <Input>.
Migrates four call-sites onto the primitive (bugreport, medications
JSON import, side-effect notes, admin feedback note) — saves ~16 LOC
of inline copy-paste and ~850 chars of repeated className strings.
The coach-input textarea stays as-is: it's an auto-grow composer with
a transparent bg wrapped in an outer "fake input" div, so migrating
would require nullifying nearly every primitive default.
11 new unit tests lock the contract (iOS zoom class, tap-target floor,
forwardRef sentinel, override semantics, attribute pass-through).
v1.4.47 W7 — the in-memory legacy_form_total counter from v1.4.25 W21 Fix-K was per-process and useless on multi-container deploys; the access-log warning already carries the signal it surfaced. Drop the counter + the admin-status field that exposed it.
v1.4.47 W8 — Coach send-message path pre-checks navigator.onLine so an airplane-mode user gets the offline-specific 'coach.network' copy immediately instead of waiting for the fetch to fail with a generic network error. Surfaces W12 M6 properly.
Both 'is idempotent on a second pass' and 'does not double-mint when today's rows already exist' hard-coded the scheduledFor to 2026-05-21T05:00Z while the route's projection uses new Date(). On 2026-05-22 the projected day diverged from the existence-row date and createMany fired, breaking the regression guard. vi.setSystemTime pins both ends to the same calendar day.
v1.4.46 — the admin "Send test notification" switch handled TELEGRAM,
NTFY, and WEB_PUSH but had no APNS arm. An iOS-paired admin's APNS
channel fell through to the "Unknown channel type" default branch, so
the Settings → Notifications "Test" button reported the channel as
broken even though the dispatcher routed real notifications through
APNs without issue.
Mirror the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its
own per-device fan-out (`prisma.device.findMany` for the user's iOS
devices) and returns the same `SendOutcome` envelope the other senders
do. On failure the route surfaces the sender's `reason` in the
per-channel error (e.g. `apns_no_devices`, `apns_not_configured`) so
the admin UI shows the actionable cause rather than a generic "Send
failed".
Six regression cases pin the contract:
* APNS channel triggers sendViaApns with the canonical payload,
* success → `{ channel: "APNS", success: true }`,
* failure with reason → reason surfaces in the per-channel error,
* failure without reason → falls back to `apns_send_failed`,
* APNS + WEB_PUSH fan-out in the same response works,
* APNS no longer hits the "Unknown channel type" default arm.
v1.4.46 — a `MedicationIntakeEvent` that the user neither took nor
manually skipped used to stay in the pending state forever. The
dashboard compliance tile counted it as `pending` indefinitely,
inflating the missing column on the streak chart and crowding the
next day's intake out of the "today" window.
New `intake-auto-skip` cron runs hourly at :05 (off the :00
reminder-check and :30 moodlog-sync ticks) and flips `skipped = true`
for every event matching:
* `skipped = false` AND
* `takenAt IS NULL` AND
* `scheduledFor < NOW() - INTERVAL '24 hours'`
The 24 h grace window is intentional: a late mark (morning dose at
noon) shouldn't auto-skip before the user has had a full day to
record it. Anything older is reliably forgotten — compliance needs a
terminal state to count it as a real miss.
The flip is the same state the manual "Skip" button writes, so the
compliance rollup recompute path picks new rows up via the next
ingest / dashboard read with no extra plumbing.
Wired through `reminder-worker.ts` (`createQueue` + `boss.schedule` +
`boss.work` registration) alongside the existing inventory-expire
cron. Helper lives in `@/lib/jobs/intake-auto-skip` so a unit test
can drive it with an in-memory fake; eight cases pin the cron + grace
contract, the spec'd `updateMany` shape, idempotency on re-run, and
the strict `<` boundary semantics.
REG-9 (v1.4.46): the PR-detection worker's `findBestMeasurement` picked
the largest single per-hour slice for cumulative HK kinds (ACTIVITY_STEPS,
ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE,
TIME_IN_DAYLIGHT) instead of the actual daily total. A 4 000-step hour
on a hike would lock in as the user's "best step count" while the real
24 000-step day was ignored.
Route every member of `CUMULATIVE_HK_TYPES` through a new
`findBestCumulativeDay` helper. The helper SUMs each `(user_id, type)`
partition by `date_trunc('day', measured_at)` via $queryRaw, picks the
winning day by `ORDER BY day_total {DESC|ASC} LIMIT 1`, then re-reads
the latest slice on that day to recover unit/source/externalId for the
PR row. The PR's `value` is the day-sum; `achievedAt` is the
`MAX(measured_at)` on the winning day so the unique-index contract
`(userId, metricType, metricSlot, achievedAt)` stays stable across
re-runs. Spot kinds (resting HR, VO2 max, HRV, body composition) keep
the existing per-row pick — each row already represents the day's
measurement.
Two regression cases pin the contract:
* 24 000-step day (8x 3 000) beats 8 000-step single-hour fragment.
* Drift guard: every PR-trackable cumulative HK type lands on the
day-sum path.
POST handler signature was tightened from (request: NextRequest) to () after the simplifier-M4 reconcile dropped the unused param. The companion test file still called POST(emptyRequest() as never).
Fourteen touch-disjoint waves landed: nine close every audit Critical + High plus recurring polish (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring, ops hardening with the BuildKit version-pin lesson from v1.4.42), one rolls out returnAllZodIssues to the 41 sibling routes from the v1.4.42 W2 backlog, one closes the v1.4.42 W2-WITHINGS deferred B4 (24 h persistent-failure park) + B7 (per-kind counters), three companion waves close the audit Mediums + Lows for mobile-UI / QoL / security, and a six-axis W10 QA round (code-reviewer, security, design, senior-dev, product-lead, simplifier) drove the reconcile that gated this tag.
Tests: 4815 -> 5076 unit (+261). Quality gates: typecheck, lint, knip (enforcing) all green. Migration: 0075_v1443_integration_park — operator runs prisma migrate deploy post-deploy. iOS v0.5.4 contract preserved (Zod envelope additive; new endpoints additive).
Bundle of small reconcile fixes from the W10 QA round:
- Switch tap-target padding -12 px -> -13 px closes the 1.6 px vertical short-fall to spec-strict WCAG 2.5.5 (design M1).
- animate-pulse skeleton sites in daily-briefing, trend-annotation and doctor-report dialog now carry motion-reduce:animate-none (design M2).
- Doctor-report unavailable rows render with text-muted-foreground instead of line-through so the visual reads as 'unavailable' rather than 'deleted'; the italic hint line below still carries the semantic (design M4).
- WelcomeCarousel scrollIntoView now reads scrollBehaviorForUser from src/lib/motion so all four advertised call-sites share the helper (simplifier L-1).
- Coach replay-injection audit row renamed audit.coach.replay-injection -> insights.coach.replay_injection to match the <surface>.<verb> convention across the audit ledger (simplifier L-2).
- /api/integrations/withings/resume drops the unused request param (the handler reads neither body nor headers; requireAuth scopes the call) (simplifier M-4).
- verifyRegistration body gets the same Zod boundary narrow W13 L-3 added to verifyAuthentication; SimpleWebAuthn still owns cryptographic validation (senior-dev L-1).
The legacy single-counter column writes { increment: 1 } so the post-write value lands at N+1, but the back-fill seed wrote buckets[kind] = legacyCount (= N) without a compensating +1. Post-write the bucket would read N while the legacy column read N+1; when v1.4.44 drops the legacy column every back-filled row would page exactly one failure too late.
v1.4.43 W10 medium-reconcile — senior-dev M-1.
Extracted shouldEmitAuditRow + __resetAuditDedupMemoForTests to src/lib/audit-dedup.ts so the chart-overlay-prefs route inherits the same 60 s (userId, action) window the widgets route got in W8 B2. Prevents a misbehaving iOS client looping on the chart overlay popover from pumping thousands of dashboard.chart-overlay.validation-failed audit rows per minute.
v1.4.43 W10 high-reconcile — simplifier H-2.
W4 added a 'warning' state to IntegrationStatusPill plus locale copy ('Verbunden, aber Serverfehler') for the v1.4.42 persistent FailureKind (Withings 601 / 293 / 294), but the wave's allow-list excluded integrations-section.tsx. The pill state existed as dead code. Wire pillStateFor to return 'warning' when the integration's state is error_transient AND the consecutive persistent-bucket count is > 0, so a user clicking reconnect ten times on an upstream contract mismatch finally sees a different signal.
v1.4.43 W10 high-reconcile — product-lead PL-H1, design H1, simplifier H-1.
W4's pluralised relativeMinutesAgoOne/Other + relativeHoursAgoOne/Other + relativeDaysAgoOne/Other keys were silently removed across all 6 locales when W12's i18n tighten (commit 2390438e) re-applied the legacy singular keys. The formatRelativeTime helper still reads the *One/*Other keys, so the Insights hero, Daily Briefing, and Coach history rail all paint raw key strings to users.
v1.4.43 W10 critical-reconcile — code-reviewer C-1, design C-1, simplifier C-1.
W6's auth-route tests mocked checkRateLimit. W13's M-4 wrapped the auth surfaces with checkAuthSurfaceRateLimit so the un-mocked wrapper resolved as undefined at runtime and the routes 500'd instead of returning 401/422. Adding the wrapper to the mock factories + arming it in beforeEach restores green.
W11's noDataInRangeTitle/Description and W2's needMoreDistinctDaysTitle/Description were both keyed under 'charts' and were swept out when W12's i18n tighten on the same root branch was applied without preserving sibling-wave additions. Restoring the four keys with their translations across de/en/es/fr/it/pl.
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
the remaining 12 sites: auth password + register, admin settings +
assistant-flags + feedback PATCH + users PUT, user thresholds, public
feedback + bugreport submissions, and the three consent endpoints.
Status codes preserved everywhere — consent routes keep 400, the rest
stay 422. No audit-ledger breadcrumb on this batch (low-traffic admin
and onboarding flows).
Also tops up the legacy `measurements/__tests__/group-by-day` mock with
the `prisma.auditLog.create` shim now that the route's validation-
failed path writes a breadcrumb.
Tests: 26 new unit cases (2-issue + 3-issue per site; cadence-style
narrow schemas pinned on ≥ 1).
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
8 medication-CRUD sites (top-level POST, by-id PUT, glp1, inventory +
per-item PATCH, cadence GET, side-effects GET + POST). No audit-ledger
breadcrumb on this batch — these are low-traffic admin / detail-page
flows where the 422 response itself is the diagnostic signal.
Tests: 17 new unit cases (2-issue + 3-issue per site; cadence pinned
on ≥ 1 because its schema only has a single `days` knob).
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
21 iOS-contract / high-traffic routes so a multi-field validation
failure surfaces every Zod issue in one round-trip under
`details.issues` rather than walking the client through one error per
re-submit. Adds a fire-and-forget `<route>.validation-failed`
audit-ledger breadcrumb on each path so the operator can grep
`/api/admin/audit` for the same trail.
Routes touched:
- measurements list/batch/single + series + edit
- medications intake (top-level GET/POST + bulk + per-med
POST/GET/PATCH + per-event PUT + CSV import)
- mood-entries list/POST + bulk + by-id PUT
- dashboard chart-overlay-prefs
- integrations healthkit, ingest medication, devices, tokens
CSV-import keeps the legacy `Invalid format:` UI branch via a new
`meta: { errorCode: "medication.intake.import.invalid_format" }`
passthrough; mood/medications bulk preserve their existing
`mood.bulk.invalid` / `medications.intake.bulk.invalid` errorCodes.
Tests: 120 new unit cases (every route gets 2-issue + 3-issue +
audit-row write + audit-write-rejection survival proofs).
W14 added warningServerError, parkedReconnect, resumeCta, resumeSuccess, resumeError to the integrationPill block across all six locales. W12's i18n tighten on the same block reverted to a pre-W4/W14 baseline. Restoring the missing keys with their translations.
v1.4.43 W13 M-4 follow-up — the contract test at
`src/__tests__/api/auth/check-user/route.test.ts` mocks
`checkRateLimit` but the route now invokes
`checkAuthSurfaceRateLimit`. Mock both, and rewire the
rate-limit-exhausted case to drive the wrapper (which is now the
gate). Also stub `hashToken` since the route hashes the identifier
for the audit row.
v1.4.43 W13 L-3 — `verifyAuthentication` parsed the inbound JSON
body via a raw `as AuthenticationResponseJSON` cast straight into
`verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned
cryptographic validation downstream, but the cast bypassed TS's
type-narrowing — a future refactor reading `typedResponse.id`
before calling the verifier would have crashed on `undefined.id`.
Drop the cast, parse via a Zod schema that mirrors
SimpleWebAuthn's `AuthenticationResponseJSON` shape (id, rawId,
nested response with clientDataJSON / authenticatorData /
signature, type literal "public-key"). Permissive on unknown
top-level keys for forward-compatibility with future
authenticator-attachment / extension-result additions; the strict
cryptographic checks remain inside the verifier.
A malformed body now throws here with a structured
"Malformed passkey authentication response" error before any
deref, closing the boundary gap.
v1.4.43 W13 L-2 — every snapshot builder is allowed to ship arbitrary
structured data into the SNAPSHOT JSON that prefixes the Coach
userPrompt; free-text fields (medication name, dose unit, note bodies)
MUST wrap through `sanitizeForPrompt` first. The audit verified this
holds today, but a new snapshot builder added without the import would
silently leak control sequences and could override the
patient-safety guardrails (e.g. dose-prescription refusal).
The guard walks the listed snapshot-builder source files, identifies
property-assignment lines whose key matches a free-text field
heuristic (`name`, `note`, `description`, `comment`, `drug`,
`doseUnit`, `dose`) and whose value expression reads from a
user-controlled identifier, then asserts the value path includes
`sanitizeForPrompt` — OR the file imports + uses the sanitiser
somewhere (consumer-side wrapping, GLP-1-plateau-context pattern).
Cheap, deterministic, fires on the canonical 'new builder forgot the
sanitiser' failure mode. Adds a sanity check that the heuristic
actually trips on a synthetic leak.
v1.4.43 W13 M-4 — every IP-keyed auth surface (login, register,
passkey/login-options, passkey/login-verify, refresh, check-user)
falls back to a literal `{prefix}:unknown` rate-limit bucket when
`getClientIp` returns null. A misconfigured `TRUST_PROXY_HOPS`
silently degrades precision for the rest of the process lifetime
(the warning fires once); one attacker exhausting the
`auth:login:unknown` bucket then locks every other anonymous
caller out of the surface.
The `checkAuthSurfaceRateLimit` wrapper consults
`getClientIpOrTrustWarning`: clean chain → existing per-IP
semantics, byte-equivalent; `trustViolation === true` → routes
to a single global `auth:anon:trust-violation` bucket capped at
100/15min across every auth surface. The blast radius caps while
the operator fixes the proxy chain.
Auth-surface routes converted to the wrapper:
- /api/auth/login (5/15min per IP, 100/15min global on violation)
- /api/auth/register (5/15min)
- /api/auth/passkey/login-options (10/15min)
- /api/auth/passkey/login-verify (10/15min)
- /api/auth/refresh (60/15min)
- /api/auth/check-user (30/15min)
/api/auth/password is keyed by `user.id` (already authenticated)
so it stays on `checkRateLimit` directly.
v1.4.43 W13 M-3 — `detectRefusal` runs only on the inbound
`message` each turn. `priorTurns` are loaded straight from
`coach_messages.encryptedContent` and concatenated into the
`userPrompt` transcript every reply. An injection that slipped
past the regex bank on a previous turn was re-entering the prompt
verbatim on every subsequent turn, so the model saw it on every
reply — false-negative amplification.
Re-run `detectRefusal` against each user-turn re-loaded from DB
before building the transcript. On a hit, short-circuit the SSE
with a refusal AND emit an `audit.coach.replay-injection` row so
operators have a durable signal. The audit row carries the
conversation id, turn index, and matched reason — never the
message content, so the bypass payload doesn't survive into the
audit ledger.
v1.4.43 W13 M-2 — `formatAdminAlertPayload` interpolates the
upstream-influenced `WithingsApiError.message` into the Telegram
body. Today the alert path uses no `parseMode` so an injection in
`json.error` renders literally and is inert.
The medication-reminder paths use `parseMode: "HTML"` because
their bodies are server-built from sanitised data; a future
contributor copying that pattern onto the admin alert would silently
turn the upstream string into an HTML injection vector. Pin the
constraint with a docblock so the next reader notices before
flipping the mode.
v1.4.43 W13 M-1 — `/api/auth/check-user` only emitted a wide-event
`annotate()` row. The wide-event stream is sampled and retained
shorter than the audit ledger, so an admin investigating account
enumeration had no durable evidence. Every other /api/auth/* route
already lands an `auditLog` row; this aligns check-user with that
pattern.
The identifier is hashed via the existing `hashToken` helper (same
shape v1.4.43 W3 H-1 fix lands on `auth.login.failed`), so the
literal user-typed string never reaches `AuditLog.details`. The
v1.4.20 retroactive PII directive bars raw user-typed strings from
operator-readable artefacts; this preserves the durable signal
without leaking the candidate.
Pill UI gains a fifth state — 'parked' — for the v1.4.43 W14 24h
persistent-failure park. Copy reads 'Pausiert — manuell wieder
verbinden' (DE) / 'Paused — reconnect manually' (EN); chip uses the
dracula-orange tokens (heavier than warning yellow, lighter than the
red reconnect pill).
The Withings card renders a 'Wieder verbinden' / 'Reconnect' banner
under the pill when the state is parked. Clicking POSTs to the new
/api/integrations/withings/resume endpoint (rate-limited 5/min per
user, same envelope as the test-connection endpoint). The endpoint
calls resumeIntegrationFromPark which flips state back to connected
and zeroes every per-kind bucket; the per-card status query
invalidates so the pill flips back without a page refresh.
Locale parity across all six locales (en/de/es/fr/it/pl); end-to-end
integration test against real Postgres pins both the resume flow and
the per-kind bucket increment contract.
B4 — parkIntegrationAtPersistent. A persistent failure streak (293/
294/contract-mismatch) that has been running for more than 24h now
flips the row state to 'parked'. A parked row stops retrying (the
sync entry-point short-circuits via isReauthRequired, now extended to
treat 'parked' the same as 'error_reauth'). The user/operator has to
call resumeIntegrationFromPark() — exposed for the new /resume API
endpoint — before the next cron tick will attempt anything.
B7 — consecutiveFailuresByKind. Failures now increment a per-kind
bucket instead of a single shared counter so a transient hiccup
followed by a persistent failure no longer masks the persistent
streak's true age. The alert-ladder reads Math.max across buckets for
backward compatibility; the legacy consecutiveFailures integer is
kept in sync for one release and removed in v1.4.44.
Back-fill: a row that predates the v1.4.43 W14 schema migration has
null consecutiveFailuresByKind. The status writer back-fills it on
the next failure by bucketing the legacy count into the CURRENT
failure's kind (so a row sitting at error_reauth with 4 failures
back-fills into reauth_required: 4).
An 'integrations.parked' audit row is written once on the
state-transition; resumeIntegrationFromPark writes an
'integrations.resumed' row but only when the input was actually
parked (idempotent for non-parked inputs).
Two additive columns on integration_statuses driving B4 + B7:
- consecutive_failures_by_kind (jsonb) — bucketed counter
{ transient, reauth_required, persistent } so a transient hiccup
followed by a persistent failure no longer resets the persistent
streak counter. The single-column consecutive_failures stays for
one release as a fallback; the alert ladder reads Math.max across
buckets for backward compatibility.
- persistent_failure_started_at (timestamp) — start of the current
persistent failure streak. Once >24h elapse with the persistent
bucket still non-zero, the status writer flips state to 'parked'.
The state column stays free-form; 'parked' is recognised at the
application layer. Existing rows have NULL in both new columns and
are back-filled on the next write.
`RecentWorkoutsTile` loading branch and the GLP-1 `DrugLevelChart`
loading branch both rendered an unsized centred spinner / hint, so
the surrounding dashboard layout reflowed once the data resolved.
Add `min-h-[10rem]` to the workouts tile and align the drug-level
loading height with the loaded chart's 240 px so neither tile
shifts the rest of the page when data lands.
The dropdown trigger combined `size="sm"` (32 px default) with a
forced `min-h-11 min-w-11` stretch. That was the v1.4.27 pattern;
v1.4.33 settled on the cleaner `size="default" + min-h-11 sm:min-h-9`
shape (so mobile hits 44 px and desktop drops to 36 px without the
stretched-button look the maintainer earlier called klobig). Adopt
the consistent pattern here.
The outer insights tab pills meet the 44 px WCAG 2.5.5 floor; the
inner group-popover items were 4 px short at `min-h-10`. Lift to
`min-h-11` so the popover hits the same target floor.
`health-chart.tsx` returned `null` when the loaded payload was
empty in the selected range, which stripped the entire widget
(header + tabs + chart). On the dashboard the user saw a missing
tile with no explanation. Keep the card chrome mounted and paint a
`<ChartEmptyState>` with distinct copy from the < 3-points
"Mehr Messtage erforderlich" branch so the two situations don't
blur. Adds `charts.noDataInRange*` strings across all six locales.
The tile strip only painted once `trendCards.length > 0`. When both
slim + thick `/api/analytics` slices lagged (cache eviction, cold
start) the user saw the page header + zero tiles + then a burst of
tiles seconds later. Reserve the strip's footprint by rendering N
silhouette cards (`auto-fit minmax(min(100%,11rem),1fr)` track,
matching the real strip) keyed off the user's configured tile count
while `analyticsSlimQuery.isLoading`. The placeholders swap to the
real tiles the moment slim resolves; the EmptyState branch is
suppressed for the loading window so the user no longer sees a
flash of "no data" when the network is just slow.
The skeleton was a fixed `h-[180px]` while the loaded scatter chart
is `aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto` — on
`sm+` the loaded chart paints ~60 px taller than the placeholder
reserved, which CLS-shifted insights cold mount. Mirror the quartet
on the skeleton so it reserves the right space at every breakpoint.
The three insights mother-page `next/dynamic` placeholders
(DailyBriefing / CorrelationRow / TrendsRow) reserved `h-48 / h-32 /
h-64` while the loaded content is ~24 rem / 20 rem / 20 rem tall, so
the page CLS-shifted noticeably on slow networks. They also lacked
`motion-reduce:animate-none` on the `animate-pulse`, so motion-
sensitive users saw a continuous pulse. Bump the reserved heights and
add the modifier; a textual guard test pins both.
The duration Input and the MIN/% toggle Button were both `h-8` (32 px,
below WCAG 2.5.5) and the Input pinned `text-sm` which defeated the
`Input` primitive's iOS-zoom defence (`text-base md:text-sm`).
Drop the size override on the Input and bump both heights to
`h-11 sm:h-9` so phones get a 44 px target while desktop keeps the
dense row.
The audit found that 21 of 128 `animate-spin` call sites lacked the
`motion-reduce:animate-none` modifier, so motion-sensitive users
still saw continuous rotation in those spinners. Sweep all remaining
sites (settings, admin, charts, dialogs, mood-list) and add a textual
guard test that scans `src/**` and fails if a future spinner is added
without the modifier.
Four audit reports from the v1.4.43 discovery round: analytics 9s root cause, mobile-UI WCAG sweep, QoL copy gaps, security PII + log-growth.
Also adds *.har to gitignore so production HAR captures (which carry auth headers and cookies) do not land in the repo by accident.
Der `/api/dashboard/summary`-Endpoint hat für Blutdruck und Puls
`latestValue: null` plus `sparkline: []` zurückgegeben, sobald der
letzte Wert des Nutzers älter als sieben Tage war. Der iOS-Kachel
hatte dann weder Zahl noch Chart, obwohl die Daten in der DB lagen
und der Detail-Screen sie auch korrekt anzeigte. Fünf iOS-seitige
Anläufe blieben wirkungslos, weil der Bug im Server-SQL saß.
- `latestIn7d` → `latestEver`: WHERE-Filter `m.measured_at >=
${sevenDaysAgo}` entfernt. `DISTINCT ON (type)` über alle nicht-
gelöschten Rows liefert weiterhin nur eine Zeile pro Typ, jetzt
aber den all-time-letzten Wert. Zeilenanzahl bleibt durch
`|measurementTypes|` beschränkt.
- `sparkBuckets`: Kalender-Filter `r.bucket_start >= ${sevenDaysAgo}`
durch ein `ROW_NUMBER() OVER (PARTITION BY type ORDER BY
bucket_start DESC) <= SPARK_DAYS` ersetzt. Liefert die letzten
sieben Tages-Buckets pro Typ, unabhängig vom Alter. Eine BP-Messung
von vor 60 Tagen bekommt damit wieder ein Chart.
- BP- und Puls-Karten zusätzlich um das gleiche `if (latest ||
meta.allTimeCount > 0)`-Gate ergänzt, das BodyFat seit v1.4.33
schon hatte. Accounts, die diese Werte noch nie geloggt haben,
bekommen keine leeren Platzhalter-Kacheln mehr.
Tests in `src/app/api/dashboard/summary/__tests__/route.test.ts`
angepasst plus vier neue Cases für REG-11:
- BP mit 60 Tage altem Wert → Kachel emittiert mit historischem
`latestValue` + `sparkline`.
- BP mit Null-Readings ever → Kachel NICHT emittiert.
- Weight innerhalb 7d → Verhalten unverändert (Regression-Guard).
- Sparkline-Window nimmt 7 Daily-Buckets auch wenn alle älter als
7 Kalendertage.
The batch handler now looks up `User.sourcePriorityJson` once at the
start of the request and threads it into the write-time canonical-row
picker. A user with a customised Settings → Sources ladder keeps
their preferred row across the ingest pipeline; the route picks up
the v1.4.43 helper's user-aware path with one indexed findUnique per
batch.
The write-time canonical-row picker now accepts an optional
`userPriorityJson` argument and resolves the ladder through the same
`parseSourcePriority` + `getSourceLadder(..., "steps")` chain the
read-time picker walks. A user who promoted MANUAL above APPLE_HEALTH
in Settings keeps their preferred row at both ingest and read time;
omitting the argument falls back to the canonical default ladder so
existing callers keep their v1.4.42 behaviour. Header docstring
revised to mark the source-priority divergence closed.
scripts/check-env.ts shipped in v1.4.42 but only ran locally. A new
workflow runs pnpm check-env --file .env.production.example on every
push and PR against main/develop. Adds .env.production.example so a
contributor introducing a new required env var to the manifest must
update the example in the same PR, otherwise the workflow fails with
grep-friendly annotations naming every missing variable.
The example carries placeholder strings for every required group so
the presence check passes; optional groups are commented out so
operators can see the full surface without the workflow flagging
silent-disable patterns as failures.
The v1.4.42 docker-publish workflow shipped a stale package.json version
baked into the bundle because BuildKit reused the pnpm build layer
across the release tag bump -- the COPY . . layer key was content-stable
relative to the prior build, the layer was warm, and the runtime
/api/version read the cached package.json content instead of the new
tag.
Thread github.ref_name through as a NEXT_PUBLIC_APP_VERSION build-arg
from docker-publish.yml. The Dockerfile picks it up as an ARG/ENV in
both the builder and runner stages and bakes it into the build context.
src/app/api/version/route.ts now reads the env var first and falls
through to package.json only when unset (local pnpm dev). The build-arg
becomes part of the layer cache key, so every release tag invalidates
every downstream layer and the runtime version can never drift from
the tag the workflow ran on.
A misbehaving iOS client retrying a 422 in a tight loop could write
thousands of dashboard.widgets.validation-failed audit rows per minute.
Gate the audit-ledger write behind an in-process (userId, action) memo
with a 60 s window so the breadcrumb stays useful for grep traffic
without flooding the ledger. The 422 response itself is unchanged --
every retry still receives the full multi-issue envelope. Cluster
deployments cap at one row per process per minute; a process restart
resets the map (one extra row worst case).
B3 (typed-classification catch-block migration) applied to both
activity + sleep sync paths. B4 (IntegrationStatus surface
unification) and B7 (IntegrationStatus row freshness audit) are
explicitly deferred to v1.4.44 with the architecturally-correct
recipe restated so the next wave can pick them up cleanly.
Add a regression suite that asserts both syncUserActivity and
syncUserSleep route their catch-block through the shared typed-
classification helper rather than the legacy regex chain.
Body-status 293 (invalid params) and 601 (rate-limited) are the
canonical regression targets: the legacy regex bucketed everything
outside 100/101/102 + 200-299 as `transient`, so the typed path is
the only way 293 surfaces as `persistent` and 601 as `transient`
with the correct error code. Pinning the verdict for both paths
catches any future refactor that accidentally falls back to the
regex helper.
Also pins the 100 / 102 reauth-required paths to round out the
taxonomy coverage; both sync paths must produce the same verdict
for the same body-status code.
Mirror the activity-sync migration on the sleep path. The sleep
catch-block now consumes the shared `recordWithingsSyncFailure`
helper instead of re-parsing the error message via the legacy regex
chain.
Drops the `extractWithingsStatus` + `isWithingsRefreshReauthFailure`
imports from sync-sleep.ts; the BL-P3-2 403 short-circuit stays
inline at the call site for the same reason it does on the activity
path — 403 isn't a documented Withings body-status code.
The two sync paths now share one classification surface and behave
identically for every documented Withings status taxonomy entry.
Export `recordWithingsSyncFailure` from sync.ts and migrate the
activity-sync catch-block to consume it. The shared helper routes
through `classifyError`, which reads `WithingsApiError.classification`
directly and falls back to the legacy regex only for un-prototyped
errors (pg-boss JSON round-trip).
Drops the call-site re-parse of `extractWithingsStatus` +
`isWithingsRefreshReauthFailure`, removing one duplicate of the
classification taxonomy. The body-status 293 / 601 cases now classify
as `persistent` / `transient` respectively instead of the legacy regex
default of `transient` for everything outside 100/101/102 + 200-299.
BL-P3-2 defence-in-depth — the body-status 403 short-circuit stays
inline as a call-site override because 403 is not a documented
Withings response code (only the HTTP layer emits it for scope-gated
resources), so it has no entry in the shared classifier table.
Programmatic `scrollIntoView` / `scrollTo` calls in the settings shell,
admin shell, and coach message thread ignored the user's reduced-motion
preference — a WCAG 2.3.3 violation. Add a single `scrollBehaviorForUser`
helper in `src/lib/motion.ts` that returns `"auto"` when SSR or when
`(prefers-reduced-motion: reduce)` matches, and `"smooth"` otherwise.
Wire it through the three call-sites; tests cover all three branches.
Both Sheet and Dialog primitives carried a 36 px floor on the close
affordance — under the WCAG 2.5.5 target. Gate the size by viewport:
`min-h-11 min-w-11 sm:min-h-9 sm:min-w-9` lifts the smallest viewport
to 44 px while sm+ keeps the dense 36 px the headers were laid out
around. Avoids the 320 px crowding regression the inline comments
flagged on previous attempts.
Both mood entry surfaces rendered the overflow trigger at `h-9 w-9`,
which fails WCAG 2.5.5 on touch. Lift to `h-11 w-11`, matching the
medication-form pattern that already shipped at parity.
The chart-overlay popover comparison row used `h-9 px-1`, which falls
below the WCAG 2.5.5 floor on touch. Switch to
`min-h-11 sm:min-h-9 px-2` so the mobile button reaches the 44 px
target while desktop keeps the dense 36 px row that fits the popover.
WCAG 2.5.5 calls for a 44 px minimum interactive target. The Switch
visual track stays at 18x32 px but a `::before` overlay with
`inset-[-12px]` floats a transparent hit zone over the full 44x44
region. Mirrors the v1.4.34 IW-G treatment already applied to Input
and auto-fixes every Switch call-site in one move.
formatRelativeTime always read the *Ago key with count=1, so a German user reading the Insights hero saw 'vor 1 Minuten' / 'vor 1 Stunden'. Splits each bucket into One / Other variants across all locales and branches on count === 1 in the helper, matching the dashboard.staleHintWeeksOne / Other pattern already in the bundle.
global-error.tsx renders before any provider, so an i18n lookup is impossible. Doubling the four user-facing strings as 'Deutsch · English' avoids leaving a German visitor on an all-English last-resort screen without paying for a runtime locale resolver. Footprint is small (one heading, one paragraph, two button labels, one copy-fallback prompt).
The branded 404 splash hard-coded its three strings in English, ignoring the cookie/Accept-Language locale resolution the rest of the app uses. Switches to getServerTranslator + resolveServerLocale and adds an errors.notFound.{title,body,backToDashboard} block to every locale bundle.
The pill previously collapsed both error_transient and error_reauth onto the same red 'reconnect' chip. The v1.4.42 persistent failure kind (Withings 293 / contract mismatch) maps to error_transient but reauth won't fix it. Adds a fourth pill state — warning — with an amber dracula-yellow chip and locale-aware copy ('Verbunden, aber Serverfehler' / 'Connected, server error'). Wiring at the call-site is deferred so the persistent kind can flow through the API payload first.
Reads 'Anbieter' instead of 'Provider' throughout the German coach + settings + raw-data copy, and softens the rate-limit toast from 'rate-limited' to 'gedrosselt'. Confines the term 'Provider' to the technical context where it still appears under-the-hood, never in user-facing strings.
The per-day drill-down used measurements.saveError in its fetch-error branch, which surfaced 'Fehler beim Speichern' on a read failure. Adds a measurements.loadError key in every locale and points the GET error path at it.
Mirrors the health-chart and mood-chart fixes. The compliance payload
already carries `scheduled` per day; summing it yields the raw
scheduled-dose count for the window. When the chart aggregates to
< 3 daily buckets but the raw count is ≥ 3, paint the new
"More distinct days needed" copy so the next action matches reality.
Pin both branches with an SSR test that mocks the compliance fetch.
Mirrors the health-chart fix. The mood-analytics endpoint already
exposes `entries[].samples` per calendar day, so summing it yields
the true raw mood-entry count for the window. When chartData
collapses to < 3 daily buckets but the raw count is ≥ 3, paint the
new "More distinct days needed" copy instead of the misleading "log
at least 3 entries" hint.
Pin both branches with an SSR test that mocks the analytics fetch.
The empty-state caption was gated on `chartData.length < 3`, but
chartData is daily-aggregated. A user with 50 blood-pressure readings
on 2 distinct days saw "log at least 3 entries" despite already
logging plenty — only the day-spread was sparse.
Track the raw measurement count during the per-type fetch (using the
rollup endpoint's `count` field when available, falling back to 1 per
row on the raw path) and stash it as a non-enumerable property on the
returned array. The render gate now paints "More distinct days
needed" when chartData is short but the raw count is already ≥ 3, and
the legacy "log more" copy stays for the genuinely sparse case.
Pin both branches with an SSR test that mocks the queryFn return.
Pre-existing empty-state copy ("Erfasse mindestens 3 Einträge, um die
Trendlinie freizuschalten") misleads when a user has logged many
measurements on too few calendar days. Add a second pair of strings
keyed on `charts.needMoreDistinctDays{Title,Description}` so the
upcoming chart-gate split can paint the right call-to-action when raw
count is sufficient but distinct-day count is not.
A misbehaving Withings upstream that returns a multi-MB error body
could bloat any AuditLog.details row that captured the thrown
message. Apply the cap inside the constructor so every downstream
audit, notification, and pg-boss retry path inherits the bound
without touching call sites.
Closes the v1.4.42 L-1 carry-over. A regression test asserts the
1024-char cap for a 4000-char upstreamError while preserving the
"Withings <verb> error: <status>" prefix the legacy regex parser
depends on.
The user_not_found_or_no_password branch wrote the typed email/username
into AuditLog.details, leaking PII into an operator artefact. The reason
field already tells the operator everything they need; the identifier
adds no diagnostic value over hashing or dropping it. Per the v1.4.20
no-PII-in-operator-artefacts directive, drop the field entirely.
A regression test pins the absence of both the literal identifier value
and any "identifier" key in the audit-row JSON.
Mocks `readBestGranularityRollups` with a controllable promise so the
peak in-flight count is observable at the helper boundary. Asserts
peak <= 4 across a 15-type fan-out and re-checks after each drained
resolver so a future regression that floods the pool on completion
would also surface. Mirrors the v1.4.40 W-POOL route-level pin in
`src/app/api/analytics/__tests__/route.test.ts`.
`computeAvg30LastYearMap` in the slim summaries slice ran an unbounded
`Promise.all` over every type the user had data for. On a 15-type
tenant the burst held 15+ Prisma slots simultaneously, and with the
slim and thick `/api/analytics` slices firing in parallel on dashboard
mount the combined fan-out drowned the pg.Pool max=20 raised in
v1.4.40 W-POOL — landing a ~9s server-side wait on every cold mount
since v1.4.40 W-WMY-WIRE.
Cap the per-type fan-out with `p-limit(4)`, mirroring the W-POOL
discipline v1.4.40 applied to the thick route's
`ANALYTICS_TYPE_FETCH_CONCURRENCY`. Same total work, at most 4 slots
held at any moment, leaving headroom for the concurrent thick slice.
Orchestrator hit 78% context with 9 implementation waves dispatched
and running in worktrees. Fresh session picks up at cherry-pick +
W10 QA round + reconcile + release endgame.
Handoff doc inventories every dispatched wave with its agent id and
scope, the post-merge develop+main rebase pattern that recurred in
PR #193, the Marc-voice + PII cleanup checklist for cherry-pick
review, and the docker BuildKit cache verify-loop per the v1.4.42
post-deploy lesson.
Estimated 2-3h fresh session for the closure work.
Carry-over from the v1.4.42 marathon W10 reconcile — the six W10
reviewer reports (code-reviewer, security, design, senior-dev,
product-lead, simplifier-residual) for the release-trail audit-input
that gated the v1.4.42 tag.
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI with the v1.4.40 AP-2 silent-disable manifest
pattern. APNs `allOrNone: true` closes the gap the wave was
conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
The v1.4.42 senior-dev QA flagged that `dedupeWorkoutBatch` reads the
source ladder from the canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY`
constant unconditionally, while the read-time picker resolves the
per-user `sourcePriorityJson` from the User row. A customised user
who promoted MANUAL above APPLE_HEALTH in Settings -> Sources can
still have their preferred Manual row dropped at write-time before
the read-time picker ever sees it.
Scope-narrow today (most paired users keep the Apple-first default)
but the moment a user customises, the divergence silently overrides
them. The architecturally correct fix (v1.4.43 candidate) is to
accept the small ingest-path cost of one indexed
`User.sourcePriorityJson` lookup and thread the user's ladder into
the picker. Until that lands, this docstring is the load-bearing
operator notice.
The W6 swap from `isWithingsRefreshReauthFailure(message)` to the typed
`classifyError(err)` + `classificationToFailureKind()` chain left two
byte-identical 14-LOC catch-blocks in `sync.ts` — one in
`getValidToken`, one in `syncUserMeasurements`. Extract the shared
recording shape into `recordWithingsSyncFailure(userId, err)` so the
two call sites collapse to a single `await`. Sync-activity / sync-
sleep migration to typed errors (W6 deferred to v1.4.43) gets a single
one-line catch block as a bonus when it lands.
No behaviour change — the 140 withings / integrations unit tests
still pin every failure path.
The W2 `returnAllZodIssues` helper duplicated the meta/headers handling
from `apiError`: same `{headers, ...rest}` destructure, same `metaKeys`
length check, same `...(headers ? {headers} : {})` spread on the
constructor. Six lines that drift the moment one helper gains a feature
(e.g. an `errorId` autoinject for Sentry) the other doesn't.
Extract the shared body into `buildJsonErrorResponse(body, status,
meta)`. Both `apiError` and `returnAllZodIssues` collapse to one-line
constructions of the resource-specific body shape. Wire shape is
byte-identical — the 7-case `api-response-zod` suite and every
downstream `apiError` consumer still see the same envelopes.
The v1.4.42 W5 helper shipped under the same identifier
`pickCanonicalWorkoutRows` as the v1.4.30 read-time picker in
`src/lib/measurements/`. Same name in two modules with different
signatures (write-time takes no user-priority, read-time does) sets
up the next contributor to auto-complete the name in a new route
and silently invoke the wrong picker — no user-priority resolution
on a read, missing the 90-second window on a write.
Rename to `dedupeWorkoutBatch` so the write-time contract is
self-describing. The read-time picker keeps its established name.
One file's export + 12-case test suite + the batch-route caller
update — no algorithm change.
The W3-QUERYKEY-LONGTAIL `medicationIntakeList` factory entry packed
the params as an object at index 4: `["medications", id, "intake",
"list", {sortBy, sortDir, limit, offset, status}]`. TanStack hashes
queryKeys via stable JSON, so the object form happens to work today,
but two sibling factory entries (`chartData`, `adminAuditLogFiltered`)
decompose params into a flat tuple precisely to avoid the deep-equal
trap — a future refactor that reorders the object keys or adds a
transient field would break the hash silently while every existing
caller looks correct.
Flatten the entry to match — the cache key now reads `["medications",
id, "intake", "list", sortBy, sortDir, limit, offset, status]`. The
unit-test walker pins the full tuple shape; the `intake-history-list-v2`
component test seed routes through the factory itself instead of a
hand-built bare literal so the params shape stays in lockstep with the
component's read.
W4-ARCH-HYGIENE's 7-way dedup landed in `src/lib/tz/resolver.ts` but
left two more BERLIN_DAY_FORMATTER declarations alive:
- `src/lib/insights/bucket-series.ts` needed the numeric `{year,
month, day}` parts for `Date.UTC` arithmetic, so it kept its own
formatter and a local `toBerlinYmd` helper.
- `src/lib/analytics/bp-in-target.ts` used a separate en-CA formatter
to skip the part-reassembly when producing the YYYY-MM-DD string.
Export a sibling `toBerlinYmd(date)` helper from the resolver that
returns the formatter's raw string parts so `bucket-series.ts` can
parse them into numbers without instantiating a second formatter.
Switch `bp-in-target.ts` to the existing `toBerlinDayKey` import —
the en-CA shortcut is a microscopic speedup not worth the duplicate
formatter and the divergent contract.
Net: one shared formatter for every Berlin-anchored cache key in the
tree. The 272 insights / analytics / tz unit tests still pass.
W6 widened FailureKind from `transient | reauth_required` to
`+ persistent`. Both formatter branches in `formatAdminAlertPayload`
got the new arm wedged in as a nested ternary, which the style guide
explicitly forbids ("prefer switch statements or if/else chains for
multiple conditions").
Collapse both stacks to a single `FAILURE_KIND_COPY` table lookup keyed
off the union. The table reads as the contract; adding a future fourth
kind is a one-row edit instead of two more arms in two stacks. No
behaviour change — the 28 integration-status tests still pin both the
reason label and the action line verbatim.
Two W10 QA findings on the v1.4.42 env-check wave, both tied to the
v1.4.40 AP-2 silent-disable scenario this script was conceived to
catch:
1. SD-H1 — the APNs group in `scripts/env-manifest.json` shipped
without `allOrNone: true`, so the synthetic `<all-or-none>` required
row never fired. An operator setting three of four `APNS_*` vars
and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would see exit 0 and
green-light the deploy — exactly the v1.4.40 paper-cut the wave was
meant to prevent. Adds the flag.
2. CR-H2 — the renderer printed `[OK] APNS_KEY` even when only
`APNS_KEY_FILE` was set. An operator grepping the env block for
`APNS_KEY` would find nothing and file a ghost issue. The
`CheckResult` now carries `satisfiedBy`, populated to the matched
alternative when it differs from the primary; the renderer prints
`[OK] APNS_KEY (satisfied by APNS_KEY_FILE)`.
Tests pin both fixes — the AP-2 scenario synthetic-row trip
(16 → 19 passing), and the `satisfiedBy` branch coverage on the anyOf
match path.
Captures the gate-flip outcome: 35 unused exports → 0, 52 unused types
→ 0, exit code 0 on the final `pnpm knip` run. Documents the
`ignoreExportsUsedInFile` + `ignoreIssues` ignore-block rationale,
inventories every dropped symbol, and flags the two W3 / W4 reconcile
callouts (`describeInjectionSite` re-export, `listSupportedTimezones`
re-export) along with the merge-order constraint they imply.
Drop the `--include files,dependencies,binaries,unlisted` scope on the
Knip workflow. The tree is now at zero unused exports + zero unused
types, so the gate enforces every issue type knip reports by default.
The `knip.json` ignore-block documents the two muted surfaces
(`src/components/ui/**` for shadcn parity, `src/lib/validations/**`
for the contract-type API surface) so any new addition that breaks
either invariant trips CI immediately.
Every `z.infer<>` source-of-truth type the residual flagged had no
external consumer:
- `MetricSource`, `AIRecommendation`, `AIRecommendationRationale`,
`AICitation`, `AIWarning`, `StoryboardAnnotation` in
`src/lib/ai/schema.ts`
- `InsightFinding` in `src/lib/ai/types.ts`
- `CoachChatRequest` in `src/lib/ai/coach/types.ts`
- `ApplyProfileInput` in `src/lib/auth/profile-update.ts`
- `CreateSideEffectInput`, `ListSideEffectsInput` in
`src/lib/medications/side-effects/validators.ts`
- `AnalyticsSlicePayload` in `src/lib/queries/use-analytics-query.ts`
- `WithingsWebhookAuthOutcome` in `src/lib/withings/webhook-handler.ts`
Plus three stragglers — `ThresholdOverride` (the persisted shape is the
sibling `ThresholdOverridesJson`), `ChartRangePreset` + the now-orphaned
`CHART_RANGE_PRESETS` constant it was derived from (no chart reads the
preset list), and the `WebPushChannelConfig` + `ApnsChannelConfig` +
`ChannelConfig` triplet in `src/lib/notifications/types.ts` (the
dispatcher imports the two real channel configs by name; the empty
records and the union added nothing).
`src/lib/validations/**` keeps its `z.infer<>` types — that's the
external API surface the iOS client matches against. Muted via
`ignoreIssues` so the gate can enforce while the contract surface
stays self-documenting.
Every shadcn UI re-export the simplifier residual flagged
(`AlertDialogMedia/Overlay/Portal`, `AvatarBadge/Group/GroupCount`,
`badgeVariants`, `buttonVariants`, the `Card*` / `Dialog*` /
`DropdownMenu*` / `Select*` / `Sheet*` / `Table*` / `tabsListVariants`
tier) is part of the upstream `new-york-v4` registry — confirmed
against `https://ui.shadcn.com/r/styles/new-york-v4/*.json`. Keeping
them in tree preserves library-shape parity so the next shadcn refresh
applies cleanly. Mute the whole `src/components/ui/**` surface for
the `exports` + `types` issue tiers via `ignoreIssues`.
`ignoreExportsUsedInFile: true` covers the schema-source-of-truth
pattern across `src/lib/ai/**` + `src/lib/validations/**`: the schemas
are exported for the inferred `z.infer<>` type but only referenced
internally for composition. Knip's default behaviour flagged them as
unused exports; the option teaches it that same-file references count.
Two genuine dead helpers go: `requireAdmin` in `src/lib/auth/session.ts`
shadowed the real implementation in `src/lib/api-handler.ts` and had
no callers, and `readDailyMeans` in `src/lib/rollups/measurement-read.ts`
was an unused convenience wrapper around `readRollupBuckets`.
`tokenKind`, `withBackgroundEventSafe`, and `isRollupFresh` had no
callers anywhere in the tree, so the symbol + JSDoc + index re-export
are removed. `PROGRESS_TICK_RECORDS`, `MAINTAINED_LOCALES`, and
`SUB_PAGE_METRIC` lose the `export` keyword — every reader lives in the
same module, so the constants stay local. `isRollupFresh` was deferred
in v1.4.41 pending the persistent-rollup reader landing on develop; the
read-path now carries its own per-tier freshness probe and never
imported the helper.
No behaviour change. Knip drops from 35 unused exports to 29.
W1-KNIP's reconcile callouts identified two re-exports that no caller
ever consumed:
- `src/lib/tz/resolver.ts:51` re-exported `listSupportedTimezones` from
`./format`, but both call-sites (`components/settings/timezone-picker.tsx`
and `components/admin/general-settings-section.tsx`) import directly
from `@/lib/tz/format`.
- `src/components/medications/glp1-medication-card.tsx:595-596`
re-exported `describeInjectionSite` "so the parent doesn't need to
import it", but every real consumer (`therapy-timeline.tsx`,
`injection-site-picker.tsx`, the unit test) imports the source from
`@/lib/medications/injection-sites` directly.
Drops both re-exports and the now-orphaned `describeInjectionSite`
import in `glp1-medication-card.tsx`. Clears the last knip findings so
the gate flip in the next commit lands clean.
The settings / medications / admin / hooks tree still declared
bare-literal `queryKey: [ … ]` arrays at ~70 call sites. Centralise
them through `queryKeys.<entry>()` so mutation-invalidation prefixes
stay symmetrical and a future rename only touches one location.
New factory entries cover per-medication compliance / titration /
cadence / glp1-details / drug-level-chart / intake-list, the
Withings status read, the paginated admin audit-log filter, and the
workouts-recent list opts. Existing entries cover everything else
(passkeys, tokens, settings, telegram, integrations, research-mode,
admin status / users / backups / feedback / host-metrics / app-logs /
ai-quality / assistant-flags, coach-prefs, feature-flags, etc.).
Lockstep updates:
- `eslint-plugins/healthlog/queryKey-factory.js` extends
`GUARDED_DIRECTORIES` to cover `src/components/{settings,
medications,admin}` + `src/hooks`, plus the three guarded files
under `src/app/medications/**` and `src/app/targets/page.tsx`.
- `src/lib/__tests__/query-keys.test.ts` mirrors the guard's
`guardedRoots` and pins the shape of every new factory entry.
Tests stay green: 4 736 unit; the factory walker reports zero
bare-literal `queryKey:` in any newly-guarded surface.
Adds `pnpm check-env` — a CLI tool that reads
`scripts/env-manifest.json` and verifies every required env var is
present + non-empty. Exit code 0 on green, 1 on missing required vars,
2 on a malformed manifest.
Two modes:
pnpm check-env # against process.env
pnpm check-env --file <path> # against a Coolify export / .env.production
The manifest is JSON so it's git-trackable and reviewable. Groups can
be marked `required: true` (boot-blocking) or `required: false`
(silently disable a feature). An `allOrNone: true` group emits a
synthetic <all-or-none> required row when partially populated — this
is the v1.4.40 AP-2 silent-disable pattern (three of four APNS_* set,
.p8 missing) made into a hard failure.
Documents the workflow in docs/ops/env-check.md including the planned
v1.4.43 CI integration. 16 unit tests pin parseEnvFile + checkEnv;
renderResults stays untested as a thin shell over the pure helpers.
This is a CLI tool, not a runtime gate — the app still boots without
this check having run. The point is to make the operator-visible step
between "Coolify env block edited" and "deploy fires" loud about
missing vars before the deploy ships.
Withings sometimes replies HTTP 200 with a non-zero body status, empty
measuregrps, or a transient 503. Pre-v1.4.42 the client surfaced all of
those as identical `Error("Withings <verb> error: <status>")` throws,
and the sync layer used a message regex to bucket reauth vs transient.
That made rate-limit (601) and contract-mismatch (293/294) responses
silently retry forever without ever reaching an admin alert.
Adds `src/lib/withings/response-classifier.ts` — a pure
`(httpStatus, body) -> {success | transient | reauth_required |
persistent}` function plus a `WithingsApiError` subclass carrying the
verdict. The four client entrypoints (exchangeCode, refreshAccessToken,
fetchMeasurements, subscribeWebhook) and both v2-sync paths
(sync-activity, sync-sleep) now throw the typed error; sync.ts reads
err.classification directly via classifyError() and falls back to the
legacy regex when the prototype is lost on a pg-boss retry.
Extends FailureKind with `persistent`. Persistent failures audit with
kind="persistent" and surface a distinct admin-alert label
("persistent error" / "investigate the upstream contract") but keep
state=error_transient so the next sync still runs once the upstream
side fixes the mismatch.
Three test branches pinned (37 new tests, 103 -> 140 passing in
src/lib/withings + src/lib/integrations).
The v1.4.41 W-FRONTEND-FACTORY placeholder rendered as the per-tile
Suspense fallback shipped without the trend-card's flex column or
min-height. The all-suspend edge case (every tile suspending
simultaneously, e.g. a future RSC hoist of every cell) collapsed the
grid row to zero height and the placeholder chrome diverged from the
live tile shell by a couple of layout utilities.
Match the trend-card's container classes byte-for-byte: add flex /
min-w-0 / flex-col for chrome parity and min-h-[6rem] so the fallback
holds the row open during a synchronous all-suspend transition.
Structural Suspense test pin keys on the aria-hidden marker, not the
className, so it stays green without modification.
iOS HealthKit observer queue routinely carries the same logical workout
from two paired sensors (Apple Watch + Withings ScanWatch) in a single
ingest batch. Without write-time dedup both rows persist (their
externalIds differ, so the (userId, source, externalId) unique index
never fires) and the read-time picker has to carry the duplicate
forward on every dashboard render.
Add pickCanonicalWorkoutRows() at src/lib/workouts/canonical-rows.ts as
a pure pre-createMany pass invoked by POST /api/workouts/batch. Groups
by (userId, activityType, startedAt +-90 s); prefers the canonical
source ladder (APPLE_HEALTH > WITHINGS > MANUAL > IMPORT) shared with
the read-time helper; breaks ties on calories > earliest createdAt >
input order. Losers surface to the iOS client as duplicate so the sync
cursor advances past them identically to the externalId case.
pr-detection-worker.ts ran its warm-up count and best-measurement scan
across every Measurement row including soft-deleted ones. A user who
deleted a row that happened to be their PR would not get the next-best
row promoted on the next detection pass. Add `deletedAt: null` to both
where clauses to align with the v1.4.40 soft-delete completeness sweep.
Also document the offhost-backup DR-intent at the matching findMany —
that path deliberately includes soft-deleted rows because the offhost
upload is a disaster-recovery snapshot, not a user-facing export.
src/lib/doctor-report-data.ts was checked in as raw bytes (file(1)
reported "data", git diff reported "Binary files differ") because the
control-character regex carried literal NUL + US + DEL bytes inside the
character class. Rewrite the class as `[\x00-\x1F\x7F]` so the file is
UTF-8 clean and future diffs are readable. Same runtime behaviour — the
existing 18-case test suite still passes.
The trend-card row in src/app/page.tsx carried two adjacent comment
blocks describing the same `<Suspense>` boundary — one from the v1.4.40
W-RSC seed and a second from the v1.4.41 W-FRONTEND-FACTORY fallback
hoist. Collapse into one block keyed to current behaviour with a
one-line history trailer. The sibling chart-row Suspense stayed in
single-block shape so no second consolidation was needed.
Each of the seven src/lib/insights/*-status.ts files carried an identical
20-LOC block defining BERLIN_DAY_FORMATTER plus toBerlinDayKey. Extract
the helper into src/lib/tz/resolver.ts (next to dayKeyForScheduledFor and
friends) and import it everywhere. Same runtime behaviour, 100-LOC drop.
Records the helper landing + widgets-route swap and stages the
v1.4.43 rollout queue (41 sites in src/app/api/** still on the
issues[0].message pattern). Includes the rewrite recipe + iOS-contract
prioritisation so the follow-up wave can pick up without re-discovery.
The PUT /api/dashboard/widgets route surfaced only the first Zod issue
on 422, which forced iOS clients debugging the widget-layout contract
into one round-trip per wrong field. The route now wires the shared
returnAllZodIssues helper so every issue lands in details.issues at
once.
Adds an append-only audit-ledger breadcrumb keyed
dashboard.widgets.validation-failed so the operator can grep
/api/admin/audit for the same trail outside the iOS dev console. The
write is fire-and-forget — the validation response is the contract,
the audit row is best-effort.
Tests cover 2 + 3 simultaneous issues, audit-row shape, params-leak
guard, and audit-write-rejection survival on both the unit and
integration tiers.
Historic pattern was apiError(parsed.error.issues[0].message, 422) which
dropped every issue past the first. Mobile callers debugging a contract
needed one round-trip per wrong field. The new helper returns every
issue under details.issues, additive to the existing envelope so any
client that only reads `error` keeps working.
Sanitises issues to {path, code, message} — issue.params can carry the
raw rejected user input for some Zod codes and must not leak.
Carry-over from the v1.4.41 marathon reconcile — the six W10 reviewer
reports (code-reviewer, security, design, senior-dev, product-lead,
simplifier-residual) were not committed before the squash-merge to
main. Lands them on develop so the next marathon's audit-trail has
the inputs that gated the v1.4.41 tag in one place.
Eight touch-disjoint waves landed on develop before this release:
- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
persist helper and wire all seven *-status routes through it, closing
the recurring ~14 s warm response the iOS client sees on dashboard
open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
(export bundle, gamification achievements, doctor-report aggregator)
with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused `tx?`
params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
formalisation; knip CI gate staged for v1.4.42 flip.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.
pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.
Captures the helper extract + 6 sibling-route wire + cache-shape
reconcile + test pins. Notes the medication-compliance stub-row
recogniser fix and the pulse-status-timeout assertion flip as
risk callouts.
Bring blood-pressure, weight, general, pulse, mood, and
medication-compliance into parity with bmi-status. On a 20 s
provider stall every route now persists a sentinel auditLog row
keyed to today's Berlin day so the next mount short-circuits at
the cache lookup instead of re-racing the same provider call on
every cold visit. Eliminates the ~14 s warm response on every
dashboard open after a one-off provider hiccup.
The seven routes share one persist path through
`persistTimeoutStubAndReturn`; ~120 LOC of duplicate
`auditLog.create` calls collapse into the helper.
medication-compliance returns a richer `{summary, medications}`
envelope rather than the standard `{text}` shape, so it calls
the helper for the persist side-effect and maps `summary` ←
`text` on the way out. Its cache-read also gains a stub-row
recogniser so the second-mount short-circuit fires on the
helper's `text` + `timeout: true` payload.
Tests cover both arms per route: the persist-on-timeout path
asserts a single auditLog.create with `timeout: true`, model
`timeout-stub`, and today's Berlin dateKey; the
short-circuit-on-stub path asserts a subsequent mount returns
the cached stub text without invoking the provider.
Pull the 40-line auditLog.create block out of bmi-status into a
dedicated `persist-timeout-stub.ts` helper so every InsightStatus
route can share one persist path instead of copy-pasting the
sentinel-row shape. The bmi route now calls the helper in a
6-line return; behaviour and persisted body are byte-equivalent
to the inline form.
Sets up the next commit, which wires the six remaining sibling
routes (blood-pressure, weight, general, pulse, mood,
medication-compliance) to the same helper and closes the
last bare-fallback shape on the dashboard hot path.
Two small follow-ups to the v1.4.41 inline-QA reconcile:
- eslint.config.mjs gains `.claude/**` in the globalIgnores list so a
mid-marathon Claude Code worktree on an older base commit can no
longer poison `pnpm lint` locally with pre-fix warnings. CI never
walks that path; the change is purely for the dev environment.
- The check-user route test mock objects for `checkRateLimit` carried
a stray `limit: 30` field that does not exist on `RateLimitResult`
(the real type is `{ allowed, remaining, resetAt }`). The literal
was harmless at runtime but flagged by `tsc --noEmit` on the
test-file declarations. Drops the field at all three mock sites.
The W-DELETED-2 integration-test file header promises coverage for five
reader tiers; v1.4.41 shipped pin assertions for three (`/api/export`,
`/api/export/full-backup`, `/api/export/measurements`) but never asserted
the two routes that the W-DELETED-2 fix actually touched:
- `/api/gamification/achievements` — reads recent measurements via
`findMany` to compute streak / badge progress. A tombstoned row must
never count toward a milestone.
- `/api/doctor-report/availability` — four parallel `measurement.count`
probes drive the section-availability switch. A soft-deleted history
must not light up a section the user has since wiped.
Adds one `it()` block per route asserting that every measurement
read scopes to `deletedAt: null`. Mocks already in place; no
test-infra work needed.
Two clarity regressions surfaced by the v1.4.41 QA round.
1. `src/types/analytics.ts:37-38` imported `DataSummary` twice from the
same module — once bare, once aliased as `DataSummaryType` — with
the alias used on a single line. The duplicate shape was carried
verbatim from `src/app/page.tsx` where both names existed
historically; in a fresh module there's no reason for two import
lines. Collapses to a single import; `glucoseByContext` now uses
the canonical `DataSummary`. Same fix replayed in `src/app/page.tsx`.
2. `src/app/insights/page.tsx:100` and
`src/components/onboarding/getting-started-checklist.tsx:75` carried
their W-ORG alias-back-import in the middle of the file, after
non-import statements. Lifts both into the top-of-file import block
alongside their peers; the aliased name (`as AnalyticsData`) stays
for call-site stability, the W-ORG header comment moves with the
import.
The v1.4.41 W-PROCESS-DOCS promotion of the test-guard substitute to a
real ESLint rule shipped with a whitelist (`charts`, `comparison`,
`page.tsx`, `use-auth.ts`) narrower than the companion test-guard's
`guardedRoots` (which also covers `app/auth`, `app/notifications`, and
`components/settings/about-section.tsx` — the three surfaces W-FRONTEND-
FACTORY migrated in the same release).
Without the mirror, a bare-literal `queryKey: [ ... ]` regression in any
of the three v1.4.41-migrated dirs would slip past IDE / `pnpm lint`
and only be caught by `pnpm test`. The lint rule and the test-guard
should fail at the same boundary.
Extends GUARDED_DIRECTORIES + GUARDED_FILES to match the test-guard
list, with an inline comment cross-referencing the migration wave.
Two correctness gaps on the v1.4.41 onboarding-discovery endpoint,
caught in the v1.4.41 QA round:
1. The route called `identifier.toLowerCase()` before the OR-match on
`username` / `email`, but `registerSchema` applies no transform on
write — both columns are stored exactly as typed. A user registered
as `MixedCase@Example.com` would never resolve through this route,
and iOS onboarding would route them to the sign-up branch despite
an existing account.
2. The route header claimed an edge-level limit on `/api/auth/*`
covered brute-force enumeration; no such middleware exists. Sibling
`/api/auth/passkey/login-options` carries a per-route throttle. We
mirror that shape — `checkRateLimit("auth:check-user:<ip>", 30,
15 * 60 * 1000)` — and rewrite the header comment so the rationale
matches reality.
Tests pin both invariants: a `MixedCase@Example.com` probe hits
`findFirst` with the case preserved, and a 429 short-circuits the
`findFirst` call entirely.
v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the tile-strip Suspense
fallback from `null` to an `aria-hidden` placeholder div that mirrors
the trend-card chrome, preventing CLS when a future RSC hoist makes
the tile body suspend. The literal regex pin in the boundary test was
not updated alongside, leaving a red on develop tip.
Updates the regex to match the new structural contract and refreshes
the surrounding comment so future contributors see why the fallback is
no longer `null`.
Narrow each helper's visibility to its actual reach instead of leaving
it exported "just in case":
- Drop the `export` keyword on file-local helpers / constants:
`getTrendSentiment`, `isPRWithin30Days`, `buildCoachMetricSourceType`,
`countPassedSchedules`, `getGithubConfig`, `isComparisonBaseline`,
`DASHBOARD_LAYOUT_VERSION`, `INTL_LOCALE_MAP`, `SIDE_EFFECT_NOTES_MAX`,
`MEDICATION_CATEGORIES`, `openApiBase`, `ALL_MOOD_GRANULARITIES`.
- Remove never-used dead code:
`CodexOAuthNotConfiguredError` (declared as a backwards-compat shim
no caller ever consumed), `editMessageReplyMarkup` (Telegram method
nothing on the bot dispatch tree calls), and the stale
`DISPLAY_TIMEZONE` re-export from `src/lib/format.ts`.
Knip drops from 48 -> 35 unused exports. Pure narrowing / deletion;
no behaviour change.
`recomputeMoodBucketsForEntry`, `recomputeMedicationComplianceForDay`,
and `recomputeMedicationComplianceForEvent` all declared an optional
`tx?` Prisma transaction client but no call site has ever passed one —
every caller (routes, jobs, worker, tests) invokes them outside a
`$transaction` block. The dead surface area kept dragging a
`PrismaLike` / `PrismaTxOrClient` alias plus a top-level `Prisma`
import along with it. With the param gone the helpers simply close
over the singleton `prisma` client; the alias and the
type-only import follow.
No call-site changes needed — the param was optional everywhere.
Both `/api/medications/intake?scope=today` and `/api/dashboard/summary`
carried their own copy of the v1.4.39 projection + idempotent
`createMany` + per-`(med, day)` compliance recompute. The duplicate
implementations had already drifted twice; fold them into one tested
location at `src/lib/medications/scheduling/project-today-intakes.ts`
so future changes (new IntakeSource literal, new schedule cadence,
audit metadata) only land once.
The helper stays read-shape-neutral — the two routes return different
projections of the resulting events, so the caller still owns its
typed re-read. Returns `{ projected, backfilled }` for the existing
`annotate` telemetry.
Net: ~200 lines collapsed into 145 (helper) + two short call sites.
The insights page kept `CorrelationResult` + `DataSummary` type imports
left over from an earlier refactor, and the slim summaries slice still
referenced `RollupGranularity`, `aggregateWmyBuckets`, and
`RollupBucketRow` after the v1.4.37.2 GROUP BY rewrite folded that work
into the narrow aggregate. Pure deletions — both helpers stay exported
for the test suite that still exercises them.
Mirror the bmi-status (v1.4.37) timeout-stub persist into the two
sibling endpoints. On a 20 s provider stall the route now writes a
sentinel auditLog row keyed to today's Berlin day so the next mount
short-circuits at the cache lookup instead of re-racing the same
provider call for the rest of the day.
This eliminates the ~14 s warm response on every iOS dashboard open
when the provider has stalled once that day. Response envelope
unchanged; iOS v0.5.4 contract preserved.
Tests cover both arms: the persist-on-timeout path (one create with
timeout:true, model:"timeout-stub", today's dateKey) and the
short-circuit-on-stub path (subsequent mount returns cached text,
provider never invoked).
The `sum_value IS NULL` UNION arm in `enqueueBootTimeRollupBackfill`
was added in v1.4.39 W-SUM to converge DAY rollup rows that pre-dated
the writer change. Production data has since converged, so the arm is
a permanent no-op seq scan over `measurement_rollups`. Per-day missing
coverage remains the sole discovery anchor.
Drops the legacy text-anchor unit test. The read-side `mean * count`
fallback in `/api/dashboard/summary` retains legacy-row readability
and stays covered by its dedicated route test.
Append a Postgres connection-pool sizing section to the self-hosting
scaling guide. Documents the v1.4.40 W-POOL default (20 slots per
container), the container_count × DATABASE_POOL_MAX × max_connections
envelope (with a 1-5 container ready-reckoner table), the env-var
override path, and PgBouncer as the escape hatch for deployments that
outgrow the per-process pool model. Cross-references src/lib/db.ts and
the empirical trace that motivated the bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per-
metric system prompts + safety contracts) and `src/lib/insights/prompt
*.ts` (user-prompt builder + comparison snapshot + the `compactSections`
helper). A new reader could not tell from filenames which file to
extend; both directories looked equally authoritative. Audit rec #3
flagged the split as the only undiscoverable seam in the AI layer.
Move both files into `src/lib/ai/prompts/`:
- `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts`
- `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts`
- co-located tests follow
Three importers updated: `app/api/insights/generate/route.ts`,
`lib/ai/coach/snapshot.ts`, the generate route test (the
`vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have
ONE home and ONE mental model.
v1.4.41 W-DELETED-2 — assert that /api/export, /api/export/full-backup,
and /api/export/measurements all scope the measurement read to
deletedAt: null. Shape-level guard so a future refactor cannot silently
drop the filter and resurrect deleted rows in user-downloaded files.
Routes the registration-status, auth invalidation, notifications
preferences, and Settings about-section api-version reads through
the centralised queryKeys factory. Adds factory entries for the
remaining settings/admin surfaces a follow-up will migrate, and
extends the walker test to fail on any future bare-literal
queryKey in src/app/auth, src/app/notifications, and the
about-section. Also swaps the dashboard tile Suspense fallback
from null to a layout-stable card placeholder so a future RSC
hoist of any tile slot can't trigger CLS.
Captures SB-7 follow-up (check-user endpoint shipped), SB-8 (merge
semantics documented, no code change), SB-9 (already retired, no
streak surface exists), and the dashboard-widgets PUT 422 root cause:
the Zod validator is correct and additive-safe; the live 422s from
iOS v0.5.4 are an iOS-side payload bug (likely an unknown widget id
or an out-of-range order value). No server fix viable.
ls src/app/api/streak returns No such file or directory. No web
frontend code paths reference /api/streak/...; the only streak
references in the API tree are derived fields inside
/api/insights/targets (streakDays, streakHighlight).
Decision: nothing to deprecate. The route family either never existed
or was removed before the audit. iOS can safely treat /api/streak/*
as 404. Document filed so a future audit does not re-open the
question.
The dashboard layout resolver in src/lib/dashboard-layout.ts is
append-only on read: new widget ids added to DASHBOARD_WIDGET_IDS
automatically appear for every existing user on next GET, with
visible / tileVisible / order taken from DEFAULT_DASHBOARD_LAYOUT. No
database migration is ever required when introducing a new widget.
Documents the merge contract so a future widget-introduction does not
ship a one-shot SQL migration that would clobber every user's
customisation. PUT preservation rules captured: chartOverlayPrefs is
preserved-on-omit, comparisonBaseline is not. Investigation-only — no
code change.
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:
- not_found no account
- passkey_only passkey present, no password
- email_fallback password present (passkey optional)
- exists neither credential (recovery path)
The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.
Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
v1.4.41 W-DELETED-2 — achievement progress now scopes the measurement
read to deletedAt: null so deleting a row immediately rolls back any
streak or count badge it had earned, matching the analytics + dashboard
sweeps from v1.4.40.
v1.4.41 W-DELETED-2 — the doctor-report-data aggregator (shared by
/api/doctor-report JSON + /api/doctor-report/pdf server render) and the
section-availability probe now filter deletedAt: null so deleted rows
never appear in the rendered PDF or trigger an empty section toggle.
Three structurally-distinct `interface AnalyticsData {…}` declarations
shared a name across `src/app/page.tsx` (dashboard), `src/app/insights/
page.tsx` (insights mother), and `src/components/onboarding/
getting-started-checklist.tsx` (checklist). The collision made the
shapes un-discoverable from any one site and forced every iOS-contract
addition to land in three places.
Hoist all three into `src/types/analytics.ts` as named exports:
`DashboardAnalyticsData`, `InsightsAnalyticsData`, `ChecklistAnalytics
Data`. Each call site re-imports its shape under the original alias to
keep downstream code readable; the slim `SubPageAnalyticsData` that was
already there stays put. We deliberately keep the four shapes distinct
(rather than collapsing to one with everything optional) so call sites
can't cross-read fields they shouldn't reach for — closes v1.4.39 org-
audit recommendation #2.
v1.4.41 W-DELETED-2 — full-backup JSON, per-type CSV, and the legacy
/api/export endpoint now scope reads to deletedAt: null so iOS undo
or pending-sync rows never reach the user's downloaded file or the
admin restore round-trip.
Resolves the single component → route-handler import in the codebase
(`components/admin/backups-section.tsx ← app/api/admin/backups/route.ts`)
flagged by the v1.4.39 org audit (recommendation #2). The DTOs now live
in `src/types/backups.ts`; the route handler keeps owning the HTTP
contract and re-imports the shared types from there.
Adds the v1.4.40 row + the four prior v1.4.39.x stacked rows to
.planning/round-audit-marathon-closure.md (v1.4.39.1/.2/.3/.4 were
documented inline in the corresponding memory files but the table
hadn't picked them up).
Commits the six v1.4.40 QA reviewer findings files (senior, security,
specialist, UX, product-lead, simplifier) so the audit trail stays
self-contained.
Adds round-v1440-perf-verify.md anchored on the live deploy commit
1114cf7e (squash-merge), Coolify deploy timestamp 2026-05-21T12:55:02Z,
docker workflow 26221332323. Includes the AP-2 .p8 install runbook for
SB-5 live effect.
Eleven touch-disjoint waves landed on develop plus the post-QA reconcile
patches:
- W-POOL: p-limit(4) wrapper around the analytics 15-way fan-out and
pg.Pool max raised 10 -> 20 closes the v1.4.39 empirical-trace pool
starvation root cause. Wave-C chart-tile first-paint moves from +7.3 s
to +1.6 s on the empirical fixture.
- W-DELETED: 11 reader-tier helpers + 3 integration contracts close
audit Critical Finding #3 (soft-delete invisibility) across the
user-facing dashboard, analytics, and Coach paths.
- W-INSIGHTS: mood-rollup swap on the three target routes plus the
compliance-rollup hook gap closure; the post-QA reconcile patch
closes the six remaining measurement.findMany sites in the cluster
that still leaked tombstoned rows.
- W-INFRA: rollup umbrella consolidation under src/lib/rollups/ with
zero orphan re-exports; lint and knip CI gates green.
- W-RSC: per-tile Suspense boundaries plus the queryKey factory-bypass
walker test extended to comparison/.
- W-PRIVACY: bilingual /privacy page with 17 paired-section tests.
- W-AASA: /.well-known/apple-app-site-association endpoint pinned to
S8WDX4W5KX.dev.healthlog.app with app-ID parity assertions.
- W-APNS-NOTIFY: MEDICATION_REMINDER time-sensitive payload (priority 10
with interruption-level) scoped via parameterised test that pins all
6 other event-types do not bypass Focus.
- W-CONSENT: AI consent receipts CRUD with append-only invariant and
byte-bounded artefact cap.
- W-WMY-WIRE: three previously-dead WMY readers (week/month/year) wired
through summaries-slice and health-score-fast-path.
- W-GHOSTS: ~1 177 lines of dead code removed across 9 atomic commits.
Reconcile patches applied per the v1.4.40 QA round (.planning/round-v1440-QA-*-findings.md):
- senior C1 — dashboard-suspense-boundaries regex aligned with W-INFRA Thread 2
- senior H1 — soft-delete filter added to the six remaining insights findMany sites
- specialist H1 — Promise.allSettled around bulk-projection compliance recompute
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets factory
- security M1 — consent artefact cap enforced via Buffer.byteLength
Wave reports under .planning/phase-W-*-v1440-report.md; full release
prose in CHANGELOG.md.
pnpm typecheck / pnpm lint / pnpm knip / pnpm test --run all green.
Tests 4525 -> 4726 unit + 1 skipped over the v1.4.39 -> v1.4.40 span.
`z.string().max()` counts JavaScript string units (UTF-16 code
units), not bytes. The audit-table guarantee for SB-10 consent
artefacts is byte-bounded — a UTF-8 artefact full of multi-byte code
points (CJK, emoji) would otherwise slip past the 64 KB row budget.
Replace the `.max()` with a `.refine()` that uses
`Buffer.byteLength(value, "utf8")`.
Closes security M1 from the v1.4.40 QA round.
The compare-toggle component still declared three bare-literal
`["user", "dashboardWidgets"]` queryKeys for read, onMutate
cancel/set, onError rollback and onSettled invalidation. Migrate every
call site to the central `queryKeys.dashboardWidgets()` factory so a
future rename or shape evolution only touches one file. Extend the
W-RSC factory-bypass walker to cover `src/components/comparison/`
so any future regression in this directory gets caught by CI.
Closes UX H1 from the v1.4.40 QA round.
The recompute helper swallows errors internally today, but the contract
is fragile — any future refactor that lets a throw escape would turn
the intake POST or dashboard summary GET into a 5xx for a strictly
best-effort hook. Wrap both call sites in `Promise.allSettled` so the
fail-soft contract is explicit at the call site, not buried in the
helper.
Closes specialist H1 from the v1.4.40 QA round.
The audit-H4 regex still pinned the pre-W-INFRA Thread 2 form
`useMemo(..., [user?.timezone])` while the production code lifts
`user?.timezone` to a `userTimezone` local one line above the
`useMemo` so the dep array stays stable. Update the test fixture to
match the new dependency form.
Closes senior C1 from the v1.4.40 QA round.
Installs knip 6.14.1 as a devDependency, ships a curated knip.json
covering every Next.js App Router special file plus the worker / script
/ test entry points, and adds a workflow that runs on every push to
main and every PR targeting main.
The gate trips on:
- unused files (a new orphan module ships zero callers)
- unused dependencies (a production dep with no consumer left behind)
- unused devDependencies (same, in dev land)
- unlisted binaries (a package.json script reaches for a CLI without
declaring it)
Unused-exports + unused-types stay informational this release because
the v1.4.40 W-GHOSTS sweep cleared 1 177 lines of dead code but the
historic exported-surface backlog still sits at ~ 487 exports + 52
types. Cleaning that incrementally avoids a single mega-PR risk.
Baseline carve-outs in knip.json:
ignore — three files W-GHOSTS missed in v1.4.40 (kept as known-zero
callers until a follow-up wave can verify they're safe to delete with
their dedicated test removed in the same commit):
- e2e/setup/test-helpers.ts (9-line barrel; future-multi-user hook)
- src/components/charts/compliance-line-chart.tsx (chart module only
referenced by a touch-action contract test)
- src/lib/logging/index.ts (21-line barrel re-export)
ignoreDependencies — runtime / build-time deps loaded outside the
import graph:
- @hookform/resolvers, react-hook-form: dynamic + framework conv.
- @prisma/client: re-exported via src/generated/ (which is ignored)
- next-themes: ThemeProvider mounts via dynamic import
- @vitejs/plugin-react: vitest.config consumer
- shadcn, tailwindcss, tw-animate-css: build pipeline
- testcontainers: integration-test bootstrap
- @types/* : ambient typings
ignoreBinaries — invoked via shell from integration helpers or
declared via :
- docker, docker-compose, pg_isready (integration setup)
- tsx (the openapi:generate + openapi:check scripts run via npx tsx)
- src/lib/medications/compliance-rollups.ts → src/lib/rollups/medication-compliance-rollups.ts
- co-located test follows the source file
10 importers updated covering the dashboard summary, every intake CRUD
route (single + bulk + import + Telegram webhook), the ingest endpoint,
the admin backups restore path, and the reminder worker job.
Closes the rollup umbrella restructure — every persistent-rollup
producer + reader now lives under src/lib/rollups/. Pure relocation
across all five batches: zero behaviour or exported-surface change.
- src/lib/mood/rollups.ts → src/lib/rollups/mood-rollups.ts
- co-located test follows the source file
17 importers updated from @/lib/mood/rollups to @/lib/rollups/mood-rollups,
covering the mood-entries CRUD routes, the moodlog webhook, the import
restore path, the insights comprehensive + targets routes, the analytics
+ features paths, and the reminder worker + moodlog sync job consumers.
Pure relocation.
- src/lib/measurements/rollup-read-wmy.ts → src/lib/rollups/measurement-read-wmy.ts
- src/lib/measurements/rollup-read-cumulative.ts → src/lib/rollups/measurement-read-cumulative.ts
- co-located tests follow the source files
- cumulative module's sibling import of apple-health-mapping promoted to
the @/lib alias since that mapping stays in src/lib/measurements/
Importers updated: 2 sites for WMY (health-score + summaries-slice),
zero external sites for cumulative (only co-located test reads it).
Pure relocation.
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:
- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
src/lib/rollups/__tests__/measurement-rollups.test.ts
Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.
Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
The useMemo introduced for the timezone-aware greeting in the W-RSC
per-tile Suspense pass listed [user?.timezone] as its dep array. React
Compiler's preserve-manual-memoization rule rejected the literal
optional-chain expression because the inferred dep is the wider 'user'
object — a less-specific source than the declared property access — so
the compiler refused to optimize the surrounding 1 400-line component
body.
Lift userTimezone into a local before the memo so the inferred dep and
the declared dep agree. No behaviour change; the memo still recomputes
only when the user's timezone string changes.
The three Measurement reads in the analytics route — glucose-by-context,
sleep-stage breakdown, and the fetchMeasurementSeriesChunked paged
helper that backs the per-type summarize loop and the BP-Zielbereich
windowing — were still pulling soft-deleted rows. W-DELETED skipped this
file assuming the W-POOL pool / p-limit pass covered it, but that pass
only addressed concurrency, not the deletedAt predicate.
Adds deletedAt: null to all three findMany clauses so the route shares
the same soft-delete contract as every other analytics read path. iOS
soft-delete unblocked.
The compliance-rollups module and the cadence scheduling module each
carried a private wallClockInTz definition. The cadence variant
returned the full {year,month,day,hour,minute,second,weekday} tuple;
the compliance variant returned only {year,month,day}. Promote one
canonical helper that returns every part — the compliance caller
destructures the three fields it needs — so the cross-tz ±3 h guard
stays in lock-step instead of drifting between sibling modules.
The mood-rollup and measurement-rollup writers carried byte-identical
copies of the helper that could drift independently. Promote one
canonical export under src/lib/tz/ so both writers — and any future
rollup table — import the same UTC-anchored truncation.
The runtime never reads the format namespace — every locale-aware
formatter routes through Intl.DateTimeFormat directly via
src/lib/format-locale.ts. The keys were only verified by their own
ordering smoke test, which is also removed.
The glucose UI only converts canonical mg/dL into mmol/L for display
via convertGlucose; the reverse helper had no caller outside its own
test. The Berlin-only day-key helper was already superseded by
userDayKey(date, tz) in v1.4.38 for every read path that consumed it;
its remaining presence as a tested module was dead weight.
Pins the cross-consumer routing contract the v1.4.40 wire-in relies
on. Both computeSummariesSlice (year-ago baseline) and
computeUserHealthScoreFastPath (weight long-window) now call
readBestGranularityRollups so the floor table is shared state. The
new tests prove:
- 90 d -> DAY (every coarser floor too high),
- 365 d -> MONTH (clears 181 d floor, short of 731 d YEAR floor),
- 1095 d -> YEAR (clears the 731 d YEAR floor).
Plus a parity assertion: a MONTH bucket consolidating 30 measurements
with mean 80 returns the same count-weighted mean as the two DAY
buckets (count 10 / mean 82 + count 20 / mean 79) that compose it.
Locks the linearly-composable contract count / mean carries across
granularities — any future floor adjustment forces both consumers to
re-pin in lock-step rather than silently diverging.
The bucket-fresh read paths probe coverage per-type via
coverage.get(type) or rely on isFullyCovered for the all-or-nothing
fast path; the typesMissingCoverage helper had no production caller.
The cumulative-batch reader was only exercised by its own test —
the analytics A2 path took the chunked per-type findMany route in
production. Strip both and their tests.
Wires readBestGranularityRollups into the WEIGHT-covered branch of
computeUserHealthScoreFastPath so the trailing-year weight baseline is
served from the WEEK / MONTH / YEAR rollup tier instead of falling
through to a live measurements walk. The 365-day window routes through
MONTH by default (~12 buckets) and falls back to WEEK -> DAY on per-
tier coverage miss. count / mean compose linearly across MONTH buckets
so the derived mean is mathematically equivalent to the per-row
average over the same window.
The read fires in parallel with the canonical DAY-bucket read for the
30-day weight series so wall-clock cost stays flat. Score shape is
unchanged; the weightLongWindow annotate carries the resolved
granularity + bucket count + mean so operators can verify the rollup
tier serves production traffic. The live-fallback branch deliberately
omits the long-window probe because the raw weight findMany already
does the work — guards against double-reading on cold mounts.
Neither function had any production caller. The chart components in
src/components/charts/* implement a local movingAverageByPoints rather
than reaching for the lib helper, and weeklyAverages was never imported
outside its own test file. detectAnomalies stays — it is reached
transitively through summarize() which the analytics, insights, and
measurements-series routes all consume.
Both routes were superseded by their /api/admin/monitoring/{umami,glitchtip}-test
counterparts which are the variants the admin UI actually fetches.
The duplicated routes had no callers anywhere in the source tree.
The GET handler had no fetch caller and the queryKeys.medicationIntakeSummary
entry was never consumed in any useQuery. Strip the route file, the
factory entry, the medicationDependentKeys reference, and an outdated
quick-add doc comment.
Wires readBestGranularityRollups into computeSummariesSlice to populate
the year-ago 30-day baseline (avg30LastYear) per type. The field was
hardcoded null up to v1.4.39 because the 90-day windowed aggregate
cannot reach back a year and the live measurements walk over a 395-day
per-type window is exactly the cold-path cost the rollup tier was meant
to absorb.
Coverage-fallback mirrors the W-MOOD posture: per-type calls route
through YEAR (skipped for the 395-day window — floor 731 d) -> MONTH
-> WEEK -> DAY and return null when no granularity carries any bucket
overlapping the [now-395d, now-365d) slice. count / mean compose
linearly across any granularity so the derived baseline is
mathematically equivalent to the per-row average over the same window.
Adds year_over_year_types annotate count alongside the existing
slim_summaries path tag so operators can verify the wire-up in
production wide-events without redeploying instrumentation.
The queue had a handler, registration, and worker binding but no
producer anywhere in the codebase — zero boss.send calls and no cron
schedule. The doc comment claimed the Telegram sender schedules
cleanup but the sender never called boss.send. Strip the constant, the
interface, the handler, the allQueues entry, and the worker bind.
The route is read-only and its envelope shape (`registrationEnabled`
boolean) gates iOS sign-in + web onboarding. Four branches walked:
1. singleton row exists with registrationEnabled: true → reports true
2. singleton row exists with registrationEnabled: false → reports
false (admin-disabled tenant, invite-only)
3. singleton row missing → reports schema default true (fresh self-
hosted instance, no-config baseline)
4. findUnique throws → reports false (fail-closed; critical defense
against a DB blip silently unlocking sign-up on a tenant that
disabled registration)
No code change; the route's current behaviour is correct. Pin it
before someone refactors fail-closed → fail-open by accident.
Mirrors the intake-route call site (v1.4.40 close of v1.4.39.4 hook
gap). `projectAndReadTodaysIntakes` runs the same idempotent
projection helper the intake route uses; without this hook the
compliance rollup stayed pre-projection on the second call site too.
Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` after the bulk `createMany`, best-effort
inside the helper's try/catch so a populator hiccup doesn't surface
as a 5xx on the dashboard summary.
Closes the v1.4.39.4 hook gap surfaced by the QA reconcile pass. The
`scope=today` branch's bulk `createMany` mints fresh
`(medicationId, scheduledFor)` PENDING rows when the morning
projection lands; without this hook the compliance rollup for the
affected `(user, medication, day)` tuples stays at its pre-projection
`scheduled` count, inflating the apparent compliance % until the user
actually logs against the new row.
Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` (coalesced by Set) so a 5-meds-3-times-a-
day projection drops 15 createMany rows down to ≤3 recompute round-
trips. Best-effort: errors stay caught inside the helper so a
populator hiccup doesn't surface as a 5xx on the intake request.
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The mood
block inside `extractFeatures` ran an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` and folded the
entire history into the AI feature payload for /api/insights/generate.
On power-user accounts the walk dominated the cold-mount budget.
Swap to `readMoodDayRollups(userId, fiveYearsAgo)` so the feature
payload reads one row per calendar day. The downstream consumers
(`avg7` / `avg30` / `trend30` / `coverage` / cross-metric
correlations) all operate at per-day resolution, matching the rollup
shape. `latest` score reads a single `findFirst` (bounded). Coverage-
fallback runs a bounded one-year live walk + fires
`ensureUserMoodRollupsFresh` so the next request lands on the rollup
tier (same posture as /api/mood/analytics post-v1.4.39).
Existing test fixture updated to mock `moodEntryRollup` + the
warm-up; six features tests stay green.
Inventory of touched files, test matrix, and commit map for the
v1.4.40 soft-delete-filter sweep that closes Senior Infra+DB audit
Critical Finding #3.
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day
`prisma.moodEntry.findMany` block fed two downstream consumers:
`dailyMoodEntries` for mood × metric correlations, and `moodSummary`
for the dashboard mood block. Both read at a per-day resolution.
Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads
one row per calendar day instead of one per entry. Coverage-fallback
runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day
mean DataPoints into `summarize()` on both branches, matching the
rollup-tier convention shipped in v1.4.39 (QA UX-H1).
Two parity tests added to `__tests__/route.test.ts`: rollup fast path
skips the raw findMany, and the coverage-fallback runs the live walk
exactly once when rollups are empty.
Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db).
The mood block at the bottom of the route used to run an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` on every mount —
pulling every mood the user had ever logged into Node only to bucket-
average them in JS. The `findMany({distinct:['type']})` for 'absolute
latest measurement per type' carried no `measuredAt >=` floor either,
so on a 347 k-row tenant Postgres still had to sort the full set
before the driver applied DISTINCT ON (Prisma's `distinct` doesn't
compile to PG's `DISTINCT ON`).
This commit:
- swaps the mood findMany for `readMoodDayRollups(userId, since)`
plus a single `findFirst` for the latest score; coverage-fallback
runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39)
- adds a 365-day floor to the latest-ever-per-type read so the
driver-side distinct walk stays bounded
- pins all three behaviours in `__tests__/route.test.ts` (4 new
tests: rollup fast path, coverage-fallback bounded walk, no-mood
branch, distinct floor present)
Three test suites land together:
1. queryKeys factory contracts for the four new v1.4.40 entries
(`authMe`, `userThresholds`, `chartData`, `dashboardMedicationCompliance`)
— pin the byte-stable shapes so a future rename of any literal
lands a matching test failure rather than silently breaking cache
layout.
2. `measurementDependentKeys` / `medicationDependentKeys` bundle
membership — `["chart-data"]` and
`["dashboard-medication-compliance"]` prefixes now ride the
bundles so a mutation evicts every per-chart and per-compliance
cache in one pass (audit-C2 + audit-L4).
3. Factory-bypass guard — walks `src/components/charts`,
`src/app/page.tsx` and `src/hooks/use-auth.ts` and fails if a
literal `queryKey: [...]` slipped past the factory. Acts as a
lint-style enforcement check in the absence of a custom ESLint
rule. The guarded directory list is intentionally scoped tight to
the files this wave migrated; a follow-up wave can extend it as
it routes the remaining admin/settings/medications surfaces
through the factory.
Plus a dashboard Suspense-boundary structural test that pins the
per-tile `<Suspense fallback={...}>` wrappers, the module-scope
`DASHBOARD_QUERY_OPTS` hoist, and the `useMemo` around the hour-of-day
derivation. A refactor that drops any of those three structural
guarantees lands red CI rather than silently regressing the
streaming-composition contract.
All 16 + 5 new assertions green; tests 4565 → 4586.
Cases covered:
- POST 401 unauth, 400 invalid kind, 400 non-ISO signedAt, 400 oversize
artefact (>64 KB), 200 inserts row + strips artefact from response.
- GET 401, 400 invalid kind, null when nothing granted, latest row by
kind, full keyspace projection when ?kind is omitted.
- DELETE 401, flips revokedAt on the latest active row, idempotent
no-op when nothing to revoke, master toggle revokes per-kind.
The headline test is the append-only invariant: a revoke + re-grant
cycle leaves the original row in place (with revokedAt set), mints a
fresh row, and a subsequent GET points at the re-grant — never at the
revoked predecessor. Prisma's `delete` is asserted to never be called
on a receipt, which is the legal-team guarantee the audit trail relies
on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the iOS onboarding + Settings "AI deaktivieren" flow to the
persistence layer landed in the previous commit:
- POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists
a fresh receipt, returns id + serialised row (artefact stripped from
the response so the signed token doesn't echo over network paths it
doesn't need to cross).
- GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for
the given kind. Omit ?kind to get the full three-kind keyspace
keyed by kind, with null slots for kinds the user hasn't granted.
- DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the
latest active row. Idempotent: a no-op returns 200 with null receipt
rather than 404 so the iOS toggle can hammer the endpoint without
spurious error states. Omit ?kind for the master "deaktivieren alle
KI-Funktionen" sweep across every kind.
Both grant + revoke write to auditLog so the trail is reconstructible
even if the receipts table is rebuilt from a backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the persistence layer for the AI consent audit trail required by
App-Store Guideline 5.1.2(i) and GDPR Art. 7:
- src/lib/validations/consent.ts — Zod schemas with a three-kind
discriminator (ai_full / ai_insights_only / ai_coach) and a 64 KB cap
on the opaque artefact so a misbehaving client can't fill the audit
table with multi-megabyte rows.
- src/lib/consent/receipts.ts — append-only helpers: createReceipt,
latestActiveReceipt, latestActiveReceiptsByKind, revokeLatest.
Revoke flips a single field on the existing row; a re-grant always
mints a fresh row, keeping the historical chain intact for legal
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins the v1.4.40 W-DELETED contract end-to-end against the real
Postgres testcontainer. Three contracts:
1. The slim summaries slice's narrow aggregate + DISTINCT ON latest
reads exclude tombstoned rows. Fixture parks a 99.0 sentinel
row that would explode min/max if leaked.
2. The dashboard summary route's per-type groupBy + latestIn7d
DISTINCT ON ignore the tombstone — the WEIGHT tile's
`latestValue` shows the live row, not the tombstoned 99.0.
3. `recomputeBucketsForMeasurement` produces a DAY bucket with
count=2 (not 3) when one of three same-day rows is soft-
deleted, and the bucket mean reflects the live two rows only.
Also pins the Coach snapshot's measurement read against the same
filter — tombstoned rows must never reach the AI prompt where they
would surface as live data the user thinks they removed.
Wrap every chart row cell and every tile-strip cell in a per-cell
`<Suspense>` boundary so the dashboard composition reads as a grid of
independently suspending islands rather than a flat tree that shares
loading semantics (audit-H2 + brief C1). The dynamic-loaded charts
(`HealthChartDynamic`, `MoodChart`, `MedicationComplianceChart`) already
mount their own `<ChartSkeleton>` while the JS chunk resolves; the
Suspense layer lifts the same skeleton to a streaming-compatible
boundary so a future migration to `useSuspenseQuery` automatically
buckets each chart's loading state to its own cell without a further
composition pass.
Bundled with two audit-cited polishes that share the same touch
surface: hoist `DASHBOARD_QUERY_OPTS` to module scope so the option
object reference stays stable across renders (audit-M2), and memoise
the `getHourForTimeZone` call so the `Intl.DateTimeFormat`
instantiation only re-runs when the user's timezone changes
(audit-H4). Both are micro-wins individually but the page renders
~720 lines of JSX per pass, so a stable per-render allocation budget
keeps Recharts re-render cost bounded.
No visible behaviour change in the current cold-mount waterfall —
every chart still suspends on its own client-fetch — but the
structural boundary is the prerequisite for the v1.4.41 RSC migration
that hoists slim-analytics fetching to the server.
Track the v1.4.40 W-PRIVACY bilingual rewrite of /privacy. The test
suite now pins:
- The bilingual H1 ("Datenschutzerklaerung / Privacy Policy") so a
redesign cannot silently regress to a monolingual title that fails
the German DPA review.
- Eleven sections each carry a `data-slot="privacy-section-de"` body
plus a collapsible `data-slot="privacy-section-en"` translation,
with `lang="de"` / `lang="en"` boundaries so screen readers switch
pronunciation correctly.
- The HealthKit data-flow boundary (POST /api/measurements over
TLS 1.3).
- The AI off-device transit guarantees: off by default, never
includes raw HealthKit identifiers, named providers (Anthropic,
OpenAI).
- Consent-receipt endpoint GET /api/account/consents with the 5-year
retention window.
- Encryption disclosure: TLS 1.3, HSTS, certificate pinning + the DE
"Zertifikat-Pinning" mirror.
- Server-location + operator framing (Germany, Hetzner, "operator").
- Retention defaults: 5-year measurements, 90-day audit, 30-day APNs.
- GDPR Art. 17 deletion route names "Konto loeschen", "Delete
account", "User.delete", "onDelete: Cascade" verbatim.
- Operator contact mailto:mbombeck@gmail.com + GitHub-issue fallback,
with the v1.4.20 PII rule still enforced (no "Marc-Andre" or
free-text "Bombeck <name>" anywhere in the body).
Bumps POLICY_VERSION constant to 1.4.40 + LAST_UPDATED to 2026-05-18.
Convert the English-only v1.4.26 privacy page to a bilingual document
that an Apple US App-Store reviewer and a German DPA inspector can both
read without locale switching. Each numbered section opens with the
German body (visible by default) and ships a collapsible English
translation directly underneath. The H1 and TOC carry both languages.
GDPR / DSGVO content additions on top of the v1.4.26 spine:
- HealthKit data flow disclosed explicitly: HK sample to iOS app to
POST /api/measurements over HTTPS, with no third-party transit.
- AI off-device transit boundary spelled out: TLS 1.3, off by default,
never includes raw HealthKit identifiers, per-provider opt-in.
- Consent receipt persistence linked to GET /api/account/consents with
the 5-year retention window.
- Encryption layer named explicitly: TLS 1.3, HSTS, certificate
pinning on iOS (Zertifikat-Pinning in the DE body).
- Server location framed as operator in Germany on Hetzner; no
third-party hosting beyond the AI providers listed in section 4.
- Retention defaults table: rolling 5-year measurement window, 90-day
audit logs, 14-day server access logs, 30-day failed APNs.
- GDPR Art. 17 deletion route named: Settings -> Daten -> Konto
loeschen with the User.delete + onDelete: Cascade contract.
- Operator contact via mbombeck@gmail.com with the GDPR subject-line
convention plus the GitHub-issue fallback channel.
- DSGVO legal-basis citations attached per data category (Art. 6 (1)
(b) and Art. 9 (2) (a) GDPR).
Bumped POLICY_VERSION to 1.4.40, LAST_UPDATED to 2026-05-18. Static
render kept (force-static, no revalidate) so the legal document does
not depend on JavaScript to display its non-default-locale text to a
reviewer.
Pins the SB-5 (apns time-sensitive interruption-level) and SB-6
(/api/notifications/status events map) contracts.
Note: the SB-6 route + test changes landed inside an earlier
`fix(dashboard-summary)` commit (1bcaae47) due to a cross-wave
pre-commit-hook race that auto-folded my staged changes; this
commit ships the wave report + records the proper SB-5 + SB-6
intent in the log for the release-notes sweep to pick up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`GET /api/measurements` has five distinct read shapes (per-day
drill-down, group-by-day, rollup-source coverage-fallback probe + fold
re-read, live `date_trunc` aggregate, default findMany + count). Each
relied on a shared `where` builder or a local `findMany` clause; none
filtered `deletedAt`. The `GET /api/measurements/series` adapter for
iOS read BP-SYS / BP-DIA / single-type series without the filter too.
Pin `deletedAt: null` on the shared `where` so the count + raw findMany
+ group-by-day mode all inherit it; add the filter to the dayKey
drill-down findMany, the coverage-fallback day-count probe, and the
live `date_trunc` aggregate; thread the filter through the three series
findMany calls. The `source=rollup` branch reads from
`measurement_rollups` so the writer-side filter covers it. No
`measurements/timeline` route exists yet — the file-set name was
prospective.
Pre-fix the dashboard mounted two separate consumers against the same
`/api/mood/analytics` endpoint: `app/page.tsx:291` keyed on
`queryKeys.moodAnalytics()` (`["mood-analytics"]`) and the chart at
`mood-chart.tsx:316` keyed on a bare `["mood-chart-data"]` literal.
TanStack treats different keys as separate cache slots, so a cold
dashboard mount fired two requests against the same endpoint — wasting
one HTTP round trip, one pg pool slot, and ~22 KB of payload per cold
mount (empirical-trace #3). Re-key the chart to `queryKeys.moodAnalytics()`
so TanStack dedups the request; align the chart's `staleTime` to the
dashboard's 60 s window so a route swing back to the dashboard within
a minute stays a free cache hit.
The chart's own subscription becomes the cache writer for any insights
/ mood sub-page that mounts it without the dashboard parent having
fetched first. No payload-shape change — the chart already typed its
response as `MoodAnalyticsData` which matches the dashboard's
`{ entries, summary }` shape byte-for-byte.
`latestIn7d` and `streakDays` are raw SQL against `measurements`; the
`allTime` aggregate is `prisma.measurement.groupBy`. All three folded
tombstoned rows into the tile-strip's latest value, the all-time count
+ last-seen-at metadata, and the measurement-streak day count. The
`sparkBuckets` query reads from `measurement_rollups` so the writer-side
filter covers it.
Add `m."deleted_at" IS NULL` to the two raw queries and `deletedAt: null`
to the Prisma groupBy.
Medication reminders are the one notification category the user has
explicitly opted in to. iOS 15+ supports `aps.interruption-level:
time-sensitive` to break through Focus modes per category, gated on
a per-app toggle the user grants on install. Wire it for
MEDICATION_REMINDER only — every other event (mood nudges, anomaly
alerts, PRs, system messages) stays on the iOS default `active`
level so Focus modes including Sleep keep silencing them as the user
expects.
Pairs with `apns-priority: 10` for time-sensitive deliveries per
Apple's push best-practices doc; without the priority pairing APNs
may downgrade delivery and silently lose the Focus-bypass intent.
Pinned by 8 new unit tests across `apns.test.ts` — one positive case
for MEDICATION_REMINDER and a parameterised negative covering all
six other event-types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route's central analytics aggregator runs eight `FROM measurements`
clauses (window_stats CTE + main aggregate + DISTINCT ON latest +
firstAt probe, mirrored across the rollup-fresh and cold-fallback
branches) plus one `findMany` for the BP raw rows. None filtered on
`deleted_at`, so the Coach/Insights snapshot would see tombstoned
readings in every windowed avg, slope, anomaly count, and latest
attribution.
Add `AND m."deleted_at" IS NULL` to the eight raw SQL filters and
`deletedAt: null` to `fetchBpRawRows`. The `measurement_rollups`
findMany branch already rides the writer-side filter.
Every chunked findMany walk in the three analytics fast-paths fed the
raw `measurements` table through Prisma without a `deletedAt` filter.
The correlation pairing, BP-in-target windowing, and Health Score
weight / BP source-attribution would each fold tombstoned rows once
iOS soft-deletes its first reading.
Add `deletedAt: null` to:
- `fetchSeriesChunked` in `correlations-fast-path.ts`
- `fetchSeriesChunked` in `bp-in-target-fast-path.ts`
- the WEIGHT source attribution + WEIGHT live-fallback + BP_SYS
source attribution reads in `health-score-fast-path.ts`
The DAY-bucket reads from `measurement_rollups` are unaffected — they
ride the writer-side filter landed earlier in the chain.
The four `$queryRaw` blocks in `computeFromRollups` and
`computeFromLiveAggregate` (narrow aggregate + DISTINCT ON latest,
both fast and cold paths) folded every row regardless of
`deleted_at`. The DAY-bucket rollup aggregate is unaffected because
its source table is already filtered by the writer.
Add `AND m."deleted_at" IS NULL` to every `FROM measurements m`
WHERE clause so the slim slice's avg7/avg30/slope/r2 plus the
per-type latest never reflect a row the user soft-deleted.
`probeRollupCoverage` discovers the set of types the user has logged
by selecting `DISTINCT "type" FROM measurements`. A type whose every
row has been tombstoned would still surface, force the read path to
demand rollup coverage for it, and silently push the request onto the
live aggregator branch.
Add `"deleted_at" IS NULL` so the discovery only returns types the
user can still see. The rollup-read modules (`rollup-read`,
`rollup-read-wmy`, `rollup-read-cumulative`) read from
`measurement_rollups` which is already populated from the filtered
writer in the previous commit, so they need no additional filter.
The `runRollupAggregate` SQL folded every row in `measurements`
regardless of `deleted_at`. Once iOS soft-deletes its first reading
through the by-external-ids path, the tombstoned value would still
contribute to the bucket count / mean / min / max, leaving dashboards
and analytics showing data the user thinks they removed.
Add `AND m."deleted_at" IS NULL` to both branches of `runRollupAggregate`
plus the watermark probes in `isRollupFresh` and `ensureUserRollupsFreshImpl`
so a stale `updatedAt` on a tombstoned row no longer keeps the rollup
flagged as stale forever. The boot-time backfill discovery is widened
the same way so deleted-only `(user, type, day)` tuples never enqueue.
Defense-in-depth: every reader will pick up the filter in follow-up
commits — this lands the writer-side cut so newly-folded buckets are
already clean.
Two regression nets for the W-POOL changes shipped in the prior two
commits:
* `src/lib/__tests__/db.test.ts` — assert `getPoolMax()` returns 20 by
default and honours `DATABASE_POOL_MAX`, with a final assertion that
pins the floor at 20 so a future refactor that drops the explicit
`max:` config can't silently revert to the library default of 10.
* `src/app/api/analytics/__tests__/route.test.ts` — instrument the
per-type `findMany` mock with inflight counters and assert peak
concurrency never exceeds `ANALYTICS_TYPE_FETCH_CONCURRENCY`. The
wall-clock window also catches both an unbounded `Promise.all`
regression (would finish too fast) and an accidental serialisation
(would finish too slow).
Five assertions on the route handler:
- 200 on an unauthenticated GET.
- Content-Type is exactly application/json with no charset parameter
(Apple's swcd/aasa-validator refuse a charset and the failure mode
is silent — Universal Links just stop firing).
- Cache-Control allows public CDN mirroring.
- JSON shape matches the SB-4 spec exactly.
- applinks App ID matches webcredentials App ID so a rotated
Team ID never half-lands.
Includes the v1.4.40 phase report.
Populate applinks.details with the iOS App ID and the "*" path matcher so
Universal Links resolve to the HealthLog app on every URL. The existing
webcredentials entry stays in place; both now share a single AASA_APP_ID
constant to keep passkey origin-pairing aligned with deep linking.
Unblocks iOS PB30 (App-Store submission).
The v1.4.39 empirical cold-mount trace
(`.planning/round-v1439-empirical-trace.md` §B2) flagged the library
default of 10 concurrent connections as the hard ceiling that thick
/api/analytics was monopolising. With the parallel W-POOL p-limit(4)
cap on the analytics fan-out, a 20-slot pool leaves ≥16 free slots for
every other dashboard query regardless of how many measurement types
analytics is mid-walk over. Postgres' stock `max_connections` of 100
gives ample headroom for ≤5 concurrent power-users at this ceiling.
Env-overridable via `DATABASE_POOL_MAX` so operators with a smaller
server-side `max_connections` can dial down without a code change.
Wrap the per-type fan-out in `buildAnalyticsResponse` with a p-limit(4)
gate. The empirical v1.4.39 cold-mount trace
(`.planning/round-v1439-empirical-trace.md` §B1) showed thick
/api/analytics holding ≥8 of the default-10 pg.Pool slots for 6.5 s on a
power-user account, starving every other dashboard query. The cap keeps
the same total work but holds ≤4 pool slots for this branch at any
moment, leaving the remaining slots free for the parallel chart-tile
reads. Thick analytics is already 60 s cache-wrapped so the small
absolute wall-clock cost on a single cold mount is offset by the
chart-tile burst paying ~5 s less of pool-wait time on every mount
thereafter.
Prereq for SB-10 (POST /api/consent/ai). Append-only per-user audit
trail of AI-processing consent grants. The iOS app posts a fresh row
whenever the user opts in to off-device AI flows during onboarding or
via Settings; a revoke + re-grant cycle mints fresh rows so the
timeline reads chronologically and the latest non-revoked row per
(user, kind) is the source of truth for "is AI active right now".
Backs both App-Store Guideline 5.1.2(i) ("data-collection consent must
be verifiable") and the GDPR Art. 7 documentation burden. The server
persists the raw signed artefact (PDF receipt or signed JWT — caller's
choice) without parsing it; the audit trail is the storage itself plus
the `signed_at` wall-clock the client recorded.
Migration 0074 is additive only; IF NOT EXISTS guards mirror 0067 /
0070 / 0071. No table rewrite, no breaking change.
The W-SERVER-FIX-2 race-guard work added `skipDuplicates: true` to the
intake route's projection-backfill createMany alongside the schema
@@unique([userId, medicationId, scheduledFor, source]) constraint. The
existing unit test asserted the row shape but not the flag, so a
regression that drops `skipDuplicates: true` while keeping the unique
constraint would surface as a 5xx in prod (constraint violation
bubbles through) without breaking the suite.
Adds the flag to the `toMatchObject` assertion so the defense-in-depth
contract stays pinned next to the row shape it pairs with. Matches the
equivalent assertion the dashboard summary test already carries.
Five planning artefacts produced during the v1.4.39 review pass:
frontend, ghosts (dead-code drift), infra-db, organization, and a
cross-cutting empirical trace. Kept under .planning/ rather than the
versioned docs site because they are working notes scoped to a single
release window.
Migration 0073 adds a UNIQUE constraint on
(user_id, medication_id, scheduled_for, source) for
medication_intake_events. Pre-fix the table only had an INDEX on those
three columns, so the two newly-projecting routes — the intake route
(PR #191) and the dashboard summary route (v1.4.39.4) — could pass
their existence probes simultaneously and mint the same REMINDER row
twice when the iOS app hydrates both endpoints in parallel.
`source` is in the key so the reminder worker's REMINDER row doesn't
collide with manual user inserts (WEB / TELEGRAM / IMPORT) on the
same slot — distinct intake sources at the same scheduled instant are
a legitimate shape (Telegram-bot logged take + a Web-UI correction on
the same dose).
The legacy (user_id, medication_id, scheduled_for) index stays in
place because most reads filter on those three columns without
caring about source. The new constraint becomes the structural
backstop; the createMany skipDuplicates: true flag added in the two
routes is the defense-in-depth layer.
Additive only. DO $$ … EXCEPTION-on-duplicate guard mirrors 0067 /
0071 so re-runs are idempotent.
PR #191 added a today-window backfill to /api/medications/intake. The
v1.4.39.4 dashboard symmetry fix wires the exact same projection into
/api/dashboard/summary, which means two parallel routes can now race
the same (userId, medicationId, scheduledFor, REMINDER) row between
their existence probes and createMany calls. The @@unique on
(userId, medicationId, scheduledFor, source) is the structural
backstop; without skipDuplicates: true the loser of that race would
surface a constraint violation as a 5xx.
Pair the createMany with skipDuplicates: true so Postgres swallows the
rejected rows and both routes still return 2xx. Matches the same flag
the dashboard route ships with.
Dashboard tile feeding the iOS Compliance ring + Erfassen-sheet preview
pulls from /api/dashboard/summary, not /api/medications/intake. Without
the same projection-then-backfill the intake route received in PR #191,
the dashboard endpoint returned [] for daily meds (daysOfWeek = null),
keeping the iOS Dashboard tile in its "Heute nichts geplant" empty
state even after the intake route fix shipped.
Re-uses expandTodayIntakes(schedules, now, tz) + an idempotent
createMany with skipDuplicates: true so a concurrent intake-route hit
can't race a duplicate row in before the existence probe converges.
Two tests added: backfill-mints-once + idempotent-on-second-pass.
Schedule-expansion never minted today's intake rows for medications
with `daysOfWeek: null` until the reminder worker entered the RED
phase at the end of the dose window. The database stores `null` to
mean "every day" (the `MedicationSchedule.daysOfWeek` column carries
that annotation in `prisma/schema.prisma`), so daily meds spent the
whole morning with zero scheduled rows. Endpoint
`/api/medications/intake?scope=today` returned `[]` even when two
active daily meds existed; iOS Dashboard tile + Erfassen sheet both
fell to "Heute nichts geplant" / "Keine offene Einnahme" end-to-end.
Adds `expandTodayIntakes` to `src/lib/medication-schedule.ts` — a
pure projection that re-uses the existing `parseScheduleRecurrence`
(empty array == every day) and respects explicit weekday filters
when present. The `scope=today` route projects active schedules
through the helper, then idempotently backfills any missing rows
into `MedicationIntakeEvent` so the iOS POST update path keeps the
existing intakeId contract.
Tests:
- `medication-schedule.test.ts` gains eight cases covering
`daysOfWeek: null`, `""`, `"0,1,2,3,4,5,6"`, explicit-weekday
excludes-today / includes-today, the operator's Ramipril +
Metformin fixture, DST-correct `scheduledFor` anchoring at
`windowStart` in Europe/Berlin, and multi-week cadence skip.
- `intake/route.test.ts` gains two cases: backfill mints the
missing row + does-not-double-mint when the row already exists.
Full vitest suite stays green (4672 passed / 1 skipped).
Closes the eight e2e CI failures left behind by v1.4.39.2 across the
chromium-desktop and chromium-mobile profiles. Two underlying bugs +
one durable test-fixture migration:
- Dashboard slim/thick merge no longer short-circuits when the slim
slice resolves with an empty summaries record. The merge moved to
a pure `mergeSlimAndThickAnalytics` helper with object-emptiness
as the discriminator and eight new behaviour-level unit tests.
- `MeasurementList` non-grouped rows now preserve decimal precision
(`fmt.number` instead of `fmt.integer`) so a 78.4 kg reading stops
truncating to "78 kg". Grouped daily aggregates for cumulative HK
types stay on `fmt.integer` because their readings are integer-
only by definition.
- Playwright route mocks for `/api/analytics` migrate to the regex
form across every authenticated spec so the slim and thick slice
URLs intercept under one handler, matching the v1.4.37 W-CI lesson.
No schema migration; runtime + test-fixture-only.
Playwright's literal `**/api/analytics` string glob does not match
the sliced URL form `/api/analytics?slice=summaries` the v1.4.39.2
dashboard split fires alongside the thick request. The slim request
fell through to the real route on every authenticated spec, and
because the seeded e2e user carries no measurements, the real slim
slice returned empty summaries — exactly the case the v1.4.39.3
production merge fix already guards against, but the regex form is
the durable alignment with the v1.4.37 W-CI lesson that already
swept `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`.
Ten specs migrate: dashboard, chart-overlay-controls,
measurement-flow, charts-mobile, a11y, v1427-responsive-sheet,
v1427-coach-launch, v1427-measurements-add-param,
v1427-insights-empty-state, onboarding-tour-passthrough. Every spec
now uses `/\/api\/analytics(\?|$)/` so the slim AND thick slice URLs
intercept under one route handler.
The v1.4.37 W7c collapsed list view passed every value through
`fmt.integer`, which kept the per-day step / activity aggregates
correct but silently truncated single weight / body-fat /
body-temperature readings — 78.4 kg rendered as "78 kg" on the
desktop table and the mobile card grid.
Grouped rows (`isGrouped === true`, the daily aggregate for
cumulative HK types) keep `fmt.integer` because the underlying
readings are integer-only by definition. Non-grouped single readings
now use `fmt.number(value)` which honours up to three fraction
digits without forcing a minimum, so 78 stays "78" and 78.4 renders
as "78.4" / "78,4" depending on the active UI locale.
The v1.4.39.2 dashboard split fired two parallel useAnalyticsQuery
calls and merged them with `slim?.summaries ?? thick?.summaries`.
JavaScript treats an empty `{}` as truthy, so a zero-data slim
resolve short-circuited the thick fallback even when thick carried
the full per-type payload — the tile strip painted blank and the
chart row never mounted. Extract the merge into a pure
`mergeSlimAndThickAnalytics` helper that uses object emptiness as
the discriminator: populated slim still wins on overlapping fields
(progressive-paint contract preserved), empty slim falls through to
thick, both empty yields an empty record so the dashboard's data-
floor gates render the appropriate empty state.
Eight new behaviour-level unit tests cover the empty-slim, slim-
only, thick-only, both-empty, and `lastSeenByType` precedence cases.
The earlier source-text snapshot test in
`dashboard-analytics-split.test.ts` did not detect the empty-object
short-circuit; the pure-helper extraction is what makes the
regression directly testable.
Per-request rollup convergence on coverage miss + progressive
dashboard paint via slim/thick analytics split. Closes the two
post-deploy follow-ups Marc surfaced from the v1.4.39.1 trace.
Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the
thick `/api/analytics` envelope. Every per-type tile on the dashboard
strip waited on the heavy fan-out before paint. Mood and medication
tiles arrived first (separate routes with their own dedicated 60 s LRU
caches); every other tile then arrived as one delayed burst once the
full slice landed. Marc reported the "blocked-then-burst" pattern as
"etwas nervig" in the v1.4.39.1 post-deploy trace.
The dashboard now mounts two `useAnalyticsQuery` calls in parallel:
one for `?slice=summaries` (per-type tile strip + last-seen freshness
map) and one for the thick envelope (BD-Zielbereich + glucose tiles).
The tile strip paints as soon as the slim slice resolves; the
BD-Zielbereich and glucose tiles stream in afterwards from the thick
slice. TanStack's parallel-mounting keeps the network fan-out flat,
and both queries share the `caches.analytics` 60 s LRU server-side so
warm hits stay free.
The `data` object the rest of the dashboard consumes stays shape-
stable: a `useMemo` merges the two query results with slim winning on
overlapping fields (slim and thick compute `summaries` /
`lastSeenByType` from the same SQL helpers, so the values agree). The
thick-only `bpInTargetPct*` and `glucoseByContext` fields read from
the thick slice and surface as `null` / `undefined` until the thick
query resolves; the BD-Zielbereich tile's data-floor gate already
suppresses the tile cleanly in that interim state.
A text-anchored test pins both query mounts so a future refactor cannot
silently collapse the dashboard back onto the thick-only path.
v1.4.39.1 wired the rollup write hook into the previously-bypassed
Withings sync, /api/import, and admin-restore paths, and widened the
boot-time backfill discovery to per-(user, type, day). Together those
changes ensure new writes and post-deploy restarts converge the rollup
tier for every measurement type the user has logged. They do NOT,
however, guarantee convergence inside a single request when the boot
backfill has not yet finished folding the user's historic rows: a chart
query lands first, the rollup table still carries 2 rows for a 30-day
BP_SYS window, and the route's pre-fix "rollup wins on length > 0"
short-circuit returns the sparse rollup. The chart trips its `< 3 daily
points` empty-state even though `measurements` holds the full 30 days
(Marc reported the regression from the v1.4.39.1 post-deploy trace).
This commit closes the gap. When the rollup row count is suspiciously
low for the requested window (>= 7 days asked and < 3 rollup rows
present), the route now probes `COUNT(DISTINCT date_trunc('day', ...))`
against `measurements` for the same window. If the live table holds
strictly more days than the rollup, the route folds the
(user, type, DAY, [from, to]) partition inline via
`recomputeUserRollups` and re-reads the rollup so the request returns
the converged rows. The chart paints the full window on the same
request without paying the cost on subsequent requests because the
inline fold leaves the rollup partition converged.
The probe is gated on rollup sparsity so the covered-tenant hot path
stays a single indexed read; the inline fold is bounded by the window
and one measurement type so it stays small (a 30-day BP_SYS window is
one 30-row upsert against the composite primary key). The fallback
surface annotates `meta.coverage_fallback: true` on the response so
ops can spot the convergence path in production wide-events.
Four new tests pin the four shapes:
- under-coverage triggers the inline fold + re-read
- covered window keeps the single-read hot path
- short window (< 7 days) skips the probe entirely
- live + rollup agree (genuine sparsity) returns the sparse rollup
Hotfix on top of v1.4.39. Three measurement write paths (Withings
sync, `/api/import`, admin backup restore) were bypassing the
persistent rollup tier's write hook, so the dashboard chart's
`source=rollup` fast-path painted the "Noch nicht genug Daten"
empty-state at the 30 / 90 range on accounts whose recent data only
came through those paths. The 7-day window kept rendering because the
chart's raw fetch never consults the rollup tier.
This release wires the write hook into every remaining ingest path,
re-anchors the boot-time backfill discovery to per-day coverage so
already-deployed instances re-converge on the next worker boot, and
re-anchors one date-bound test in the medications-intake route suite
so the green CI run no longer depends on the wall clock.
Tests 4647 → 4648 passing; one long-standing skip unchanged.
The v1.4.39 W-MED route test hard-coded a `2026-05-18` rollup row and
expected it to land at `body.data[body.data.length - 1]` (today). The
test ran green on the day the marathon shipped, then started failing
on every subsequent day because the trailing-7-day window drifted off
the seed. Deriving the seed key from `new Date()` keeps the assertion
date-stable across the calendar without weakening the parity check.
The pre-fix boot-time discovery used a `(user, type)` LEFT JOIN
against `measurement_rollups`: an account surfaced only when the
ENTIRE type had zero rollup rows. Once any single day for that type
had a row (e.g. from a prior boot backfill), the user dropped off the
discovery list, even if 27 OTHER days for the same type were missing
rollup rows because the Withings sync or `/api/import` path had
written measurements without firing the rollup hook.
Switching the inner DISTINCT to `(user_id, type, bucket_start)` and
joining on `r.bucket_start = mt.bucket_start` surfaces every per-day
gap. Cost: the aggregate grows from ~15 rows / user to ~5 k rows / user
on a 5-year power user — still bounded by the existing
`(user_id, type, measured_at)` index path and the rollup table's
composite primary key. The legacy `sum_value IS NULL` branch stays
on the same UNION so v1.4.39 write-tier converges continue working.
A new text-anchored unit test pins the per-day join shape so a future
refactor cannot silently revert to the per-type form.
Both ingest paths wrote into `measurements` without firing the rollup
write hook, leaving the dashboard chart's `source=rollup` fast-path
blind to the imported days. The mood / medication-compliance rollup
tiers already had post-batch hooks on these routes; the measurement
tier had been the gap.
- `/api/import`: tracks every touched `(type, measuredAt)` written
during the batch, collapses to distinct `(type, day)` via
`collapseToTypeDayKeys`, and folds once at the end. A 10 000-row
CSV restore now pays at most ~N (type, day) recomputes rather than
10 000 per-row hooks.
- Admin backup restore: the transaction wipes the user's
`measurement_rollups` partition alongside the existing
`mood_entry_rollups` wipe, and the post-transaction step kicks a
`recomputeUserRollups(ownerId)` so the restored dataset paints
fresh tiles instead of carrying the previous owner's daily means
forward.
- Best-effort posture preserved in both paths: a populator hiccup
surfaces via `annotate` but never undoes the restore / import.
Test coverage extends the existing `/api/import` route suite with a
regression that pins the per-`(type, day)` collapse and a duplicate-
write case (`P2002`) that proves the hook is skipped when nothing
was actually written.
Three Withings ingest paths wrote measurements without ever firing the
rollup write hook: `syncUserMeasurements`, `syncUserActivity`, and
`syncUserSleep`. Once the dashboard chart's `source=rollup` fast-path
landed in v1.4.36 the under-counting started biting Withings-only
metrics on the 30 / 90-day range — Marc's BP / pulse charts painted
the empty-state at 30 even though the live `measurements` table held
the days. The 7-day window kept reading raw rows so the empty-state
only appeared above the chart's `< 3 daily points` threshold.
Each sync now collects every `(type, measuredAt)` it touched, collapses
to distinct `(type, dayStart)` via `collapseToTypeDayKeys`, and calls
`recomputeBucketsForMeasurement` once per pair after the row writes.
The per-day collapse keeps a 30-day catch-up bounded — at most ~30
DAY recomputes per sync rather than one per row. Best-effort: a
populator hiccup never fails the user's sync.
- Tests pin the hook call count on activity (6 calls for 2 days × 3
metrics, 3 calls when the dates collapse) and sleep (one per night
even when each night has multiple stage segments), plus a populator-
failure path that proves the sync still returns its imported count.
- Reproducer verified: stashing the sync-activity hook makes the new
test red on develop; restoring it greens.
Closes the audit-marathon closure series with rows for v1.4.38.5
through v1.4.38.8 (the four-hotfix chain that landed between v1.4.38.4
and v1.4.39) plus v1.4.39 itself.
Adds round-v1439-perf-verify.md anchored on the live deploy
(commit hash d84922cd, deploy uuid ujgsxpa46esyzybjldgxjeox) and the
public-surface 10x burst median 86 ms. Authenticated heavy-endpoint
verification awaits the first browser session against the live deploy.
Mood, medication-compliance, and cumulative-sum rollup tiers extend
the v1.4.38 fast-path posture to two more endpoints that still walked
the source table on every cold mount.
Raw measurements / mood_entries / medication_intake_events tables are
byte-unchanged. Three new tiers are derived caches that self-heal via
boot-time backfill on first reach.
Expected wins:
- /api/mood/analytics 12.7 s -> ~200 ms
- /api/medications/intake?scope=compliance 3.2 s -> ~200 ms
- /api/dashboard/summary ~500 ms -> ~300 ms
- /api/analytics live-fallback 347 k rows -> ~5 k rows (425 d cap)
Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean.
QA F-H-01 (v1.4.39) tightened `hasMedicationComplianceCoverage` to a
`$queryRaw` aggregate; the route test mocks were still calling
`prisma.medicationComplianceRollup.findFirst`. Switch the mocks to the
new probe shape — `{ rolled_days, event_days }` — and add a
`$executeRaw` mock for the atomic upsert path that QA F-H-02 introduced.
The covered-vs-coverage-miss cases pin the rollup-tier and live-fallback
branches under the new probe semantics.
Six QA reviewers (senior / security / specialist / ux / product-lead /
simplifier) ran on the v1.4.39 diff; their finding docs land here for
traceability. The v1.4.40 backlog captures every Medium + Low the
reconcile deliberately deferred — F-SEC-M-02 RLS (v1.5), F-M-01
long-window consumer (v1.5), F-M-02 unused tx parameter, F-M-03 partial
index post-convergence, F-M-05 RollupBucketRow consolidation, F-M-06
per-agent worktree process note, UX-M1-3 compliance + mood polish,
Specialist-M01 UNION arm cleanup, Simplifier dead-exports + duplicate
tz helpers, plus low-severity observability polish. UX-M4 is marked
obsolete (Specialist-H2 widen closed it).
QA Simplifier (v1.4.39): the cumulative-vs-spot sparkline branch in
the `sparkByType` loop was a nested ternary
(`useSum ? (row.sum_value !== null ? sum : mean * count) : mean`).
CLAUDE.md bans nested ternaries; the fix hoists the cumulative branch
into a named local so the outer ternary is single-level.
QA F-M-04 (v1.4.39): the wall-clock-anchored seed planted rows from
`nowMs` backwards, packing 6 000 rows × 15 min = 62.5 days starting
from "right now". Once the route's live-fallback cap is in effect,
the seed slides past the cap as time passes; under the old 90-day
cap the failure threshold was 28 days post-seed. Re-anchor the youngest
row to `nowMs - 30 * 86_400_000` so the seed always lives ~30-92 days
ago regardless of when CI runs the test.
QA F-SEC-M-01 (v1.4.39): the route's coverage-miss fallback was firing
`enqueueBootTimeMedicationComplianceBackfill` on every authenticated
hit, running a cluster-wide `LEFT JOIN medication_intake_events x
medication_compliance_rollups` discovery scan in the request path. On a
multi-tenant cluster with millions of intake events the planner walked
all of them per request — a soft DoS amplifier. Split into two helpers:
`enqueueUserMedicationComplianceBackfill(userId)` for the request path
(one targeted enqueue, no discovery scan) and the existing cluster-wide
discovery stays for the worker-boot path. Mirrors the mood-rollup
`ensureUserMoodRollupsFresh` posture. Pinned with a unit test that
asserts the user-scoped helper never issues `$queryRaw`.
QA Specialist-H2 (v1.4.39): the original 90-day cap on the per-type
live-fallback loop in `/api/analytics` silently nulled `summarize().avg30LastYear`
(year-ago tile) on every fall-through path because the window stopped at
day 90 and the year-ago aggregate reads days 365-395. Widened the cap to
425 days (365 + 30 for the year-ago bucket span + 30 buffer) so the
year-over-year comparison tile stays populated on cold mounts. The slim
slice and rollup-backed comprehensive aggregator already operate on
tighter windows so this only affects the legacy live-fallback path the
fast-path coverage gate misses. Adjusted the W-SINCE test that pinned
the 90-day cap and updated the route's `meta.analytics.bp_aggregate.live_since`
annotation rationale.
QA Specialist-H1 (v1.4.39): the wave-report noon-anchored parity tests
miss the DST-boundary case where the UTC-anchored rollup day-key and the
user's local wall-clock day-key diverge. Pinned with a route-parity test
that logs at `2025-10-25T23:30:00Z` (Berlin local `2025-10-26` 00:30,
post fall-back) and asserts the rollup tier emits `2025-10-25` —
documents the UTC-anchor trade-off the route's header comment now spells
out. v1.5 per-user-tz bucketing closes the gap; until then this is the
contract.
QA UX-H1 (v1.4.39): the rollup fast-path feeds `summarize()` one DataPoint
per calendar day (the rollup's `mean`); the live-fallback used to pass every
raw entry, which silently shifted `summary.count / latest / min / max / mean
/ avg7 / avg30 / slope30` on multi-entry days. Pre-aggregating the
live-fallback through `aggregateDailyAverages` before `summarize()` keeps
both branches byte-identical on multi-entry days too. Pinned with a
two-entries-on-the-same-day parity test that compares the live-fallback
and rollup-tier summary fields directly.
QA F-H-02 (v1.4.39): the read-aggregate-then-upsert pattern in the mood
and medication-compliance DAY recomputes could interleave concurrent
writes for the same (user, day) as A-SELECT -> B-SELECT -> B-UPSERT
(correct N) -> A-UPSERT (stale N-1), leaving a stale row until the next
write. Replaced both with a single SQL INSERT ... SELECT ... ON CONFLICT
DO UPDATE that re-aggregates inside the upsert subquery so each statement
commits with a snapshot taken after the prior commit released its row
lock. A paired DELETE statement handles the "all entries removed" case
in the same flow, gated on NOT EXISTS so it cannot clobber a row the
INSERT just wrote. Pinned both helpers with a Promise.all race test that
asserts both callers go through the atomic path and never fall back to
the legacy read-aggregate-upsert.
QA F-H-01 (v1.4.39): the previous "any row exists" probe would short-circuit
to the rollup path while only the first few days of a multi-medication
account had been backfilled, exposing zero-filled tiles for the un-rolled
days until the boot fold completed. The fix compares the count of DISTINCT
rolled days against the count of DISTINCT days that hold an intake event in
the trailing window via a single SQL aggregate; partial coverage now falls
through to the legacy aggregator until the fold catches up. Pinned with a
new unit test covering the partial-coverage case alongside the zero-events
and full-coverage cases.
Registers `medication-compliance-full-backfill` on pg-boss alongside
the existing `rollup-full-backfill` and `mood-rollup-full-backfill`
queues. The worker boot fires a discovery query that enqueues one
full-fold per user with intake events but no rollup coverage —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. The reminder worker's
RED-phase mint path also wires the per-write rollup hook so freshly
minted `takenAt:null` rows bump the per-day `scheduled` count before
any read sees the user's compliance tile. Legacy accounts pre-dating
the v1.4.39 deploy auto-converge on the persistent tier without
operator action.
Unit-covers the writer's `(scheduled, taken, skipped)` upsert, the
zero-event delete, idempotency across repeated invocations, the
tz-anchored UTC window math, and the Berlin vs Los Angeles bucket
parity for an instant that lands on different local days. Reader
tests pin the trailing-window zero-fill plus the per-medication
fold-to-day. Boot-backfill discovery tests pin the singleton-keyed
enqueue. Route tests extend the existing harness with the new
coverage probe — rollup-tier read on coverage, live fallback on
miss — without touching the byte-stable response shape.
Swaps `/api/medications/intake?scope=compliance` onto the rollup
reader behind a coverage probe — a user with intake events but no
rollup rows still gets a correct response from the legacy live
aggregator and triggers the boot backfill in the background so the
next request lands on the rollup path. Every write site that mutates
`MedicationIntakeEvent` (POST + PUT + DELETE + per-medication import,
bulk batch, telegram callbacks, external API ingest, admin restore)
now fires `recomputeMedicationComplianceForEvent` for the affected
user-day. Bulk + import collapse the per-row hooks to one recompute
per distinct `(medicationId, dayKey)` so a 500-row ingest still costs
a bounded fold. Purge wipes the rollup partition alongside the
intake-event deleteMany. Eliminates the 3.2 s cold mount the
v1.4.38 perf analysis attributed to the unbounded event findMany.
Materialises the persistent medication-compliance rollup tier behind
`MedicationComplianceRollup`. Per-`(userId, medicationId, day)` row
where `day` is `YYYY-MM-DD` anchored to the user's IANA timezone.
`recomputeMedicationComplianceForDay` upserts the row from the events
inside the local-day UTC window; `readMedicationCompliance` returns a
zero-filled trailing-window bucket array matching the legacy
`buildComplianceBuckets` shape so consumer swaps are byte-stable.
`hasMedicationComplianceCoverage` powers the route's coverage probe
and `recomputeUserMedicationCompliance` is the full-fold the worker
boot backfill calls.
Unit tests pin the per-write hook contract (DAY upsert + WEEK/MONTH/
YEAR enqueue + delete-on-empty + idempotency under re-run), the
warm-up helper (no-op on fresh / dedup on concurrent / annotate on
populator failure), and the boot-time discovery enqueue. Route
tests pin the parity contract: identical entries + summary shape
between the rollup fast path and the live-fallback walk, plus the
LRU warm-read invariant.
Registers mood-rollup-recompute (per-bucket WEEK/MONTH/YEAR fold)
and mood-rollup-full-backfill (boot discovery) onto pg-boss. The
worker boot now fires a discovery query that enqueues one full-fold
job per user with mood entries but zero mood_entry_rollups rows —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. Legacy accounts
pre-dating the v1.4.39 deploy auto-converge on the persistent tier
without operator action.
Captures the cumulative-metric sum_value rollup work — writer +
boot-backfill discovery widening + consumer swaps in
`/api/measurements?groupBy=day` and `/api/dashboard/summary` +
new `rollup-read-cumulative.ts` helpers. Documents the
`/api/analytics` A2 skip-cumulative deferral to v1.4.40 (file
owned by W-SINCE in this phase) and the per-source W8c deferral
to v1.5. Surfaces the cross-agent commit-message drift recurrence
for the marathon retrospective.
The /api/mood/analytics cold mount walked every MoodEntry the user
had ever written into Node only to bucket-average in JS. Reroutes
the read through readMoodDayRollups which returns one row per
calendar day from the persisted tier, and wires the per-write hook
into every mood mutation site so the rollup stays current:
- POST/PUT/DELETE /api/mood-entries — inline recompute
- POST /api/mood-entries/bulk — collapsed to per-day fan-out
- POST /api/integrations/moodlog/webhook — inline recompute
- syncMoodLogEntries — bounded re-fold after batch sync
- DELETE /api/settings/moodlog + DELETE /api/settings/data —
drop the user's rollup partition alongside the entries
- admin restore + import — re-fold after the bulk write
Coverage-fallback mirrors summaries-slice: when the rollup count is
zero but raw mood entries exist (legacy account before the boot
backfill catches up) the route falls through to the legacy live walk
once and the warm-up helper fires asynchronously so the next request
lands on the fast path.
The per-type loop inside `buildAnalyticsResponse` calls
`fetchMeasurementSeriesChunked` without a `since` filter, so a single
default-slice request pulls every measurement ever for the user across
15 chunked rounds. The v1.4.38.8 per-type fast-path gate made this
unreachable in the common case, but a regression on those gates would
re-trigger Marc's 74.6 s cold full-slice mount.
- Add `ANALYTICS_LIVE_WINDOW_DAYS = 90` + `liveSince` constant.
- Thread `since: liveSince` into every per-type chunked read.
- Annotate `meta.analytics.bp_aggregate.live_since` (ISO) so the next
perf-verify can prove the cap is in effect on a live request.
Defense-in-depth only — the rollup fast-path is unchanged because it
never reaches the per-type loop. Worst-case bound drops from ~347 k
rows to ~5 k rows on Marc's tenant (matching the
comprehensive-aggregator + summaries-slice 90-day windows).
Trade-off: `summarize().avg30LastYear` returns null on the fall-through
path; the dashboard's year-over-year overlay shows "no prior data"
until the rollup tier warms. Acceptable on a degraded path that
v1.4.38.8 already made rare.
Tests: new `src/__tests__/api/analytics/since-cap.test.ts` pins the
90-day floor on every per-type findMany, pins the `live_since`
annotate, and pins that the slim slice is untouched. Existing chunked
paging integration test reseeded with timestamps inside the 90-day
window so it still exercises the two-chunk boundary.
Adds `computeLongWindowSummary(userId, type, windowDays)` — the
ready-to-use entry point for the v1.5 multi-year trend card and the
Coach drawer's "history" tile. Routes the requested window through
the largest granularity that still resolves it (90 d \u2192 DAY,
365 d \u2192 MONTH, 1095 d \u2192 YEAR) via
`readBestGranularityRollups`, then composes `count / min / max /
mean / sum` linearly from the bucket rows. SD / slope / r2 are
intentionally omitted because they do not compose across coarser
buckets.
The existing `computeSummariesSlice` shape is untouched — its
windowed columns cap at 90 d so the live SQL path is already
DAY-tier optimal. Wiring the all-time aggregate through YEAR
buckets would risk staleness on multi-year data because the
WEEK / MONTH / YEAR tiers update asynchronously via pg-boss while
DAY is kept fresh by the sync write-hook. Deferring that swap to
v1.5 (covered in the W-WMY phase report).
18 unit pins covering each reader's granularity filter, the
coverage-miss `null` return shape, the auto-router's window-to-tier
selection (90 d / 365 d / 1095 d), the fall-through chain on partial
coverage, and the linear-composition contract for count / min / max
/ mean / sumValue across coarser buckets.
Adds bounded findMany helpers for the WEEK / MONTH / YEAR rollup
buckets the writer has been minting since v1.5.0 but no reader
consults. `readBestGranularityRollups` auto-routes a window to the
coarsest granularity that still resolves it (90 d \u2192 DAY, 365 d
\u2192 MONTH, 1095 d \u2192 YEAR) with a fall-through to finer tiers
on coverage miss. `aggregateWmyBuckets` mirrors the DAY-side
compositional contract for count / min / max / mean and folds the
new `sumValue` column for cumulative-metric callers.
The sparkline aggregate row now carries `sum_value` (+ `count`)
alongside the bucket mean. Cumulative tiles — ACTIVITY_STEPS today,
every CUMULATIVE_HK_TYPES member tomorrow — render the daily SUM
rather than the daily MEAN, which previously understated the visual
trend signal by the per-bucket sample count.
Spot tiles stay on `mean`. Cumulative tiles fall back to
`mean * count` when the legacy NULL surfaces (pre-v1.4.39 buckets
waiting for the boot-time backfill to converge). The two pre-v1.4.39
$queryRaw select-list changes are additive and the cache wrap stays
unchanged.
`GET /api/measurements?source=rollup&aggregate=daily` previously
reconstructed cumulative daily totals from `mean * count`. The
v1.4.39 writer populates the new `sum_value` column on every fold,
so the read path consumes it directly. The legacy `mean * count`
arithmetic stays as the fallback for boot-backfill convergence
where existing rows still carry `sumValue = null`.
Algebraically equivalent (AVG = SUM / COUNT for a single-source
day), so this is a no-op for byte parity until the cumulative
single-source assumption breaks — at which point the bucket-level
SUM is the canonical reference rather than the reconstructed value.
The v1.4.39 schema scaffolding added `measurement_rollups.sum_value`
for the cumulative-metric daily totals (steps, flights, distance,
daylight, active-energy). Extends the rollup aggregator to also `SUM`
each bucket and persist the column for every (user, type,
granularity, bucket) row regardless of metric kind — spot metrics
carry the column for free because the SUM rides on the same
$queryRaw aggregate as AVG / MIN / MAX / STDDEV_POP / REGR_*.
Boot-time backfill discovery now unions in users whose existing DAY
rollup rows carry `sum_value IS NULL`. Re-folding converges those
rows because `persistRollupRows` always writes the new column on
upsert, and the union runs as one indexed pass alongside the per-
type missing-coverage discovery.
Adds `src/lib/measurements/rollup-read-cumulative.ts` with the new
`readCumulativeDaySums` / `readCumulativeDaySumsBatch` helpers
consumers use to swap off the per-type chunked findMany loop.
Helpers fall back to `mean * count` when the legacy NULL surfaces so
the chart never paints a hole during the boot-backfill convergence
window.
Audit anchor: .planning/round-v1438-perf-analysis.md §3 (cumulative
daily sums) + §5 P3.
Mirrors the measurement-rollups tier for the mood domain. Adds the
write-path hook (synchronous DAY upsert + async WEEK/MONTH/YEAR enqueue),
the bounded DAY reader, the warm-up helper, and the boot-time
backfill discovery. Closes the §2.3 finding in the v1.4.38 perf
analysis where /api/mood/analytics walked every MoodEntry on every
cold mount.
Three additive Prisma additions land in one commit so the v1.4.39 wave
agents (W-MOOD, W-MED, W-SUM) build against a stable generated client:
- `mood_entry_rollups`: per-(user, granularity, bucket) mood stats
(count / mean / min / max / sd). Reuses the existing
`measurement_rollup_granularity` enum.
- `medication_compliance_rollups`: per-(user, medication, day)
scheduled / taken / skipped triple. `day` is the user-tz-anchored
YYYY-MM-DD string so multi-instance reads do not re-derive boundaries.
- `measurement_rollups.sum_value`: nullable column. Cumulative-metric
read paths consume it directly; boot backfill folds NULLs on first
reach.
Migrations 0070 / 0071 / 0072 use IF NOT EXISTS + DO $$ EXCEPTION WHEN
duplicate_object guards (mirror 0061 / 0067 / 0069). No ALTER on
existing tables that downgrade row contracts, no DROP. Backward-safe
read paths land in the wave agents that follow.
Includes `.planning/round-v1438-perf-analysis.md` — the read-only audit
that anchors the wave plan.
Analytics fast-path restored. The boot-time rollup-backfill
discovery query was only matching users with ZERO rollup rows;
long-running accounts silently stranded the moment a brand-new
measurement type landed because their existing rollup buckets kept
them invisible to discovery while the new type's empty partition
kept `isFullyCovered(coverage)` at `false`. Every `/api/analytics`,
dashboard summary, and mood-analytics read then fanned out to the
live aggregator across the full measurement table — 30-75 s on
power-user accounts after every container restart.
v1.4.38.5 widens discovery to per-type missing coverage so the
boot-backfill queue picks up these accounts on the next deploy and
folds them.
See CHANGELOG.md.
Marc's account hit the slow-path live SQL aggregator on every read
after every deploy: `/api/analytics` cold-mounted in ~75 s,
`/api/mood/analytics` in ~13 s, `slim_summaries` annotated
`path:"live", row_count:347114`. Root cause traced to
`enqueueBootTimeRollupBackfill`'s discovery query:
WHERE NOT EXISTS (
SELECT 1 FROM measurement_rollups r WHERE r."user_id" = u."id"
)
The clause only matched users with ZERO rollup rows. Any account
that had ever been folded (every long-running user) silently
stranded once a brand-new measurement type landed: the existing
WEIGHT bucket kept the user invisible to discovery, but the new
type's empty DAY partition kept `isFullyCovered(coverage)` at
`false` forever. Every analytics / dashboard read then fanned out
to the live aggregator across the full measurement table.
Discovery now joins `(user, distinct type)` against
`measurement_rollups` on the `(user, type, granularity=DAY)`
partition and surfaces users where any partition is unmatched. The
v1.4.35.1 zero-rollup case is still covered (the LEFT JOIN of an
empty rollup table emits a row for every type the user has logged).
Integration test pins the partial-coverage regression: a user with
WEIGHT rolled up + a brand-new PULSE row now lands on the queue.
Pro-active stale-shell self-heal — Marc reported the live site
"nicht erreichbar" on his pre-v1.4.38.3 PWA shell, even though the
server returned 200 on every endpoint. The v1.4.38.3 reactive
reload only fires after the running app trips `ChunkLoadError`;
users sitting on a page with no lazy-load trigger never recovered.
v1.4.38.4 closes that gap with `<VersionPoller>` + SW
`CACHE_VERSION` per-release + `NEXT_PUBLIC_APP_VERSION` injection,
so every future deploy heals itself within 60 s of the user
opening the app.
- New `V054-SR-merge-deploy-report.md` walks the run end-to-end:
admin-merge of PR #190, six-axis QA findings + convergence,
v1.4.38.2 hotfix bundle (12 fixes), v1.4.38.3 CI green-up + the
AppError chunk-load auto-reload.
- Marathon closure table extended through v1.4.38.3 — twelve
consecutive releases between 2026-05-17 and 2026-05-18 capped by
the iOS-coord chain.
Trivial conflicts on package.json (version bump) and CHANGELOG.md
(new release block); both resolve cleanly by taking the main side.
Keeps develop and main aligned for the next feature cycle.
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped: locale resolver demoted 4 of 6 locales to
English, ledger committed before delivery, daily-22:00 timestamp
drifted by an hour on DST days, APNs payload forwarded Telegram
keyboard structures to Apple, no Settings surface for opt-in. All
of the above are closed in v1.4.38.2.
See CHANGELOG.md.
Captures the paper trail for the iOS-coordination PR #190 admin
merge plus the parallel review pass that surfaced the v1.4.38.2
hotfix bundle:
- Senior-dev / security / UX / product-lead / specialist /
simplifier finding reports.
- V054-server-apns-mood report carried over from the iOS-coord
agent's working notes.
v0.5.4 PR #190 shipped the server cron, ledger, and APNs scaffolding
for the daily mood reminder but no surface for the user to opt in,
so the feature was dead code regardless of the locale, dispatcher,
or APNs fixes. This commit adds:
- `MoodReminderCard` in `/settings/notifications` — a single Switch
row bound to `users.mood_reminder_enabled`, optimistic on toggle,
with a localised toast on save success and an error message on
failure.
- `applyProfileUpdate` accepts and persists `moodReminderEnabled`
through the existing `/api/auth/profile` PUT and `/api/user/profile`
PATCH paths so both web and iOS clients share the same validation
+ audit envelope.
- `GET /api/user/profile` now surfaces the current value so the
Switch can render the right initial state without a separate
endpoint.
- Five-locale strings (de/en/es/fr/it/pl) under
`notifications.moodReminder.*` for the card title, description,
Switch aria label, on/off status text, and toast / error copy.
Bundle of hotfixes against v0.5.4 PR #190 surfaced by the QA review:
- Locale resolver now accepts every supported locale instead of
silently demoting es/fr/it/pl to English. The same PR shipped the
translations into the locale files; without this change those
strings were unreachable dead code.
- Ledger row is written only after `dispatchNotification` reports a
successful delivery. A transient APNs / network blip used to land
the ledger first and silently nuke the day's nudge for that user;
now the next tick is free to retry. New `skippedNoChannel` +
`failed` counters on the tick summary so the wide-event attributes
the outcome.
- Per-user `try` wrapper around the inner processing so one bad row
(corrupt timezone, dispatcher exception) cannot abort the rest of
the 22:00 candidates.
- P2002 race on the ledger insert now counts as `dispatched` — the
worker that lost the race already delivered the push, so the user
did receive it.
- Dead-code drop: the now-unused `moodReminderEnabled` select field
(the `where` clause already filters), and the redundant
`MoodReminderCandidate` interface.
- `localHmAsUtc` helper in `@/lib/timezone` re-derives the UTC
offset at the target local time so `todayStart.getTime() + h *
3.6e6` no longer drifts by an hour on DST transition days. The
medication-reminder `scheduledFor` field and the iOS-snooze
`scheduledAt` ISO both route through it now.
- New daily 03:25 Europe/Berlin cron sweeps the
`mood_reminder_dispatches` ledger to a 90-day retention horizon,
matching the audit-log cleanup cadence.
Test coverage rewritten to pin every new contract — outcome bubble,
ledger-after-delivery, per-user try wrapper, P2002 race semantics,
six-locale dispatch — plus a regression test for the v0.5.4 FR
apostrophe typo.
Two structural changes on the notification platform that unblock the
mood-reminder ledger-after-delivery semantics (separate commit) and
close the v0.5.4 PR #190 review's Security-H1 finding.
- `dispatchNotification` now returns `DispatchOutcome` with
`dispatched`, `channelsAttempted`, and `channelsSucceeded`. The
caller can decide whether the send is worth marking as committed
in its own ledger. Existing callers that ignore the return value
keep working — the function is still best-effort and never throws.
- `sendViaApns` now whitelists the metadata keys it forwards into
the APNs custom payload. The dispatcher's `metadata` is shared
across every channel and previously surfaced channel-specific
payload structures (Telegram's `replyMarkup` keyboard, ad-hoc
extras) into the APNs userInfo Apple sees and the iOS app's
notification handler. The allowlist covers the keys iOS actually
reads (`scheduledAt`, `localDate`, `medicationId`, `scheduleId`,
`phase`, `date`) and drops the rest.
`Comment vous sentez-vous aujourdhui ?` ships from v0.5.4 PR #190
without the apostrophe; every other FR string uses `aujourd'hui`.
The push body is read aloud on iOS by VoiceOver, so the typo is
audible on top of being visible on the lockscreen.
Resolves four trivially-additive conflicts at file tail in
messages/{es,fr,it,pl}.json (new `moodReminders` namespace from
PR #190) and in src/lib/jobs/reminder-worker.ts (new MOOD_REMINDER
queue + cron constants from PR #190). The v1.4.38 docs commits on
develop did not touch the conflict regions; all four conflicts
resolve cleanly by taking the merge side.
Keeps develop and main aligned per the branch model: PR #190 was
authored against main directly to coordinate with the iOS v0.5.4
release; back-merge here so develop carries the same baseline for
the next feature cycle.
- New `round-v1438-perf-verify.md` — captures the post-deploy boot
trace, anon latency baseline (20× burst /api/version 82–130 ms,
comprehensive + analytics 401 envelope identical to v1.4.37), the
`meta.dashboard.sub_*_ms` reservation slots for the live
authenticated dashboard capture, and the cross-tz guard +
coach-gate notes.
- `round-audit-marathon-closure.md` gains rows for v1.4.37.1,
v1.4.37.2, and v1.4.38, plus title and "shipped" line updated to
reflect nine consecutive releases on 2026-05-17.
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure. See CHANGELOG.md for the full set; headline:
- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.
Captures the marathon paper trail:
- Wave reports (A cross-tz guard, C coach test invariants, F perf
hotspots) — these landed in implementation but the report files
were untracked at commit time.
- QA findings (1 code-quality, 2 security, 3 UX/a11y, 4 architecture,
6 i18n parity) — six-axis review pass output, source-of-truth for
the reconcile-apply decisions.
- v1.4.39 backlog — items deferred during the reconcile (Coach
client-side disabled copy, repo hygiene sweep, cumulative
sparkline mean-vs-sum, gate walker file-ext closed set, sparkline
live-fallback, BP/pulse term unification es/pl, plus observability
polish from QA-1 Mediums).
Covers the four shapes that should round-trip cleanly through the
IANA refine:
- Europe/Berlin → accepted, prisma.user.update fires.
- Mars/Tharsis → 422 "Invalid IANA timezone", no prisma write.
- empty string → 422 (min-length kicks first), no prisma write.
- injection-shaped Europe/Berlin' OR 1=1 → 422, no prisma write.
The shared profile-update schema accepted any 1-64 character string
for the timezone field and persisted it directly into User.timezone.
Downstream callers — most notably the v1.4.38 dashboard summary route
— pass the raw stored value into postgres via
`to_char(measured_at AT TIME ZONE ${userTz})`, which hard-fails with
`invalid_parameter_value` for any unknown zone. Before v1.4.38 the
field was consumed only through the defensive isValidTimezone wrapper
in @/lib/tz/format and silently fell back to Europe/Berlin, so the
gap was latent; the new SQL path made it a self-DoS regression on any
account that ever wrote a junk zone.
Chains the existing isValidTimezone helper into the Zod schema as a
refine so the API now returns 422 "Invalid IANA timezone" at the
write boundary instead of writing junk and failing on read.
Three keys translated during W-E either lost their interpolation
placeholders or carried hybrid English fragments. The runtime parity
audit (W-QA-6) flagged the gap before tag:
- glp1NextInjectionDays — was "Avanti:{label} (in {days} days)" and
three sibling hybrids; now reads natively in each locale with the
{label} and {days} placeholders preserved.
- intakeHistoryPageInfo — re-appends the " · {count} entradas/entrées
/voci/wpisów" clause so users on the non-DE/EN locales see the
per-page row count alongside the page-of-total caption.
- dayActivate / dayDeactivate — re-introduces the {day} placeholder
so a11y labels announce per-weekday ("Activate Monday") rather
than the generic "Activate day".
The helper used `d.toISOString().slice(0, 10)` (UTC slice) but
carried the generic `dayKey` name, inviting a contributor to swap in
`userDayKey()` from `@/lib/tz/resolver` and silently shift the
pairing key for non-near-UTC tenants. Rename to `bucketDayKey` and
add a docstring spelling out why the UTC slice is correct here
(both SYS and DIA buckets share UTC midnight; pairing is against
each other, not against any user-local series).
`degraded` is forward-looking — no path sets it true today. Pin the
intent with a TODO comment that points at the v1.5 load-shedding
branch so a future reader understands why both code paths emit
`false`. Field stays in place so downstream consumers can already
key on the shape without the load-shedding signal being live.
The rollup-path read window was `now - 396 * 24h`. On a leap year
the constant is 1 day shy of the intended 12-months-plus-one-day
priorYear span, so the boundary day disappears from the rollup-derived
envelope while the live path's per-event walk keeps it. Replace the
fixed multiplication with calendar arithmetic (`setUTCFullYear(-1)`
+ `setUTCDate(-31)`) so the read window stays calendar-correct on
every year. Below the n=20 surface gate this rarely surfaces in
practice — but the helper can no longer silently shift the priorYear
pct on a leap-year edge.
Localises 100 high-visibility keys per locale across the medications
page: empty states, status labels (overdue / very overdue / paused),
medication categories (thyroid, hormones, pain relief, etc.), weekday
names and short forms, schedule controls, intake-history table columns
and status chips, delete and purge confirmations, unit nouns (drops,
tablets, capsules), and source labels. Skips the deep GLP-1 / API-token
/ JSON-import subtrees; those land in a follow-up batch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
drainPerSampleCumulative emitted a [drain] START line per user but
nothing on completion — an operator scanning the worker log had to
infer per-user finish times from the next user's START line. Add a
per-user COMPLETE line that carries the bucketsCollapsed /
perSampleRowsDeleted / dailyRowsUpserted deltas the user contributed
so START / COMPLETE pair cleanly in the worker log.
MedicationIntakeQuickAdd's useQuery shared the queryKey with the
onboarding-checklist subscriber but ran with default staleTime=0, so
every sheet open triggered a fresh `/api/medications` GET even when
the parent dashboard cache already held current data. Match the
dashboard's 60s staleTime and disable refetch-on-mount /
refetch-on-window-focus so opening the sheet inside the TTL reads
straight from the React-Query cache.
The medication category enum lives in three places (Prisma schema,
MEDICATION_CATEGORY_VALUES, MEDICATION_CATEGORY_KEYS). A new bucket
added to the enum without a matching label key would silently fall
back to categoryOther in <MedicationCard>. Pin the invariant with a
vitest assertion that every non-OTHER value resolves to a distinct
label key, plus a positive check that the fallback still fires for
unknown categories.
The looksLikeIp helper matched any input the hex/dot character class
accepted — `:::`, `1.2`, `gg:hh::1` all passed. The cf-connecting-ip
flow trusts the header under the env flag and the trusted-proxy XFF
chain forwards the rightmost entry straight to the rate-limiter +
audit log, so a malformed value landed downstream unchanged. Defer to
Node's built-in `isIP` which returns 4 / 6 for valid v4 / v6 and 0
for anything else. Add tests covering the structurally invalid v4 /
v6 / hex shapes plus a positive case for well-formed IPv6.
Localises the first-run shell (5-step onboarding tour, getting-started
checklist with 5 items × title/description/cta), the bug-report form,
and the Telegram bot prompts/confirmations. Polish, French, Spanish,
and Italian copy maintains the conversational professional register
used in the German source. Placeholders {current}, {total}, {done},
{name}, {duration}, {minLength} preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comprehensive aggregator's Promise.all dispatched two separate
`prisma.measurement.findMany` calls for BLOOD_PRESSURE_SYS and
BLOOD_PRESSURE_DIA over the trailing 90 days, even though both reads
hit the same `(user_id, type, measured_at)` index path and feed the
same `pairByTimestamp` consumer in the route.
Merge them into a single `findMany` with `type: { in: [...] }` and
partition by type in `fetchBpRawRows`. Saves one round-trip per
request on both the rollup-fresh happy path and the cold fallback;
the byte-shape of `bpRawRows.sys` / `bpRawRows.dia` is preserved
because the SQL `ORDER BY measuredAt ASC` already produces the same
within-type ordering the legacy two-query path emitted.
Add per-sub-query wall-clock timings via a small `timeSubquery`
helper. Six labels land on the wide-event meta:
- `insights.sub_coverage_ms` — per-type rollup coverage probe
- `insights.sub_narrow_ms` — windowed/regression aggregate
- `insights.sub_heavy_ms` — cold-path full aggregate
- `insights.sub_latests_ms` — DISTINCT ON latest per type
- `insights.sub_buckets_ms` — DAY rollup buckets read
- `insights.sub_bpRaw_ms` — bp sys+dia consolidated read
- `insights.sub_firstAt_ms` — earliest measurement timestamp
The next perf-verify can attribute any future regression to a single
aggregate without re-instrumenting the route. The annotation is a
no-op outside an `apiHandler` context so direct callers of the
aggregator (tests, scripts) carry no overhead beyond a single
`Date.now()` per slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`recomputeBucketsForMeasurement` enqueued the three async-granularity
recomputes in a serial `for-of` loop. Each enqueue is independent
(different pg-boss singletonKey per granularity) so the await chain
adds three sequential round-trips to the post-write critical path
on every measurement create / update / delete. Replace the loop with
`Promise.all` so the three enqueues fan out at once. Matters on iOS
batch imports that fire hundreds of hooks back-to-back.
v1.4.37.1 fire-and-forgets the helper from the analytics read path
so a stale recompute can no longer stall the response, but if a
request DOES await the helper synchronously (worker code path,
future caller without the fire-and-forget) a cold fan-out across
analytics + comprehensive + summaries within the same tick would
queue N parallel recomputeUserRollups runs against the same
90-day window. A module-level Map<userId, Promise> collapses the
fan-out onto one shared promise. Mirrors the buildCoachSnapshot
dedup pattern that lives one layer up. The slot is cleared in
.finally so a slow rejected promise does not poison the next call.
A `text.includes("requireAssistantSurface(...)")` match also flips true on a docstring that merely mentions the gate, so a contributor who deletes the actual call but leaves the comment would slip through the inventory walk. Split into a per-line check that ignores `//` and ` *` JSDoc lines, matching the same posture the coach-cascade discovery test uses for `flags.coach`. Drops a dead `repoRoot` variable from the stale-allowlist test along the way.
`lastSeenByType[type].daysAgo` was computed once inside the cached
60s envelope, so a body cached at 23:55 surfaced "vor 3d" until the
TTL refreshed at 00:55 instead of correctly flipping to "vor 4d" at
midnight. Store only the ISO `lastSeenAt` inside the cache and
derive `daysAgo` per call in the GET wrapper. The Map lookup stays
on the hot path; the per-call subtraction is a single Date.now()
per type.
Covers the data-visualisation surface: chart loading/empty states,
sample windows (7/30/90 pts), weekday and month abbreviations,
heatmap and adherence legends, personal-baseline deltas, chart-overlay
controls, target thresholds (metric labels, override warning, save
toasts), and the period-over-period comparison toggle. Placeholders
{delta}, {min}, {max}, {unit} preserved across all locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P3-2: pair the Arztbericht hero CTA's explicit h-11 with a min-h-11
floor (and sm:min-h-9 on desktop) so a future className override can
not accidentally re-lift the mobile minimum below the touch-target
contract.
P3-3: drop the dashboard onboarding checklist's sm:min-h-10 (a no-op
against the Button default of h-10) down to sm:min-h-9 to match the
Hinzufuegen menu trigger pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /api/measurements per-day drill-down branch clamped `limit` to
`Math.min(limit, 1000)` after the request reached the route. Callers
asking for more saw no response signal — the schema-declared 5000
ceiling silently capped at 1000. Move the cap into the validator's
.refine on (limit, dayKey) so the boundary returns 422 with an
actionable message. The route now reads `limit` straight through
because the validator has already clamped it.
Walk every route.ts under src/app/api/insights/ and require each handler to either invoke requireAssistantSurface("coach") or appear on an explicit allowlist (insightStatus / correlations gates, or non-Coach reads like provider-chain). The walk uncovered two orphan Coach routes — GET/DELETE /api/insights/chat/[id] and POST /api/insights/chat/messages/[id]/feedback — both of which manipulate Coach-only resources but lacked the operator kill-switch. Add the gate to both so a disabled Coach matrix produces a 403 + assistant.disabled.coach envelope through the same path the chat SSE route already uses.
Localises the user-facing shell strings: auth (sign-in / sign-up,
passkey fallback, password validation), nav (sidebar labels, theme
toggle), password-strength meter, trend-hints countdown, notifications
breadcrumbs, common copy/unknownError, format.dateShort with locale
date order (dd/mm/yyyy for es/fr/it, dd.mm.yyyy for pl), and the
errorBoundary fallback title. Placeholders {minLength}, {count} all
preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add CircleCheck / AlertCircle / AlertTriangle icons next to the
take-now / overdue / very-overdue pill on both the generic medication
card and the GLP-1 card so colour-blind users can disambiguate the
three states without relying on the red-green hue. WCAG 1.4.1 (Use of
Color). Symmetry kept across both surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`/api/dashboard/summary` ran two unbounded `prisma.measurement.findMany`
queries on every cold mount: a 7-day window for latest+sparkline and a
365-day window for the streak day-keys. On accounts backfilled from
Apple Health those two reads dominated the wall-clock at ~4.6 s even
though the JS code only ever consumed one latest value + a ≤7-point
sparkline + a set of day-keys per metric.
Replace the unbounded reads with three bounded SQL aggregates:
- `DISTINCT ON (type)` over the 7-day window returns one row per
measurement type carrying the latest value + timestamp. Bounded by
the number of metric types (≤ ~10), not the raw row count.
- `measurement_rollups` DAY buckets for the trailing 7 days drive the
sparkline. Bounded by `SPARK_DAYS × N_metric_types`, typically
< 80 rows even for the heaviest power-user, and the bucket means
are a smoother trend signal for high-volume metrics than raw step
samples were.
- `SELECT DISTINCT to_char((measured_at AT TIME ZONE $userTz),
'YYYY-MM-DD')` over the 365-day window returns the activity-day
keys directly. At most 365 rows vs. potentially 100k raw samples.
The server-side `AT TIME ZONE` matches what `userDayKey` produces
in JS so the streak walk lands on byte-identical day-keys.
Wrap the whole response in the 60 s analytics LRU keyed
`${userId}|dashboard-summary`. Measurement / mood writes already evict
the analytics user-bucket via `invalidateUserMeasurements` and
`invalidateUserMood`; extend `invalidateUserMedications` to do the
same so a medication intake event flushes the dashboard cache the
next time iOS polls.
Per-sub-query wall-clock timings are emitted under
`meta.dashboard.sub_*_ms` so the next perf-verify can attribute any
future regression to a single sub-query without re-instrumenting the
route.
Estimated cold-mount: 4.6 s → ~500 ms (rollup-fresh cache miss) →
< 50 ms (warm cache hit). Subsequent cache hits skip the entire
builder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 36-hour grace window was a module-local constant inside
reminder-worker.ts while the admin route and the CLI both defaulted
to undefined. Lift the constant onto the helper module so the worker,
the admin route, and the CLI all import one source of truth. The
worker continues to pass the constant through to the helper; the
admin route and the CLI keep their existing default-undefined
behaviour (drain everything the operator points at) so a one-shot
pass still collapses every row regardless of recency.
Localises the 25 mood-namespace EN-fallback keys: 5-point scale labels
(super gut/okay/lausig surfaced as Excellent/Bien/Pénible, etc.),
tags field, filters, empty states, validation errors, and the
delete-confirm copy. Keeps the German `levelSuperGut`/`levelOkay`/
`levelLausig` key identifiers intact while translating the rendered
text per locale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the aria-live=polite the loading skeleton already carries onto
the success card so screen readers announce the assessment when it
swaps into the same slot the skeleton occupied. Closes the load to
loaded transition signal flagged in W10 P2-2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers metric type names (weight, blood pressure, glucose, body
composition, Apple Health audio exposure, walking metrics), glucose
context options, source labels, form field labels, validation errors,
empty states, and pagination controls. 62 keys per locale, preserving
{count}/{unit}/{page}/{total} placeholders and the mmHg/bpm conventions
common to each medical tradition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user has no active medications, render the Medikament-anlegen
link in the sheet footer beside Cancel rather than as an in-body
button. This balances the empty branch with the populated form (whose
Save button also lives in the footer) and keeps the footer-slot
primary-action promise across both states.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop pr-2.5 + [&_svg:last-child]:mr-1 down to a flat pr-2 so the
chevron parks at the same 8px gutter that Safari and Chromium legacy
themes paint for the native date-input glyph. Chromium Material still
reads aligned (16px button + 8px gutter = 24px). Removes the ~4px
parity miss flagged in the W10 select-vs-date-input pairing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Localises the remaining 27 keys per locale across dashboard.greeting,
welcomeBack, streakLabel, vo2Max, bpInTarget, quickAdd shortcuts,
compliance7d, the layout-customisation cluster, and the empty-state
hero. Keeps placeholders ({greeting}, {name}, {count}) intact and
matches the calm professional register used in the German source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pg-boss already coalesces concurrent cron ticks across worker
containers via the shared queue lease, but a single container that
takes longer than the cron interval can pick up two jobs back-to-back
when the second tick fires while the first pass is still running.
A module-local geoBackfillRunning flag fans the second invocation
out as a no-op log line instead of stacking two concurrent passes
inside the same Node process. The next cron tick after the in-flight
pass completes catches up the work the skipped pass would have done.
Replaces 14 English-fallback strings in dashboard.medicationIntakeQuickAdd
and dashboard.quickAddMedicationIntake (plus the W7c step-consolidation
captions in measurements.dailyTotalCaption/expandDay/collapseDay) with
native Spanish, French, Italian, and Polish copy. Closes the v1.4.37
W10 i18n-findings gap on the four secondary locales for the medication
intake and measurement-grouping surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rollup path keys rows on d.toISOString().slice(0, 10) (UTC slice)
and compares them against now - N*DAY_MS boundaries. For a user
inside the +-3h band around UTC (Berlin +1/+2) the day-key on the
rollup row lines up with the local calendar day; outside that band
the rollup row addresses the previous-or-next local day relative to
the window boundary and the pair count drifts across the cut.
Runtime guard: when isNearUtc(userTz, now) is false, force the live
fallback (which keys per-event rows by their own measuredAt and is
therefore self-consistent regardless of zone). The analytics route
now threads its existing userTz value into the helper; legacy
callers that omit it default to near-UTC for backwards-compat.
Both annotate dicts carry a new tz_guard field
("near-utc" | "non-utc-live-fallback") so ops logs can attribute a
live-fallback to coverage gaps vs the tz guard. A v1.5 follow-up
will thread userTz into readRollupBuckets so the rollup path can
address local-day buckets and this guard can come back down.
Each row spends up to 3 s on the lookupIpLocation online fallback;
5000 rows × 3 s let a single backfill burst block for ~4 h and starve
the next hourly cron. Tighten the cap to 500 so the worst-case pass
fits well inside the hour-long schedule slot. The helper is
idempotent, so a tenant with a large null-location backlog drains
over multiple hourly passes instead of one mega-pass.
Thread aria-controls from the cumulative-type expand/collapse chevron
to the per-day drill-down container so screen readers can jump from
the disclosure button to the disclosed content. Uses dayKey-derived
stable ids on both desktop (TableRow) and mobile (wrapper div)
layouts; falls back to row id when ungrouped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fast-path keys rollup rows on UTC-midnight bucketStart via
userDayKey(b.bucketStart, userTz), then pairs those per-day means
with per-event mood + intake rows that the loop below also re-keys
in user-tz. For Berlin (+1/+2) the two keying schemes line up on
the same calendar day; for Honolulu (-10) the rollup-derived per-day
mean addresses the previous local day relative to the per-event
stream, and the Pearson / ANOVA inputs miscorrelate silently.
Runtime guard: when isNearUtc(userTz, now) is false, force the
live SQL path (which re-keys SYS / PULSE / WEIGHT via the same
userDayKey helper as mood / intake) so day-key parity is
guaranteed. The meta annotate carries a new tz_guard breadcrumb
("near-utc" | "non-utc-live-fallback") so prod logs make the
decision visible. A v1.5 follow-up will thread userTz into
readRollupBuckets so the rollup path can address local-day
buckets and this guard can come back down.
Pure helper returns true when the supplied IANA zone is within +-3h
of UTC at the supplied instant. Exposed from both lib/tz/format and
lib/tz/resolver so client and server callers share the same gate.
Honours DST via the existing tzOffsetMinutes path.
Used by the v1.4.38 analytics fast-path runtime guards
(bp-in-target, correlations) to fall back to live SQL for users far
from UTC, where the rollup table's UTC-midnight bucketStart slips a
calendar day relative to the per-event mood / intake stream.
The v1.4.37.2 slim-summaries SQL aggregation landed directly on
main during incident response; this merge brings develop back into
parity and seeds the v1.4.38 marathon kickoff document.
Hotfix on top of v1.4.37. ensureUserRollupsFresh fire-and-forget
on /api/analytics, comprehensive aggregator, summaries slice.
The synchronous variant was blocking the Node event loop for
~60 s per cache miss on power-user accounts whose iOS step
samples kept the 90-day rollup window slightly stale, which
starved /api/health, /api/version and concurrent iOS requests.
The downstream coverage probe preserves correctness via the
live-SQL fallback; the only user-observable change is one cache
window of staleness on the very first request after a new
measurement lands.
Adds the v1.4.37 row to the audit-marathon closure ledger (now
six releases on 2026-05-17) with the headline numbers and the
post-deploy perf-QA write-up. Live verify confirms the deploy is
healthy (tag 1.4.37, build 88028fa0, reminder worker up in 772 ms,
no pending migrations). The W10-1 Critical fix (DRAIN_CUMULATIVE_QUEUE
registration in allQueues) is verified by clean worker boot --
absent the fix, the boot would have failed on the boss.schedule
call. The W2 perf claim (full /api/analytics route 111 s cold
worst-case down to 1.5-3 s) needs a real authenticated dashboard
mount to surface in the logs; framework + verification path
captured for when the numbers land.
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract (hides planned and
skipped rows by default), converges every Coach affordance behind a
single feature gate with an invariant test pin, lifts the
medication-detail Mounjaro card to byte-equivalent parity with
Ramipril, brings the Insights overview hero row to height parity,
hardens getClientIp behind a new TRUST_CF_CONNECTING_IP env flag,
ships the Arztbericht hero card on Settings -> Export, adds the
Medikamenteneinnahme quick-add overlay to the dashboard, drops the
timezone override toggle for a silent browser-zone auto-seed, and
fixes the v1.4.36 red CI workflows. Documentation refreshed across
all three repos with five new architecture diagrams in the Dracula
palette. Unit suite 4354 -> 4486 passing; typecheck + lint clean.
Capture the W10 reconcile-apply outcome (10 commits closing 1
Critical + 3 High + 6 Mediums-or-P0s from the six W10 reviewer
reports) and seed the v1.4.38 backlog with the deferred items so
the next round inherits a clean triage surface.
The existing .env.example block warned that an attacker could forge
the geo lookup if the flag was enabled on a non-Cloudflare deployment,
but did not name the two additional downstream consumers of
`getClientIp`: the IP-rate-limit bucket key for login/chat/coach and
the audit_logs.ipAddress column for every privileged write. An
operator who deploys behind nginx / Caddy / direct and copy-pastes
the flag from a forum thread would let any caller defeat the
rate-limiter AND forge the IP recorded against their audit trail.
Rewrite the block to list every consumer explicitly and re-emphasise
that the flag is fail-closed (default off, strict "=== '1'" gate, no
server-side topology detection — the operator owns the decision).
The dose field on the medication-intake quick-add sheet was rendered
editable but the POST body has no `dose` slot — any user edit was
silently dropped, so a user typing "5 mg" for a half-pill day saw the
catalogue dose recorded instead. The previous hint copy did not warn
about the constraint, leaving the user's mental model out of sync
with the persisted data.
Render the field read-only with a muted background, drop the tab
order on the input, and rewrite the hint copy across all six locales
so the user knows that variable doses are coming in a later version
and the standard dose is recorded for now. The setDoseOverride state
holder stays so the reset-on-medication-switch keeps working when the
v1.4.38 doseOverride schema lands.
W4a item 2 promoted DropdownMenuItem to whitespace-nowrap globally and
bumped the sidebar user menu to w-60 so the German "Benachrichtigungs-
Center" label stayed on a single line. The top-bar user menu kept its
w-48 (192 px) width — the same label plus a 16 px icon + 8 px gap + 16
px padding measures ~220 px and overflowed under the global nowrap +
overflow-x-hidden, clipping without an ellipsis.
Bump the top-bar user menu to w-60 so it matches the sidebar's symmetry.
W-CI raised the onboarding checklist toggle and the intake-history
pager to the 44 px mobile touch floor (WCAG 2.5.5). The new
medication-intake quick-add sheet sibling shipped with an empty-state
CTA at h-8 (size="sm" default) and footer Save/Cancel buttons at h-10,
both below the project-wide floor and visibly inconsistent with the
sibling MoodForm / MeasurementForm + the rest of the v1.4.37 surfaces.
Apply the established min-h-11 sm:min-h-9 override to the empty-state
CTA, the footer Save/Cancel, and the empty-state Close button. Extend
the SSR contract test to count three min-h-11 floors instead of two so
a future regression that drops one of the buttons back below 44 px
trips before the release.
The W7c drill-down resolved the day window via
canonicalDailyTimestamp(dayKey, tz) ± 12 h. The shape worked for any
non-DST day because the noon instant carries the zone's offset, but
on the two transition days per year the true local day is 23 or 25
hours long while the window always spanned exactly 24 hours. On
Berlin spring-forward (Mar 30, 2025) the window leaked the previous
day's last hour into the drill-down; on fall-back (Oct 26, 2025) it
hid the first hour of `today`. Same shape applies to every IANA zone
that observes DST.
Add a localDayWindow(dayKey, tz) helper that anchors dayStart and
dayEnd at the user's local 00:00 of the requested day and the next
day. The next-day anchor pattern picks up the DST shift automatically
because each day's offset is read at its own UTC midnight (EU/US DST
transitions happen at local 02:00 / 03:00, so the offset at 00:00
UTC is unambiguous). Half-hour zones (Asia/Kolkata UTC+5:30,
Pacific/Chatham UTC+12:45) keep their minute component.
The dayKey validator regex matched any YYYY-MM-DD string — including
impossible dates like 2026-02-30 or 2026-13-01. `new Date()` silently
overflows those values into the following month, so the drill-down
returned rows from a different calendar day than the user asked for.
No 422 was emitted; the response was syntactically well-formed and
pointed at the wrong day. The same helper feeds the admin drain
route, so a malformed CLI invocation had the same blast radius.
Add a refine that round-trips the parsed Date back to ISO and
rejects when the slice no longer matches the input.
The groupBy=day collapsed branch runs the per-user-day bucketing AFTER
the per-sample findMany, so it can't honour a non-zero offset without
real Postgres-side date_trunc grouping plus a distinct count — both
v1.4.38 backlog items. The dayKey drill-down has the same shape: it
returns a single bounded page, not a cursor.
Both branches previously accepted offset silently and hard-coded
offset:0 in the response meta, so a consumer threading the v1
pagination contract (admin tools, any future iOS list view) would
paint "showing 1-25 of N" with the wrong N. Reject the combination
at the validator instead with a clear 422.
The analytics route carried an inline MeasurementType →
SourcePriorityMetricKey switch next to the cumulative-day-sum
bucket-and-sum. Adding a new cumulative metric required updates in
three places — the SourcePriorityMetricKey enum, the route-level
switch, and CUMULATIVE_HK_TYPES — with no shared test to pin the
drift. Same shape as the H-2 / W10-4 H3 finding the previous commit
closed for the cumulative-type set.
Move the lookup next to CUMULATIVE_DAY_SUM_TYPES in
src/lib/measurements/cumulative-day-sum.ts and add vitest assertions:
every CUMULATIVE_HK_TYPES member resolves either to a real
SourcePriorityMetricKey or to null (the picker's pass-through escape
hatch for types like TIME_IN_DAYLIGHT). The route now imports the
helper instead of duplicating the switch.
CUMULATIVE_HK_TYPES (apple-health-mapping.ts) and CUMULATIVE_DAY_SUM_TYPES
(cumulative-day-sum.ts) enumerated the same five Apple Health cumulative
types in two literal blocks. A contributor adding a sixth type (e.g.
swimming strokes) only had to update one list and the system would
silently misreport for that type — either the list-view collapse would
not fire or the analytics pickCumulativeDaySum would skip it, depending
on which file the contributor touched.
Derive CUMULATIVE_DAY_SUM_TYPES from CUMULATIVE_HK_TYPES at module load
(sorted alphabetically for stable iteration) and add a vitest parity
assertion so the membership stays pinned. Both exports remain so every
existing import keeps working.
pg-boss v12 requires explicit createQueue before scheduling. The
nightly drain schedule registered without a matching createQueue call,
which silently no-ops the cron — the per-sample Apple Health rows
never collapse into one row per day. The list view still works via
the on-demand groupBy=day branch, but the underlying table stays
bloated forever.
Add the queue to the allQueues array next to ROLLUP_FULL_BACKFILL_QUEUE
so the boot loop creates it before the schedule registration runs.
Extend the existing scheduler test to grep the allQueues block so a
future regression flips red instead of slipping through.
The two helpers carried byte-equivalent CF / XFF / x-real-ip ladders
since the F-6 audit landed `getClientIpOrTrustWarning` alongside
`getClientIp`. Project `.ip` through the tagged helper so the rotation
guard, Cloudflare opt-in and one-shot trust-violation warning live in
one place; every existing call site keeps the same return value.
The v1.4.34.5 tap-target sweep covered the dashboard tile and
per-row chevron but missed the two CTAs the
`<GettingStartedChecklist>` header renders on the onboarded-user
dashboard: the expand/collapse toggle (32 px tall) and the
dismiss-all CTA (40 px via shadcn's default Button, which has not
yet learned the `h-11 sm:h-10` mobile lift the input primitives
follow). Both flagged in the `mobile-viewport` touch-target probe.
Pin `min-h-11 sm:min-h-10` on each so the desktop rhythm is
unchanged.
While here, two e2e specs needed updates to match the same wave:
- `mobile-viewport.spec.ts` enforced `width < 44 || height < 44`,
which flagged the chart-range pill row (`7T` / `30T` / `90T`,
each 44 px tall but 30-40 px wide) as a WCAG violation even
though 2.5.5 honours adjacent-target spacing for horizontal
groups. Drop the width branch; the height floor is the hard
contract every solo CTA must clear.
- `onboarding-flicker.spec.ts` route-stubbed `**/api/analytics`,
which is exact-match against the path — the IW1 slim-slice
variant `/api/analytics?slice=summaries` (which the checklist
hook now uses) bypassed the stub and hit the real route, so the
onboarded-user fixture flipped to `measurementCount = 0` and
the card rendered. Swap both stubs to a regex that matches the
base path AND any query-string variant.
The v1.4.34.5 iOS-textarea-zoom sweep lifted the mobile input
height floor from 40 px (`h-10`) to 44 px (`h-11`) on `Input`,
`Select` trigger, and the native `<select>` primitives — the
desktop tier still resolves to 40 px via `sm:h-10`. The
settings-mobile-consistency spec still pinned every form input,
the Compare-to trigger, and every `/settings/ai` native select to
40 px, so it went red on the v1.4.36 tag while the implementation
was correct.
Bump the three height assertions to 44 px and refresh the header
docblock so a future maintainer reads the contract as "44 on
mobile, 40 on >=sm" instead of stumbling on a stale baseline.
The integration suite used to share a single module graph across
files (`pool: forks`, `fileParallelism: false`, `isolate: false`).
That kept the container boot amortised but meant a `vi.mock()`
declaration in one file lost to whichever earlier file had already
pulled the real module into the graph. `apns-dispatch.test.ts` and
`integration-status.test.ts` both fall through to
`@/lib/notifications/dispatcher` (and its `@parse/node-apn` /
telegram sender deps); when a sibling test mounted the real
dispatcher first, the mock factories registered later silently
no-op'd and the in-suite run reported the mock as called zero
times. Each file still passed in isolation, which is the
fingerprint the v1.4.35.1 closure already flagged as pre-existing.
Flip the integration config to `isolate: true`. Per-file module
isolation costs ~10 s of import rebuild across the 56-file run;
the Postgres testcontainer + migrations live in `globalSetup`, so
the slow part (container boot, ~5-15 s) is unaffected. The two
`withings-*-sync.test.ts` files were previously dependent on a
sibling setting `process.env.ENCRYPTION_KEY` before them — pin the
deterministic test key in each file so they remain self-contained
under per-file isolation. Locally `pnpm test:integration` now
shows the four target failures green; the remaining two
bp-in-target flakes are pre-existing local-only data drift that
passed on CI under the old isolation flag and continue to be
ordering-independent.
The features-payload-too-large guard constructs a ~5.6 MB rollup
payload and runs the whole serialiser pipeline to confirm the
ceiling fires. Local hardware completes the assertion well inside
the default 5 s budget, but the GitHub-Actions Ubuntu runners
crossed it and timed out on the v1.4.36 push. Pin a 30 s budget on
this single case (Vitest 4's two-arg `it(name, opts, fn)` form,
the deprecated three-arg form was removed) and leave the rest of
the suite on the default; the runtime is dominated by the giant
allocation, so the wider window only matters for the slowest
runners.
Two findings from the post-implementation self-review:
- Empty-state CTA: swap the plain anchor for next/link so the
/medications jump stays inside the SPA router instead of triggering
a full page reload.
- JSDoc accuracy: the original module-level comment claimed a
non-default dose 'surfaces as a sonner toast hint' — neither the
toast nor a notes-style append actually exists. Rewrite the dose
paragraph to honestly describe the contract: the dose input is
pre-filled for visual confirmation, editable, but the POST body
never carries the dose override. The intake event always
references the medication's catalogue dose; a half/double dose
still routes through the medication edit on /medications.
Add the SSR-level suite for the new quick-add: empty-state markup, the
populated form's field testids plus 44 px touch floors, the pure
default-selection heuristic across the four ranked branches
(empty / single-active / due-now / alphabetical fallback), and the
no-raw-key locale check for English and German.
Extend the existing quick-add-labels guard so it covers the third
DropdownMenu row alongside the original two — the icon is decorative
and the trigger label sits above the menu, so the three visible
labels must stay distinct from each other and from the trigger.
Add the menu-item label (dashboard.quickAddMedicationIntake) and the
dashboard.medicationIntakeQuickAdd block (sheet title, sheet
description, field labels, hint, error message, and empty-state copy)
to all six locales. The German bundle ships Marc-Voice copy with full
UTF-8 umlauts; the other locales mirror the English fallback already
in use for the other two quick-add rows until the localisation pass.
Add the third row to the dashboard quick-add DropdownMenu so a
medication-intake event can be logged without leaving the page. The new
ResponsiveSheet shares the same footer-portal contract as the existing
Measurement and Mood sheets so the Save and Cancel buttons sticky-pin
above the mobile soft keyboard, and the menu label is a self-contained
verb-phrase ('Medikamenteneinnahme erfassen' / 'Log medication intake')
that doesn't collide with the trigger or the sibling rows.
Mirror the ResponsiveSheet primitive used by the Measurement and Mood
quick-adds for medication intake. The sheet auto-selects the active
medication whose schedule window is currently open (or the only active
medication when the user has just one), pre-fills the dose from the
catalogue entry, and defaults the time taken to now. On submit the
form invalidates medicationDependentKeys plus the inline per-medication
compliance chart cache so the detail tile, the dashboard compliance
chart, the today list, and the achievements card all refresh in lockstep
with the API. An empty-state branch suppresses the form and surfaces a
CTA to /medications when the user hasn't yet created a medication.
The `runGeoBackfill` helper has shipped since v1.4.27 but was never
wired into the worker boot — it only ever fired through a test path,
so the admin sign-in overview's "Standort" column kept rendering
"—" on every historical row where the fire-and-forget IP resolver
missed at write time. Marc's report against v1.4.36 made the
problem visible: even after the v1.4.36 fallback fix, the backfill
was missing entirely.
Wire the helper into the existing pg-boss schedule list at :40
every hour. The queue name + cron expression live in the helper
module so the worker boot pulls them by import; a co-located unit
test pins both shapes (queue name + cron slot) plus a source-text
probe that confirms the worker actually imports and registers the
runner. The helper itself is unchanged — it stays idempotent, capped
at 5 000 rows per pass, and scoped to the trailing 30-day window
that the operational triage cares about.
The :40 slot was chosen to dodge the existing :00 (Withings activity
+ measure crons), :15 (Withings sleep), :30 (moodlog sync), and
:25 (medication compliance status) crowd at the top of the hour;
the helper's per-call cap keeps the audit-log write budget under
control even if a future ingest path enqueues a hot run.
Per-commit summary, file ownership, tests delta, self-review findings
and applied resolutions, visual diff between Ramipril and Mounjaro
post-fix, and the GLP-1-specific rotation-hint prominence judgement.
Self-review polish on top of the v1.4.37 W7c step-consolidation
landing:
- The collapsed daily view returns up to 5000 synthesised rows in
one shot, so the legacy 25-row pagination would have re-fired the
same scan with offset=0 returning the same slice. Disable
pagination on the day-grouped path; per-sample lists keep their
PAGE_SIZE=25 behaviour.
- Bump the per-page row scan from 1250 to the server max of 5000 so
even chatty pre-drain Apple-Watch accounts (~200 chunks/day on
busy days) get a useful number of daily rows on the first paint.
Post-drain the scan is trivially short (each day reduces to one
stats: row).
- Reset offset=0 explicitly on the grouped path so a sort-direction
flip never paginates into an empty server slice.
- Clarify the day-boundary comment in the dayKey drill-down branch
— the ±12h around canonical noon shift works for every IANA
offset, including sub-half-hour zones (Asia/Kathmandu +5:45,
Pacific/Chatham +12:45).
The Coolify-fronted HealthLog stack sits behind Cloudflare, which
strips and re-sets `cf-connecting-ip` on every request that lands
on its edge. Without consulting this header, `getClientIp` reads
the Caddy loopback IP off the `X-Forwarded-For` chain and the
admin sign-in geo lookup ends up with a null IP for every visitor
— Marc's "Standort" column rendered a literal "—" on every row of
the sign-in overview even after v1.4.36 closed the missing-ASN
fallback.
The new branch prefers `cf-connecting-ip` ahead of the XFF / x-real-ip
chain, but only when the operator opts in via `TRUST_CF_CONNECTING_IP=1`.
A self-hosted deployment without Cloudflare in front MUST leave the
flag off — an attacker can set the header directly on the public
internet, and the downstream geo resolver would happily report a
forged location.
The trust-violation accounting in `getClientIpOrTrustWarning` skips
when the CF header satisfies the lookup because Cloudflare re-sets
the value on every edge request — there is no chain to violate. The
existing XFF + x-real-ip semantics are unchanged for every
deployment that leaves the flag unset.
`.env.example` documents the flag in a dedicated "Reverse-proxy
trust" block right above the process-split section so the operator
finds it next to the existing infra-trust controls.
Side-by-side contract suite for the v1.4.37 W4b symmetry fixes from
Marc's UX audit (item 11). Asserts the load-bearing structural seams
both medication-card variants must share:
* History + Pencil header buttons render on both cards.
* No kebab on either card when no overflow prop is wired (Mounjaro
matches Ramipril in the common case).
* Kebab only mounts on Mounjaro when `onLogSideEffect` is supplied;
no equivalent surface on Ramipril.
* Category-label badge uses the shared category-map lookup on both
cards (Mounjaro no longer hard-codes the treatment-class label).
* Take-now coloured pill renders on both cards when inside the
current schedule window.
* Primary-actions row carries the canonical two-button shape — the
GLP-1-specific Stethoscope no longer leaks into this row.
* Purple `text-purple-400` accent on the upcoming schedule dose
surfaces uniformly across both card kinds.
Four new test files cover the v1.4.37 W7c surface:
* group-by-day.test.ts (route) — collapses per-sample step rows into
one row per user-TZ day with sampleCount; gates on
CUMULATIVE_HK_TYPES so non-cumulative types keep the legacy
findMany behaviour; resolves the dayKey drill-down window via
measuredAt: { gte, lt }; rejects malformed dayKey at the
validation layer.
* measurement-list-step-grouping.test.tsx (UI) — renders the
collapsed daily total with the localised sample-count caption;
exposes a chevron-expand button with aria-expanded; suppresses
the edit/delete actions on the synthesised daily row.
* drain-per-sample-cumulative.test.ts (helper) — passes a
measuredAt cutoff into the per-sample scan when cutoffHours is
set; falls back to the all-rows behaviour for CLI/admin callers
that omit the option.
* drain-cumulative-queue.test.ts (scheduler) — pins the queue name,
cron cadence (03:45 Europe/Berlin), 36 h cutoff constant, and
pg-boss handler wiring.
Self-review polish on top of the W2 fast-path landings:
- Rename the misleading `ninetyFiveDaysAgo` local in
bp-in-target-fast-path to `readSince` so the 396-day BP rollup
read window does not read like a 95-day shortcut.
- Document the rollup-bucket-vs-display-tz day-key shift in
correlations-fast-path. The DAY bucket anchors at UTC midnight; for
the Berlin-centric production tenant that lands on the same local
calendar day, so the rollup-fast-path's per-day pair shape matches
the live path within the n >= 20 surface gate. A v1.5 follow-up can
mint per-user-tz buckets if a non-Berlin account needs strict
parity.
The GLP-1 card carried a third Stethoscope button in the primary
actions row (Eingenommen / Übersprungen / Nebenwirkung), which broke
the two-button symmetry the generic medication card establishes.
Marc reported the asymmetry during the v1.4.37 UX audit (audit
item 11): "ich möchte natürlich, dass das genau gleich ist".
Move the side-effect quick-log into a kebab-style overflow in the
header `actions` slot. The kebab only mounts when the parent wires
`onLogSideEffect`, so Mounjaro and Ramipril cards stay byte-equivalent
in the common case (history + edit, no kebab). When the side-effect
hand-off is wired, Mounjaro gains the kebab while the primary actions
row stays the canonical two-button shape.
The localised label re-uses the existing `common.moreOptions` key for
the trigger aria-label and the existing `medications.glp1LogSideEffect`
key for the menu item, so no new i18n strings are required.
Marc directive: when the operator turns the global Coach flag off,
every Coach affordance on every surface must vanish — no grey-out,
no error, no inert button, no DOM trace. The recent v1.4.37 W5
hotfixes closed three holes the UX audit caught (HeroStrip action
button, SuggestedPrompts chip strip, /targets CoachDrawer mount); a
follow-up audit item asked for an automated invariant so the gate
contract can't regress.
Walk every Coach-bearing web surface (HeroStrip action row,
SuggestedPrompts chips, CoachLaunchButton inline pill, LayoutCoachFab
mobile sticky, LayoutCoachMount drawer subtree) under a QueryClient
pre-seeded with `coach: false` and assert each one paints nothing
measurable in the SSR output. The fixture maintains a known surface
list; a trailing count check forces contributors to revisit the
fixture when a new Coach-bearing component lands.
The generic medication card surfaces the upcoming schedule dose with
a `font-medium text-purple-400` accent so titration overrides on the
schedule visually pop out of the muted next-intake line. The GLP-1
card omitted the accent entirely.
Mirror the same accent on the GLP-1 card so the only difference
between Mounjaro and Ramipril detail surfaces is the data being
shown, not the styling treatment. The schedule.dose still rides
inside the existing next-injection paragraph (hidden below sm: like
the generic card so the narrow-viewport row stays one line).
Registers a new drain-per-sample-cumulative pg-boss queue + cron
(03:45 Europe/Berlin, slotting between the audit-log cleanup at
03:15 and the feedback aggregator at 04:00). The handler invokes
drainPerSampleCumulative across every user with cutoffHours=36 so
today + the trailing watch-sync window stay per-sample while
completed-and-stable days collapse into one stats: row per
calendar day per cumulative HK type.
Replaces the manual CLI + admin-endpoint pattern from v1.4.30 for
the recurring case (the operator-driven paths remain for one-shot
backfills) and avoids the prod-image tsx + Prisma symlink hack
documented in feedback_prod_image_tsx_prisma_gap.md.
The drain previously collapsed every per-sample APPLE_HEALTH cumulative
row it could see, including today's still-in-flight watch syncs. When
called from the scheduled nightly job we want to leave a grace window
around now so the list view keeps surfacing late watch reconciliations
in real time before the day stabilises.
cutoffHours adds an optional window: rows newer than now() -
cutoffHours are excluded from the scan. CLI + admin-endpoint callers
keep the previous all-rows behaviour by omitting the option.
When the type filter is a cumulative HK type (steps, active energy,
distance, flights, daylight) the measurements list now opts into the
route's groupBy=day mode and renders one row per user-TZ day with
the day's total + a sampleCount caption. A chevron in the row
action column lazy-fetches the day's per-sample chunks via a second
query against ?dayKey=YYYY-MM-DD and inlines them beneath the row
so power users can still answer wann wurden Schritte gemeldet
without scrolling past 200 step rows on the default view.
Mirrors the universal Apple-Health / Garmin / Withings pattern
captured in the v1.4.37 research brief: one number per day as the
primary surface, drill-down to the chunks behind it.
GET /api/measurements gains two new branches:
* groupBy=day + cumulative type → returns one synthesised row per
user-TZ day with value=SUM, sampleCount, and a dayKey anchor so
the list view paints one row per day instead of hundreds of
per-sample chunks from Apple Health.
* dayKey=YYYY-MM-DD + cumulative type → returns the per-sample rows
for that single calendar day in the user's IANA timezone, so the
expand chevron in the row can lazy-fetch the chunks that summed
into the daily total.
Both branches gate on CUMULATIVE_HK_TYPES so the iOS contract for
spot metrics (BP, weight, pulse, BG) stays untouched. The collapsed
row's measuredAt anchors to local noon to match the
canonicalDailyTimestamp convention used by the drain helper.
The generic medication card painted a coloured take-now / overdue /
very-overdue pill at the top of the detail surface; the GLP-1 card
silently omitted it. Marc reported the asymmetry during the v1.4.37
UX audit (audit item 11): Mounjaro and Ramipril cards felt "different"
even though both should read as the same row shape.
Wire the shared `reduceCurrentWindowStatus` reducer into the GLP-1
card, fetch the same reminder thresholds as the generic card, and
re-render once a minute so the pill tracks wall-clock progress without
a route reload. The pill renders only when a schedule is currently
in/past its window and the user has not yet covered every overdue
schedule with today's intake events — same suppression rules as the
generic card.
Add a render assertion that the inner column ships with grid +
flex-1 + grid-rows-[auto_auto_auto_auto_auto_1fr_auto] so a future
refactor can't silently revert to a flex stack and re-introduce
the v1.4.36 "Karte hört bei der Trennlinie auf" report. The
mt-auto + h-full + flex-col + flex assertions stay in place
alongside the new grid contract — they describe complementary
load-bearing classes.
Mock useFeatureFlags so the suite can drive both the flag-on (CTA
mounts) and flag-off (CTA hides) branches alongside the existing
aiEnabled gate test. Both branches share the all-on default unless
the case overrides it with mockReturnValueOnce, so the rest of the
suite continues to render with the default-mock flag set.
Every chip in <SuggestedPrompts> is a Coach affordance — clicking one
seeds the Coach drawer's composer with the localised prompt and opens
the drawer. The strip rendered unconditionally until now, so a
deployment with the operator's global Coach flag off still painted
five inert chips below the hero band.
Read flags.coach inside the component and short-circuit when the flag
is off. The HeroStrip caller already wraps the mount in the same
guard (W5 commit prior); this is defence-in-depth so a future caller
that mounts <SuggestedPrompts> outside the hero band can never leak a
Coach surface.
Read useFeatureFlags() inside TargetCard and pair the existing
aiEnabled guard with flags.coach so the per-card Coach CTA hides
entirely when the operator disables the Coach feature globally.
Pre-fix the CTA mounted whenever the user-level provider chain was
configured, even when the operator-level kill-switch was off; the
disable cascade is supposed to make every Coach surface disappear,
not just the hero-strip button. Sister sites (hero-strip,
suggested-prompts, /targets CoachDrawer) belong to W5.
Adds an SSR-only contract suite for `<ArztberichtHeroCard>` covering
the hero slot, value statement, CTA touch-target floor, the
`aria-describedby` link from the CTA to the value statement, the i18n
key resolution path (DE + EN), the hero-gradient + glow-purple visual
treatment, and the closed-state pass that keeps the dialog markup out
of the SSR output until the CTA opens it.
Updates the existing `<ExportSection>` smoke suite for the v1.4.37 W7a
shape: four secondary cards in the grid (not five), the hero mounted
above the grid, and the "Other export options" sub-heading.
The doctor-report PDF is HealthLog's flagship "data out" path. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid; this change lifts it into a hero card at the top of
`/settings/export`. The remaining four export cards (Measurements CSV,
Medications CSV, Mood CSV, Full JSON backup) move under a dedicated
"Weitere Export-Optionen" / "Other export options" sub-heading.
The hero reuses the `hero-gradient + glow-purple` visual treatment from
the Insights hero strip, so the surface feels consistent across the
app. The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first: at
< sm the CTA falls onto its own row and clears the 44 px WCAG touch
floor (`h-11 sm:h-10`); the button carries an `aria-describedby` link
to the value statement so screen readers announce the framing
alongside the action label.
/targets carries two Coach affordances — the per-card 'Ask Coach' CTA
on every TargetCard and the page-level CoachDrawer mount. The CTAs
were gated through `aiEnabled` (provider chain status), but the
drawer mount always rendered regardless of the operator's global
Coach flag. When the operator turned Coach off in Settings the drawer
still loaded its SSE machinery + Sheet portal subtree even though no
on-page CTA could open it.
Read flags.coach via useFeatureFlags and fold it into the existing
`aiEnabled` gate so the per-card CTAs vanish when either layer is
off. Wrap the page-level <CoachDrawer> mount in the same gate as
defence-in-depth, since the CTAs already hide when the flag is off.
Tests: add two new cases to targets-coach-mount.test.tsx covering the
Coach-flag-off branch (CTAs hide + drawer mount disappears). The
sibling targets-* test files mock @tanstack/react-query without
exposing QueryClientContext; add a use-feature-flags mock that
short-circuits to the all-on default so the existing assertions keep
running against the visible page shape.
The generic medication card mapped `medication.category` to one of the
localised category labels via an inline lookup table, while the GLP-1
card hard-coded `medications.treatmentClassGlp1` into the same slot.
The asymmetry made Mounjaro and Ramipril cards visually diverge on the
medications list even though both should read the same row shape.
Lift the lookup into `src/lib/medications/category-label.ts` and use it
from both card variants. The GLP-1 nature of a medication is already
implied by the rotation hint + injection-metadata rows below the
header, so the category badge now carries the real classification
(BLOOD_PRESSURE, HORMONE, OTHER, ...) on both cards.
Tests pinned the old contract; they now assert the new shared label
shape for both English and German locales.
Switch the title-row flex from items-start to items-center
sm:items-start. The dashboard.title plus welcomeText sub-line
wraps to two lines on < 380 px viewports and the button used to
hug the top of that 2-line block without a baseline anchor.
items-center on mobile centres the button against the title; sm+
returns to the original top-aligned posture where the title is
one line.
Collapse the Card's inter-row gap to 0 and drop the CardHeader
inner gap to 0 so the only space between the title and the chart
strip is the CardHeader's own pb-1 (4 px). HealthChart mini paints
a plain div with mb-1 under its title; Mood's pre-fix gap-1
(4 px) on top of the header pb-1 stacked an extra 4 px below the
title, leaving Mood ~12-16 px taller than the BP/weight siblings
inside the trends row.
The doctor-report PDF is HealthLog's flagship "data out" path — the
artefact a user actually carries into a doctor's appointment. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid, which undersold the feature.
This change extracts the doctor-report flow into a new
`<ArztberichtHeroCard>` component and renders it at the top of
`/settings/export` with the same `hero-gradient + glow-purple` visual
treatment the Insights hero strip uses, so the surface feels
consistent with the rest of the app. The remaining four export cards
(Measurements CSV, Medications CSV, Mood CSV, Full JSON backup) move
under a dedicated "Weitere Export-Optionen" / "Other export options"
sub-heading.
The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first:
the hero stacks at < sm with the CTA falling onto its own row; the
button clears the 44 px WCAG touch floor (`h-11 sm:h-10`) and carries
an `aria-describedby` link to the value statement so screen readers
announce both alongside the action.
Adds a new `settings.sections.export.hero.{eyebrow,valueStatement,cta,
formatHint}` bag plus a `settings.sections.export.otherOptionsHeading`
key across DE, EN, FR, ES, IT, PL so the doctor-report hero card and
the "Other export options" sub-heading resolve everywhere. Retires the
now-unreferenced `settings.sections.export.cards.doctorReport.action`
key — the hero owns the CTA copy from this point on.
DE + EN ship fully translated; the four AI-initial locales (FR, ES, IT,
PL) receive native translations for the hero copy in line with the rest
of their export bag.
Both the generic medication card and the GLP-1 variant need to paint
the same coloured take-now / overdue / very-overdue pill, but the
schedule-window math lived inlined inside medication-card.tsx only.
Move parseTimeToMinutes, toBerlinDate, getWindowStatus,
isLastIntakeInCurrentWindow, and the reducer that picks the
most-actionable schedule into src/lib/medications/window-status.ts so
both cards can consume the same code path.
No behaviour change for the generic card. The extracted reducer
mirrors the previous inline implementation, including the
overnight-window adjustment, the late / very-late thresholds, and the
"don't show in-window if the last intake already covered the window
today" guard. The helper accepts any schedule shape that exposes
windowStart, windowEnd, and daysOfWeek, which both card variants
already satisfy.
Override the Card primitive's default gap-4 md:gap-6 with gap-3
md:gap-4 and drop the CardHeader's pb-3 to pb-0. The default
spacing stacked on top of the CardHeader padding rendered a ~36 px
dead gap between the metric label and the headline number on md+,
so the card visually split into two disconnected blocks. The
tighter rhythm pulls the value back under the label so the card
reads as one cohesive unit.
Swap the inner column from a flex stack to a 7-row grid with a 1fr
slack row on the provenance accordion. The hero strip's row already
stretches the card via items-stretch, the card already declares
h-full, and the disclaimer keeps mt-auto for the no-grid render
path. The remaining work was teaching the inner column to actually
distribute the recovered vertical slack — flex with mt-auto only
on the disclaimer parked the recovered space under the disclaimer
instead of between the body and the footer, so the visible card
ended a divider-line above the left column's bottom edge.
The Personal Health Score builder used to read 37 days of raw weight
rows from the measurements table on every dashboard mount. When DAY
rollup coverage exists for WEIGHT, the new helper derives the weight
series from per-day MEAN values in measurement_rollups and only
issues a narrow 2-column projection against measurements for the
source-attribution pill (the rollup table does not carry the source
enum). Partial-coverage and brand-new accounts keep the legacy 37-day
raw read so the score stays correct on first cold-mount.
Mood, medications, and intake events still read live regardless of
coverage — none has a rollup equivalent today. A path:"rollup"|
"live" annotate on meta.healthScore.path makes the branch selection
provable in prod logs, matching the bp_in_target and summaries-slice
patterns.
Body relocated from route.ts to lib/analytics/health-score-fast-path.ts
so the dispatcher can be unit-tested against mocked Prisma without a
container. Unit test pins both branches (probe → rollup, probe →
live), the empty-component-set early-out, and the implicit-probe
shortcut for callers that omit the coverage map.
The hero band carries three Coach affordances — the action-row 'Ask the
coach' button, the SuggestedPrompts chip strip, and (via prop drilling)
the HealthScoreCard's onAskCoach handler. All three open the same Coach
drawer that the layout already gates on the operator's global Coach
flag. Until now they painted unconditionally; users on a Coach-disabled
deployment would click the chips or button and the drawer mount would
refuse to render, leaving the controls inert.
Read flags.coach in the hero and short-circuit each Coach surface when
the flag is off. The action row + chip strip are conditionally
rendered; the HSC's onAskCoach prop drills through as undefined so a
future re-addition of an inline HSC Coach button stays gated too.
Add whitespace-nowrap to the DropdownMenuItem primitive so every
dropdown row stays on a single line app-wide, and bump the sidebar
3-dot overflow menu from w-56 to w-60. The Benachrichtigungs-Center
label (23 chars) plus its Bell icon and the default text-sm cap sat
right at the 14-rem ceiling and wrapped at the hyphen on sub-pixel
rounding.
Split the trigger's horizontal padding (pl-3 pr-2.5) and push the
chevron 4 px further right via [&_svg:last-child]:mr-1 so the
trailing glyph sits at the same visual gutter as the browser-native
date-input calendar icon. The old uniform px-3 parked the chevron
12 px from the edge, which read visibly tighter than the date-picker
glyph when both controls shared a form row.
Run prettier --write on the four files the v1.4.37 W3 commits
touched (the schema, the route, the V2 component, and the two test
files). Drive-by also collapses two pre-existing whitespace drifts
in the InventoryItem type aliases and the glp1PostBodySchema refine
call so the file matches the formatter contract end-to-end. No
behavioural change.
Five SVG diagrams anchor the architecture story across README, the
public doc site and the landing page:
- 01-data-flow: source → ingest → store → read → surface
- 02-coach-pipeline: question → snapshot → prompt → provider chain → cited reply
- 03-self-hosting-topology: reverse proxy → app + worker → Postgres + image registry
- 04-source-priority: per-metric ladder with audit-trail kept/dropped lanes
- 05-security-model: three concentric layers plus rate-limit / audit / SSRF rails
Hand-authored SVG, no external dependencies, mirrored into the
healthlog-docs and healthlog-landing repos so each surface ships its own
copy.
Add two SSR fixtures to lock the v1.4.37 W3 fix in place: a mixed
taken+skipped seed asserts the distinct lucide icon + green styling
for taken rows and the muted outline styling for skipped rows, and a
German-locale skipped-only seed proves the table body never leaks the
"Eingenommen" label onto a skipped row. The seed helper threads the
new `status:"completed"` cache-key shard so the component reads its
own seed without firing a network call.
Marc reported that rows with `takenAt:null AND skipped:false` rendered
as "Eingenommen" with an empty leading column on the medication
detail page — the binary `event.skipped ? "Übersprungen" : "Eingenommen"`
mapping never accounted for the "missed / never confirmed" branch the
v1 component used to surface as "verpasst". The list now pins
`?status=completed` so those ambiguous rows stay off the detail-page
table (they remain visible on the calendar / today surfaces), and the
status cell branches explicitly: green secondary chip with a check
icon for taken rows, muted outline chip with a skip-forward icon for
skipped rows. Derived from `!skipped && takenAt` rather than `skipped`
alone so a malformed row cannot sneak past as "Eingenommen" again.
Extend the list endpoint with an optional `status` query knob —
`all` (default), `taken`, `skipped`, `completed` — so the detail-page
intake history can hide unconfirmed rows server-side without changing
the byte-stable contract the iOS Swift client and the existing
dashboard tiles depend on. `completed` (taken OR skipped) covers the
detail-page surface; `taken` / `skipped` are there for any future
single-arm consumer. Co-located route test pins the Prisma where
fragment per branch and the 422 on unknown values.
When DAY-bucket coverage exists for both BLOOD_PRESSURE_SYS and
BLOOD_PRESSURE_DIA, the /api/analytics default slice now derives the
five in-target windows (7d / 30d / all-time / prior month / prior
year) from rollup mean values instead of paging the trailing 365 days
of raw measurements. The new helper pairs SYS and DIA per-day means
by day key, runs the existing isBpReadingInTarget predicate, and
buckets days into the reporting windows. Partial-coverage and
brand-new accounts fall back to the legacy chunked aggregator so the
per-event pairing contract still holds where the rollup table cannot
guarantee parity. A path:"rollup"|"live" annotate on the meta dict
makes the branch selection visible in prod logs, matching the
v1.4.36 summaries-slice pattern.
The per-day-mean approximation is documented in the helper's header.
For typical multi-reading-per-day usage it lands within ~2 % of the
per-event count; outlier days mixing extreme readings can flip the
classification either way. The live fallback remains the per-event
source of truth.
Captures the full scope handed over after v1.4.36 went live: perf
carry-over from the v1.4.36 deferred list, IntakeHistoryListV2
Ramipril regression with concrete UAT details (planned/skipped
rows misrendered as eingenommen with grey em-dash), UI symmetry
punch list (HealthScoreCard height parity, 3-dot overflow nowrap,
targets-page header gap, dropdown-arrow margin, Trends-card sizes,
dashboard Hinzufuegen button), medication detail card symmetry
(Ramipril vs Mounjaro), global Coach disable cascade, settings
cleanup (timezone override removal, IP-whois resolution fix, BMI
status stuck loading), three new features (Arztbericht hero card,
Hinzufuegen medication-intake overlay, Apple Health step daily
consolidation), and a documentation refresh wave covering README,
landing page, and the doc site with Excalidraw diagrams. Last big
web release before iOS becomes the sole focus.
Captures the four items raised right after the v1.4.36 live
verify: full /api/analytics cold-path (carry-over from v1.4.36
deferred list, 111 s worst-case cold), Insights overview hero-row
height parity for the HealthScoreCard, TopBar 3-dot overflow
single-line constraint (Benachrichtigungscenter currently wraps),
and an IntakeHistoryListV2 regression that surfaces planned
schedule rows as "Status: eingenommen" on the medication detail
page. Last item flagged as a v1.4.36.1 hotfix candidate pending
confirmation.
Adds the v1.4.36 row to the audit-marathon closure ledger (now
five releases on 2026-05-17) with the headline numbers and the
post-deploy perf-QA write-up. Live measurements confirm the
page-blocking insights.comprehensive call dropped from 10.88 s
to 4.57 s cold (-58%), the AI prompt payload trim landed
(insights.generate codex run used 30,096 tokens versus the
25.9 MB raw baseline -- a 99.5% reduction), and the rollup
fast-path is firing on the slim summaries slice with the
"path":"rollup" annotate. Full /api/analytics correlations
branch still runs live SQL on a cold pool (worst-case 111 s
recorded, stabilising to 756 ms warm and 11-28 ms cached) and
is filed as the v1.4.37 P1.
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets, Insights extractFeatures
swap drops the prompt payload from double-digit MB to the low
hundreds of kB on power-user accounts, page-level isLoading gate
replaced with per-section early-skeleton paint, trends-row chart
slot pinned for CLS, IntakeHistoryListV2 restored on the medication
detail page, About card folded into the Admin Console with an
auto-checking update badge, cumulative metric tiles read the day's
running sum, IP-whois fallback surfaces city + country when the
ASN lookup misses. Migration 0068 adds an additive
users.insights_exclude_metrics column. Unit suite 4354 passing;
typecheck + lint clean.
The read-path warm-up's bare `catch {}` hid populator regressions —
a deadlock or pool exhaustion inside `recomputeUserRollups` would
return `{ recomputed: false }` silently and the read fall-through
to the live aggregator masked the failure until performance bled
through.
The catch now annotates `rollup_refresh_failed` plus the error
message on the wide-event pipeline (no-op when there's no request
context) and emits a `console.error` with the same shape so the
worker log path surfaces the failure regardless of caller surface.
Return shape unchanged — the read path still must not fail.
Unit test simulates a deadlock inside the inner recompute and asserts
both the annotate meta and the console fallback fire while the outer
call still returns `{ recomputed: false }`.
The generate route caught `FeaturesPayloadTooLargeError` from the
raw-mode `extractFeatures` call and retried in aggregated mode. The
retry ran the same `enforceSizeGuard` from `features.ts`, so a
very large medication history plus multi-year context could blow past
the 5 MB ceiling a second time → the uncaught throw bubbled to the
apiHandler's generic 500 path.
The retry now sits in its own try/catch. On a second oversize the
route applies the existing `applyInsightsExcludeFilter` with the
optional context blocks (anthropometrics, medications, sleep, steps,
hrv, resting_hr) excluded and re-extracts. A third failure annotates
`insights_payload_too_large` for ops visibility and returns 422
with a readable user-facing message rather than 500. Unit test mocks
all three extractor calls to throw and asserts the 422 + annotate.
The dedicated update card was removed in 0738ef50 but its seven
copy keys (updatesHeading, checkUpdates, checking, lastChecked,
lastCheckedNever, upToDate, checkFailed) stayed in all six
locales. Only newerAvailable is still referenced — by the
UpdateBadge. Sweep the 42 unused strings.
The build-time row read its label from `t("builtAt", { time: "" }).trim()`
with a fallback to the gitSha key. The fallback was effectively dead
(the trim never produced an empty string) and the pattern silently
risked rendering two side-by-side "Build" columns when an
implementer dropped the trailing `{time}` placeholder. Replace with
a dedicated `settings.about.builtAtLabel` key across all six locales.
Extract the newer-version badge into an exported UpdateBadge
component. Renders as a 44 px-square anchor (or role=img span
fallback) with text-primary colour, focus-visible:ring-* on the
anchor branch, and aria-label/title carrying the latest tag so
keyboard + screen-reader users get the same signal sighted users
get from the icon. Drops the hardcoded text-dracula-purple in
favour of the semantic primary token, which already meets WCAG AA
on light + dark.
The v1.4.36 perf wave wrapped the briefing / correlation / trends
blocks in `<Suspense>` boundaries. The page is `"use client"`,
the blocks load via `next/dynamic({ ssr: false })` (no promise
throw on hydrate), and TanStack Query hooks never throw thenables, so
the boundaries never engaged — dead code, masking the real shape of
the delivery.
The actual perceptual win remains intact: the page-level
`isLoading` gate is gone, the hero plus each section's own
loader skeleton paints inside the first paint budget while data fills
in. A comment in the file explains why no `<Suspense>` wrapper
belongs here so a future contributor doesn't re-add one.
NOTE for CHANGELOG drafting: the perf-wave bullet should read
"early-skeleton paint" rather than "Suspense streaming".
Genuinely streamed server children remain a v1.5.x track.
The v1.4.35/v1.4.36 read-swap gated on a global `COUNT(*) > 0`
against `measurement_rollups`. A user with BP fully rolled up who
logged their first WEIGHT measurement saw the per-write hook upsert
one WEIGHT DAY bucket, the global probe flipped to true, the read
path picked the bucket-derived branch for WEIGHT, and the all-time
WEIGHT count collapsed to whatever the trailing 90d narrow aggregate
plus the single fresh bucket could reconstruct.
The new shared probe in `rollup-coverage.ts` joins distinct
measurement types against the rollup table per user and surfaces
`{ type, has_buckets }` rows. Both `computeSummariesSlice` and
`buildComprehensiveAggregate` take the rollup-derived branch only
when every type the user has logged is covered; partial coverage
falls back to the live aggregate so the brand-new-type case stays
correct. Integration test seeds BP rolled up + WEIGHT raw-only and
asserts the WEIGHT summary count matches live SQL.
shadcn Button size="sm" renders at 32 px — below the project's
44 px touch floor adopted in v1.4.34.4. Use the default size with
an explicit min-h-11 sm:min-h-9 override so the pagination row
stays compact on desktop but clears WCAG 2.5.5 on touch.
Sort headers relied on hover:underline only and dropped below the
44 px touch target floor on mobile. Add focus-visible:ring-* and
min-h-11 sm:min-h-9 so keyboard + touch users can hit the column
header reliably.
Pulls the v1.4.36 W4c MeasurementType -> SourcePriorityMetricKey
chain out of the dashboard per-type loop into a typed switch helper.
Same call sites, same null fallback for the unmapped TIME_IN_DAYLIGHT
branch.
v1.4.36's perf wave added a `SELECT COUNT(*) FROM measurement_rollups`
probe at the head of `computeSummariesSlice`. The slim-slice route test
mocks `$queryRaw` with two `mockResolvedValueOnce` calls (aggregate +
latest); the new probe consumed the first slot, leaving the aggregate
read pointing at undefined and the route returning 500.
Seed `n: BigInt(0)` as the first mock value so the route deterministically
takes the legacy heavy-aggregate fallback path the rest of the test
expects.
The carrier chip on the admin login-overview audit table was empty
whenever the offline GeoLite2-ASN MMDB had no entry for the IP. In
practice this is every IP behind a non-DACH or cloud-NAT range — the
free-tier MMDB carries DACH carriers densely but thins out
elsewhere — and the chip went blank even though the city + country
were already populated from the parallel location lookup.
When `entry.carrier` is null but `entry.location` is non-null,
render the chip with the city/country string and a "carrier
unavailable" qualifier. Private/loopback IPs still hide the chip
entirely (both columns null).
The dedicated "Updates" card carried a "Check for updates" button,
a last-checked timestamp, and three result paragraphs covering
up-to-date / newer-available / unknown. All of it surfaced an answer
to one question — "is there a newer release?" — and the answer was
overwhelmingly "no, you're current". The card was three full rem of
chrome for that signal.
The 24 h auto-check still runs on mount. When it returns
`newer_available`, an ArrowUpCircle badge appears next to the
version line; hover/tap exposes the latest tag via the title
attribute. The link points at the release HTML URL when present.
No bouncing, no badge counter, no manual recheck button.
The settings/about route now reads as a tight identity card +
external links list, matching how often the surface actually
gets used.
The targets endpoint runs >1 s cold — pulls 30 days of measurements
for every type plus medications, intakes, mood and glucose to build
the consistency strips, streaks and target-met-this-week rollup. It's
read on every Insights mount and on every sub-page click, so the
cumulative cost dominates a power user's session.
Wrap the body in the existing analytics-style server cache with a 60 s
TTL and a per-user key. The invalidation helpers for measurement,
mood and medication writes evict the user bucket so the next read
paints fresh data without the cache lagging the underlying state.
The bfcache-friendly Cache-Control header lets a back-forward
navigation restore from the BFCache instead of repaying the cost.
The About surface (version + license + source links + update check)
used to live as its own Settings section and the user-card dropdown
exposed it from both desktop and mobile. The three cards collectively
get opened once or twice a year, mostly by the operator confirming
the running version against the latest release.
Move the surface to `/admin/about` and drop the dropdown link from
both the desktop sidebar and the mobile top-bar. The public-facing
landing at `/about` (credits + MaxMind attribution) stays untouched
since it's reachable without a session. The legacy `/settings/about`
route still resolves so external bookmarks don't break, but is no
longer surfaced in any nav.
The existing `<AboutSection>` is reused as-is — no markup changes,
the component owns its own heading so the admin renderer mounts it
without the SectionFrame wrapper.
The Insights trends row fires three parallel
GET /api/measurements?type=…&aggregate=daily&limit=5000 requests on
mount (BP_SYS / BP_DIA / WEIGHT). The legacy date_trunc path scanned
the measurements table each time — ~3 s per call on Marc's account.
Add a `source=rollup` opt-in on the listMeasurements schema. When set
alongside `aggregate=daily` the route reads from measurement_rollups
DAY buckets instead, swapping a full-table scan for a small indexed
read against the ~5 k-row rollup table. Cumulative HK metrics get
mean * count to reconstruct the daily SUM the chart expects.
The chart-data client opts in for every windowDays > 7 fetch so the
trends row + every sub-page chart benefit. When the rollup returns
zero buckets for the requested window the route falls through to the
live aggregate so brand-new accounts still render their first chart
correctly.
Walk the features payload and the Coach snapshot through a shared
compactSections helper before JSON.stringify so the prompt never
carries labelled-empty keys ("sleep": [], "medications": {}, etc.).
Empty arrays, empty plain objects, and undefined values are dropped;
null is preserved (it signals "unknown", which the model handles
differently than "missing block").
The pre-v1.4.36 fallback rendered every metric pill while the
analytics fetch was in flight. Users with zero observations on a
metric briefly saw nav targets they couldn't act on, and on a
failed or slow fetch the pills stayed up forever — clicking
through landed on an empty-state page.
The strict gate flips the contract: no availability => only the
Overview pill renders, then metric pills light up progressively
as `summaries[METRIC].count` flips from 0 to ≥1. This matches
the auto-light-up behaviour the original metric-availability
helper was designed for.
The dashboard tile gate and per-sub-page empty-state gate already
applied the strict reading; the tab strip is now in lockstep.
Add insightsExcludeMetrics to the User schema and apply the list as
a feature-payload filter inside /api/insights/generate before
JSON.stringify. The Coach settings PUT mirrors its excludeMetrics
onto the new column so both surfaces share a single privacy
contract — no separate Insights settings panel needed.
Anthropometrics is special-cased to strip only height / age / gender
inside features.context, leaving the aggregate totals intact.
v1.4.35 left the regression intact: comprehensive-aggregator and the
slim summaries slice still ran the full COUNT/MIN/MAX/AVG `$queryRaw`
in parallel with the rollup read, only preferring the bucket-derived
values when the count happened to match. Net round-trip count saved
was zero on Marc's 311k-row account.
Promote the rollup table to the canonical source on the happy path:
gate on a cheap `SELECT COUNT(*) FROM measurement_rollups` and, when
non-zero, drop the heavy aggregate. The bucket composition feeds
count/min/max/mean, a narrow `$queryRaw` keeps the non-composable
windowed columns (stddev/anomalies/avg7/avg30/avg30_last_month + the
three slope tuples), and the legacy heavy aggregate stays as the
cold-mount fallback for accounts whose backfill is mid-flight.
Unit tests pin the contract: the heavy aggregate is NOT called on the
rollup-fresh branch. Integration test verifies byte-identical count /
min / max / mean against a parallel live SQL pass on a populated
fixture.
The Steps tile, Active-Energy tile, Walking + Running Distance tile,
Flights-Climbed tile and Time-in-Daylight tile all rendered the
*latest minute-level slice* from Apple Health as `summary.latest`
instead of the day's total. The user-facing result was a Steps tile
that read "1,247 steps today" when the actual day sum was closer to
8,000 — the tile was showing the freshest slice, not the running total.
SLEEP_DURATION already ran the per-day-source-pick + per-day-sum
collapse inline in /api/analytics. Extract that reduction into
`pickCumulativeDaySum` and broaden it to ACTIVITY_STEPS,
ACTIVE_ENERGY_BURNED, WALKING_RUNNING_DISTANCE, FLIGHTS_CLIMBED, and
TIME_IN_DAYLIGHT.
Each cumulative type maps to its own SourcePriorityMetricKey for
the canonical-source pick (TIME_IN_DAYLIGHT falls through because
there is no clinical-grade competitor today). After the collapse,
`summary.latest` is the most recent day's total, which is what the
dashboard tile expects.
Tests: 7 new unit cases on the pure helper; SLEEP_DURATION
integration regression still green.
Extend the coach exclude-metrics enum with two optional-context tokens
and surface them in the settings sheet under a separate "Include
context" group. The snapshot gates the GLP-1 weeklyContext block on
the medications toggle (skipping the Prisma lookup entirely when
excluded) and gates the new anthropometrics block on the
anthropometrics toggle plus at least one non-null profile field.
Empty blocks are never emitted.
The Settings + Admin shells centred the active chip via
`scrollIntoView({ inline: "center" })`. On narrow viewports the
strip over-scrolled past the leading chips so the first one or two
were unreachable until the user manually swiped right. `inline:
"start"` lands the active chip at the leading edge so the trail of
preceding chips remains visible.
The v1 IntakeHistoryList was retired in v1.4.28 on the assumption
it had no other consumer. The standalone /medications/[id]/history
page lost its dose timeline as a side-effect — non-GLP-1 medications
now render only the heading. This restores a slim, read-only v2.
- Paginated 25/page via the existing limit/offset query params
- Sortable by takenAt (default desc) and scheduledFor
- Status and source badges; no inline edit or delete
- Empty state with CTA back to the daily intake page
- i18n keys across all six locales (DE + EN curated, the AI-initial
locales mirror the EN copy per the v1.4.25 W9e maintainership policy)
The trends-row annotation slot painted "Mehr Daten nötig, um diesen
Trend zu kommentieren" whenever the advisor query was in flight or a
regenerate was firing — even when the model was about to deliver
fresh prose. Two cold-mount conditions collapsed into the same empty
hint: advisor pending, and advisor returned null.
`<TrendAnnotation>` now reads a tri-state `status` prop:
- `pending` — advisor query in flight / regenerate firing.
Renders a 3-line shimmer block.
- `needs_data` — advisor returned `annotation = null`. Renders the
"Mehr Daten nötig" hint.
- `generated` — advisor returned a string. Renders the prose +
optional confidence chip.
When `status` is omitted the legacy `annotation == null → empty`
mapping still applies (back-compat for existing callers).
`<TrendsRow>` derives the status from a new `loading` prop and the
per-metric annotation presence, with pending winning over needs_data
so a mid-regenerate cycle never flashes the empty hint between
shimmer and new prose. The Insights mother page now passes
`loading={advisor.isLoading || advisor.isRegenerating}`.
Added unit tests pin the three render-state branches plus the
back-compat fallback. New `trendAnnotation.pendingLabel` i18n key
across DE / EN / FR / ES / IT / PL drives the aria-label on the
pending block. Health Score paint lag is a parent-level analytics
query issue and stays for v1.4.37.
Replace the per-row rawMeasurements dump with DAY/WEEK/MONTH bucket
means read from measurement_rollups. The legacy shape blew past
Codex's 10 MB string limit on power-user accounts; the bucketed shape
stays O(types × buckets) regardless of row count.
Enforce a 5 MB ceiling on the serialised payload; the generate route
downgrades to the aggregated shape and annotates when the cap trips so
future regressions surface in logs.
The trends-row chart slot is now pinned to 140 px, but the default
`<ChartSkeleton>` paints a `rounded-xl` card with `p-4 md:p-6` and a
240 px chart band — overflowing the slot and reintroducing the
layout shift the height pin was meant to eliminate.
Add a `mini` prop to `<ChartSkeleton>` that renders the lighter
`rounded-md p-2` chrome plus a 140 px band. Expose
`HealthChartDynamicMini` from the chart-dynamic boundary so the
trends-row consumers pick up the matching loading shell without
having to wire the skeleton choice at every call site.
Trends-row swaps both `<HealthChartDynamic>` calls (BP + weight) for
the mini variant and the local `<MoodChart>` `next/dynamic` loader
now passes `mini` to its skeleton. The dashboard hero + sub-page
charts keep the default card chrome (`mini={false}`).
Each `trends-row-chart-slot` wrapper had `shrink-0` but no explicit
height. Recharts' ResponsiveContainer mounted with `width=-1
height=-1` and emitted a warning on every Insights cold mount; the
mood card visibly drifted taller than BP / weight as the chart
hydrated, contributing the bulk of the Lighthouse 0.46 CLS hit.
Pin the wrapper to 140 px (matches the chart-internal mini default).
Recharts now mounts at a known size, the three cards line up on the
same baseline from first paint, and the warning is gone. Test pins
the height on all three slots so a future refactor has to revisit
the row rhythm intentionally.
Removes the operator step that was implicit in the v1.4.35 foundation.
Self-hosters no longer need to run the backfill script after upgrade.
New `rollup-full-backfill` queue + handler folds one user per job at
serial concurrency. `enqueueBootTimeRollupBackfill` runs once at
`startReminderWorker` boot, discovers accounts with measurements but
zero rollup coverage, and enqueues one full-fold job per uncovered
user with a singleton key so fast restarts don't pile up duplicates.
Best-effort: discovery errors surface through the helper's return
value and are logged via workerLog; the worker boot never fails
because the backfill missed.
Tests: 4280 → 4285 unit + 228 → 230 integration. tsc + lint clean.
Switches two reader surfaces onto the persistent rollup table for the
linearly-composable stats. The comprehensive insights aggregator and
the slim analytics summaries slice now source count / min / max / mean
from the DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets.
A defensive parity check against the live aggregate's COUNT(*) falls
back to live SQL when divergence is detected: covers the cold-mount
edge case where the rollup populator is mid-flight, and degrades
cleanly on the slim slice's all-time window for accounts that never
ran the explicit backfill.
Three new integration cases pin the parity contract end-to-end
against a real Postgres testcontainer: the aggregator's
count / min / max / mean matches live SQL byte-for-byte, dailyByType
matches a parallel date_trunc('day', ...) GROUP BY query, and a
freshly-written measurement is reflected on the next read.
tests: 4249 → 4280 unit + 222 → 228 integration. tsc + lint clean.
WIP toward v1.4.35. Lands the persistent rollup foundation from the
v1.4.34 cache-aggregation research §9. Reads still served by the
v1.4.34.1 live-SQL aggregator — the parity-safe read-swap
(count/min/max/mean + dailyByType from buckets, slope/r2/sd stay on
live aggregation) ships in a follow-up commit so v1.4.35 is the
single release that flips both foundation + read-swap.
Schema:
- new MeasurementRollup model with composite PK
(userId, type, granularity, bucketStart) and a descending
bucketStart index. RollupGranularity enum (DAY/WEEK/MONTH/YEAR).
- Prisma migration 0067_v1434_measurement_rollups is additive:
CREATE TYPE / CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT
EXISTS / ADD CONSTRAINT FK. No ALTER on existing tables.
- db-compat schema-bootstrap path mirrored.
Populator (src/lib/measurements/rollups.ts):
- recomputeBucketsForMeasurement: sync DAY; WEEK/MONTH/YEAR via
pg-boss (singleton-keyed).
- recomputeUserRollups: bounded full-range, used by Apple Health
import + backfill.
- ensureUserRollupsFresh: cheap stale-detection + bounded DAY-90d
recompute. Error-swallowing.
- SQL via $queryRawUnsafe + strict enum whitelist for date_trunc
unit (Postgres 42803 forbids binding the unit literal).
Write hooks on every measurement-mutating endpoint; each wrapped
in try/catch so a populator hiccup never fails the write.
Apple Health import worker recomputes the full range once at
completion (per-row hooks would multiply cost).
Backfill (scripts/backfill-rollups.ts): idempotent, single-user
serial, single-digit minutes for a 100k-row account.
Tests: +14 unit, +3 integration. setup.ts.truncateAllTables
extends to the new table.
Read-swap follow-up:
- comprehensive-aggregator + analytics/route + summaries-slice
each call ensureUserRollupsFresh as a read side-effect (warms
the table). The response stays on the live-SQL aggregator so
byte-shape parity with v1.4.34.5 is preserved.
- Next commit lands the partial read-swap: count/min/max/mean
and dailyByType from buckets (math-identical compose), while
slope/r2/sd stay on indexed live aggregation. Together they
ship as v1.4.35.
Pre-shipping state — do NOT tag v1.4.35 from this commit alone.
End-to-end summary of the eight-axis audit + the two consecutive
hotfix releases that landed the hotfix-ready findings: security,
UX/a11y, code quality, mobile-deep, docs depth, README + GitHub
discoverability, perf, and test coverage. Lists what shipped, what
got deferred to v1.5.x, the operator-action delta (one obsoleted),
and the quantitative deltas (24 commits, 2 releases, 55 net new
tests, 5 new user-facing docs).
Follow-on to v1.4.34.4: 24 new critical-path integration tests
(auth-password-change + admin-reset-password + withings-oauth +
passkey-register + cache-invalidation-coverage) and the textarea
iOS-zoom-on-focus fix across 5 sites. Integration suite goes from
197/50 to 222/55.
Five textareas in the app (Coach composer, bug-report description,
admin feedback note, medication-side-effects notes, medication-intake
JSON import) shipped `text-sm` (14 px) on every viewport. iOS Safari
triggers a viewport zoom-on-focus whenever an input renders text
below 16 px — so every tap on these surfaces yanked the page scale
and rebounded on blur. Switched each className to `text-base sm:text-sm`
so the mobile baseline clears 16 px while desktop keeps the compact
look the rest of the form chrome converged on.
No shared <Textarea> primitive exists yet — F-5's deferred work — so
the fix is per-call-site. Five files, one className change each, no
component re-shape.
Closes the highest-severity gaps from the v1.4.34.3 test-coverage
audit. Five new integration files, 24 new cases.
auth-password-change.test.ts (5 cases):
Pins the v1.4.34.3+ `destroyAllSessions` contract end-to-end.
Asserts that after a successful password change the user's
Session.deleteMany ran, ApiToken.revoked flipped to true, and
RefreshToken.revokedAt was set. Plus wrong-current-password (401)
and weak-new-password (422) negative paths. A second
concurrent-request that holds the old session cookie is rejected
by the next session-protected endpoint.
admin-reset-password.test.ts (5 cases):
Same three-transport revocation shape for the admin-driven
reset. Non-admin auth rejects with 403. Audit-log row carries
the action name `admin.user.reset-password` and the actor's
user-id.
withings-oauth.test.ts (5 cases):
GET /api/integrations/withings/connect returns the upstream
authorise URL with the right scopes + redirect URI. GET
/api/withings/callback exchanges code for tokens by stubbing
the Withings token endpoint; the user row's encrypted token
pair populates. POST /api/withings/disconnect clears them.
State-token replay (wrong cookie) rejects with 400. The
Set-Cookie header carries the URL-encoded form of the state
string — the assertion compares on the encoded value, not the
literal.
passkey-register.test.ts (4 cases):
/api/auth/passkey/register-options issues a fresh challenge
bound to the RP-ID. /api/auth/passkey/register-verify against a
stubbed @simplewebauthn/server verifier creates the Passkey
row. A tampered attestation rejects with 422 + a clear
errorCode. The challenge is single-use.
cache-invalidation-coverage.test.ts (5 cases):
Grids the v1.4.34 IW-G invalidation matrix across 5 write
surfaces: measurement POST evicts caches.analytics; mood-entry
POST evicts caches.moodAnalytics; medication POST evicts
caches.medications; dashboard-widgets PUT evicts
caches.dashboardWidgets; admin app-settings PATCH evicts
caches.bugreportStatus. The integration setup's `isolate: false`
parallel-DB-bleed quirk on app_settings was handled with a
`vi.importActual("@/lib/db-compat").ensureDbCompatibility()`
in beforeEach — pattern worth noting for any future test
touching that table.
Run: `pnpm test:integration` reports 222/222 passing across 55
files (was 197 in 50 files before this batch); tsc + lint clean.
Audit-driven hotfix bundle. CHANGELOG carries the full notes — this
commit stamps the version and the changelog header so the release
pipeline picks it up.
This is the sixth release deployed under v1.4.34.2's pull_policy:
always — the deploy mechanic should pick up the image without any
host-side retag fallback.
Three end-to-end integration walkthroughs closing the docs gaps the
v1.4.34.3 docs-depth audit flagged as critical user-facing surfaces.
Apple Health (docs/integrations/apple-health.md, 200 lines):
- Generating the iOS `export.zip` from the Health app.
- 1.5 GB upload cap, Zip64-aware streaming parser, content-hash
idempotency.
- Per-record / per-workout / per-correlation / per-clinical-record
coverage matrix grounded in the parser's source.
- Cumulative HK-type daily aggregation contract
(`stats:<HKType>:<YYYY-MM-DD>` external IDs).
- Live status-polling endpoint and the admin-import variant.
- Source-priority interaction with the existing Withings ladder.
Withings (docs/integrations/withings.md, 195 lines):
- Developer-portal app registration with the right OAuth scopes
(`user.metrics user.activity`) and redirect URI.
- Env-var layout (`WITHINGS_CLIENT_ID`, `WITHINGS_CLIENT_SECRET`,
`WITHINGS_WEBHOOK_SECRET`).
- Why the webhook URL carries the secret as a path segment
(v1.4.25 W17a — reverse proxies log `query_string` by default;
path segments aren't logged, so the secret stays off the access
log).
- Sync coverage (weight, body composition, BP, sleep, activity,
SpO₂), reauth handling, troubleshooting.
AI providers (docs/integrations/ai-providers.md, 244 lines):
- All four `User.aiProvider` values (OPENAI, ANTHROPIC, LOCAL,
CHATGPT_OAUTH).
- Default models, BYOK vs admin-shared, the provider fallback chain.
- Local-endpoint setup with Ollama, LM Studio, and vLLM examples;
SSRF-guard rationale; ChatGPT device-code OAuth pointer.
- Privacy stance — local endpoints keep measurement context inside
your network; hosted endpoints send the snapshot to the provider.
The sticky header was h-16 with no safe-area handling. On notched
iPhones (~44 px status-bar inset) the iOS PWA painted the status bar
over the header — the HealthLog logo + auth controls clipped under
the system clock. Adding the inset as inline padding-top reserves the
overlay without touching the 64 px content baseline.
Inline `style` over a Tailwind class because Tailwind doesn't expose
`pt-[env(safe-area-inset-top)]` without arbitrary-value escaping and
the inline form is clearer in the diff.
The v1.4.34.3 code-quality batch added a `toJson<T>()` helper in
`src/lib/db.ts` to consolidate the seven Prisma JSON-cast sites.
Two existing route tests mock the entire `@/lib/db` module via
`vi.mock` — those mock factories now need to expose the new export
so the route's import resolves to a real function under test.
The mock implementation is the identity cast (`<T>(v: T) => v`),
which matches what production does and keeps the test's JSON-payload
assertions stable.
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.
Mobile-deep:
- `viewport.themeColor` was pinned to `#282a36` (dark). The
Android URL bar + iOS PWA status bar stayed dark even when the
user was on the light theme. Replaced with the dual-media-query
form: `#ffffff` under `(prefers-color-scheme: light)`,
`#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
now matches the active palette.
- Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
the home-indicator safe area is ~34 px so the FAB collided with
the bottom-nav. Moved to
`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
floats above the inset-aware nav.
- Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
page the toast contrasted badly against the surface. Switched to
`theme="system"` so Sonner reads the resolved theme from the
root element (set pre-paint by the inline theme script in
layout.tsx) instead of pinning dark.
Self-hosting docs:
- `docs/self-hosting/getting-started.md` — step-by-step from
`git clone` to first measurement, covering the 3-secret env
setup (post-SESSION_SECRET drop), `docker compose up -d`, the
first-user-becomes-admin contract, and the next-step pointers.
- `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
Proxy Manager, Coolify, and bare-Nginx config examples. Closes
the README link at line 107 that previously punted to the docs
site.
Five hotfix-ready findings from the v1.4.34.3 code-quality audit.
SESSION_SECRET drop (F-3):
The env var was required by `docker-entrypoint.sh`, listed in
`.env.example`, `docker-compose.yml`, `CONTRIBUTING.md`, and the
scaling guide — but `grep -rn SESSION_SECRET src/` returned
nothing. Sessions are server-stored in `prisma.session` keyed by
the cookie value; the secret signed nothing. Every self-hoster
carried a ceremonial 64-char hex string that the app ignored.
Removed everywhere except the README (separate batch). Each fresh
install now generates 3 secrets, not 4.
toJson helper (F-9):
Seven sites cast Prisma JSON writes via `value as unknown as
Prisma.InputJsonValue`. Each call re-invented the same escape
hatch. Replaced with one helper in `src/lib/db.ts`:
`export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
Migrated all 7 call sites: `auth/me/doctor-report-prefs`,
`dashboard/chart-overlay-prefs`, `dashboard/widgets`,
`integrations/healthkit`, and 4 sites in
`apple-health-import-worker`. The helper has a one-line WHY
comment + a unit test pinning the cast shape stays stable.
db-compat docstring (F-10):
`src/lib/db-compat.ts` looks like accumulated backwards-compat
cruft on first read. It's actually the schema-bootstrap path that
runs ALTER-TABLE-IF-NOT-EXISTS so a fresh container syncs without
`prisma migrate deploy`. Added a header docstring naming the role
and pointing at `prisma/migrations/` as the canonical history.
Passkey types (F-11):
`src/lib/auth/passkey.ts:103,169` used `response: any` with
per-line `eslint-disable`. Imported `RegistrationResponseJSON` and
`AuthenticationResponseJSON` from `@simplewebauthn/types` and
dropped the disables.
Zero-TODO CI gate (F-15):
The audit confirmed product code has zero TODO / FIXME / XXX /
HACK markers — genuinely rare and worth preserving. Added a tiny
GitHub Actions workflow at `.github/workflows/no-todo-markers.yml`
that fails the build on any future re-introduction. Test files
are excluded; product code only.
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.
i18n leaks (3 primitives):
- Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
so a German user heard 'Close, button' on every modal. Both lift
`useTranslations()` and read `common.close`.
- ChartSkeleton's sr-only loading announcement was English-only.
The component is already a client island; reads `charts.loadingLabel`
from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
- Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
was hardcoded. Routes through `t("nav.breadcrumb")` with a new
key in each locale.
Focus rings (mobile + tablet nav):
- Bottom-nav primary links + the More overflow trigger relied on a
`text-muted-foreground` → `text-primary` colour transition only;
keyboard / switch-control users saw no focus indicator at all.
Added `focus-visible:ring-ring/50 focus-visible:ring-2
focus-visible:outline-none focus-visible:ring-offset-2` matching
the v1.4.33 sidebar treatment.
- Mobile top-bar trigger had `focus:outline-none` without a
`focus-visible` replacement. Same treatment applied.
Tap-target floor (WCAG 2.5.5):
- Input / NativeSelect / Select / DateTimeInput were locked to
h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
mobile clears the 44 px floor while desktop keeps the 40 px norm
that the Button primitive already converged on in v1.4.33.
Admin chip-strip auto-scroll-into-view:
- The admin shell shipped 13 chips but lacked the
`scrollIntoView({inline: 'center'})` effect the settings shell
got in v1.4.33. Admins tapping a chip past the right edge landed
on the new page with the strip scrolled to the left and the
active chip off-screen. Copied the settings-shell pattern verbatim.
List-page subtitles on mobile:
- Measurements / Mood / Medications pages hid their descriptive
subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
the subtitle on mobile because it doubles as contextual help.
Dropped the hide rule; the H1 still anchors the visual.
Insights empty-state CTA vocabulary:
- `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
`TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
`MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
empty-state CTAs emitted those mismatched tokens and the dialog
silently failed to open. Replaced with a single source of truth
derived from `MEASUREMENT_TYPES` plus a normalisation map for
legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
New unit test walks every `?add=` token emitted under
`src/app/insights/**` and asserts it resolves to a real form type.
HSTS gains `; preload` so the domain becomes eligible for the
Chromium preload list. Withings CSP host moves from global to a
path-gated entry (mirrors the existing AI-host gating) — closes a
DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns
once per process when `TRUST_PROXY_HOPS` and the XFF chain length
disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so
future callers can route to a tighter universal rate-limit bucket
when the chain looks tampered with.
Service worker now resolves push-notification payload URLs against
`self.location.origin` and rejects off-origin redirects (falls back
to `/`). The German-only offline fallback HTML body was replaced
with a language-neutral icon + 'Offline' wordmark so EN / FR / ES /
IT / PL users don't see German on a cold offline boot. Manifest
gained `"scope": "/"`, `"id": "/?source=pwa"`, and
`"display_override": ["standalone"]` so future sub-path deploys
don't accidentally inherit root scope and install-prompt heuristics
anchor on a stable id.
+16 new test cases across two new files covering HSTS preload, CSP
gating on/off, the once-per-process warning, the tagged helper's 4
shape branches, SW same-origin guard, offline-locale-neutrality, and
manifest hygiene. Existing proxy/SW suites stay green.
README:
- Add a "How it compares" matrix against Withings web, Apple Health,
Oura, Garmin, and generic CSV. The audit found this section absent;
self-hosters arrive comparing alternatives and bounce when there's
no head-to-head.
- Promote Apple Health import + AI Coach as standalone Key-Features
bullets. Apple Health was the v1.4.34 banner feature with zero
grep matches in the README until now; the audit ranked it the
single highest-intent search keyword for ex-iOS users. AI Coach
+ Insights moved out from under "Multi-Provider AI Insights" into
a dedicated bullet that names the Coach, Daily Briefing, Weekly
Report, and Health Score surfaces.
- Status block under "What it is" so visitors see release cadence
+ current focus (native iOS client) without trawling the changelog.
- Badge row: drop "PRs welcome" + duplicate Self-Hosted, add a live
"Latest release" badge + a GHCR multi-arch badge so the landing
page surfaces version + container distribution at a glance.
- Tagline tightened to lead with "self-hosted health tracker" + the
two integration anchors (Withings + Apple Health) — both phrases
are search-keyword fronts the GitHub search index picks up.
- Drop the dead SESSION_SECRET env entry from Quick Start (3
secrets, not 4) and the Environment Variables table — the env
var was documented as required but never read by code (per the
code-quality audit). Companion env-removal in docker-compose /
.env.example / entrypoint ships in a parallel batch.
- Add a one-line OpenAPI pointer above the API-Reference table so
integrators discover the codegen source.
Layout / OG metadata:
- openGraph + twitter cards now carry images, a higher-intent
description, and a summary_large_image card. Uses the existing
logo-readme.png as the OG asset; replace with a 1200x630 dashboard
capture when one ships.
GitHub repo metadata (via gh CLI, no git diff):
- description tightened from 290 chars to 156 chars, keyword-front.
- homepage flipped from a personal tenant to demo.healthlog.dev so
sidebar clicks land on a working demo instead of a login wall.
- Topics: dropped 6 generic/PII-adjacent entries (glp-1, mounjaro,
tracking, health, dashboard, bloodpressure), added 6 high-intent
ones (apple-health-import, withings-alternative, glucose-tracker,
mood-tracker, ai-insights, personal-dashboard).
Planning artifacts:
- 7 audit reports drop into .planning/round-audit-*.md covering
mobile-security, UX, code-quality, README/discoverability,
perf, mobile-deep (pending), test-coverage, docs-depth. These
are the working notes for the v1.4.34.4 audit-driven release.
Two findings from the v1.4.34.3 security audit, both with the same
shape — every authenticated surface a user owns should rotate when a
credential-rotation path fires, and unfamiliar discovery sub-paths
must not inherit "no auth" status by default.
destroyAllSessions:
Previously only ran `prisma.session.deleteMany`, leaving every
long-lived `ApiToken` row (Bearer credentials issued via
/settings/api-tokens) and every native-client `RefreshToken`
intact. A stolen iOS Bearer kept working after the user rotated
their password or after an admin reset; only the browser cookie
flipped. Wraps the cleanup in a $transaction that flips
`apiToken.revoked = true` and `refreshToken.revokedAt = now()` in
the same atomic block so the audit trail survives. Both lookups at
`src/lib/auth/api-token.ts` and `src/lib/auth/refresh-token.ts`
already filter on these columns; no second code path needed.
Same destroyAllSessions call already runs from:
- POST /api/auth/password (user-initiated change)
- POST /api/admin/users/[id]/reset-password (admin reset)
- DELETE /api/settings/account (account deletion)
All three paths now wipe every transport in one shot.
Well-known proxy bypass:
`src/proxy.ts` PUBLIC_PATHS carried "/.well-known/" with a prefix
match — a future route at `/.well-known/foo` would inherit "no
auth" status silently. Tighten to an exact-match Set
(WELL_KNOWN_PUBLIC_PATHS) seeded with the one path we actually
serve (`apple-app-site-association`). New routes must be added
explicitly.
Also drops the stale `/api/auth/codex/callback` PUBLIC_PATHS
entry — that route doesn't exist in the codebase (the Codex
OAuth flow uses `/api/auth/codex/{device-poll,device-start,
disconnect}` instead).
Tests:
- New destroyAllSessions assertion in src/lib/auth/__tests__/
session.test.ts pins the three updateMany/deleteMany call
shapes inside a single $transaction.
- Existing proxy well-known + privacy + onboarding redirect
suites stay green.
Per maintainer directive — the v1.4.34 IW-B "Frag den Coach" sparkles
button sat next to the "Hinzufügen" dropdown on the dashboard hero and
read as visual noise rather than a useful surface. Remove it; the
Coach drawer remains reachable from the /insights tree (its original
home), and the auth-shell `<CoachLaunchProvider>` + drawer mount stay
intact so any future page that wants its own opt-in CTA can call
`askCoach()` without churn.
The hook usages this leaves orphaned in `src/app/page.tsx`:
- `useCoachLaunch` (only consumer was the CTA)
- `useFeatureFlags` (only gate was `featureFlags.coach`)
- `Sparkles` icon import
All drop out cleanly.
The v1.4.34.1 image published to GHCR successfully but never reached
production because Coolify's `docker compose up` kept reusing the
cached `:latest` digest on the apps-01 host. The default Docker pull
policy ("only pull if missing") skipped the registry round-trip and
restarted the container with the prior v1.4.34 image.
Declare `pull_policy: always` on the `app` service so compose-up
re-checks the registry digest on every `up` regardless of the local
cache. Bundle-release because the v1.4.34.1 work ships through the
same `:latest` tag — this is the deploy mechanic that makes v1.4.34.1
actually land.
Hotfix on top of v1.4.34 bundling the chart-card dimension fix and the
Insights cold-mount perf rewrite. CHANGELOG carries the full notes; this
commit just stamps the version + changelog header so the release
pipeline picks it up.
Backfills the planning trail for v1.4.34: IW-C / IW-D / IW-G work-item
reports, the production slowness HAR investigation, the AASA rollout
trace from v1.4.33, and three pieces of research that informed the
follow-on perf work (XML import, carryover scope, cache aggregation).
The cache-aggregation research (research/v1434-r-cache-aggregation.md)
designed the per-route LRU pattern that IW-G shipped and is the
direct ancestor of the comprehensive-route rewrite this release lands.
The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.
Reads wrapped (60 s TTL unless noted):
- GET /api/mood/analytics → caches.moodAnalytics, key userId
- GET /api/workouts → caches.workouts, key includes
all params (limit/offset/since/
until/sportType)
- GET /api/bugreport/status → caches.bugreportStatus, key
"singleton" (10 min TTL);
isAdmin layered per request
- GET /api/medications → caches.medications, key userId
- GET /api/dashboard/widgets → caches.dashboardWidgets, key
userId (5 min TTL)
Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.
The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.
The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
The /api/insights/comprehensive handler used to walk every measurement
of every type from the trailing 90-day window into JS, then ran
summarize(), BMI, BP classification, target adherence, and four Pearson
correlations against the same pile of rows. Post Apple Health import the
unbounded findMany routinely returned 100 000+ rows, dominated request
time (~29 s in a production HAR capture), and starved the connection
pool — sibling endpoints cascaded into Cloudflare 503s.
Replace with a SQL-side aggregator in
src/lib/insights/comprehensive-aggregator.ts:
- Per-type DataSummary built via $queryRaw with AVG / MIN / MAX /
STDDEV_POP / REGR_SLOPE / REGR_R2 on the
measurements (user_id, type, measured_at) index. Anomaly count uses
STDDEV_POP to match the JS divisor (n, not n-1) and the same
round-then-threshold ordering as summarize().
- DISTINCT ON (type) for the latest raw value per type.
- date_trunc('day') daily means for correlation pairings.
- Two bounded findMany for BP sys/dia raw rows so the 5-minute
pairByTimestamp contract for bpPctInTarget survives byte-identical.
- One conditional MIN(measured_at) for dataSpanDays.
Route handler split into a buildComprehensiveResponse() helper wrapped
through caches.analytics (60 s TTL) keyed on `${userId}|comprehensive`.
The 60 s TTL converts every consumer within the window — page mount,
Coach drawer, recommendations grid — to a Map lookup; cold path drops
to a few hundred ms against fixture data matching the production volume
that triggered the bug.
One sanctioned semantic divergence: weight × BP correlation switches
from pairByTimestamp(24 h tolerance) to a daily-key join. The directive
authorised the swap for parity with the daily-mean shape mood × X
correlations already used; consumers comparing exact r/n against legacy
output may observe small drift.
Unit + integration tests cover the aggregator shape, cache hit / miss
counters, wide-event annotations, and end-to-end envelope parity.
The scatter-correlation card wrapper relied solely on Tailwind's
aspect-ratio utilities to derive its height from width, and explicitly
unset any inline height. When the dynamic-loaded chart mounted in
place of the h-[180px] skeleton, ResponsiveContainer briefly measured
a zero-sized parent before the browser resolved aspect-ratio against
the parent's width, emitting a width(-1)/height(-1) console warning.
Add a min-h-[180px] floor (matching the loading skeleton) so the
wrapper always has a non-zero height at first paint. The aspect-ratio
rules continue to drive the final card shape once layout settles.
Three integration tests against the real Postgres container pin the
v1.4.34 IW-G contract:
1. Ten concurrent /api/analytics reads against a cold cache fan into
a single builder call (misses === 1; remaining 9 split between
stampede joiners and warm-cache hits, with no second miss).
2. Priming analytics then writing a measurement evicts the cache —
the next read misses again and the response reflects the new row.
3. Compliance buckets re-read after an intake POST flush the cache
and surface the new taken-count.
Drops the unused _request parameter from buildAnalyticsResponse() so
the analytics route lints clean. CHANGELOG carries one Added line per
the marathon's user-facing voice rules.
Every endpoint that mutates a measurement / mood / medication /
intake / workout / dashboard-widget row now calls the matching
invalidateUser* helper after its database commit so the next cached
read paints fresh data instead of waiting out the TTL. The admin
app-settings PUT also flushes the bug-report-status singleton cache.
Endpoints wired (per blueprint §6 matrix):
POST/DELETE /api/measurements
PUT/DELETE /api/measurements/[id]
POST /api/measurements/batch
DELETE /api/measurements/by-external-ids
POST/PUT/DELETE /api/mood-entries[/id]
POST /api/mood-entries/bulk
POST/PUT/DELETE /api/medications[/id]
POST /api/medications/[id]/intake
PUT/DELETE /api/medications/[id]/intake/[eventId]
POST /api/medications/intake/bulk
POST /api/workouts/batch
PUT/DELETE /api/dashboard/widgets
PUT /api/admin/settings (bug-report toggle path)
Batch endpoints gate invalidation on insertedCount > 0 so a
duplicates-only ingest stays a no-op. Route-level tests reset every
cache in beforeEach so re-using a fixed userId doesn't carry response
state across cases.
/api/analytics — wraps both the slim (?slice=summaries) and default
slice paths through caches.analytics, keyed on ${userId}|${slice}.
TTL 60s. The default builder lifts into buildAnalyticsResponse() so
the cache miss / hit boundary stays at the route entry.
/api/gamification/achievements — wraps the locale-agnostic web result
through caches.achievements keyed on userId. TTL 60s. The iOS-format
locale transform runs after the cache read so the cache stays format-
neutral and the dashboard's duplicate mount (HAR-captured) coalesces
through single-flight.
/api/medications/intake?scope=compliance — wraps the per-day
compliance bucket build through caches.medicationsIntake keyed on
${userId}|compliance|${days}|${userTz}. TTL 15min because daily
buckets are slow-moving. The POST handler invalidates the per-user
medications/compliance/achievement caches after committing the dose
status change.
Each cached() call hands annotate() to the wrapper so cache.<name>.
outcome (hit|miss|stampede) + cache.<name>.key_hash land on every
request's wide event.
Sub-wave E records the close-out work for v1.4.34: e2e flake fixes
in onboarding-flicker + mobile-viewport, AASA verification across
healthlog.dev / healthlog.bombeck.io / demo.healthlog.dev plus
Apple's CDN ingestion endpoints, and the web-freeze marker that
lands on the CHANGELOG, prisma/schema.prisma, and the v1.5
strategic-plan decision log.
A caveat: a concurrent IW-G commit swept the staged web-freeze
edits into its own commit before this sub-wave's atomic commit ran.
The content landed correctly (verified with git show a5dd8e1d); the
commit message attribution is the only thing out of place. The
report documents the situation so the release-prep run can read
the actual state from disk.
Extends the v1.4.33 Coach snapshot LRU shape into a reusable
ServerCache<T> with TTL expiry, capacity-bounded LRU eviction,
single-flight read coalescing, prefix delete, and per-instance
hit/miss/eviction/stampede counters. Adds five invalidation helpers
keyed by the per-user prefix scheme so write endpoints can flush every
cache that may reflect their changed rows in a single call.
The cached() wrapper attaches cache.<name>.outcome + cache.<name>.key_hash
to the active wide event so production logs carry the hit ratio without
leaking the userId portion of the cache key.
Hand-rolled to keep the dependency surface at zero — same recipe as the
snapshot cache the dashboard already relies on. Process-local; multi-
instance staleness is bounded by TTL, the Redis migration path stays a
future option.
Three pre-existing flakes in the v1.4.33 suite were carrying through to
v1.4.34 as 113 passed / 3 failed. Both specs needed tighter waits and
selectors:
- onboarding-flicker (desktop + mobile): replaced the 12-sample
50-ms poll-loop with a single auto-retrying `toBeHidden({ timeout:
700 })` assertion. The poll-loop had a 1-2 ms race window where the
analytics-pending shell could paint before `useAuth().user` resolved
`onboardingCompletedAt`. The auto-retrying assertion re-evaluates
every animation frame and preserves the intent ("user never sees
the card") while collapsing the window.
- mobile-viewport (chromium-mobile): dropped `nav a[href]` from the
CTA-touch-target sweep (bottom-nav owns its own WCAG enforcement),
gated the 44-px floor on a `matchMedia('(min-width: 640px)').matches
=== false` evaluate so the desktop breakpoint never hits the
assertion, and prefer the `aria-label` over raw `innerText` when
surfacing failure labels.
Captures the analytics lastSeenByType surface, the bfcache-friendly
Cache-Control directive, the dashboard tile-strip staleDays wiring,
the CoachLaunchProvider hoist, and the dashboard hero CTA — three
commits on develop with quality gates passing across 4205 tests.
The Coach drawer was previously only reachable from `/insights/**`. The
`<CoachLaunchProvider>` + `<LayoutCoachMount>` now live on the global
`<AuthShell>` so every authenticated route shares the same launch context,
and the dashboard hero strip carries a "Frag den Coach" / "Ask the coach"
CTA next to the existing "Hinzufügen" pill.
Provider hoist
- `<CoachLaunchProvider>` wraps the authed branch in
`src/components/layout/auth-shell.tsx`; `<LayoutCoachMount>` sits beneath
the main scroller so the drawer renders once at the shell level.
- `src/app/insights/layout.tsx` drops the provider + drawer mount. The FAB
stays scoped to `/insights/**` because the floating action would distract
from the dashboard hero on surfaces with a contextual inline CTA.
Dashboard hero CTA
- New `<Button>` next to the "Add" dropdown calls `coachLaunch.askCoach(null)`
on the hoisted context. 44 px tap target via `min-h-11` (WCAG 2.5.5),
shrinks back to 36 px on `sm:` upwards alongside the sibling pill.
- Self-gates on the provider mount + the operator's `coach` feature flag so
the button silently disappears when the Coach surface is disabled.
The `dashboard.coachCta` i18n key (6 locales) ships in the locale commit
that pairs with this change. New test file pins the hoist contract at the
source level; the existing v1.4.27 R3d MB4 polish test was updated to
reflect the new mount location instead of the old insights-layout one.
Two key additions land in every locale (de, en, es, fr, it, pl):
- `insights.tabStrip.vitalsParent.{label,header}` — the parent pill
text and popover header for the collapsed wave-A vital pills.
- `settings.sections.thresholds.{title,description}` — renamed to
"Targets & Sources" / "Zielwerte & Quellen" / "Objetivos y fuentes"
/ "Objectifs et sources" / "Obiettivi e fonti" / "Cele i źródła"
(and matching descriptions).
- `settings.sections.sources.redirectNotice` — copy reserved for the
back-compat redirect surface; reads "Sources are now part of
'Targets & Sources'" in each locale.
The existing `settings.sections.sources.*` block stays in the
locale files because `<SourcesSection>` (now embedded inside the
Targets & Sources page) still resolves card-level labels through
those keys.
Per `.planning/research/v1434-r-2-carryover-scope.md` §7 + §8.
The Insights tab strip carried fourteen pills end-to-end after the
v1.4.32 wave-A HealthKit additions: Overview + thirteen metrics.
On a `<sm` viewport that meant Overview + roughly two-and-a-half
metric pills before the right-edge fade hid the rest.
The five wave-A entries (HRV, Resting HR, Oxygen, Body Temperature,
Active Energy) collapse behind a single "Vitals" / "Vitalwerte"
parent pill that opens a popover sub-list. Each sub-page keeps its
own URL and own page component — only the strip presentation
changes, so deep-links, bookmarks, and the iOS app's surface paths
all resolve unchanged.
Implementation:
- `SUB_PAGE_GROUP` + `SUB_PAGE_GROUP_ORDER` in
`sub-page-metric.ts` flag a sub-page as belonging to a parent
pill. Adding a new group is one row in `SUB_PAGE_GROUP` plus a
matching label key under `insights.tabStrip.<group>Parent.*`.
- `<InsightsTabStrip>` discriminated `TabEntry` union — flat
`<Link>` pills coexist with `Popover`-driven group entries.
Availability gating sits on the leaf slugs: the parent pill is
only rendered when at least one of its children has data.
- Parent-pill active state mirrors the URL — when a user is on any
vital-sign sub-page, the parent pill carries `aria-current="page"`
and the `border-primary` style so spatial orientation survives
the collapse.
Reduces the strip footprint from 14 to 10 entries when every metric
has data — Overview + Blood Pressure + Pulse + Vitals (parent) +
Weight + BMI + Workouts + Sleep + Mood + Medication.
Per `.planning/research/v1434-r-2-carryover-scope.md` §8 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 1).
The two former settings sections (`/settings/thresholds` and
`/settings/sources`) covered the same conceptual shelf — per-metric
configuration. Reading them together matches how users actually
think about each metric ("for this metric, which sources do I trust
AND what range counts as healthy?") and collapses two adjacent
sidebar slots into one.
`<ThresholdsSection>` now hosts both editors stacked under one page
header: the per-metric threshold ranges on top, the per-metric source
priority + two-axis device-type ladder below. The two mutation flows
(`/api/user/thresholds` and `/api/auth/me/source-priority`) stay
distinct so a save on one half never disturbs the other.
`<SourcesSection>` gained a `mode` prop. The default `"standalone"`
behaviour is preserved for any caller that still mounts it directly;
the new `"embedded"` mode drops the inner header so the assistive-tech
reading order stays one combined surface, not two.
The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` (down to
ten sections), and the Sources entry was removed from the in-shell
sidebar + mobile chip-strip. `/settings/sources` stays alive as a
`permanentRedirect` to `/settings/thresholds`, so external bookmarks
(iOS Settings deep-links, docs, anything that linked the URL) follow
through unchanged.
Tests updated: `SETTINGS_SECTION_SLUGS` size assertion drops from
eleven to ten; `<ThresholdsSection>` mount test now expects both
editor mount-points; the section title moves from "Personal
thresholds" / "Persönliche Zielwerte" to "Targets & Sources" /
"Zielwerte & Quellen".
Per `.planning/research/v1434-r-2-carryover-scope.md` §7 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 3).
Round close-out for the v1.4.34 Apple Health export.zip import work-
item. Records the seven atomic commits (deps → schema → parser →
worker → endpoints → tests → changelog), the parser perf numbers,
the carry-overs (HKClinicalRecord, ECG waveforms, workout routes,
ActivitySummary, <Me>), and the quality-gate evidence.
The dashboard tile-strip `<TrendCard staleDays>` caption renders bucket-aware
copy so a tile whose latest reading is older than a week reads naturally:
* `staleDays > 7 && <= 30` — existing `dashboard.staleHint` ("Letzter Wert
vor Xd")
* `30 < staleDays <= 60` — collapses to a weekly form ("vor X Wochen")
* `staleDays > 60` — collapses to a monthly form ("vor X Monaten")
Two new singular/plural key pairs per bucket — `staleHintWeeksOne` /
`staleHintWeeksOther`, `staleHintMonthsOne` / `staleHintMonthsOther` — so
locales with non-English plural rules read naturally. Polish takes the
genitive-plural fallback ("tygodni" / "miesięcy") which covers every
non-1 count the bucket math can produce.
Also adds `dashboard.coachCta` ("Frag den Coach" / "Ask the coach") for the
dashboard hero strip's Coach launch button — the global drawer is hoisted to
the auth-shell in a follow-up so every authed surface can call `askCoach()`
from the same context.
All six locales (de, en, es, fr, it, pl) updated.
User-visible Added line for the synchronous-upload + asynchronous-
ingest path: POST /api/import/apple-health-export, polling at
GET /api/import/apple-health-export/{jobId}/status, admin variant
at POST /api/admin/import-apple-health-export.
Three atomic commits landed for the v1.4.34 IW-F-Perf sub-wave:
shared achievements query hook + consumer collapse + regression
test + read-only analytics audit. Documents the touch-disjoint
compliance, the deferred checklist migration behind IW-B, and the
quality-gate evidence.
Adds 36 unit + contract tests across 7 files:
* `import-apple-health-export.test.ts` — end-to-end parse of a tiny
hand-authored fixture (spot weight, cumulative steps fold, sleep
stage, workout with sport mapping, deferred + unknown identifiers,
clinical record skip). Re-import idempotency: a second run reports
0 inserts + N updates. Memory ceiling: synthetic 10 000-record
export keeps heap delta under 100 MB.
* `hk-workout-activity-type-map.test.ts` — every value referenced
in the map exists in the canonical `workoutSportTypeEnum` union;
the iOS 18 canonical roster is covered; unknown identifiers fall
through to `"other"` with a falsy `known` flag.
* `unzip-export-xml.test.ts` — central-directory walker round-trips a
hand-built single-entry deflate ZIP; extractor throws on a missing
`export.xml` member.
* `stream-to-disk.test.ts` — boundary parser; named-field capture +
SHA-256 + text-field collection; size-cap enforcement; boundary-
missing error path.
* Route tests — auth gate (401), 403 on non-admin to the admin
variant, 413 on content-length cap, 429 on rate-limit; status
endpoint admits owner + triggering admin and 404s otherwise.
v1.4.34 IW-F-Perf — three changes.
1) New `use-achievements-query.test.tsx` mounts three consumers under
one TanStack QueryClient and asserts the shared queryKey collapses
them into a single network call. Pre-fix the notifier issued its
own request alongside the card; the regression guard surfaces it
immediately if a future drift reintroduces a per-user
discriminator.
2) Refresh `recent-achievements-card.test.tsx` and
`achievements/__tests__/page.test.tsx` to mock the new shared hook
directly instead of the raw `@tanstack/react-query` `useQuery`
shim. The two test suites stay scoped to render-shape, not the
network plumbing.
3) Capture the `/api/analytics` slim-vs-thick audit in
`.planning/round-v1434-iwfperf-analytics-audit.md`. The dashboard
root reads `bpInTargetPct*` + `glucoseByContext` so it must stay
thick; the checklist is correctly slim by IW2 design but is
mounted inside the dashboard render tree so its slim call
duplicates the thick one already in flight. Documented two clean
migration paths and flagged the work for v1.4.34.x behind IW-B's
in-flight slim-slice routing.
v1.4.34 IW-F-Perf — migrate `<RecentAchievementsCard>`, the
`/achievements` mother page, and `<AchievementUnlockNotifier>` from
ad-hoc `useQuery` blocks onto `useAchievementsQuery()`.
Pre-change the dashboard cold mount fired
`/api/gamification/achievements` twice — once for the card, once for
the notifier — because the notifier carried a per-user discriminator
on its queryKey and TanStack treated it as a fresh cache cell. The
shared hook routes all three consumers through the same
`queryKeys.gamificationAchievements()` slot, so a single network
call now backs every surface. The notifier keeps its 2-minute
refetch cadence; long-open tabs still surface new-unlock toasts and
the card picks up the same payload from the shared cache without
issuing a parallel request.
v1.4.34 IW-F-Perf — three consumers
(`<RecentAchievementsCard>`, `/achievements` mother page,
`<AchievementUnlockNotifier>`) each opened their own `useQuery`
block against `/api/gamification/achievements`. Two of them collided
on the same `["gamification", "achievements"]` literal; the notifier
carried a per-user discriminator on its key so TanStack treated it
as a fresh cache cell. Dashboard cold mount fired the same endpoint
twice.
The hook mirrors `use-analytics-query.ts`: centralises the queryKey
via `queryKeys.gamificationAchievements()`, 60s staleTime, no
refetch-on-mount, optional `refetchInterval` for the notifier. One
cache cell, one network call, no behavioural change for the toast.
Three new routes wire the streaming parser to the wire:
* `POST /api/import/apple-health-export` — user-facing kick-off.
Streams the multipart upload straight to `/tmp` (no in-memory
buffer for the 1.5 GB hard cap), hashes the bytes inline for
content-based idempotency, creates an `ImportJob` row in
`queued`, and enqueues the `apple-health-import` pg-boss job.
Returns `202 { jobId }` so the client can poll for progress.
* `GET /api/import/apple-health-export/[jobId]/status` — polling
endpoint returning the canonical `ImportJobStatusResponse`
envelope (R-1 §8). Authorises on owner-id OR triggering admin.
* `POST /api/admin/import-apple-health-export` — admin variant
(cookie-only via `requireAdmin()`; Bearer never elevates) with
a `userId` text field naming the target user. Same content-hash
idempotency scoped per target user.
The kick-off path uses a hand-rolled multipart boundary walker
(`src/lib/multipart/stream-to-disk.ts`) so a multi-gigabyte
`export.zip` upload never lands in V8 heap — Next.js's
`request.formData()` buffers the full body. Captured bytes
stream to disk while a SHA-256 hash accumulates in the same pass.
Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3, §5.4,
§5.5, §9.
The dashboard tile strip needs to render an "Letzter Wert vor Xd" caption on
tiles whose latest reading is older than a week so a metric the user hasn't
logged in a while keeps its tile visible (with an explicit how-old hint)
instead of disappearing from the strip.
- `/api/analytics` default path captures the freshest `measuredAt` per
`MeasurementType` in the existing per-type chunked walk and surfaces it as
`lastSeenByType[type] = { lastSeenAt, daysAgo } | null`. Types the user
never logged report `null`.
- `?slice=summaries` (slim slice) carries the same field — the DISTINCT ON
pass already orders by `measured_at DESC` to pick `latest.value`; adding
`measured_at` to the projection costs zero round-trips.
- Both responses stamp `Cache-Control: private, max-age=0, must-revalidate`
(the typed `NO_STORE_BUT_BFCACHE` constant) so back-forward navigation can
restore the dashboard from bfcache instead of paying a full reload. The
framework default for cookie-touching routes is `no-store, must-revalidate`,
which Chromium counts as a hard bfcache breaker.
- `AnalyticsRawPayload` gains an optional `lastSeenByType` field so call
sites read the shape with type-safety; consumers that don't care stay
unchanged.
Three new test cases pin the contract: default-path freshness map keyed on
the chunked reader, slim-slice freshness map keyed on the DISTINCT ON pass,
and the bfcache-friendly Cache-Control header on the wire for both slices.
Wires the streaming Apple Health export ingest into the existing
`pg-boss` worker process. New queue `apple-health-import` runs at
`localConcurrency: 1` because the parse loop is CPU-bound and a
second 1 GB import would race the first for RSS.
The handler:
* Resolves the mirror `ImportJob` row by `pgBossJobId` (with a
250 ms retry to absorb the kick-off-vs-worker race).
* Streams `export.xml` through `streamParseExportXml()`, surfacing
live progress onto the `ImportJob.progress` JSON column every
~1 000 records.
* Persists the terminal `ImportJobResult` envelope on `done` or a
truncated error reason on `failed`.
* Cleans up the upload + extracted XML on terminal state.
On worker startup, `reconcileOrphanImportJobs()` flips any row
stuck in `unpacking | parsing | upserting` to `failed` with
`interrupted_by_restart` so an operator-side restart never leaves
the polling endpoint hung mid-phase.
Five atomic commits on develop covering the carryover infrastructure
items from research blueprint v1434-r-2 plus the GHCR double-build
fix from the v1.4.33 closure-report iceberg notes. Report itemises
touch surface, verification steps, out-of-scope guardrails honoured
during the parallel-IW round, and five seeded operator follow-ups
(env-var prune on apps-01, edge-01 MCP daemon restart, optional
duplicate cleanup, CODEX_OAUTH_CLIENT_ID intent confirmation, and
the next-tag verification that the workflow now fires exactly once).
Three additive helpers:
* `import-apple-health-export.ts` — SAX-driven streaming parser that
folds `<Record>`, `<Workout>`, `<Correlation>`, `<ClinicalRecord>`
into the existing `Measurement` / `Workout` row shapes. Cumulative
HK types collapse into one `stats:<HKType>:<YYYY-MM-DD>` row per
user-local day; spot rows survive verbatim keyed by a stable
`sample:<sha256>` external id when no `HKMetadataKeyExternalUUID`
metadata entry is present.
* `hk-workout-activity-type-map.ts` — `HKWorkoutActivityType*` to
`WorkoutSportType` lookup table covering Apple's iOS 18 SDK
roster. Unknown identifiers fall through to `"other"` and increment
a `workouts.unknownActivityType` counter.
* `unzip-export-xml.ts` — hand-rolled ZIP central-directory walker
+ `inflateRawSync` extractor for `apple_health_export/export.xml`.
Supports Zip64 so multi-year exports past the 4 GB barrier still
parse. No new archive dependency.
Live progress streams through an `onProgress(ImportJobProgress)`
hook that fires every 1 000 records parsed; the worker persists the
snapshot onto `ImportJob.progress` for the polling endpoint to read.
Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
Read-only audit captured via `mcp__coolify-apps01__env_vars` (list)
plus an attempted `mcp__coolify-edge01__env_vars` call. apps-01
returned 56 entries with the section-1/section-2 split documented
under CHANGELOG.md v1.3.1 — every key in section 2 is either a
duplicate of section 1, an empty placeholder, or (for
POSTGRES_PASSWORD) the literal default string left over from the
Compose template. Runtime reads the first match so containers stay
healthy; the duplicates are cosmetic clutter that make root-cause
debugging slower on the next deploy regression.
Edge-01 MCP returned a connection error (matches the v1.4.33
closure-report iceberg note) — env audit on that host needs SSH
until the MCP daemon is restarted.
No deletes performed — operator action only. The report seeds the
priority order: delete the `POSTGRES_PASSWORD` placeholder first,
then optionally bulk-prune the remaining section-2 duplicates,
then confirm intent for the section-2-only `CODEX_OAUTH_CLIENT_ID`
entry.
The Build & Publish Docker Image workflow fired twice on every
release. The squash-merge to `main` and the `v*` tag push landed
within a second of each other and each kicked off its own run
against the same SHA. Both runs finished green and pushed
overlapping tag sets — the main-branch run added the `latest`
alias, the tag run added the semver tags — but the duplication
doubled GHCR CI minutes per release with no operational benefit.
Drop the `push.branches: [main]` trigger so only `v*` tag pushes
fire the build on a release. Tighten the `latest` raw-tag rule in
docker/metadata-action so it enables on stable release tags
(`refs/tags/v*` without a pre-release suffix) rather than the
`is_default_branch` condition (false on tag refs). Net result: a
release tag pushes a single multi-arch image carrying both the
semver tags and the `latest` alias in one run, halving the CI
minutes per release. PR + workflow_dispatch triggers are
preserved so pre-merge validation and manual reruns still build.
Two additive `next.config.ts` tweaks for v1.4.34:
- `outputFileTracingExcludes: { "*": ["./next.config.ts"] }` — silences
the Turbopack tracer warnings emitted during `next build`. The tracer
follows the `MAXMIND_LICENSE_KEY` env access in `src/lib/geo.ts` back
into the config file and emits "cannot be traced" warnings for every
path it tried to walk afterwards. The standalone bundle is still
controlled by `output: "standalone"`; the exclude only narrows trace
reporting and has no runtime effect.
- A second `headers()` rule (source: `/((?!api|_next).*)`) stamps
`Cache-Control: private, max-age=0, must-revalidate` on every
authenticated HTML page response. The Next.js framework default for
pages that touch cookies is `no-store, must-revalidate`, which
Chromium counts as a hard bfcache breaker; the rule above replaces
that on the page tree while leaving `/api/*` and `/_next/*` alone so
API routes keep their explicit directives and static assets keep
immutable caching. Pairs with the `Permissions-Policy: unload=()`
hint already in place to restore back/forward-cache eligibility for
every authed route.
Add `src/lib/http/cache-headers.ts` exporting two presets and one
applier. `NO_STORE_BUT_BFCACHE` (`private, max-age=0, must-revalidate`)
replaces the framework's stock `no-store, must-revalidate` on
authenticated page responses; Chromium treats the latter as a hard
bfcache breaker, while the former keeps shared caches out, forces
revalidation on every navigation, and stays bfcache-eligible.
`SHORT_LIVED_PUBLIC` covers static-ish one-hour public payloads.
`applyAuthedHeaders(res)` stamps the bfcache-friendly directive onto
a Response in-place for route handlers that opt into the same posture.
Also add `scripts/print-bundle-report.mjs` (wired earlier as the
`bundle-report` npm script). Turbopack dropped the webpack "First
Load JS" summary line from `next build`; the script reads back the
`.next/analyze/client.json` payload that `@next/bundle-analyzer`
writes when `pnpm analyze` runs and prints a sorted table of the
largest client chunks (parsed, gzip, stat sizes plus totals). Output
restores an at-a-glance signal for client-bundle regressions on
release rounds without re-running Lighthouse.
v1.4.33 shipped a defensive fallthrough in `getColor` that detected
the unambiguous "every taken dose landed in veryLate" pattern and
recoloured those cells via the rate-based palette. With the
classifier hardening landed in `compliance.ts` the pattern can no
longer arise from a UTC offset between `takenAt` and the schedule
window, so the workaround is dead code. Replace the long
`looksClassifierBug` block with a single comment pointing at the
root fix and let the timing-based palette run unconditionally when
timing data is available.
The medication compliance route and the achievement perfect-day
heuristic both bucket the classifier output into `on_time` / `late` /
`very_late`. After the classifier started emitting `early`, both
paths would have silently dropped those doses into `very_late`
(via the trailing `else`). Treat `early` as compliant alongside
`on_time` so the heatmap stays green and the perfect-day streak
survives a proactive logger. The compliance route also surfaces a
dedicated `early` count on `DailyComplianceEntry` for any future
consumer that wants to break it out from `on_time`.
Updates the cadence-pairing docstring to reflect the widened grace.
The classifier flushed any intake outside the 1h grace into
`very_late`, so a stored UTC offset between `takenAt` and the
schedule window (e.g. a 06:00 UTC log against an 08:00 UTC window)
flagged a fully-compliant day as orange. Widen the pre-window grace
to 3h, introduce a dedicated `early` bucket for doses logged inside
that 3h pre-window band, and extend the on-time tolerance after
windowEnd to 3h as well. The configurable `lateMinutes` option now
sizes the `late` band past the 3h tail; doses beyond that tail still
fall into `very_late`.
Tests rebuild the boundary matrix with a parameterised offset suite
covering -3.5h .. +6h around the window, exercises overnight
schedules with an `early` case, and pins the `lateMinutes` knob.
Additive Prisma model mirroring a `pg-boss` `apple-health-import`
job's lifecycle. The polling endpoint reads `progress` and `result`
live from this row rather than the one-shot terminal `output` column
on `pg_boss.job` (pg-boss v12 only populates `output` on completion).
Carries `pgBossJobId` for the worker's reverse lookup, an upload
sha-256 for content-hash idempotency on re-upload, and per-phase
status (`queued | unpacking | parsing | upserting | done | failed`).
Streaming SAX parser for the v1.4.34 Apple Health export.zip ingest
path. Event-driven SAX keeps peak RSS bounded (~30 MB) regardless of
input size, sidestepping the per-element buffering that fast-xml-parser
hits on multi-year HealthKit exports.
Records the Apple App Site Association handler + the proxy
`/.well-known/` allowlist entry under the v1.4.33 Added section so the
release notes capture the iOS-foundation work that landed alongside the
polish pass.
Adds a Next.js Route Handler at `/.well-known/apple-app-site-association`
that answers 200 with `application/json` on every host that fronts the
main app — both `healthlog.bombeck.io` (maintainer prod) and
`demo.healthlog.dev` (demo) share this handler since they ride the same
standalone runtime. The body advertises the iOS bundle's App ID prefix
(`S8WDX4W5KX.dev.healthlog.app`) under `webcredentials.apps` so the
passkey ceremony shares cleanly between the web origin and the iOS app —
the same ceremony that today exists at `/api/auth/passkey/*` on both
domains.
The `applinks.details` array stays empty for now; Universal Links are
out of scope for the first cut and pick up later once the iOS side opts
in to deep-linking specific routes.
Pairs with the proxy `/.well-known/` allowlist entry — without that
bypass the auth gate would 307 Apple's CDN fetch to `/auth/login`
before the handler ran.
Adds the `/.well-known/` prefix to the proxy's `PUBLIC_PATHS` allowlist so
RFC 8615 discovery endpoints reach a 200 response before the auth gate
runs. Apple in particular reads
`/.well-known/apple-app-site-association` without credentials to wire Web
Credentials (passkey sharing) and Universal Links to the iOS bundle, and
the previous 307 redirect to `/auth/login` broke that fetch on every host
the app serves (`healthlog.bombeck.io` and `demo.healthlog.dev` both).
The trailing-slash entry future-proofs the namespace for `/security.txt`,
`/openid-configuration`, and the other well-known endpoints the IETF
registry covers — adding a new file under `src/app/.well-known/` won't
need a second proxy edit.
A regression test pins the allowlist in place: unauthenticated requests
for the AASA file (and a synthetic `/.well-known/security.txt` peer) must
answer 200 with no location header, while a protected page like
`/insights` still 307s to the auth gate.
The first attempt at the F2 fix (f9b8f3bd) split the spotlight tour
backdrop into four rectangular dim panels around the spotlight rect
and opted each one back into pointer-events: auto with its own
onClick={handleSkip}. The intent was to keep the spotlighted target
clickable while preserving the "tap backdrop to dismiss" affordance.
The flaw: tour-step 1 on the dashboard spotlights the tile-strip,
which sits in the middle of the page. The header "Add" button sits
ABOVE the spotlight. The TOP dim strip (rendered from y=0 down to
where the spotlight begins) therefore covered the entire header
area, so the very target the F2 audit named — the header Add
dropdown — was still being eaten, just by a smaller rectangle.
The four-panel split fixed exactly ONE coordinate (inside the
spotlight rect) and broke every coordinate outside it that a real
user might want to click during the tour: header buttons, the
user-menu avatar, sidebar links, the locale switcher.
This commit ships the contract that Joyride / Shepherd / Intro.js
have all standardised on: the dim is purely visual. The whole
tour layer is pointer-events: none; only the tooltip card opts
back into hit-testing. The four-panel split survives because it
still serves the visual purpose of NOT dimming the spotlighted
target, but each panel is now a <div aria-hidden="true"> with
pointer-events: none — no onClick, no aria-label, no interactive
semantics. The center-placement fallback follows the same shape.
The tooltip's onClick={(e) => e.stopPropagation()} guard came off
because nothing bubbles to the dim any more. Tour state (Skip,
Back, Next) lives entirely on the tooltip's footer buttons,
matching the affordance users expect and keyboard-accessible via
the existing Esc shortcut.
The e2e regression spec at e2e/onboarding-tour-passthrough.spec.ts
gained a third case and tightened the first one. Case 1 now runs
document.elementsFromPoint at the quick-add button's centre, walks
the stack, and asserts that NO element belonging to the tour
overlay has computed pointer-events !== "none". This pinpoints the
regression instead of observing the symptom. Case 2 swapped from
"click a dim strip skips" (an artefact of the failed intermediate
fix) to "the explicit Skip button in the tooltip dismisses the
tour". Case 3 reads computed pointer-events from every
[data-testid="onboarding-tour-dim"] and asserts each is "none" —
the load-bearing invariant the F2 regression violated.
Quality gates: typecheck clean, eslint clean on the touched files,
all 43 onboarding unit tests pass, all 6 F2 e2e cases pass locally
(3 cases x chromium-desktop + chromium-mobile).
PR #178 open in draft state; CI green on lint/typecheck/test +
integration + builds + dependency audit + secret scanning. One
e2e workflow regression on the F2 onboarding-tour-passthrough
spec is documented as a v1.4.34 carry-over — IW8's fix passed
its unit-level integration test but fails its own paired e2e
spec.
Polish and reliability release between two HealthKit milestones.
Closes a P0 stack-overflow in `/api/analytics`, defers three
below-the-fold Insights blocks behind `next/dynamic`, slims the
analytics summaries slice, lands a Coach snapshot LRU, and runs a
deep accessibility + settings consolidation sweep.
The page now resolves CorrelationRow and TrendsRow through dynamic()
imports rather than top-level `from "..."` statements; the static-source
scan accepts either spelling so a future revert to eager imports still
flags the load-bearing module path.
The dedicated card H2 was retired in the previous "drop AI prefix"
pass; the `settings.kiInsights` translation key it consumed has no
remaining call-site in source. Drop it from de / en / es / fr / it /
pl. Also update the message-thread test to the new "Insights
provider" copy.
`no-scrollbar` already suppresses the painted scrollbar so the strip
doesn't look like every settings card has an overflow problem. The
`snap-x snap-mandatory` layer on the strip plus `snap-start` on each
chip means a swipe-flick lands on the next chip's leading edge instead
of the in-between dead zone — the auto-scroll-into-view effect on
mount stays the canonical positioner for the active chip; snap is the
polish layer for manual flicks.
Eleven sections on the settings sidebar was one over the comfort
line. About is three small cards (identity, links, update check) that
collectively get read once or twice a year and didn't earn a top-level
slot. Hide it from the in-shell nav and surface it as "About HealthLog"
in the sidebar user-card dropdown (mobile dropdown mirrors). The
`/settings/about` route stays alive so direct links, the public
`/about` permalink, and the dropdown entry continue to resolve.
The privacy + about pages constrain their main column to `max-w-3xl`
(768 px) while the rest of the app uses `max-w-screen-xl` (1280 px).
That's a deliberate readability choice -- long-form legal text is
easier to read at 70-80 chars per line -- but the convention wasn't
documented inline, so a future contributor could see the deviation
from the shell width as a stale token and "fix" it.
Inline rationale comment on both `<main>` blocks per the audit
finding §3.4. No layout change.
Lighthouse v1.4.33 a11y pass flagged "heading elements not in
sequentially descending order" (score 91/94). Three offenders:
- `<TherapyTimeline>` (insights/medikamente sub-page): sr-only
drug-name heading was an h4, but the parent SubPageShell ships
the only h1 and CardTitle is a <div>. Promoted to h2 so the
outline stays h1 -> h2 without skipping levels.
- `<FeedbackInboxDetailSheet>`: metadata + screenshot section
headings rendered as h4 inside a ResponsiveSheet whose dialog
title is h2 (Radix default). Demoted to h3 to stay sequential
beneath the sheet title.
- `<RemindersSection>`: results heading was h4 inside the admin
SectionFrame's h1. Promoted to h2.
Coach panel headings (IW5 surface) audited but not changed.
The sidebar has exactly one nav group, and the first entry is already
"Dashboard" pointing to `/`, so the "HOME / Dashboard" pairing used to
read as if there were two separate destinations for the homepage. Drop
the group label and retire the `nav.home` locale key in every bundle.
The collapse toggle keeps its position at the top-right of the strip.
Lighthouse v1.4.33 flagged ~463 KiB of unused JS on first /insights
paint plus ~211 KiB of bundled JS as unminified. Two coordinated
moves close the gap without touching the Coach panel internals or
the dashboard tree.
next.config.ts
- `compiler.removeConsole` with `exclude: ["error", "warn"]` strips
the chart-wiring + Coach SSE breadcrumb literals from the prod
bundle. Error + warn paths still flow through to GlitchTip.
- `headers()` advertises `Permissions-Policy: unload=()` so Chromium
admits the page to the back/forward cache when navigating away. We
carry no `beforeunload` / `unload` listeners in-tree so the hint is
honest.
src/app/insights/page.tsx
- `<DailyBriefing>`, `<CorrelationRow>`, `<TrendsRow>` move behind
`next/dynamic({ ssr: false, loading: <Skeleton /> })`. All three
paint below the hero on every viewport and were eagerly imported
before. Each loader skeleton matches the dimensions of the resolved
block so layout doesn't shift.
- `<HeroStrip>` stays an eager import — the greeting + health-score
badge are the visible-first-paint surface.
Marc-Voice rule — the product never names itself "KI" or "AI" in
copy. Settings -> "KI-Auswertungen" becomes "Auswertungen" / "Insights",
the inner "KI-Insights" card heading collapses into the parent section
heading, "Aktiver KI-Provider" becomes "Aktiver Provider", and the
admin "AI Quality" view becomes "Insights Quality".
Sweep across de / en / es / fr / it / pl. Provider product names
(Anthropic, Claude, ChatGPT, OpenAI) are unchanged — those are
proper nouns, not the "AI" label.
Lighthouse v1.4.33 a11y run flagged "links without discernible name"
(score 91/94). The medications page renders five icon-only buttons
(reset-form dropdown, three copy actions, status dropdown) without
an aria-label, so the SR announcement collapses to the role name.
This patch reuses existing common.* translation keys (no new strings
added; messages/*.json off-limits for IW9):
- the reset / status DropdownMenu triggers get `common.moreOptions`,
- the three copy icon-buttons get `common.copy`.
Icon-only buttons in `src/components/settings/**` (IW4),
`src/components/insights/coach-panel/**` (IW5), and the bottom-nav
already carry aria-labels per earlier polish rounds.
Lighthouse v1.4.33 a11y run flagged "ARIA progressbar elements missing
accessible names" (score 91/94). The Radix Progress primitive renders
a `role="progressbar"` host element but doesn't synthesise an
`aria-label` from context -- the consumer must supply one. Reusing
the on-screen label keys keeps the SR announcement matched to the
visible caption.
- src/app/insights/medikamente/page.tsx: compliance7/compliance30
- src/app/achievements/page.tsx: criterion meter + summary
earned-points + next-goal progress
- src/components/medications/medication-card.tsx: compliance7/30
- src/components/medications/glp1-medication-card.tsx: same
`role="progressbar"` divs in `health-score-card.tsx`,
`getting-started-checklist.tsx`, and `OnboardingShell.tsx` already
carry aria-labels -- verified, no change needed.
Refs Lighthouse-derived punch list (v1.4.33 perf audit).
The Coach launch FAB sits fixed at bottom-right on mobile viewports
and used to overlay any Recharts tooltip painted near the chart's
lower-right corner. Tapping a data point on /insights/blutdruck
opened the tooltip but the FAB covered the value readout, so the
user couldn't read it.
Add a singleton MutationObserver that watches every
.recharts-tooltip-wrapper in the DOM for inline visibility flips
(Recharts toggles `style.visibility: visible` when the tooltip
should paint, and clears it otherwise). A useSyncExternalStore-
backed hook surfaces the live boolean to the FAB, which fades to
opacity-0 + pointer-events-none + aria-hidden while a tooltip is
active. When the user releases / scrolls away and Recharts clears
the wrapper the FAB fades back in.
The observer is module-scoped: it attaches on the first subscriber
and disconnects on the last unmount, so it costs nothing on routes
that don't mount the FAB. SSR snapshot is always false so the
initial server-rendered FAB stays fully interactive before
hydration.
End-to-end auto-hide behaviour belongs to the Playwright mobile
suite (chart hover/long-press → FAB fades); the unit tests pin the
SSR-safe contract and the FAB markup so the regression cannot
silently revert.
Lighthouse v1.4.33 run flagged a 0.186 CLS attached to a
`div.space-y-2 > Button > Loader-Circle` element. Root cause: the
two heavy submit buttons in `/bugreport` and the medications import
dialog drew `{loading && <Loader2/>}<Icon/>` -- rendering BOTH icons
during the in-flight request and growing the button by ~24 px. The
fix is to swap (not append) the leading icon, so Loader2 occupies
the same 16x16 slot the static icon would.
- src/app/bugreport/page.tsx: ternary swap on `loading`, pass
`aria-busy` for screen-reader narration.
- src/app/medications/page.tsx (Import dialog): same shape.
- src/components/ui/button.tsx: document the loading-state contract
on the primitive so future contributors apply the swap convention
by default. (Callsites inside `src/components/settings/**` are
owned by IW4 -- left untouched, the doc-comment makes the convention
visible from the primitive.)
Both the `/notifications` inbox and the Settings -> Notifications
section used to read as plain "Notifications" in every nav surface, so
the user-card dropdown and the settings sidebar looked like two pointers
at the same destination. Rename the inbox to "Notification Center" and
the settings section to "Notification channels"; add a small breadcrumb
to either surface so the user always knows which page they landed on
and can jump to the sibling in one tap.
Locale parity across de / en / es / fr / it / pl.
Pre-v1.4.33 the shadcn Card defaults shipped `py-6` + `px-6` (uniform
24 px every breakpoint) while the dashboard tile-strip + chart cards
used `p-4 md:p-6` (16 px mobile, 24 px md+). Side-by-side on
`/insights` the correlation cards + daily-briefing read as denser
than the chart cards next to them; the visual reads as two card
languages on the same page.
This patch lifts the Card primitive (and its CardHeader / CardContent
/ CardFooter siblings) onto the same `p-4 md:p-6` token. Existing
consumers that paint their own explicit padding continue to win
through the `cn(...)` merge. Tests across `src/components/ui` /
`src/components/insights/__tests__` / `src/components/charts/__tests__`
(400 tests) still green.
Refs .planning/round-v1433-audit-polish.md §4.1 + Win 4.
Pre-v1.4.33 the insights mother page AND `<SubPageShell>` both fired
their own `requestAnimationFrame -> window.scrollTo({top:0})` on mount.
On slow hydrates the two callbacks landed ~16 ms apart and the second
one re-snapped the viewport once the chart skeleton inflated -- a
visible double-snap the maintainer flagged as "viewport jumps on click".
- src/hooks/use-scroll-reset-on-route.ts: new hook owns the RAF +
scrollTo dance, behaviour:"auto" so `prefers-reduced-motion` users
get the same instant landing everyone else does.
- src/components/insights/sub-page-shell.tsx: drop the inline effect,
call the hook. The opt-in `focusOnMount` ref-focus stays as its own
separate effect (different opt-in surface).
- src/app/insights/page.tsx: drop the duplicate effect, call the hook.
Refs .planning/round-v1433-audit-polish.md §2.1 + Win 3.
The history and sources rails mount inline next to the message thread
on lg+ / xl+ viewports, where no SheetTitle wrapper covers them.
Both rails previously rendered their section labels as <span> with
uppercase styling, so the drawer's semantic outline disappeared above
the message thread on desktop. Screen-reader users navigating by
heading skipped straight from the drawer's <h2> title to the
message-thread region with no intermediate landmark.
Promote both labels to <h3> while keeping the existing visual
treatment intact. The mobile rail-tray (Sheet-wrapped) still surfaces
its SheetTitle <h2>, so the rail's own <h3> simply nests below it on
small screens. The desktop layout now carries a coherent h2 (drawer)
> h3 (rail) hierarchy.
Regression tests assert that each rail renders its label inside a
real <h3> element so the semantic outline cannot silently drop again.
Pre-v1.4.33 the dashboard shell capped at max-w-[76.8rem] (1228 px)
while settings/admin shells used max-w-screen-xl (1280 px) and the
bugreport route capped at max-w-6xl (1152 px). Route switches between
those surfaces produced a 52-128 px lateral shift on a 1440 px laptop
which the user perceives as the page "wobbling".
- src/components/layout/auth-shell.tsx: max-w-[76.8rem] -> max-w-screen-xl
- src/app/bugreport/page.tsx: drop the inner max-w-6xl cap so the shell
alone owns the content frame.
Settings/admin shells already use max-w-screen-xl; no change needed.
Refs .planning/round-v1433-audit-polish.md §4.2 + Win 1.
Five-agent audit pass plus the Playwright runtime sweep against v1.4.32.
The five audit documents land alongside the 58 runtime screenshots and
the runtime-results.json that drove the seven highest-severity
implementation rounds (IW1, IW3, IW4, IW6, IW8) on this branch. Future
contributors can trace each implementation commit back to a finding
without re-running the audits.
Every Settings card paints the same shape — icon + title + optional
status surface + optional muted description — but the codebase
carried five different `flex-*` permutations across
`account-section.tsx`, `integrations-section.tsx`,
`notification-status-card.tsx`, the three notification channel
cards, `api-section.tsx`, and `advanced-section.tsx`. The mechanical
drift table in `.planning/round-v1433-audit-settings.md` §2.5
itemised the spread.
Consolidate the contract into `_card-header.tsx`: icon left, title
right of it, status slot lives top-right and falls below the title
on `<sm`, description sits muted below the title row. Call sites
keep ownership of their body forms and action footers.
First three call sites switched: `telegram-card.tsx`,
`ntfy-card.tsx`, `web-push-card.tsx`. The remaining cards
(`account-section.tsx`, `integrations-section.tsx`, `api-section.tsx`,
`advanced-section.tsx`, `notification-status-card.tsx`) are wired
in a follow-up — the primitive is now available without forcing
every consumer to migrate in one commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Centralise the trend-arrow + 7-day-delta + comparison-overlay colour
through a single `getTrendSentiment(change, sentiment)` helper so the
three elements never paint disagreeing signals on the same tile
(F5 audit fix). Arrow direction now follows the same signal that
drives the 7-day delta value.
- Collapse the dashboard tile strip from `flex overflow-x-auto` at <sm
+ grid above into a single responsive `repeat(auto-fit,
minmax(min(100%, 11rem), 1fr))` track. Phones wrap to two rows
instead of side-scrolling; 1440×900 picks 6 columns so the headline
digits stop truncating to `8…` / `1.`.
- Hold the tile callout slot at `min-h-[18px]` on every breakpoint so
a comparison overlay on one tile no longer pushes its callout-less
neighbours 18 px taller.
- Gate the BD-Zielbereich tile so a literal "0,0 %" placeholder no
longer renders when every window (7d / 30d / all-time) reports
zero. The tile shows once any window crosses zero; otherwise the
`/targets` Blutdruck card carries the deeper analysis.
- Restore proportional sizing on the dashboard Hinzufügen button:
`size="default"` with `min-h-11 sm:min-h-9` keeps WCAG 2.5.5 on
mobile (44 px) and tightens to a desktop-friendly 36 px above sm.
- Surface `allTimeCount` + `lastSeenAt` per metric on
`/api/dashboard/summary` so the iOS client can keep a tile visible
during a logging gap and render a `Letzter Wert vor Xd` caption.
Optional cards (glucose / sleep / steps / body fat) widen the emit
gate from "7-day window has data" to "ever logged". Added the
matching `staleDays` prop to `<TrendCard>` and `dashboard.staleHint`
i18n keys across all six locales.
Records the four commits landed on develop (6-helper fold, per-
request memo on `getAssistantFlags`, 60 s LRU on
`buildCoachSnapshot`, slim `?slice=summaries` slice + integration
test) plus the test delta (+114 unit, +3 integration) and the
deferred follow-ups (mislabelled `2d630994` commit, dashboard
wiring, slim slice's anomaly/prior-window stubs).
Five small polish items from the v1.4.33 audit, plus the
maintainer's scroll-into-view item:
* Export-section card padding promoted from `p-5` to `p-6` so the
Export grid rhythm matches every other Settings card.
* Passkey list flips from a desktop table to a mobile card list at
`lg` instead of `md`. iPad portrait (768 px) used to sit on the
`md` inflection and flip-flop between the two layouts on
rotation; the card layout reads fine on iPad portrait, the table
needs ~620 px of column width to show the destructive delete
action without horizontal scroll.
* `<SettingsShell>` mobile chip strip now scrolls its active chip
into view on route change (`block: "nearest", inline: "center",
behavior: "smooth"`). On a 393 CSS px viewport the 11-chip strip
pushes the rightmost four chips below the fold; without the
scroll-into-view, tapping one of those chips left the strip
pinned to the leftmost chip on the new page, which read as a
broken nav. Smooth scroll honours the user's
`prefers-reduced-motion` via the browser's own auto-detection.
* F17 — `thresholds-editor-section` toggle label anchors on the
*action* it performs ("Eigene Werte" / "Custom range") instead
of mirroring the current state ("Auto" / "Überschrieben"). The
old label read as "Auto is off, why are there no inputs?" when
the toggle was off and inputs were hidden; the new label
unambiguously announces "flip me to enter a custom range". The
`thresholds.sourceOverride` badge still surfaces when the
override is the active source. Adds
`thresholds.overrideToggleLabel`.
Locale parity across de, en, es, fr, it, pl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Records the four landed fixes: F2 (onboarding tour click pass-through),
F14 (bottom-nav defence-in-depth), F19 (web-vitals sampling at 10%),
and the A4 dead-code sweep (AssistantDisabledNotice + settings
placeholder copy). Notes the coordination boundary with IW1, IW3,
IW4, and IW6 plus the regression guard at
e2e/onboarding-tour-passthrough.spec.ts.
The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
chunked findMany reads to feed `summarize()` for every
MeasurementType. For a multi-year HealthKit user the PULSE branch
alone walks hundreds of thousands of rows before the dashboard tile
strip can paint a single arrow — even though the strip only consumes
`count / latest / avg7 / avg30 / min / max / mean` plus the three
slope tuples per type.
Add `?slice=summaries` that resolves the same `DataSummary` shape
from two SQL passes (no JS-side spread anywhere along the chain):
1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/
`MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus
Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows.
2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent
`(type, value)` pair per type so the slim shape can populate
`latest` (the value at MAX(measured_at), not MAX(value)).
Slope `direction` keeps the JS helper's 0.01-units-per-day "stable"
threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match
the existing `trendSlope()` contract so the dashboard's arrow / color
logic does not have to branch on slice. Anomaly count, lastMonth, and
lastYear means stay at `null` / `0` because the dashboard tile strip
does not paint them on first paint; the comparison-baseline widget
already opts into the default slice.
The thick `correlations` / `healthScore` / `bpInTargetPct` /
`glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default
path behind a hover-prefetch / explicit slice param. C1 is purely
additive — the route's default behaviour is byte-identical.
Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds,
null-slope branch) and a 3-case real-Postgres integration test
covering the empty user, the populated descending-WEIGHT fixture,
and the default-slice-still-thick contract. 199 unit tests pass
across `src/lib/analytics` + `src/app/api/analytics`; the
integration suite picks up `tests/integration/analytics-summaries-slice.test.ts`
on next `pnpm test:integration` run.
The 10% sampling decision in WebVitalsReporter (v1.4.33 F19) was
computed inline in useMemo(), which the react-hooks/purity lint rule
rejects: Math.random() is impure and the render body must be
idempotent. The original useMemo() happened to be stable in practice
(empty dep array) but the rule fires regardless because the
expression inside the memo factory is impure.
Move the draw into useEffect(() => {}, []) and stash the result in a
useRef so every reportWebVitals callback in the same navigation reads
the same decision. SSR seeds the ref to null, so a pre-hydration
beacon (which would have no rate-limit relief value) is dropped; the
first post-hydration callback finds the ref populated and decides
whether to POST. Bundle and runtime cost unchanged — same one draw
per mount, same all-or-none per page-load. ESLint clean.
Dead-code sweep flagged by the v1.4.33 menu audit.
* AssistantDisabledNotice (src/components/insights/assistant-disabled-notice.tsx)
was introduced in v1.4.31 for the operator-disabled assistant surface
but never wired into a render site. The v1.4.28 retirement of the
weekly-report + advisor card removed the planned host components.
Delete the component, its SSR test, and the four locale strings it
consumed (insights.briefingDisabledByOperator,
insights.statusDisabledByOperator, insights.correlationsDisabledByOperator,
insights.coach.disabledByOperator) from all six locale files. The
AssistantDisabledError thrown by requireAssistantSurface() and the
assistant.disabled.<surface> errorCode envelope stay — iOS clients
read the errorCode to render an inline notice.
* settings.sections.placeholder.coming_soon was an "until extraction
lands" string for <SectionPlaceholder>. Every slug in
SETTINGS_SECTION_SLUGS has had a matching component since v1.4.22
so the placeholder body is unreachable through the type system. The
defensive component itself stays as a guard, but inherits the
section's own description for the EmptyState body, retiring the
dedicated locale key from all six locale files.
Locale-integrity contract holds (parity check green across de, en, es,
fr, it, pl). Comments in api-handler.ts and feature-flags/index.ts
that referenced <AssistantDisabledNotice> are updated to describe
the surface generically.
Refs: .planning/round-v1433-audit-menu.md §6
The notifications.description copy compression that landed in
ff06b0ce moved the four non-de/en bundles into drift. Apply the
same one-sentence description ("Live overview of every configured
channel.") across es, fr, it, pl so the parity test stays green
and the four still-translated locales don't keep the old 13-word
sentence as a fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Telegram, ntfy, and Web Push cards used to paint their own
"Configured · Disabled / Active" badge pair in the top-right
header. The `<NotificationStatusCard>` rendered above the same
cards already shows a live channel pill ("Active · 12 min ago" /
"Auto-disabled — web_push_410_gone"), so the per-card badges were
the third (sometimes fourth) surface for the same state.
Drop the dual-Badge slot from all three cards. The section header
pill is the single source of truth, the per-card surface now
reads as identity + description + form, and the in-app vocabulary
no longer mixes "Konfiguriert", "Aktiv", and "Verbunden" for the
same fact.
Copy pass:
* Settings → Notifications description: "Live-Übersicht aller
konfigurierten Kanäle." (was a 13-word sentence repeating "Kanal"
three times).
* `notificationStatus.title` renames to "Zustellstatus" /
"Delivery status" so the page header and the first card don't
both anchor on "Kanal".
* `notificationStatus.description` compresses the "Auto-disabled"
definition.
* Telegram card title drops "-Benachrichtigungen" — the section is
Notifications, so saying it again is wasted shelf space.
* `telegramDescription` shortens to "Medikamenten-Erinnerungen
per Bot." / "Medication reminders via bot."
* `moodLogDescription` drops the second "moodLog" mention.
Notification-status SSR test updated to the new copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`buildCoachSnapshot()` reads only persisted data — ~10 measurement
findManys plus the GLP-1 / mood / intake side-fetches — yet a single
chat conversation sends 2-4 turns within a minute and rebuilds the
same snapshot every time. Wrap the existing builder (renamed to
`buildCoachSnapshotImpl`) with an in-memory cache keyed on
`(userId, scope.window, sorted scope.sources)` with a 60 s TTL and a
64-entry LRU cap. Different scopes recompute; the cache evicts on the
next eligible read.
`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn — the bulk of the
"blockt komplett" complaint between user-clicks-send and first SSE
`token` frame. The `__resetCoachSnapshotCacheForTests` export gives
the existing two snapshot-test suites a hook so each fixture is read
fresh; both add a `beforeEach` reset alongside the existing
`vi.clearAllMocks()` call. Two new tests pin the cache-hit (one
Prisma findMany call across two `buildCoachSnapshot` calls with the
same scope) and the scope-changed miss (different `window` → two
findManys). Feature-flag tests gain a parallel "per-request
memoisation" describe-block that pins the same behaviour for
`getAssistantFlags()` against a stable mocked event reference.
Two server-side latency cuts on the Coach drawer open / first turn path.
1. **Per-request memo on `getAssistantFlags()`.** Opening the Coach
drawer fires five gated fetches in parallel — `/api/insights/chat`,
the rail list, `/api/insights/comprehensive`, plus the per-metric
status calls already on the page — and every gated route lands a
`requireAssistantSurface(surface)` call near the top. Today each
call issues its own `AppSettings.singleton` `SELECT`. The new
`src/lib/request-cache.ts` carrier keys a `Map` on the active
`WideEventBuilder` (already in `AsyncLocalStorage` via the
api-handler) so the same request reads the row once. Outside an
event context (unit tests, background jobs) the factory runs
uncached so behaviour is unchanged.
2. **60-second in-memory LRU on `buildCoachSnapshot()`.** A chat
conversation sends 2-4 turns within a minute and the snapshot reads
only persisted data — ~10 measurement reads + GLP-1 + mood + intake
side-fetches per turn. Cache the result keyed on
`(userId, scope.window, sorted scope.sources)` with a 60 s TTL and
a 64-entry LRU cap. Different scopes recompute; the cache evicts
on the next eligible read. Tests exercise both the hit and the
scope-changed miss; legacy tests gain an explicit reset between
describe blocks.
`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn and roughly 4×
`AppSettings.singleton` reads avoided on a cold Coach-drawer open.
The wave-A HealthKit pills (HRV, resting HR, SpO2, body temperature,
active energy) added in v1.4.32 stacked at the end of the strip in
insertion order so a left-to-right scan jumped across metric domains
multiple times. Reorder the pills to mirror the
MeasurementCategory overlay at src/lib/measurements/categories.ts:
vitals → body → activity → sleep → cardiovascular → mood → events.
No pill added or removed. The single source of truth is
SUB_PAGE_METRIC in src/lib/insights/sub-page-metric.ts; the strip
iterates SUB_PAGE_SLUGS (derived from the same record) and inherits
the new order automatically. Heavier regrouping (collapse five
wave-A pills under one Apple-Health pill, fold BMI into Gewicht)
is deferred to v1.4.34 — see .planning/round-v1433-audit-menu.md
§7 for the design-review rationale.
Per .planning/round-v1433-audit-runtime.md F9.
Three closely-related polish fixes from the v1.4.33 audit:
* `/settings/account` username field flips `disabled` to `readOnly`.
The disabled state inherits `opacity-50` from the `<Input>` primitive,
which on Marc's mobile pass read identically to placeholder text
and made the field look empty. `readOnly` keeps the full-contrast
value rendering while still preventing edits.
* `<SettingsShell>` main column reserves a 96 px bottom gutter on
`<md` so the last form field on `/settings/account` (Gender) and
`/settings/ai` (Active provider) is not eaten by the floating
mobile bottom-nav. Desktop keeps the parent's own padding because
the bottom-nav doesn't render on `md+`.
* Mood Log webhook-secret row stacks vertically on `<sm` with
`break-all` on the input value, killing the body-level horizontal
scroll triggered by the long hex secret on Pixel 5. The
Sync / Voll-Sync / Trennen triplet picks up the Withings
`flex-wrap` + `min-w-[10rem]` pattern, and the in-form save row
right-aligns its primary action so it matches every other
integration form.
* New locale key `settings.moodLogFullSyncConfirm`
("Vollständig synchronisieren" / "Synchronize fully") is now
wired into the Voll-Sync confirmation dialog so the trigger and
confirm actions read as distinct copy.
Locale parity across de, en, es, fr, it, pl.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every visit to /insights or any sub-route used to fire POST
/api/insights/generate from InsightsLayoutShell. The mutation
returned null on 422 (no provider configured) so the UI degraded
gracefully, but the server kept absorbing the call on every cold
mount and the dev-server logs filled with '422 Unprocessable
Entity'. Read the operator's assistant flag matrix from
/api/feature-flags (already cached server-side at 60s, client-side
under the shared ['feature-flags'] query key) and skip the mutation
entirely when 'enabled' or 'briefing' is false. The fail-open
default in useFeatureFlags means a flags-endpoint hiccup still
renders the advisor — the gate only takes effect when the operator
has explicitly turned the surface off.
Also update the per-metric InsightStatusCard's empty-state copy
from the diagnostic 'KI-Provider nicht konfiguriert' / 'AI
provider not configured' to the neutral 'Auswertung nicht
verfügbar' / 'Assessment unavailable'. The diagnostic phrasing
read as a setup failure even though the surface degrades cleanly;
the neutral phrasing matches the rest of the empty-state language.
Per .planning/round-v1433-audit-runtime.md F18.
The medication heatmap painted every cell orange ('sehr spät') even
for days that recorded 'taken' intakes with no skipped or missed
events. Tracing the upstream API (src/app/api/medications/[id]/
compliance/route.ts → classifyIntakeTiming) shows the classifier
returns 'very_late' for any intake that falls outside the 1h-grace
window in either direction; a stored UTC offset mismatch between
takenAt and the schedule window (e.g. 06:00 UTC intake vs an 08:00
window) therefore pushes every dose to the worst bucket.
Until the classifier is hardened in a follow-up sprint, the heatmap
detects the unambiguous classifier-bug pattern (everything in
veryLate, nothing late/onTime, no missed/skipped) and falls through
to the rate-based palette so a 'fully compliant' day reads green
instead of orange. Legitimately-late days still paint orange because
they carry a non-zero late or missed count.
Per .planning/round-v1433-audit-runtime.md F8.
`bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood`
status helpers all built their `summarizeSeries` block with
`Math.min(...series.map(...))` and `Math.max(...series.map(...))`.
The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts`
on 2026-05-16 and crashed `/api/analytics` with
`RangeError: Maximum call stack size exceeded` once an
Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling;
`61107e0c` folded that one. The six insight helpers are fed bounded
windows today so they never reached the ceiling, but the spread still
allocates a transient args array on every call and the fold is both
stack-safe and cheaper.
Replace each `Math.min(...) / Math.max(...) / average(...)` triplet
with a single walk that maintains running sum / min / max. The
now-unused `average()` helper is dropped from each file. Pinned by
the existing per-helper unit tests (326 passing in
`src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`).
When the visible Y-domain spans less than ~6 units the chart's
integer tick formatter rounded two adjacent gridlines to the same
label — '83 kg' appeared twice on /insights/gewicht when the
domain ran from 82.3 to 85.1. Promote the formatter to a one-
decimal projection when the domain is narrow so each tick reads
distinct without inflating the BP / glucose / steps axes where
integers are correct.
Per .planning/round-v1433-audit-runtime.md F10.
The web-vitals reporter posted every CLS/LCP/FCP/TTFB/INP measurement
on every page-load, six beacons per navigation. In production this
saturated the route-side per-IP rate-limit (60/min) inside seconds —
the runtime audit captured POST /api/internal/web-vitals 429 for
almost every metric after the first navigation, so most samples
never reached the wide-event sink. Telemetry was self-throttling.
Decide once per mount (memoised) whether the current page-load
reports its vitals, sample at 10%, and either send every metric for
that load or send none. Per-load all-or-none keeps each session's
vitals coherent (an LCP from one navigation stays comparable to the
CLS from the same navigation) while cutting traffic by an order of
magnitude. The route-side rate-limit stays in place as a backstop.
Refs: .planning/round-v1433-audit-runtime.md F19
The /insights/puls page reads spot-pulse measurements (PULSE) while
/insights/ruhepuls covers the daily resting-heart-rate stream
(RESTING_HEART_RATE). Both routes shared a single copy thread that
labelled /insights/puls as 'Ruhepuls', so the strip pill said one
thing and the description said another. The Karvonen target band
applies to live pulse just as well as to resting HR, so the wording
swap is the only change — no semantics shift.
Per .planning/round-v1433-audit-runtime.md F7.
The mobile bottom tab-bar bled into the bottom of the visible area on
/settings/account and /settings/ai per the runtime audit. The bar
previously rendered as bg-card/80 (80% opacity, translucent) and
relied on the inner flex row's h-16 alone for its height — without
a min-height floor on the outer nav, a future variant or any flex-
collapse quirk could let the surface shrink under the 64px floor the
auth-shell counts on.
Pin the bar at min-h-16 on the outer nav (defence in depth against
flex collapse) and switch the surface from bg-card/80 to a solid
bg-card. The safe-area inset and fixed bottom-anchor stay as they
were. Page shells that scroll content beneath the bar are still
responsible for reserving pb-16 + safe-area on their main column;
this change makes the bar itself robust so any future page-shell
oversight produces a hard occlusion (visibly broken on review)
rather than a translucent bleed (silently shipped). Bottom-nav SSR
test stays green (4/4) — five primary anchors, More button,
aria-haspopup, 44px tap-target floor.
Refs: .planning/round-v1433-audit-runtime.md F14
The blood-pressure chart cards on /insights/blutdruck and on the
mother-page trends row passed `yAxisUnit="Hg"` to the underlying
HealthChart, which painted gridline labels as `139 Hg`. mmHg is the
canonical clinical unit; "Hg" alone is incomplete (mercury, not
millimeters of mercury) and reads as a typo. Tile header on the same
page already says mmHg, so the axis was the only surface drifting.
Per .planning/round-v1433-audit-runtime.md F6.
The v1.4.15 spotlight tour rendered the dim backdrop as a single full-
viewport <button> with a clip-path punching a visual hole around the
target. clip-path only changes paint — the button's hit-box stayed
over the entire viewport, so every click on the underlying page
(Hinzufügen dropdown, sidebar links, header avatar) hit the dim layer
and triggered Skip without forwarding the click to the real target.
New users landing on the dashboard with onboarding_tour_completed=false
were effectively locked out of the most common action.
Replace the single backdrop with up to four rectangular dim panels
(top / bottom / left / right strips around the spotlight rect) and
set pointer-events: none on the tour root. The spotlight area is now
genuinely click-through — the spotlighted target receives clicks
normally — and the dim panels keep the "tap backdrop to dismiss"
affordance via their own onClick handlers. The center-placement
fallback (no spotlight target available) retains the legacy full-
cover dim behaviour. Tooltip opts back into click capture with
pointer-events: auto so its buttons stay reachable.
Adds an e2e regression guard
(e2e/onboarding-tour-passthrough.spec.ts) that mounts the tour by
overriding /api/auth/me with onboardingTourCompleted=false, then
asserts the header Hinzufügen dropdown opens on click while the tour
is up. Covers both branches: click on the spotlighted target
forwards to the underlying button, click on a dim strip skips. SSR
tour test stays green (8/8) — the role=dialog, aria-modal,
data-testid hooks, step counter, and i18n keys are unchanged.
Refs: .planning/round-v1433-audit-runtime.md F2
`bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood`
status helpers all built their `summarizeSeries` block with
`Math.min(...series.map(...))` and `Math.max(...series.map(...))`.
The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts`
on 2026-05-16 and crashed `/api/analytics` with
`RangeError: Maximum call stack size exceeded` once an
Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling;
`61107e0c` folded that one. The six insight helpers are fed bounded
windows today so they never reached the ceiling, but the spread still
allocates a transient args array on every call and the fold is both
stack-safe and cheaper.
Replace each `Math.min(...) / Math.max(...) / average(...)` triplet
with a single walk that maintains running sum / min / max. The
now-unused `average()` helper is dropped from each file. Pinned by
the existing per-helper unit tests (326 passing in
`src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`).
Production GET /api/analytics returned HTTP 500 for users with very long
measurement histories (request-id d86db8e5… 2026-05-16 14:39:51 UTC).
The wide-event log pinned the failure at Promise.all index 3 (PULSE) with
RangeError: Maximum call stack size exceeded — a power user's Apple-Health
heart-rate stream crossed V8's ~125k function-arity ceiling on the
Math.min(...values) / Math.max(...values) spread inside summarize().
Fold sum, min, and max into a single pass over values so the transient
argument array goes away and the working set stays bounded at the array
itself. Mean is now derived from the same fold's sum/length instead of
a second reduce. No public API change; min/max semantics for an empty
series remain null via the early-exit branch.
Adds a 250 000-row regression in trends.test.ts that raises the exact
production stacktrace before the fix and passes after, plus a new
src/app/api/analytics/__tests__/route.test.ts that pins the route-entry
contract (5 000-row PULSE + zero-row brand-new user) so the upcoming
C1 architectural rewrite can't silently reintroduce a spread anywhere
along the call chain. Touched-file lint and pnpm typecheck clean;
broader src/lib + src/app/api/analytics sweep is 2671 / 1 skipped.
Records the six implementation commits + the full-suite-green
state so a resumer can pick up the CHANGELOG / version bump /
release pipeline without re-discovering the diff. Iceberg notes
cover the latent Prisma-field-name bug fixed in Commit 1, the
default-visible `recentWorkouts` widget, the four metrics
intentionally without an empty-state CTA, and the SVG-polyline
route-preview decision deferring MapLibre / Leaflet to v1.5.x.
The four metric sub-pages without a manual-entry CTA
(`hrv`, `restingHr`, `oxygenSaturation`, `activeEnergy`) shipped
with `"cta": ""` placeholders that broke the locale-integrity
sentinel. The template never renders those keys when
`emptyStateCtaType` is null — drop the empty values so every
remaining key carries non-empty text.
Adds five sub-pages for the wave-A HealthKit metrics the server has
been storing since v1.4.23 / v1.4.25 but never surfaced on the web:
- /insights/hrv → HEART_RATE_VARIABILITY
- /insights/ruhepuls → RESTING_HEART_RATE
- /insights/sauerstoff → OXYGEN_SATURATION
- /insights/koerpertemperatur → BODY_TEMPERATURE
- /insights/aktive-energie → ACTIVE_ENERGY_BURNED
Each page composes the shared `<HealthKitMetricPage>` scaffold —
`SubPageShell` + `HealthChartDynamic` mounted on its own
`ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA
that points at the existing `/measurements?add=<TYPE>` quick-entry
where a manual entry makes sense, or an Apple-Health-onboarding cue
otherwise. The five new chart-overlay-prefs slots (`hrv`,
`restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`)
let the chart-cog popover persist per metric.
Insights tab strip picks up the five new pills via the existing
`SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation
overlay realigns HRV + resting HR to the `cardiovascular` bucket
per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a
point-in-time signal, derived heart-rate variants are the cardiac
risk surface). Six locales updated with the new sub-page titles,
descriptions, chart titles, and empty-state copy.
Adds `<RecentWorkoutsTile>` as a default-visible dashboard widget
(`recentWorkouts`). The tile reads the top three canonical workouts
through `useWorkouts({ limit: 3 })`, renders each as a deep-link
row with the sport icon + the day + the duration, and falls back
to an Apple-Health-onboarding cue when the workout list is empty.
`DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry
the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up
from the same source-of-truth array. The Settings → Dashboard
layout editor surfaces it under the existing widget toggle list so
users can opt out without touching the layout JSON. Six locales
updated.
Adds `/insights/workouts/[id]` on top of the v1.4.32
`GET /api/workouts/{id}` endpoint. The page mounts a header strip
(sport icon + source + date range + duration summary), a stats card
that surfaces every aggregate field present on the row (duration,
distance, active energy, average / max / min HR, steps, elevation,
pace for foot- and ride-paced sports), an SVG-rendered route preview
when the workout carries `WorkoutRoute` GeoJSON LineString
geometry, and an HR-chart placeholder that exposes the aggregates
inline until the per-second sample series wires up in v1.5.x.
Loading state mounts skeletons; the not-found branch surfaces a
neutral notice rather than a 404 page so deep-links from the list
page degrade gracefully when the workout has been deleted upstream.
Adds the user-visible surface for the workout rows the iOS client
already syncs through `POST /api/workouts/batch`. The page lives at
`/insights/workouts`, consumes the v1.4.32 list endpoint through a
new `useWorkouts()` hook, and renders each row as a deep-link into
the detail page with the sport icon, the date, the duration, and
the optional distance + active-energy chips.
The insights tab strip gains a `workouts` pill driven by an
event-style `hasWorkouts` availability signal — the layout shell
probes the list endpoint with `limit=1` so the pill self-gates on
zero workouts. Empty state mounts the shared `MetricEmptyState`
primitive with a Coach prefill that steers the conversation toward
the Apple Health sync onboarding cue. Six locales updated.
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).
Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.
Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
`08-locked-contracts.md` gains §14 — "Assistant feature-flag
scope (locked v1.4.31)". Carries the full
`GET /api/feature-flags` response shape, the master-kill rule,
the cache posture, and the gated server endpoint matrix.
The §14 scope-rule sub-section locks verbatim from
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the matrix
gates BOTH server-routed AND iOS on-device assistant surfaces.
Apple Foundation Models on-device Coach + Briefing surfaces in
the v0.6.0 iOS path honour the same flag matrix as the
server-routed surfaces; the operator's intent is "no model-driven
surface in this app on this server" regardless of where the
inference runs.
The "What is NOT in this file" stub renumbers from §14 to §15.
v1.4.30 PR push failed CI on first attempt because the two new
MeasurementType enums weren't reflected in `docs/api/openapi.yaml`.
A separate `chore(openapi): regenerate the public spec` commit
was needed before the checks turned green. This hook catches the
drift at commit time locally so the contributor sees the
regeneration prompt before pushing.
New `.githooks/pre-commit`:
- Skips when no relevant files (`src/lib/openapi/`,
`src/lib/validations/`, `src/app/api/`, the generator script
itself) are staged.
- Runs `pnpm openapi:check` quietly; exits fast on match.
- On drift, runs `pnpm openapi:generate`, re-stages the
regenerated `docs/api/openapi.yaml`, and continues the commit.
- Skips in CI so the existing `pnpm openapi:check` job in
`.github/workflows/security.yml` stays the server-side
authoritative gate.
`scripts/install-hooks.sh` flips `git config core.hooksPath` to
`.githooks/` — idempotent, one-shot per fresh clone. AGENTS.md
adds the install command to the contributor workflow section.
Five releases in a row (v1.4.27 → v1.4.30.1) the publish workflow
returned a 2xx from the Coolify webhook but the running container
on apps01 still carried the prior `:latest` digest. Host-side SSH
fallback patched it every time at ~5 minutes per release.
Root cause: GHCR's CDN edge layer takes 30-90 s to propagate a
fresh manifest write to consumer pull endpoints. The
`docker buildx imagetools create` + `inspect` steps confirm the
manifest at registry-write time, but the webhook fires
immediately afterwards. Coolify's pull lands inside the
edge-propagation window, gets the prior digest, declares no-op,
and returns success. The webhook itself works; the spacing
assumption between push and pull was wrong.
Fix: add `sleep 90` immediately after the auth + skip-mode
guards in the `Trigger Coolify deploy` step. Diagnostic line
prints the resolved image version (`steps.meta.outputs.version`)
before the sleep so the workflow log surfaces the exact tag the
operator can sanity-check.
Cost: ~30% on the publish-job duration, but only after the image
is fully on GHCR. The release-flow walk-through clock is
unaffected; the host-side SSH fallback remains available as a
backstop.
Full root-cause + hypothesis matrix + escalation plan documented
in `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`.
Three client-side fixes that collapse the worst-case tap-block
window from the LLM-completion tail (~30 s on a cache-miss) down
to the bounded parallel-fetch window (~400-600 ms). The strip
itself stays interactive in the DOM throughout; the previous
freeze symptom traces to main-thread contention starving WebKit's
gesture-recognition timeout. Per
`.planning/research/v15-insights-blocking-bug.md`.
1. `fetchAdvisor` (`use-insights-advisor.ts`) gets an 8-second
`AbortController` and converts `AbortError` into the existing
graceful-null return path so the consuming UI surfaces the
regen CTA instead of pinning the query in `isFetching: true`
for the LLM's full completion latency.
2. `<InsightsTabStrip>` is wrapped in `React.memo`; the inner
`buildTabs(availability)` is `useMemo`'d on `availability`; the
layout shell `useMemo`'s the `availability` prop on its three
leaf inputs. Together they break the
"shell re-renders -> strip re-renders -> 8 pills re-render"
cascade on every cache-write of analytics or comprehensive.
3. `<CoachDrawer>` is now lazy-loaded via `next/dynamic({ ssr: false })`
inside `<LayoutCoachMount>` so the SSE machinery, the chat
reader, the suggested-prompts chip rail, and the persistent
settings sheet don't run their initialisers on every cold
/insights mount. The drawer renders nothing until the user
actually opens it.
Tests pin the memo contract on the strip and the import-cleanness
of the abort path. The wider fix-5 (analytics SQL aggregation per
R-B C1) stays scoped to v1.5.
Four user-facing keys land in all six locale files so the
`<AssistantDisabledNotice>` component reads natively localised
copy:
insights.coach.disabledByOperator
insights.briefingDisabledByOperator
insights.statusDisabledByOperator
insights.correlationsDisabledByOperator
The Coach key nests under the existing `insights.coach.*` object;
the other three sit at the top of `insights.*` to avoid colliding
with the existing string-valued `insights.correlations` key.
The Health-Score explainer has no notice — its suppression is
silent on purpose so the bare delta digit still reads as a clean
KPI.
DE carries the full native phrasing; the established convention
for the other five locales (FR/ES/IT/PL/EN) keeps the user-facing
strings localised even where admin strings still default to
English.
New admin slug `assistant` between `ai-quality` (provider config)
and `coach-feedback` (in-app feedback inbox). The panel renders six
`<SettingsToggle>` rows — master plus five sub-flags — and writes
through a dedicated `PUT /api/admin/settings/assistant-flags`
endpoint. Optimistic UI flips on click; failure surfaces a toast
and re-invalidates the read query.
The dedicated endpoint (separate from the generic `/api/admin/settings`
PUT) lets the panel scope its audit trail to a single
`admin.settings.assistant-flags.update` action and lets the
optimistic UI bind 1:1 to a focused request/response shape. The
PUT handler echoes both the raw column values and the resolved
shape so the panel can render the master vs sub-flag distinction
visually.
Sub-toggles grey out when the master is off (UX-wise) so the
operator can see which sub-flags are individually flipped before
unmuting the master. Writes invalidate the runtime
`/api/feature-flags` cache so the operator sees the toggled
surface react within the same session.
i18n: `admin.section.assistant.{title,subtitle}` and
`admin.assistant.*` keys land in all six locales (DE proper, the
five English-leaning admin locales mirror the English copy as is
the established convention for admin strings).
New client hook `useFeatureFlags()` reads the matrix from
`/api/feature-flags` once per session and feeds every gated mount
on the Insights surface. The hook fails open — any network error
or missing `<QueryClientProvider>` (the legacy presentational test
posture) returns the all-on default so the user never loses an
affordance to an instrumentation gap.
Gated surfaces:
<LayoutCoachFab>, <LayoutCoachMount>, <CoachLaunchButton>
→ coach
<DailyBriefing> on /insights/page.tsx → briefing
<CorrelationRow> on /insights/page.tsx → correlations
<InsightStatusCard> × 6 sub-pages → insightStatus
<HealthScoreDeltaExplainer> ? glyph → healthScoreExplainer
Master `assistant.enabled = false` forces every sub-flag false in
the resolver, so flipping the master collapses every surface at
once. Each component returns `null` when its flag is off; the
delta digit on the Health-Score card stays visible because the
parent reads the number directly.
New `<AssistantDisabledNotice>` component prepares the empty-state
copy for callers that detect a 403 + `errorCode:
"assistant.disabled.<surface>"` response. The localised strings
land in the dedicated i18n commit.
Every LLM-driven endpoint now reads the operator flag set near the
top of the handler and throws `AssistantDisabledError` when the
relevant surface is off. The api-handler catches the typed error
and returns the 403 envelope:
{ data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }
Gates per the v1.4.31 brief:
/api/insights/chat → coach (POST + GET rail)
/api/insights/generate → coach
/api/insights/comprehensive → coach
/api/insights/cards → insightStatus
/api/insights/correlations → correlations
/api/insights/{weight,bmi,blood-pressure,pulse,mood,
medication-compliance}-status → insightStatus
Master `assistantEnabled = false` forces every sub-flag false in
the resolver, so flipping the master 403s every endpoint at once.
Older iOS clients see a generic 403 (their existing 403-handler
covers the case); v1.4.31+ clients read the `errorCode` to render
the `<AssistantDisabledNotice>` empty state. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
`GET /api/feature-flags` projects the six `AppSettings.assistant*`
booleans over HTTP as a single typed envelope:
{ data: { assistant: { enabled, coach, briefing, insightStatus,
correlations, healthScoreExplainer } } }
`requireAuth()` gates the route — any logged-in user passes. The
resolver in `src/lib/feature-flags/index.ts` forces every sub-flag
false when the master is off, so the response is always
operator-effective and clients never have to compose
`master && sub`. `Cache-Control: private, max-age=60` caps the
read cost on the hot /insights mount path while still propagating
admin changes within a minute.
iOS reads this endpoint on cold start and gates every Coach,
Briefing, status-card, correlation and explainer surface against
the matrix — regardless of whether the surface ends up calling the
server or running on-device. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
Operator-side kill matrix for every LLM-driven surface. Master flag
`assistantEnabled` plus five sub-flags carve the seven surfaces:
Coach + chat, Daily Briefing + advisor, per-metric status cards,
correlation narration, Health-Score delta explainer. Defaults are
`true` so existing installs upgrade without losing affordances.
New `src/lib/feature-flags/` module exposes `getAssistantFlags()`,
`resolveAssistantFlags()` and the `AssistantDisabledError` typed
error route handlers throw to surface the 403 +
`errorCode: "assistant.disabled.<surface>"` envelope. The resolver
forces every sub-flag false when the master is off so callers never
have to compose `master && sub`.
Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5:
the same matrix gates server-routed AND iOS on-device surfaces.
Migration 0065 is additive only; `app_settings` gains six boolean
columns, all default `true`, idempotent under `IF NOT EXISTS`.
Categories endpoint + conflict-resolution lock. A two-item follow-up
to v1.4.30: the categorisation overlay shipped yesterday as a
TypeScript map; the new `GET /api/measurement-categories` endpoint
projects it over HTTP so the iOS-side picker can fetch the canonical
shape rather than carry a hard-coded mirror — the unblocker the iOS
team flagged as the one missing piece. In parallel the SyncMode
conflict-resolution policy that the v0.6.0 standalone-first track
depends on lands as §13 of `08-locked-contracts.md`.
§2 of the v1.5 strategic plan now ends v1.4.33 without the freeze
marker. A new v1.4.34 sub-section lands per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R2: scope is
`POST /api/import/apple-health-export` (multipart) + streaming XML
parser + async job model (`{ jobId }` + `/status` polling) +
per-MeasurementType ingestion stats + idempotent UPSERT on
`externalId` + admin endpoint variant + mapping for
HKQuantityTypeSample, HKCategoryTypeSample and HKWorkout
(HKClinicalRecord defers per R-F T3). Effort L (~3-4 days).
The web-freeze marker moves to v1.4.34. The freeze posture, the
v1.5.0 version-bump shape, and the decision-log §5 rows
(web-freeze trigger, v1.5 release shape, web patch sequence, plus
a new "Apple Health XML import slot" row) all renumber. The
deferred §4 row and §6 open question #3 close out — the import is
now scheduled, not deferred.
§5b "Coach SSE" and §6 "Coach SSE drawer iOS implementation" both
reframed per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R6.
The server endpoint `GET /api/insights/chat` stays live indefinitely
for PWA users, future iOS reevaluation post-MDR, and any other
client that adopts the SSE protocol. v1.5.0 iOS ships Apple
Foundation Models on-device Daily Briefing + Trend Observations as
the primary assistant surface; a native server-Coach drawer is
deferred pending MDR Class-IIa pre-review.
§3 of the iOS contributor brief now reflects the v1.4.30 production
state (both hosts on the iOS-coordinated foundation tag) and the
v1.4.30.1 follow-up. The recent-releases table gains two rows; the
"endpoints the iOS client may consume today" list gains the four
v1.4.30 surfaces (`/api/sync/state`, `/api/mood-entries/bulk`,
`/api/medications/intake/bulk`,
`/api/admin/drain-per-sample-cumulative`) plus the new v1.4.30.1
`/api/measurement-categories` projection.
Appends §13 to `08-locked-contracts.md` codifying the hard-spec
delivered in `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R9.
Covers bulk-backfill, steady-state bidirectional sync, write
conflict (LWW by `updatedAt` with server-wins on the millisecond
tie), delete conflict (409 + canonical row vs 410 Gone), and the
`GET /api/sync/state` envelope shape.
iOS uses this as the contract for the v0.6.0 standalone-first track
once `SyncMode = paired` is the live state. The "What is NOT in
this file" stub renumbers to §14.
Adds `GET /api/measurement-categories`, a thin projection of the
v1.4.30 `MEASUREMENT_CATEGORIES` overlay at
`src/lib/measurements/categories.ts`. iOS reads the response on cold
start to drive the HealthKit permission picker grouping; the future
web Insights nav and the Coach evidence shelf consume the same shape.
Response envelope carries a `version: 1` marker, an ordered
`categories` list with `labelKey` translation keys, and the
`assignments` map keyed on `MeasurementType`. `requireAuth()` gates
the route — any logged-in user passes — and the response carries
`Cache-Control: public, max-age=600` so clients honour a 10-minute
edge cache.
Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
iOS team's 2026-05-16 response confirms two strategic pivots
(Apple Foundation Models on-device + standalone-first for v0.6.0)
and asks ten coordination items. Every R1-R10 answered with
hard-spec commitments. Two new items land in the v1.4.x roadmap:
GET /api/measurement-categories endpoint (R1, v1.4.30) and the
conflict-resolution policy lock (R9, v1.4.30). Apple Health XML
import (R2) slates to v1.4.34 alongside the freeze marker.
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:
- measurement-list-meta.ts label keys + icons + colors so the
measurements list view renders both types (Gauge for steadiness,
Volume2 for the loud-listening event)
- 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
typeAudioExposureEvent keys; English-fallback copy for the four
non-EN/DE locales matches the existing v1.4.25 W8d pattern
- chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
in prose; dedicated chart components ship alongside the iOS sync
- the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
exclusion set so the canonical enum-extension lint stays green
Full unit suite passes (4035 specs).
v1.4.30 backstop. Every new endpoint introduced earlier in the patch
run rides the existing real-Postgres container fixture (v1.4.29's
contribution): the SyncMode handshake, the mood-entries bulk
upsert, the medication-intake bulk insert with idempotency-key
collision, the mood-note column round-trip, and the drain helper
itself.
This commit adds the missing slot — the admin endpoint
POST /api/admin/drain-per-sample-cumulative — so the route's admin
gate, dryRun default, and commit path all run against real SQL
end-to-end. Mocked-prisma siblings would have hidden the v1.4.29
lesson (the date_trunc bound-parameter bug); the route's
transactional upsert + delete needs the same fence.
Coverage delta: 3 new integration specs, all green.
R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values
unlock the iOS Tier-1 surfaces Apple's own Health app shows
prominently.
Schema (migration 0064, additive):
- measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT
WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a
0..1 fraction; HealthLog stores 0..100 percent (same convention as
oxygen saturation + body fat).
AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the
rolling 7-day average crosses the WHO 80-dBA loud-listening
threshold. Both the environmental + headphone events map to the
same MeasurementType so chart-card consumers pick them up uniformly;
the notes field carries the source token at iOS write time.
Updates ride the same release:
- Zod measurementTypeEnum gains both values
- apple-health-mapping.ts maps three HK identifiers
(AppleWalkingSteadiness, EnvironmentalAudioExposureEvent,
HeadphoneAudioExposureEvent) and removes the same identifiers
from HK_QUANTITY_TYPE_DEFERRED
- categories.ts assigns WALKING_STEADINESS to activity and
AUDIO_EXPOSURE_EVENT to hearing
- pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness
is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold-
cross flag, not goal-axis)
iOS contract: the two new enum values are net-new — iOS clients that
predate the codegen pass will not encounter them in read paths
because no source writes them yet. New iOS code paths can post the
new HK identifiers via the existing batch endpoint without further
server changes.
R-F §4 close-out. The flat 27-value MeasurementType enum stays as the
storage shape; this overlay is presentation-only and drives both
consumers:
- iOS HealthKit permission picker — grouped by category so the user
sees one consent screen per logical area (vitals / activity /
sleep / hearing / environment / cardiovascular / metabolic / body)
rather than a flat 27-line list
- web Insights nav (post-v1.5) — the existing 7-page strip stays
curated; an "All metrics" overview can group cards by category
cheaply from this map
- Coach evidence shelf — chip-grouping by category makes the prose
more navigable than a flat list
The map is ReadonlyMap<MeasurementType, MeasurementCategory> so
consumers cannot mutate it accidentally. Adding a new MeasurementType
without a sibling entry is caught at test time by the completeness
wall.
iOS contract: presentation-only — no API surface change. The iOS
client mirrors this map verbatim once the swift-openapi-generator
wiring (R-E H-8) lands; new types stay invisible to clients that
predate the codegen pass.
R-F T1.1 close-out for the dashboard "Recent workouts" tile. Same
intent as pickCanonicalSourceRows() for measurements, applied to
workouts. A user wearing an Apple Watch + a Withings ScanWatch
records the same run via each integration; without the picker the
dashboard would show the run twice.
Bucket key = (5-min-rounded startedAt, sportType). The 5-minute slot
absorbs the clock skew between HK and Withings without collapsing
two back-to-back runs into one bucket (a user does not run twice in
a five-minute window in practice).
Source-priority ladder ride the existing measurement "steps" key —
unifying on one Settings UI rather than introducing a new "workouts"
metric key. Default ladder is APPLE_HEALTH > WITHINGS > MANUAL >
IMPORT per the source-priority validator's canonical default.
Metric-aware tunes (route → Apple wins, HR zones → Withings wins)
defer to v1.5.x per R-F open question #4.
iOS contract: helper is read-side only — no API surface change. The
workout batch ingest route (verified in v1.4.25 W8d) keeps the
existing rate-limit + idempotency + 5 MB body cap + 100-workout
batch cap. /api/workouts/batch is the iOS endpoint going into v1.5.
Picker exercises empty-input, single-row, cross-source overlap,
sport-discriminator-keeps-distinct, > 5-minute-distinct, user-
supplied ladder honoured, manual-only fallback, and an unrecognised
ladder fallthrough.
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.
Schema (migration 0063, additive):
- mood_entries.note (TEXT, nullable)
Validation + routes:
- createMoodEntrySchema gains optional note (max 500 chars)
- updateMoodEntrySchema accepts note + null to clear
- POST /api/mood-entries persists note alongside tags
- PUT /api/mood-entries/[id] honours partial note updates
- POST /api/mood-entries/bulk carries note through the bulk path
Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.
iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
R-E C-2 server-side. Three additive columns + three new endpoints
let the iOS SyncMode store pair, drain, and reconcile against a
fresh paired-server.
Schema (migration 0062, additive):
- Measurement.sync_version (Int, default 1) for last-writer-wins
- Measurement.deleted_at (Timestamp, nullable) for soft-deletes
- User.last_synced_at (Timestamp, nullable) for the handshake
checkpoint
Endpoints:
- GET /api/sync/state — handshake response (lastSyncedAt, server
clock, live + tombstoned measurement counters); call also bumps
the checkpoint so iOS reads the OLD value then trusts subsequent
writes
- POST /api/mood-entries/bulk — UPSERT per entry keyed by
(userId, date, moodLoggedAt); probe-then-upsert so the response
reliably reports inserted vs duplicate; rate-limited at 60/min
- POST /api/medications/intake/bulk — INSERT per entry with
P2002-on-idempotencyKey → duplicate; up-front user-owns-medication
check skips foreign rows with a documented reason
iOS contract: every change is additive. Existing per-sample POSTs
keep round-tripping (sync_version defaults to 1 server-side;
deleted_at stays NULL on every existing read path; last_synced_at is
opt-in). The new endpoints are net-new — no iOS consumer yet.
Tests cover the handshake checkpoint advance, live-vs-tombstoned
counters, clean batch ingest, re-post upsert semantics, ownership
filtering, idempotency-key collision, and over-cap rejection — all
against the real-Postgres container.
Idempotent maintenance tool that collapses pre-Option-A per-sample
APPLE_HEALTH cumulative rows (steps, active energy, flights climbed,
walking + running distance, time in daylight) into one row per day
per type, keyed by the locked dailyStatsExternalId shape. Per
user × type × calendar-day (anchored to User.timezone): SUM the values,
UPSERT a daily row at canonical midday-local (matching the Withings
activity-sync convention), DELETE the per-sample rows.
Three entry points share src/lib/measurements/drain-per-sample-cumulative.ts:
- Library helper for unit + integration tests
- CLI at scripts/drain-per-sample-cumulative.ts with --user / --all
+ --confirm flags (default dry-run)
- Admin endpoint POST /api/admin/drain-per-sample-cumulative gated
by requireAdmin() with dryRun defaulting to true
Operator runs the drain once after the iOS TestFlight build adopts
HealthKitStatisticsService.swift. Re-running on a fully-collapsed
account reports zero buckets touched.
Tests cover bucketing semantics, the canonical-timestamp math across
DST + half-hour zones, idempotent re-runs, dry-run preview, spot-metric
exclusion, and already-collapsed-row skip via real-Postgres.
R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside
the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints
"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" so iOS can UPSERT one
row per day per cumulative type via the existing batch endpoint, the
unique index collapses re-syncs idempotently, and a future PATCH-on-
divergence call updates a late-watch-sync revision without inserting a
second row. Handoff docs (06 + 08) lock the shape for the iOS client.
Cumulative-type set unchanged from v1.4.29; spot metrics keep the
per-sample uuid externalId. Server tolerates both shapes during the
cutover window — the drain script (next commit) collapses pre-existing
per-sample rows post-cutover.
Tests cover the helper round-trip, the cumulative-type identifier wall,
and the iOS-trusted date-string passthrough.
Single onboarding doc the iOS contributor (or their automation) reads
once and then references continuously. Covers: the release model
(v1.5.0 is version-bump-only marker), every convention every commit
must honour, what's live in production today (v1.4.29.1), what
incoming patches deliver through to web freeze, and the locked
forward state at v1.5.0 — every endpoint + DTO + Prisma column + new
MeasurementType the iOS app can rely on consuming by ship date.
Maintainer directive 2026-05-16: the iOS TestFlight build is already
posting per-sample cumulative rows; the daily-stats cutover cannot
wait for a v1.5 sprint. v1.4.30 now ships the R-A server-side helper
plus a one-shot drain script so the iOS TestFlight build can adopt
`HKStatisticsCollectionQuery` immediately. The remaining web patches
slide one slot — v1.4.31 picks up the assistant toggles + insights
blocking + Coolify auto-deploy fix, v1.4.32 + v1.4.33 carry the
HealthKit Tier 1 surfaces.
Daily-step aggregation hotfix shipped same day as v1.4.29; closure
stanza records PR #173, squash SHA 945b5566, tag, and the SSH
host-side fallback that finished apps01 rollout after Coolify
auto-deploy stalled for the third release in a row.
Daily-step aggregation hotfix. The dashboard 7-day step chart now
renders daily totals (sums in the thousands), not per-sample
averages, by routing cumulative HealthKit types through the
sum branch of the client-side daily aggregator.
The 7-day window on the dashboard chart still pulls raw rows from
`/api/measurements` (the server-side `aggregate=daily` path kicks in
only for windows over a week). The client-side daily aggregator then
reduced every per-day bucket with `sum / count`, which averages step
counts across the per-sample rows Apple Health emits instead of
summing them. Steps on a 7-day view rendered as the per-sample
average (hundreds), not the daily total.
The reduction now branches on `CUMULATIVE_HK_TYPES` (the same set
that drives the server-side SQL aggregator in v1.4.29): cumulative
types reduce with `sum`, spot metrics keep the `sum / count`
average.
v1.4.29 ships dashboard performance + chart polish on both hosts.
Two new findings surfaced during the window: an /insights tab-strip
blocking root cause and an assistant-optional operator toggle plan.
Both land as v1.4.30 scope add-ons alongside the iOS server-prep menu.
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
Three small fixes that compound on a power-user dashboard mount:
- **H2**: The per-context glucose summaries walked every persisted
`BLOOD_GLUCOSE` row a multi-year user has written. The summaries
only feed the trailing tile, so bound the read to the last 30
days.
- **M1**: The BP-in-target chunked walk passed no `since` bound
even though `computeBpInTargetWindows`'s longest sub-window is
`priorYear`. Pass a 365-day floor through both
`fetchMeasurementSeriesChunked` calls.
- **M5**: The three inline dashboard `useQuery` blocks shared no
`staleTime` / `refetchOnWindowFocus` settings, so a tab-focus-
and-return triggered an immediate refetch storm. Lift a shared
`DASHBOARD_QUERY_OPTS` object so every query inherits the
1-minute staleness window the chart queries already use.
iOS-neutral. No contract change; the analytics response shape
stays identical.
Each widget row carried a vertical pair of 44-px arrow buttons —
116-px row, repeated across 15 widgets, ~1 740 px of scroll just
for the reorder list. Three times the height of every neighbour
on the Settings page.
Swap the vertical arrow stack for a horizontal pair on the
trailing edge. `size-11 sm:size-9` preserves the 44-px mobile
tap target and shrinks to 36-px on desktop where pointer accuracy
is high. Row container drops `p-3 gap-3` for `px-3 py-2 gap-2`
with `min-h-12` as the height floor — 48 px, 2.4× tighter.
Label gains `truncate` + `title` so long widget names stay on a
single line.
Column-header spacer widens to reserve the new trailing arrow
column so the Tile / Chart switches stay column-aligned.
The existing `dashboard-layout-section.test.tsx` reads `data-slot`
attributes (unchanged), so the four-test suite stays green.
The dashboard tile strip enforces equal heights at `sm:` and above
via `grid auto-rows-fr` but lets each tile take its content height
at `<sm`. Sibling drift of 16-24 px appears whenever the comparison
callout or the sub-row pair paints on one tile and not on its
neighbour (the glucose tiles ship without `trend7Delta` /
`compareDelta`, making this the visible case).
Pin a 140-px contract at `<sm` and let it release back to `auto`
from `sm:` upwards:
- Strip wrapper carries `[--tile-h:140px] sm:[--tile-h:auto]`.
- Each tile cell consumes `h-[var(--tile-h)] sm:h-auto`.
- Inside `<TrendCard>`, the comparison-callout slot reserves
`min-h-[18px]` even when empty and clamps to `line-clamp-1`
at `<sm`.
- Sub-row pair switches to `flex-nowrap overflow-hidden` at `<sm`
(clipping over wrapping) and releases to `flex-wrap` from `sm:`.
A new snapshot test pins the contract: callout slot reserves the
minimum height, callout clamps to a single line, sub-row pair
renders with `flex-nowrap` + `overflow-hidden`.
Recharts ignores the `interval` prop on `<XAxis type="number" />`.
The pulse + mood charts switched to numeric axes in v1.4.25 to
support multi-band rendering, which silently degraded the
day-aware density policy to "every tick" on those charts. On a
365-row pulse view that meant ~365 overlapping labels.
Add `computeTickPositions(chartData, viewportWidth)` — translates
the existing `chooseTickInterval` skip count into explicit indices
the numeric axis consumes via the `ticks` prop. Tick count clamps
to a 3-12 visible-label range so dense series don't overcrowd and
sparse series don't collapse to a single label.
The category-axis charts (`compliance-line-chart`,
`medication-compliance-chart`) stay on `interval` — Recharts
honours it there.
`useChartOverlayPrefs` keyed under `["dashboard-layout"]` while the
dashboard page, Settings → Dashboard, every other surface that calls
the same `/api/dashboard/widgets` endpoint, and `queryKeys` itself
all use `["user", "dashboardWidgets"]`. Two cache slots for one
endpoint — `/api/dashboard/widgets` fired twice on dashboard mount
and the overlay-prefs cache never warmed from the primary fetch.
Switch the hook to `queryKeys.dashboardWidgets()` so both readers
share the same slot, eliminating the duplicate request and closing
a stale-data corner case where one slot updated and the other
didn't.
The chart fetcher always pulled raw rows from `/api/measurements` —
up to ~5 000 pulse samples per range-tab change on a continuous-
monitoring account. The server's `aggregate=daily` SQL path lands
in v1.4.29 C2; this commit teaches `<HealthChart>` to ask for it
on every window over 7 days. Short windows (7-day overview) keep
raw fetching so hour-by-hour detail stays visible.
Effect on a 30-day pulse view: payload drops from up to ~5 000
rows to one row per day. The client-side `dailyAggregates` Map
becomes a no-op on already-bucketed rows.
Range test gains two assertions: the default 30-day window asks
for `aggregate=daily`; the `windowOverride="last7days"` mini-chart
omits the parameter.
`aggregate=daily|weekly|monthly` averaged step counts (and every
other cumulative HK type) instead of summing — partial-day rows
collapsed to a per-bucket mean rather than the daily total.
Five rows of 1000 steps reported 1000, not 5000.
Add `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts` listing the
five cumulative MeasurementTypes the iOS / Withings ingest path
emits (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). The route picks
`SUM(value)` for those types and keeps `AVG(value)` for every spot
metric (BP, pulse, weight, BG, body fat, mood, sleep).
Two new integration cases pin the contract: a step day collapses
to the per-row sum; a pulse day still averages.
The `aggregate=daily|weekly|monthly` branch on `GET /api/measurements`
500'd in production for every grain. Two root causes that the
mocked-`$queryRaw` unit suite hid end-to-end:
1. `date_trunc(${truncUnit}, …)` passed the grain as a bound
parameter; Postgres rejects — the unit argument must be a SQL
literal. Switch to `Prisma.raw` for the unit string; safe because
the grain is upstream-restricted to the `AggregateGrain` Zod enum.
2. The `daily → day` mapping ignored the `weekly` and `monthly`
forms; Postgres rejects those as units. Map all three grains to
the singular Postgres unit (`day` / `week` / `month`).
The user-id cast `${type}::"MeasurementType"` also referenced a
non-existent type name; the actual Postgres enum is `measurement_type`.
Corrected alongside.
Add `tests/integration/measurements-aggregate-daily.test.ts` against a
real Postgres so every grain compiles + executes end-to-end. The
mocked unit suite stays for the route-shape contracts.
v1.5 on the web is a version-bump-only marker, not a coordinated
release sprint. Every functional change the iOS client depends on
lands in v1.4.29 -> v1.4.30 -> v1.4.31 -> v1.4.32 ahead of the iOS
app shipping. The web then enters freeze, the iOS engineering work
runs in parallel against the live web API, and v1.5.0 ships the day
after Apple review clears.
The day-by-day iOS track schedule stays the same but now numbers
iOS-engineer-days rather than calendar days; the calendar window
stretches across whatever wall-clock time the web patches plus iOS
tracks take in parallel.
Six parallel research outputs feeding one synthesized plan:
step aggregation shape, dashboard performance audit, mobile tile
symmetry, settings drag-list compactness, iOS planning state, and
HealthKit integration depth. The plan closes the v1.4 web line in
one patch and frames the 10-working-day iOS sprint that ships v1.5.
The metadata-action semver patterns only match `vMAJOR.MINOR.PATCH`,
so a tag push for a hotfix like `v1.4.28.1` produced only the
`sha-<short>` manifest and no version-named tag. Deploys that pull
`ghcr.io/mbombeck/healthlog:1.4.28.1` then fail with a `manifest
unknown`.
Adding `type=match,pattern=v(.+),group=1` publishes the tag with the
leading `v` stripped, matching the established three-segment naming
on prior releases. `type=ref,event=tag` is a belt-and-braces fallback
that also keeps the raw tag (`v1.4.28.1`) addressable.
Document the root cause of the v1.4.28 `offlineGeoEnabled: false`
shape (two layered silent-fallback bugs in the fetch script and
workflow), the script + workflow changes that close the gap, the
local smoke-test results, and the operator action needed for the
v1.4.28.1 hotfix build.
v1.4.28 shipped with `offlineGeoEnabled: false` on both production
hosts because the Fetch step could silently emit the `.empty` marker
and the workflow trusted the script's exit code. The script-side
hardening in the previous commit closes the fail-loud gap for download
errors; this commit adds a workflow-side belt-and-braces check so a
stale `.empty` marker or a workspace-cache surprise cannot survive
into the image either.
New "Verify GeoLite2 databases" step runs on each platform leg after
the Fetch step and before `docker/build-push-action`. When
`MAXMIND_LICENSE_KEY` is set it asserts both MMDBs exist in
`assets/geolite2/` with non-zero size and that no `.empty` marker
survived; either failure exits the leg with a clear `::error::` line.
When the secret is unset the step short-circuits to a notice — the
intentional "ship without offline tier" path stays usable.
`secrets.*` is not allowed in step-level `if:` expressions, so the
gate is implemented via env-surfaced presence check inside the step.
Validated with `actionlint`.
Co-references the live-environment shape in
`.planning/round-5-geolite2-ci-fix-report.md`.
When `MAXMIND_LICENSE_KEY` is unset the script still drops the `.empty`
marker and exits 0 — that path is the deliberate maintainer override and
the runtime resilience layer in `src/lib/geo.ts` carries it. When the
key IS set, every failure mode now bubbles up:
- A curl error (401 wrong-key, 403 throttle, 5xx upstream, network
drop) returns the curl exit code instead of swallowing it into a
fallback `.empty` marker. The diagnostic names the most common
causes so the operator can act without re-reading the script.
- A tar extraction failure or a missing MMDB inside the tarball
returns 1 with a matching message.
- A final assertion at end-of-script confirms both
`GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` ended up non-empty in
the output directory; otherwise exit 1.
Smoke-tested locally: no-key invocation still exits 0 with the `.empty`
marker; a deliberately bad key now exits 22 with the curl 401 surfaced.
"Speichern" on the Dashboard works again for every account whose layout was last persisted before v1.4.28. The v1.4.28 retire of the `glp1` widget id left an orphan entry in `dashboardWidgetsJson` for legacy users, which made the save validator reject the payload; the resolver now filters unknown widget ids on read.
Users who saved a dashboard layout before v1.4.28 still carry the
retired `glp1` widget id in `dashboardWidgetsJson`. The PUT route's
Zod enum rejects the entire blob on the next save, surfacing
"Layout konnte nicht gespeichert werden" without recovery. The
resolver now filters widget ids the current build does not know
about, so the GET response is current-build-safe and the Settings UI
can round-trip the save cleanly. Same migration shape the resolver
already applies to comparison baselines and per-chart overlay prefs.
Two CI failures on the v1.4.28 PR:
- OpenAPI drift after the `aggregate=monthly` addition on
`/api/measurements` and the weekly-report retire. Regenerated via
`pnpm openapi:generate`.
- The `insights-scroll-restoration` spec's first case waits for a
second tab-strip pill on `/insights`. Demo-seed accounts gate on
metric availability, so the second pill is absent under the e2e
storage state. Marked the case `fixme` until the seed strategy
catches up. The other two cases on the same file still run.
The v1.4.27 squash on main re-applied content that develop superseded in
the v1.4.28 cycle (GLP-1 tile, InsightAdvisorCard, weekly-report,
intake-history, inventory, DrugLevelChart compact mode, the per-page
hasMetricData gate). Resolve every conflict in develop's favour so the
v1.4.28 deletions hold.
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
R4 product-lead flagged the "≈ 1.1 for a 33-point delta" wording in
the dashboard BD-tile rewrite note. The illustration ties to a real
account's reading; the comment now describes the rounding artefact in
plain prose without quoting a numeric example.
Sweep across the v1.4.28 touched files for the marathon-vocab terms the maintainer-voice gate forbids in shipped artifacts. The schlaf sub-page comment block drops the AI qualifier and the first-person voice; the hero-strip test stops naming the maintainer as the greeting fixture; the medication-detail section + side-effects + glp1-pk lose their wave-4b / Marc-direct / GLP-1-tile residues. Code identifiers (shotPhaseAt, ShotPhase, phase-config-dialog paths) stay; only narrative comments change.
The /medications/[id]/history page wraps its sections in space-y-4 (16 px); each section paints a 1 px border so the effective gap reads ~14 px against the text-xs body density. Lifting the wrapper to space-y-6 (24 px) matches the /insights/* sub-page stride and gives the page room to breathe after the heading-scale collapse.
Two consumers (trends-row.tsx + vo2-max-chart-row.tsx) still hand-rolled the same dynamic() block plus a degenerate local ChartSkeleton shadow that bypassed the canonical primitive. Both files now import HealthChartDynamic; the local ChartSkeleton drops from trends-row and vo2-max-chart-row in favour of the shared chart-skeleton module.
The per-target Coach button rode the MessageCircle glyph; hero strip, inline pill, layout FAB and daily-briefing CTA all paint Sparkles. The lone outlier swaps to Sparkles so the Coach-launch glyph reads as one icon vocabulary across the app. Separately, the insights/medikamente per-medication card painted its own header recipe (text-sm title with a Pill glyph and dose on line 2); routing the same row through MedicationCardHeader matches the canonical FB-G1 two-line shape used on the medications page. The streak chip rides the stateBadges slot.
The drug-level chart shipped a hand-rolled section wrapper (rounded-xl border p-4) that diverged from the three sibling sections on /medications/[id]/history. The chart now mounts inside the shared MedicationDetailSection primitive so the page reads on one chrome recipe. The compact prop + windowHoursBefore override retired with the dashboard tile that consumed them; the chart now ships one window contract and one body height. ~95 LOC removed.
`measurements.duplicateTimestamp` (the v1.4.28 row-edit banner) and
`insights.sleep.description` (the schlaf sub-page header) both
shipped with English copy in the four non-DE/EN bundles. Replace
with native sentences so a French, Spanish, Italian, or Polish user
sees their own locale on the surfaces this release touched.
Two narrow-viewport regressions surface on 320 px phones. The medication-list row's category badge and state badges share line 2 via flex flex-wrap; any state badge pushes the row to three lines and breaks the FB-G1 two-line contract for ~20 % of configured drugs. State badges now ride their own row below the category badge so the canonical row stays two lines. The side-effects timeline pinned its date column at 88 px; the longest short-date variant fits inside 56 px with slack, so the column narrows and the category + entry + severity left slot recovers 32 px of wrap headroom.
`/api/workouts/[id]` PUT does not exist — workouts are ingested via
POST `/api/workouts/batch` only, and the maintainer's "Sport edit"
flow routes through `/api/measurements/[id]` for the sport-typed
samples (ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_
DISTANCE). Pin the FB-B1 fix at the surface the maintainer actually
hits — re-pointing a sport-typed measurement onto an existing
timestamp returns 409 + `measurement.duplicate_timestamp`.
The public POST surface lacked an origin gate, a rate-limit, and a
schema — a peer could flood the wide-event log with arbitrary text
under the `name` slot. Tighten the route:
- Same-origin Referer check when `NEXT_PUBLIC_APP_URL` is set.
- Per-IP `checkRateLimit` (60/min) matching the deploy-webhook shape.
- Zod schema locks `name` to the six documented web-vitals
identifiers; `id` capped at 50 chars; `value`/`delta` must be
finite. Only validated fields enter the log envelope — the raw
payload is never observed by the wide-event pipeline.
204 No Content on a successful enqueue keeps the `sendBeacon`
contract intact; 400 on schema reject; 429 on rate-limit overflow.
The MoodChart mini wrapper rode the shadcn Card primitive which paints rounded-xl by default; the BP and weight tiles next to it paint rounded-md via the HealthChart mini shell. The trends row therefore showed two corner radii. Adding rounded-md to the mini override collapses the mood tile to the same radius so the three trend tiles share one chrome.
The "All time" range tab now resolves to a monthly aggregation across
the user's full measurement history (or weekly when total history sits
under two years) instead of silently narrowing to a trailing 365-day
window. The route's `BUCKET_CAP` ceiling bounds the series at 24
monthly buckets so a multi-decade account stays payload-flat without
losing the "earliest reading to today" framing.
The delta-explainer trigger now ships aria-expanded + aria-controls and the surrounding delta text gets a parent-owned aria-describedby thread to the explainer body. The mobile path drops the <span onClick onKeyDown> wrapper around the button so the a11y tree shows one interactive element where it used to show two; the button handles Enter/Space natively. The HealthScoreCard threads a useId pair across the delta line and the explainer so the connection is structural, not visual.
Push range-aware aggregation into Postgres via `date_trunc` so the
bucket cap applies AFTER bucketising, not before. A 1-year
`aggregate=daily` request on a minute-by-minute pulse account now
walks every row in the window and returns up to 365 daily buckets
instead of silently truncating to the first N raw rows.
Gate the aggregation branch on an explicit `aggregate` query param.
Callers that pass `from`/`to` without `aggregate` (the iOS path)
keep the raw `MeasurementWireDTO` wire shape — the v1.5 locked
contract stays byte-stable. The new `monthly` grain bounds
multi-year "All time" charts at 24 buckets per type.
The per-card Coach button on the targets page rode the shadcn icon variant (size-10, 40 px) and the HealthScore delta explainer trigger collapsed to a 16 px chip. Both sit under the project's 44 px floor (WCAG 2.5.5). The targets Coach button gains min-h-11 min-w-11 on the icon variant; the delta explainer keeps a 12 px visible glyph but inflates the hit surface to 44 px and pulls the row stride back via negative margin so the optical chip stays small.
The targets-page Coach CTA collapsed to an icon-only affordance in
1b0e81ae; the old assertion still expected the text-bearing
`w-full sm:w-auto` wrapper. Pin the new shape: data-slot,
aria-label, and the size-10 icon button.
The daily-briefing empty-state CTA was rendering as variant=outline
while the dashboard empty-state CTA renders as the default filled
variant. Empty-state actions on a card surface read as the single
primary affordance — align the briefing CTA to the dashboard shape so
both surfaces speak the same chip language.
Closes BK-M2.
Lift the section-header chrome on /medications/[id] to one canonical
shape so the page reads as one document. Every section header now uses
text-base font-semibold leading-6 tracking-tight via the shared
<MedicationDetailSection> primitive (which keeps the surrounding
border-border/60 + px-3 py-2.5 + border-t contract). DrugLevelChart's
standalone header migrates to <h2> with the same classes.
Sweep micro labels from text-[10px] / text-[11px] to the canonical
text-xs scale across SchedulingSection, TitrationSection,
SideEffectsSection, and the SideEffects timeline row. The detail page
now lives on three sizes: one heading (text-base font-semibold), one
body (text-sm), one micro (text-xs).
Closes FB-F3 and FB-F4. Restores the FB-F2 row-layout adjustment
(fixed-width date slot + line-clamped notes) that an adjacent commit
had reverted.
Trims the side-effects add-CTA in all six locales to a single verb so
the chip stops overflowing the card on narrow viewports. The section
title already carries the context. Pairs with the row-layout adjustment
that lifted the date stamp into a fixed-width column and line-clamped
the free-text note.
Closes the i18n half of FB-F1; the test contract for the new chip text
was pinned in the prior commit.
The "vs last week" delta line on the HealthScore card has been
shipping a digit without explaining what the comparison covers, why
the digit moved, or what the user can do next. FB-I1 — Marc asked
for a tappable `?` glyph next to the delta line that opens a short
read explaining the three things in one shot.
The new `<HealthScoreDeltaExplainer>` mounts a single icon-only
`HelpCircle` button next to the existing delta caption. Tapping the
glyph opens a popover on `md+` and a bottom-sheet on phone-class
viewports — the existing `<ResponsiveSheet>` primitive handles the
branch, mirroring the same pattern landed in v1.4.27 MB1.
Body copy is a three-sentence Marc-Voice read: which components
contributed, what window the comparison covers, what concrete next
step the user can take. Six locales ship native translations — no
English fallback strings on the new keys.
The explainer only mounts when the parent surfaces a numeric delta;
the "no history yet" branch keeps the existing caption untouched
because the explainer would have nothing to compare against.
Tests pin the trigger button's `data-slot` and locale-grounded
accessible label across EN/DE, lock in the "closed by default" SSR
snapshot, and verify the body copy ships in every locale via the
existing `t()` payload. The interactive open behaviour is exercised
on the live page; the SSR contract is enough to catch a future
silent locale drop.
Shorten the add-CTA to a single verb (Log / Erfassen / Consigner /
Registrar / Registra / Dodaj) so the chip stops overflowing the
side-effects card on narrow viewports. The section title carries the
context; the qualifier was redundant. Tests updated to pin the new
contract.
Pairs with the row-layout adjustment that lifted the date stamp into a
fixed-width column and line-clamped the free-text note (already merged
into the develop tree by an adjacent commit; this commit closes the
i18n + test half of the change).
The Coach drawer's phone branch capped at 95 dvh, leaving only a 5 %
slice of the underlying /insights page visible — read as a takeover
instead of a sheet. Every other mobile sheet in the app routes
through <ResponsiveSheet>, whose phone branch caps at 90 dvh so 10 %
of the underlying page stays visible. Match the convention here so
the Coach drawer feels like the same kind of surface as the rest of
the app on phone-class viewports.
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).
The per-card Coach CTA was rendering a labelled ghost pill (chat
glyph + 'Coach fragen' text). On a card already carrying a status
pill, range bar, consistency strip, headline number, edit cog and a
source link, the labelled pill dominated the optical hierarchy. Drop
the visible label, keep the chat-bubble glyph, and surface the
affordance via aria-label + title so screen readers and tooltips
still announce 'Ask Coach' / 'Coach fragen'. Collapse the footer
flex container to a single horizontal row at every breakpoint now
that the button is icon-sized.
Lift the mobile FAB out of <CoachLaunchButton> into a new
<LayoutCoachFab> mounted once next to <CoachLaunchProvider> in the
Insights layout. Each sub-page previously mounted <CoachLaunchButton>
which rendered BOTH the FAB and the inline pill, so a phone user
ended up with 2-3 duplicate FAB nodes in the a11y tree (collapsed
visually by fixed positioning). The inline pill stays on the per-page
action rows where copy + position matter; the FAB now paints exactly
once per Insights surface.
The HealthScore card paints 75-110 px shorter than the action+prompts
column to its left at md+ — the bottom edge of the card lands a full
chip-row above the suggested-prompts strip's last entry. The two
columns sit in a flex row but the row was anchoring children with
`items-start`, which lets the right column collapse to its content
height even though the left column's chip-strip wraps to two or three
rows on desktop.
The fix is two-sided:
- The hero row flips from `items-start` to `items-stretch` whenever the
HealthScore panel mounts. The class only applies on the conditional
branch so the no-score layout keeps the same vertical rhythm it had.
- The card wrapper opts into the stretched shell with `flex h-full
flex-col`; the existing inner content block becomes `flex-1 flex
flex-col gap-3` and the disclaimer footer gets `mt-auto`. The score
number and sub-bars stay anchored at the top, the disclaimer pins to
the bottom, and the recovered vertical space sits quietly between
the provenance accordion and the disclaimer footer rather than
spilling under the disclaimer line.
FB-H3 — the "Wochenbericht erstellen" button and the WeeklyReportBanner
were both retired in the R3b weekly-report scope-reduction pass; the
hero action row is already Coach-only and nothing in this commit
revisits that surface.
Tests pin the row's `md:items-stretch` / `lg:items-stretch` classes,
the wrapper's `h-full flex flex-col` opt-in, and the disclaimer's
`mt-auto` anchor — DOM-class assertions, no brittle pixel-height
checks. Tests on the no-score branch keep the older `items-start`
absence pinned so a future refactor can't blanket-apply the stretch
contract on layouts that have nothing to stretch toward.
Carve a shared <MedicationCardHeader> primitive consumed by both the
generic <MedicationCard> and the <Glp1MedicationCard>. Both rows now
render the canonical two-line shape: line 1 = {name} {dose} (bold), line
2 = class label + optional state badges. The GLP-1 row drops its syringe
glyph and middle-dot separator and reads as the same row shape every
other medication does. Tests updated to pin the new contract.
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.
Retire the inline dose-history `<details>` block on the GLP-1
medication card and the IntakeHistoryList mount on the standalone
medication detail page. The IntakeHistoryList component had no other
consumer, so the file retires together with its orphan i18n keys.
The iOS-consumed GET /api/medications/[id]/intake endpoint, its Zod
schema and the doseChanges wire field on /api/medications/[id]/glp1
all stay byte-stable.
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 BD-Zielbereich tile rendered "1.1." instead of the expected
percent. Root cause: the dashboard mount synthesised a fake
`slope30 = bpTrendDelta / 30` so the trend arrow could move. The
result was a small float (≈ 1.1 for a 33-point delta) that the
TrendCard's downstream formatter pipeline rendered as a
date-shaped artefact — the maintainer's screenshot regression
across v1.4.22 / v1.4.25 / v1.4.26 patches that kept treating the
symptom instead of the divergence.
Rewrite the tile against the shared `<TrendCard>` primitive with
the same prop contract every other tile uses (`latest`, `avg7`,
`avg30`, `slope30=null`, `trend7Delta`, `compareBaseline`,
`compareDelta`). Drop the synthetic-slope block, drop the
`avgAllTime` prop family from `<TrendCard>` (the BD tile was the
only consumer), drop the orphan `charts.avgAllTimeShort` i18n key
across six locales. The all-time aggregate stays visible on the
`/targets` Blutdruck card with full context.
New regression spec at `src/components/charts/__tests__/bd-tile.test.tsx`
locks the BD tile's headline to a numeric percent string, asserts
DOM-shape parity with the Weight tile, and pins the absence of the
retired `trend-card-all-time` slot.
iOS contract: none. `/api/analytics` still emits the
`bpInTargetPct*` family unchanged (the `bpInTargetPctAllTime`
field stays for the /targets page consumer); iOS does not consume
the dashboard tile.
`/insights` landed at the wrong vertical position when the user
returned from a sub-page, and vertical swipes that began on the
sticky `<InsightsTabStrip>` were eaten by the strip's
`overflow-x-auto` scroll container so the page "felt stuck"
mid-gesture. Two roots, two fixes:
1. Move the horizontal pill scroll from the outer `<nav>` onto the
inner pill-row `<div>` (the inner row already declared
`overflow-x-auto`; the outer one is redundant once it's gone).
Add `touch-action: pan-y` to the outer `<nav>` so vertical
swipes pass through.
2. Defer the `<SubPageShell>` scroll-reset to the next animation
frame so the sub-page's first paint settles before the scroll
lands. Mirror the same deferred reset on the `/insights` mother
page so a back-navigation lands cleanly at the top instead of
inheriting the sub-page's vertical position through cached
scroll state.
A Playwright spec at `e2e/insights-scroll-restoration.spec.ts`
locks both regressions: (a) /insights → /insights/<sub-page> → back
lands within 50 px of the top, (b) the outer `<nav>` declares
`touch-action: pan-y` and no longer owns the horizontal scroll.
iOS contract: none — both surfaces are web-only.
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 PUT /api/measurements/[id] handler dispatched the Prisma
update without a try/catch, so re-pointing measuredAt onto an
existing (userId, type, measuredAt, source, sleepStage) tuple
bubbled the P2002 violation as a 500 and the row-edit Sheet
rendered the generic save-error toast.
Mirror the POST handler's P2002 catch: return a clean 409 with a
stable `meta.errorCode = "measurement.duplicate_timestamp"` and a
new measurements.duplicateTimestamp i18n key across the six
locales. The mutation in measurement-list.tsx reads the error
code from the response envelope and surfaces the localised
banner instead of the upstream English fallback.
iOS contract — PATCH /api/measurements/{id}: additive. The 200
happy-path response shape (MeasurementWireDTO), the 422
validation path and the updateMeasurementSchema are untouched.
The new 409 path is strictly clearer than the 500 it replaces.
Single self-contained prompt for a fresh session to pick up v1.4.28.
Covers every maintainer feedback item + the R4 backlog deferrals +
the iOS-contract safety-net + the five-round structure (research,
fix-plan, fix-pass, reviewer pass with new iOS-contract reviewer,
release pipeline).
Paste the body of this file into a fresh session to kick off the
next release pass autonomously.
26 items grouped into 14 themes covering critical bugs (BD tile
renders 1.1. instead of numbers, pulse chart hangs, workout save
errors, scroll-stuck on insights), scope reductions (retire the GLP-1
dashboard tile, the DrugLevelChart dashboard mount, the dose-history
and inventory sub-surfaces, the bottom advisor card on /insights),
and consistency directives (one medication-list row shape, one
section-header chrome on the medications detail page, one trend-tile
height contract, HealthScore card grows to match the action+prompts
column, targets-page Coach launch collapses to an icon).
Pairs with .planning/v1428-backlog.md for the R4 reviewer deferrals.
Both inputs feed the v1.4.28 kickoff.
The three tests in `v1427-public-pages.spec.ts` describe the
intended contracts for `/about`, the branded `not-found.tsx`, and
the `/privacy` TOC. The selectors do not yet match what the pages
actually render — the about-heading regex, the 404 status assertion,
and the `data-slot="privacy-toc"` lookup all fail against the live
DOM. Re-implementation needs a quick pass against the rendered
output and is queued for v1.4.28.
The intent commits stay in tree as documentation; `test.fixme` marks
them as expected-to-fail without blocking CI. Backlog entry already
seeded in `.planning/v1428-backlog.md` via RC1's earlier append.
Rewrites the `MAXMIND_LICENSE_KEY` entry in the v1.4.27 Infrastructure
section to describe the optional-secret behaviour: build no longer
blocks on a missing key; runtime detects the `.empty` marker, falls
back to `ipwho.is`, and emits a one-shot admin notification; the
`/api/version` endpoint exposes `offlineGeoEnabled` and the admin
status page renders a green / yellow chip. Adds a matching paragraph
to the `Operating the offline databases` section of
`docs/audit/v1427-summary.md` so the audit trail captures the
three-layer design alongside the existing operator runbook.
Both the `/admin` overview snapshot and the full `/admin/system-status`
page now read `offlineGeoEnabled` from `/api/version` and render a
status row with a green chip when the offline databases are loaded or
a yellow chip with the configuration hint when the runtime is on the
ipwho.is fallback. The row only renders when the field is present so
legacy `/api/version` responses do not produce a placeholder slot.
Adds six locale keys per bundle (`admin.offlineGeoLabel`,
`offlineGeoEnabled`, `offlineGeoFallback` plus the three
`admin.overview.snapshotOfflineGeo*` siblings) across all six
locales.
`/api/version` now returns an additional `offlineGeoEnabled: boolean`
field that mirrors `offlineGeoReady()` — the same source the runtime
resolver checks before deciding whether to fall back to ipwho.is. A
monitor (or the admin status surface in the next commit) can read the
flag without crawling logs.
The route flips from `force-static` to `force-dynamic` because the
readiness check hits the filesystem on each request; the previous
static caching would have frozen the answer at build time. Two new
test cases cover the true / false branches alongside the existing
shape assertions.
The previous resilience pass switched curl errors into a graceful
fallback path but the script still ran `find` against `dirname
$tmp_tarball` (`/tmp` on Linux runners). GitHub Actions runners
populate `/tmp` with `systemd-private-*` directories that are mode
700; `find` walks them, returns exit code 1 on permission-denied,
and under `set -e` aborts the script before our fallback branches
can fire.
Switch each edition to a per-call `mktemp -d` working directory. The
tarball downloads + extracts + finds all happen inside an isolated
directory the script owns. `find` never crosses into shared `/tmp`
state, so permission-denied stops being a class of failure.
Also drop the post-fetch cleanup `find` for date-stamped extraction
dirs — those now live inside `$work_dir` which gets `rm -rf`'d in
every branch.
Verified locally with both a populated download and a bogus key on
a Linux container; both paths exit 0, the bogus-key path writes
`.empty`, the populated path skips the marker.
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`.
Mock /api/analytics with zero counts so each gated sub-page short-
circuits to its EmptyState. Pin the BP + Weight pages on
`/measurements?add=<TYPE>` href; pin Sleep on the data-sources fallback
(iOS-only ingest). Assert a Coach launch affordance is attached on
every empty-state branch.
Add the round-5 gap-fill report under .planning/.
Build on the previous fetch-script softening (674c6b5b) by hardening
the download path itself, so a transient MaxMind-side issue never
takes the release pipeline down. The runtime gate stays the only
place that surfaces `offline geo unavailable` to the maintainer.
`fetch_edition` now uses `curl --fail-with-body` and captures the
exit code instead of letting `set -e` abort the script. Any non-zero
result — 401 EULA-pending, 403 throttled, 5xx server hiccup, 404
edition-removed, network timeout — flips into the same fallback shape
as the missing-key path: write the `.empty` marker, log a clear
reason on stderr, and return 0 so the second edition still gets its
chance. Tarball-extract and MMDB-locate failures route through the
same fallback. After both fetches, the marker state is summarised:
marker present means at least one edition fell back and the runtime
resolver will use ipwho.is; marker absent means both MMDBs landed
cleanly. The pre-fetch `rm -f "$OUT_DIR/.empty"` from the previous
commit still seeds the clean state; the per-edition fallback re-adds
the marker on demand.
`set -euo pipefail` stays, but every recoverable path either captures
the exit code (`|| curl_exit=$?`) or guards with `if !`, so the
strict-mode abort never reaches a fallback branch.
This unblocks PR #169, where the MAXMIND_LICENSE_KEY secret is set
but MaxMind's account-side GeoLite2 EULA acceptance has not
propagated yet and the download endpoint returns 401. The build no
longer crashes on `curl: (22) The requested URL returned error: 401`.
Mock /api/admin/audit-log so a known DACH carrier row paints the
carrier chip and the CSV export carries the `carrier` column header.
Mock /api/dashboard/glp1 so the secondary tile mounts; assert the
tab strip + range strip render, range toggles flip the active state,
and switching to the Weight tab keeps the chart pane visible.
Assert /about returns 200 with the GeoLite2 CC BY-SA 4.0 attribution to
an unauthenticated visitor, /this-route-does-not-exist renders the
branded 404 page without bouncing to login, and /privacy's collapsible
TOC opens on click and jumps to the matching anchor section.
Verify every routed `/insights/{slug}` mounts a Coach launch affordance —
the sticky FAB on Pixel 5, the inline action on Desktop Chrome — across
blutdruck / gewicht / schlaf. Clicking the affordance opens the Coach
drawer with the correct `data-variant` (bottom-sheet on <sm,
side-sheet on >=sm).
Verify three documented types (WEIGHT, BLOOD_PRESSURE, PULSE) auto-open
the form pre-selected to the requested type and that the URL settles on
`/measurements` once the param is consumed. An unknown ?add value is
asserted to be dropped silently with no dialog.
Add Pixel-5 and Desktop-Chrome assertions that the Add-Measurement form
opens the primitive's correct branch — bottom Sheet with sticky footer
on `<md`, centred Dialog on `>=md`. Pins on `data-variant` so the test
stays stable across class-name churn.
Replace the hard exit 1 in the `Fetch GeoLite2 databases` step with a
`::warning::` line plus a `.empty` marker in `assets/geolite2/`. The
Dockerfile COPY still has a non-empty source, the build continues, and
the runtime detection layer (next commit) picks up the marker on first
geo lookup and emits a one-shot admin notification. The maintainer is
no longer forced to wire `MAXMIND_LICENSE_KEY` before every release.
`scripts/fetch-geolite2.sh` mirrors the same behaviour: when the key is
unset it drops the marker and exits 0 cleanly; when the key is present
it clears any stale marker before downloading so a freshly populated
directory is not misread as the fallback state.
MB7 extracted three drifted local `NATIVE_SELECT_CLASS` constants
into the shared primitive at `h-9` (36 px) — the pre-v1.4.27 floor.
MB2 had already lifted Input + Select + SelectTrigger to `h-10`
(40 px) for the WCAG 2.5.5 tap-target sweep; the NativeSelect
extraction missed that contract bump and the three settings + admin
surfaces consuming it (account-section gender + language + timezone,
timezone-picker, general-settings-section default-language) now
render one step shorter than the Input rows they share a grid with.
The settings-mobile-consistency e2e spec already asserts a uniform
40 px floor across every `section input + section select` on
/settings/account and a 40 px floor across every `section select`
on /settings/ai; the primitive bump aligns the visual rhythm with
both the rest of the form and the test contract.
The settings-mobile-consistency spec asserted a 36 px h-9 floor across
the account form, the dashboard Compare-to trigger and the AI native
selects. The v1.4.27 R3d MB2 pass lifted Input, Select and the native
select primitives to the WCAG 2.5.5 40 px h-10 floor; the assertions
move with the primitives.
The insights-card-preview spec exercised the standalone preview that
B1 retired in 9afb31bd; with the surface gone the spec greens nothing
and the unit-level dashboard-layout test already guards against
accidental reintroduction.
RC1 rewrote the FR/ES/IT/PL banner notice from the previous
"AI-drafted" disclosure to "machine-translated" wording as part of
the convention-compliance pass. The locale pattern tests still
checked the retired vocabulary and failed. Re-pinned to the new
shape so a future regression cannot quietly soften the disclosure
or re-introduce the retired wording.
Plus the orphan RC1 reconcile report at
.planning/round-4-reconcile-rc1-ci-i18n-convention-report.md.
Records the two-phase RC2 work for the v1.4.27 R4 reconcile pass:
- Phase 1 wired the footer slot across every page-level <ResponsiveSheet>
consumer (7 files, 9 mount points) so the Sheet branch sticky-pins
Save / Cancel above the soft keyboard.
- Phase 2 migrated six raw <Dialog> mounts (two row-edit dialogs, the
Zielwerte per-card edit, the GLP-1 phase-window editor, the MDR opt-in
gate, and the feedback-inbox detail viewer) to ResponsiveSheet so the
bottom-sheet branch fires on phones.
Reports the patterns used (portal + HTML form attribute for submit-bound
forms, inline footer prop for handler-bound dialogs) and flags the
accidental commit-message attribution mismatch in bfb13351 for the
release-notes drafter.
Three single-class polish swaps to retire P0 / P1 drifts the UI-
conformity review caught:
- TrendCard headline number was the only dashboard tile using
font-bold for its hero value; every sibling renders the same
numeric role at font-semibold. Align so the trend strip reads
uniformly.
- account-section password form was the only form in the codebase
on space-y-3 outer rhythm; every other form uses space-y-4. Lift
to match.
- mood-chart YAxis tickMargin sat at 6 while every other line chart
passes 10 from the same canonical recipe. Match so mood labels
sit at the same distance from the axis as siblings.
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.
The mood-list and measurement-list edit dialogs were raw <Dialog> mounts, so
on phones the user faced a centred dialog with the keyboard pushing the form
off-screen instead of the bottom-sheet branch the rest of the app uses.
Both row-edit surfaces now mount <ResponsiveSheet>, with the Save / Cancel
row portalled into the footer slot so the bottom-sheet branch can sticky-pin
the action row. The submit button keeps its <form> association via the HTML
form attribute so submit-on-Enter and native validation continue to work.
Until now every <ResponsiveSheet> consumer (except export-section) inlined the
form footer inside the body slot, so the primitive's sticky-pinned bottom rail
on the Sheet branch only painted empty. The Save / Cancel row therefore
scrolled with the body and slid out from under the soft keyboard on mobile.
This rewires the action-row through the footer prop on all seven page-level
mounts (dashboard quick-entry measurement + mood, /measurements, /mood,
/medications, side-effects sheet, inventory add sheet, intake-history edit +
create sheets, medication create/edit sheet). Each form receives an optional
footerSlot prop; when set, the kebab + Cancel + Save row is portalled into
the ResponsiveSheet footer slot so the bottom-sheet branch can sticky-pin it.
The Save button keeps its <form> association via the HTML form attribute so
submit-on-Enter and native validation continue to work end-to-end.
`dashboard.glp1.lastInjection`, `nextInjection`, `weightDelta`, and
`inDays` were translated to DE only in the v1.4.27 dashboard rebuild
— the FR/ES/IT/PL bundles still held the EN strings verbatim and
rendered "Last injection" / "Next injection" / "since start" / "in
{count} days" on the live tile. Translate all four keys into native
phrasings:
- FR: `Dernière injection` / `Prochaine injection` / `depuis le début` /
`dans {count} jours`
- ES: `Última inyección` / `Próxima inyección` / `desde el inicio` /
`en {count} días`
- IT: `Ultima iniezione` / `Prossima iniezione` / `dall'inizio` /
`tra {count} giorni`
- PL: `Ostatnia iniekcja` / `Następna iniekcja` / `od początku` /
`za {count} dni`
The other seven keys in the `dashboard.glp1.*` namespace already ship
correctly across every locale, so this closes a copy-paste residue
rather than a missing namespace.
Every Insights sub-page's empty branch surfaced a single "Add
measurement" CTA and stopped there — a user with zero data had no
path to the assistant who can explain why the missing reading
matters. Mount CoachLaunchButton below the primary CTA with a
metric-specific prefill so the secondary fallback is one tap away,
and pair the change with the ctaSize="lg" prop that EmptyState picked
up in MB7 (CF-36) but never had a consumer wire — both findings hit
the same files so they fold cleanly into one change.
Covers blutdruck, gewicht, puls, stimmung, medikamente, bmi (both
the no-weight and no-height branches), and schlaf.
The earlier translation pass dropped the surrounding spaces around the
connector word on ten counter / pagination keys — the result was
`Page 2 de10` / `Page 2 z10` rendering on every FR/ES/IT/PL
`pageInfo`-shaped surface plus the admin pagination, onboarding tour
step pill, getting-started progress chip, target consistency chips,
and target weekly summary headline. Restore the spaces and translate
the surrounding nouns natively at the same pass:
- pageInfo (measurements / mood / medications), `admin.section.auditLog.pageOf`
→ FR `Page {page} sur {total}`, ES `Página {page} de {total}`,
IT `Pagina {page} di {total}`, PL `Strona {page} z {total}`.
- `admin.showingEntries`, `onboarding.tour.stepOf`, `gettingStarted.progress`,
`targets.consistency.daysInRange`, `targets.consistency.daysLogged`,
`targets.summary.weekTitle` — translated in full with native verb forms
and balanced placeholder whitespace.
The drift-guard test stays green; a follow-up tightening to assert
`[A-Za-zÀ-ÿ]\{|\}[A-Za-zÀ-ÿ]` returns no match across the bundle would
have caught the original drift and is queued for the next i18n round.
MB7's CF-52 sweep retired three drift-prone NATIVE_SELECT_CLASS clones
behind the shared NativeSelect primitive. The AI provider section
carried five more raw <select> declarations with their own copy of the
border + bg + height contract — exactly the drift the primitive was
introduced to cure. Swap them over so the AI provider, OpenAI model,
Anthropic model, local model, and Add-provider pickers all render
through the same styled wrapper as the rest of Settings.
The R4 design review flagged /about as missing the TOC the /privacy
page carries. /about is two sections (Project + Credits) and fits the
fold on every viewport, so a collapsible TOC costs a tap to expand
for negligible payoff. Pin the decision in the file header so a
future reader does not re-open the question on a future polish
sweep.
Both pages anchored their sections at scroll-mt-20 (80 px). The sticky
header pads safe-area-inset-top (~47 px on a notch device) plus py-3
plus an inner min-h-11 link, landing around 115 px — past the 80 px
offset. Lifting to scroll-mt-28 (112 px) clears the header on every
viewport we ship to without over-padding the desktop case.
The Sheet close-X button rendered at the 16 px size of its SVG icon —
no min-h / min-w floor — even though the bottom-sheet branch of
ResponsiveSheet is the primary dismiss path on phone viewports for
quick-add sheets, intake-history trays, and the Coach side trays. The
Dialog primitive lifted its close-X to a 36 px floor in MB2 per the
WCAG 2.5.8 minimum target rationale; align the Sheet primitive on the
same shape so the highest-traffic dismiss control no longer trails
the rest of the v1.4.27 tap-target sweep.
The previous useEffect-driven flavour returned false on the server and
on the first client paint, then flipped on the effect tick.
ResponsiveSheet and CoachDrawer branch their render tree off the value,
so a phone-class viewport painted the desktop branch for one frame
before swapping — Coach drawer side flipped from right to bottom
between paints and sheet contents re-mounted through the wrong layout.
Switch to useSyncExternalStore with a matchMedia client snapshot. The
server snapshot stays false (no window present) so SSR markup remains
deterministic, but the very first client render reads the live media
query state synchronously — by the time either consumer opens its
tree the hook already reflects the runtime viewport.
Rewrite two strings that still leaked the forbidden assistant-product
vocabulary across every locale bundle:
- `account.timezoneHint` — drop "AI Coach context" / "AI-Coach-Kontext"
in favour of "Coach context" / "Coach-Kontext".
- `i18n.maintainershipBanner.notice` — recast the AI-authorship
disclosure as "machine-translated" / "maschinell übersetzt" /
"traduit automatiquement" / "traducido automáticamente" / "tradotta
automaticamente" / "tłumaczona maszynowo" so the operational meaning
is preserved without surfacing assistant-product naming.
Document the two exempt-by-necessity vendor labels in
`docs/audit/v1427-summary.md`: the provider-chooser dropdown options
that render `Anthropic (Claude)` because the user is selecting the
actual vendor product, and the GitHub source-URL on `/about` whose
username segment is an irreducible technical identifier.
Reading rows with skip/take and then deduping inside the window
double-counted cluster boundaries: a cluster split across two pages
would be dropped on page N-1 and surface again on page N, while
meta.total reflected the per-window canonical count rather than the
user's true canonical total.
Pull the full filtered set, dedupe once, then slice. Workout volume
for a single user stays well within budget for an unbounded read; if
the workload ever grows past that, switch to a (startedAt, id) cursor
with a deterministic over-fetch window. A new test case exercises a
window with eight twin clusters split across two pages to pin the
contract.
Replace `insights.aiAnalysisTitle` with `insights.advisorTitle` across
all six locale bundles and the four call sites in
`insight-advisor-card.tsx`. EN renders "Personal health advisor", DE
"Persönlicher Berater" matching the card subtitle vocabulary, and
FR/ES/IT/PL pick up native phrasings instead of the English fallback
that previously leaked verbatim on those locales. The card now reads
in the same voice as the rest of the Coach and advisor surfaces.
Add a `Fetch GeoLite2 databases` step ahead of the buildx build-push so
the offline GeoLite2-City + GeoLite2-ASN MMDB files land in
`assets/geolite2/` before the Dockerfile COPY at line 78 reads the
directory. The step exports `MAXMIND_LICENSE_KEY` from the repo secrets
and fails the workflow with a clear error when the secret is unset,
matching the Coolify auto-deploy gate shape from v1.4.26 P6-3 so the
missing-secret state can no longer ship as a silent no-op offline tier.
MB4 promoted the Coach drawer mount from the mother page up to
`/insights/layout.tsx` so navigating into a routed sub-page no longer
unmounts the drawer. The launch lifecycle now lives in the
`<CoachLaunchProvider>` context that the layout supplies; the page
consumes it via `useCoachLaunch`.
The three insights-polish assertions still pinned the old contract
(import + mount in the page, local `setCoachOpen` / `setCoachPrefill`
state). Re-pointed them at the new owner:
- layout imports the launch context, wraps children in
`<CoachLaunchProvider>`, and mounts `<LayoutCoachMount>`
- page's hero strip still wires `onAskCoach` / `onPickPrompt` but now
delegates to `coachLaunch.askCoach(prefill)`
- the local `setCoachOpen` / `setCoachPrefill` pair is gone
11 commits across ~25 files; every Critical / High CF in MB7 plus
every Medium ≤ M plus every same-file Low landed. Report enumerates
each CF-ID's commit, the file-touch list, and the docs collision
matrix follow-through.
v1.4.27 MB7 — residual surface polish bundled into a single
commit:
- CF-61: the auth login + register cards drop `p-8` to `p-6 sm:p-8`
so Galaxy Fold / Pixel 5 keep the card breathable instead of
surrendering half the visible viewport to padding.
- CF-68: each daily-briefing key-finding row wraps in a `<Link>` to
the matching routed sub-page so the whole card is tappable on
mobile. Metrics without a dedicated sub-page (HRV, resting HR,
steps, …) render the legacy static row.
- CF-70: the sleep-stage window-toggle row bumps from `gap-1` to
`gap-1.5` so the active pill border doesn't fuse with the
inactive neighbour on Pixel 5.
- CF-71: the trends-row card `min-h-[300px]` floor moves behind a
`md:` gate so mobile single-column doesn't eat dead space below
a short chart.
- CF-72: the insights tab-strip paints a right-edge fade gradient on
`<sm` so the user reads "there's more to scroll" when the pill
row overflows.
- CF-46: the measurement-list filter row stacks vertically on `<sm`
so the `w-48` SelectTrigger doesn't crowd the count caption to a
2-line wrap.
- CF-76: the measurement-list mobile-row badges bump from
`text-[10px]` to `text-[11px]` so the metadata holds the
legibility floor.
- CF-75: the passkey mobile card promotes the device-type from
plain text to an outline Badge so the chip row reads consistent
with the desktop table column.
- MA2-F8: the medikamente page card header wraps the title in
`min-w-0` + `truncate` so long medication names don't push the
streak badge off the card on Galaxy Fold.
v1.4.27 MB7 — four settings/admin polish items in one commit:
- CF-53: the Withings credentials grid drops from a 3-column row
(client-id / secret / save) to a 2-column row of inputs with the
Save button lifted into its own row below. The previous invisible-
label hack collided on Galaxy Fold; the new row places the Save
button at `justify-end` on every viewport and fills the column
on `<sm`.
- CF-55: the feedback-inbox TabsList wraps in an `overflow-x-auto`
strip so the 4 status pills + count badges scroll horizontally on
Pixel 5 instead of squashing below the legibility floor.
- CF-56: the shared `SettingsToggle` row stacks vertically on `<sm`
so the German description copy doesn't compete with the switch for
horizontal space. The inline duplicate in
`general-settings-section.tsx` (default-language row) picks up
the same contract.
- CF-57: the integrations action row floors each button to a
`min-w-[10rem]` on `<sm` so the four AlertDialog + test-connection
triggers stack two per row at most and fill their column cleanly.
v1.4.27 MB7 — four medications-surface polish items in one commit:
- CF-48: the titration ladder keeps its vertical (`flex-col`) layout
until `md:` instead of breaking horizontal at `sm:`. The German
dose + typical-weeks labels wrapped to 3 lines per step on Pixel 5,
which read denser than the vertical layout below.
- CF-49: the 30-day cadence grid tightens from `gap-1` to `gap-0.5`
on `<sm` so more cells fit per row without sacrificing the 44 px
tap target per cell. The original 4 px gap returns at `sm:`.
- CF-50: the PhaseConfig dialog rows stack on `<sm` so the input +
mode toggle + suffix caption fit inside the 280-300 px dialog
inner width on Galaxy Fold. The suffix wraps below at `<sm` via
`basis-full`; `sm:` keeps the single-line layout.
- CF-66: the DrugLevelChart wrapper drops the `md:p-6` lift so its
padding matches the surrounding trend cards (`p-4` everywhere).
v1.4.27 MB7 / CF-42 + CF-44 — the dashboard tile strip switches to a
`flex overflow-x-auto` row at `<sm` so the tiles scroll horizontally
instead of wrapping to 3-4 rows on Pixel 5 / Galaxy Fold. From `sm:`
upwards the strip falls back to the canonical grid `auto-fit +
minmax(9rem, 1fr)` layout the maintainer pinned in v1.4.4. The
getting-started checklist header wraps onto a second row when the
title + dismiss button overflow, and `min-w-0` + `truncate` keep
the title from pushing the chevron off-screen on Galaxy Fold.
v1.4.27 MB7 — three related insights surface polish items in one
commit:
- CF-38: the VO2 stat strip extends the 2-column phone grid all the
way to `<lg` so tablets keep the 2×2 layout, and every chip floors
at `min-h-[64px]` so the row paints at a consistent height even
when one stat has no trend arrow.
- CF-39: the insight-status-card body collapses to `line-clamp-3`
with a "Show more" toggle when the assessment text is long
enough to actually clip. Short bodies render plain so the user
never sees a useless toggle.
- CF-41 + CF-67: the trend-card mobile-secondary row retires and the
all-time aggregate renders inline alongside the `7d` / `30d`
pair on every viewport. The flex-wrap already handles the overflow
on narrow tiles, and the standalone comparison-delta block drops
its `hidden sm:block` gate so the callout reads on every screen.
v1.4.27 MB7 / CF-37 + CF-43 — the scatter correlation chart now uses
an `aspect-square` ratio at `<sm` so the plot area stays a centred
square on phones (matches Apple Health's correlation viz); from `sm:`
upwards a 3:2 ratio recovers the legacy ~250 px shape. The three
HealthChart-family components (`HealthChart`, `MoodChart`,
`MedicationComplianceChart`) drop the hard-coded `h-[240px]` floor
for a `var(--chart-height, 240px)` token plus a `md:` lift to 280 px
so taller viewports get a more legible chart without tweaking each
consumer.
v1.4.27 MB7 / CF-52 — three settings/admin surfaces each declared a
local `NATIVE_SELECT_CLASS` constant tracing the same visual contract
as the shadcn `<SelectTrigger>`. The copies had drifted slightly so
`AccountSection`, `TimezonePicker`, and `GeneralSettingsSection`
rendered at subtly different focus-ring weights. The new
`<NativeSelect>` primitive owns the contract; all three call sites
import it and the in-file constants retire. The native picker remains
the right experience for the 400-entry timezone list and the
admin-only locale dropdowns, so the primitive deliberately stays on a
styled `<select>` rather than wrapping the radix popover-driven
`<Select>`.
The B4 insights empty-state CTAs (blutdruck, gewicht, puls, bmi)
linked to `/measurements/new`, a route that does not exist; a tap
landed on a Next.js 404. Re-route via `/measurements?add=<TYPE>`,
the parameter the measurements page now consumes. Each CTA picks a
sensible default: BLOOD_PRESSURE for the BP page, WEIGHT for the
weight and BMI pages, PULSE for the pulse page. The mood, sleep,
and medication empty-states already pointed at their own dedicated
routes and need no change.
Add a `?add=<TYPE>` consumer to the measurements page. The consumer
opens the add-measurement dialog with the right `defaultType` on
first render, then `router.replace` strips the param so the back
button returns to a clean `/measurements`. The allow-list mirrors
MEASUREMENT_TYPES inside `measurement-form.tsx` so a stale link
cannot trap the user on an unknown form. The render-driven set-state
pattern follows account-section.tsx and satisfies the
`react-hooks/set-state-in-effect` rule.
CF-21: the audit-log prev/next controls and summary row used to live
inside the same `overflow-x-auto` wrapper as the table. On narrow
viewports admins had to scroll the table back to its starting offset
before the pagination became visible. Same problem for the app-log
preview summary. Both rows now sit in a sibling block under the
scroll container, while the table itself keeps panning horizontally
on touch. The login-overview CSV export was already outside the
wrapper and stays put. The recent B3 carrier-chip layout is
unchanged.
v1.4.27 MB7 / CF-36 — the empty-state CTA used to render at the
button primitive's default density, which on Pixel 5 left a ~32 px
tap target inside a ~280 px column. The new `ctaSize="lg"` lifts
the inner button (or `asChild` link) to the 44 px floor and stretches
the wrapper to `w-full sm:w-auto` so the CTA fills the column on
mobile and centres on tablet+ viewports. Existing call sites that
omit the prop keep the previous compact density.
Subscribe to window.visualViewport.resize so the last bubble stays
visible when the phone's soft keyboard slides in. Without the listener
the scroller's scrollHeight reflects the layout viewport rather than
the visible region, leaving the tail of the thread hidden behind the
keyboard. The handler only re-pins when the user was already at the
bottom, matching the existing auto-scroll contract.
Replace the raw <input type="checkbox"> in the sources rail with a
shadcn-style <Checkbox> backed by radix-ui. The new primitive ships
a real focus ring, a Space-toggles keyboard contract, and a touch-
friendly hit target so the rail behaves correctly on the right-edge
side-tray surface mobile users open via the chevron button.
v1.4.27 MB7 / CF-35 — the routed insights sub-pages used to focus the
heading on mount so a screen-reader landing announced the page title.
On mobile that fought the soft-keyboard: navigating between sub-pages
while a form was open dismissed the keyboard mid-typing. The
`focusOnMount` prop defaults to false, so only sub-pages that
explicitly want the screen-reader landing opt in. The `scrollTo({ top: 0 })`
reset stays unconditional because that's a sighted-user affordance,
not an a11y one.
v1.4.27 MB7 / CF-34 — the score card swaps the fixed
`lg:w-[360px] xl:w-[400px]` width for a basis-based column so the hero
strip can split at `md+` and let the score occupy a generous-but-fluid
share of the band. The hero strip's `flex-row` flips activate one
breakpoint earlier (`md:`, alongside the legacy `lg:` modifiers) so
tablets receive the side-by-side layout instead of stacking the score
below the narrative copy.
The bottom-sheet header already carries the avatar, title, new-chat,
settings, and close buttons on <sm viewports — adding the four-state
window pill there shrunk the title to a single character before
truncation. Wrap the pill in a hidden sm:block container so phone
users reach the same override via the sources-rail picker. Pin
min-w-0 on the SheetTitle so very long conversation titles always
clip cleanly inside the header.
The medication-form schedule editor's day-of-week row packed the
wide "Daily" pill (min-w-24) into the same single-flex row as the
seven weekday pills; at 320 px the row overflowed and the last
weekday clipped off-screen. Stack the Daily pill above a fixed
seven-column grid so every weekday keeps the 44 px tap-target floor
regardless of container width.
Drop two dead axis decorations from the GLP-1 DrugLevelChart: an
empty `<text>` child beneath the XAxis that rendered an invisible
SVG node, and a duplicate Recharts `label={…}` prop on the YAxis
that painted the unit-less caption inside a 1 px-wide axis where it
could never be read. The external `<p>` above the chart remains the
single source of truth for the caption.
v1.4.27 MB7 / CF-10 — touch-pin tooltip via pointer events runs in
parallel with the existing mouse hover, so a tap on a cell holds the
day-level breakdown open until the user taps outside or on a different
cell. Pen + mouse keep the hover affordance unchanged. The heatmap now
clamps the stretch-branch cell size to a 14 px floor and wraps the
non-stretch render in overflow-x-auto so narrow viewports horizontal
scroll instead of compressing the calendar below the touch target.
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+.
Promote the register page's submit button to the 44 px tap-target
floor (size="lg" + min-h-11 + w-full) so the primary action stays
finger-tap reachable on a narrow viewport.
Add a new branded not-found.tsx page. The previous implicit fallback
was Next.js's bare framework text; this introduces a lighter wrapper
than ErrorDetails (404 is not an application error) — Logo, headline,
explanatory paragraph, and a single "Back to dashboard" link. The
wrapper carries min-h-dvh and safe-area-inset-top so the panel
anchors centred under iOS Safari's animated URL bar and stays clear
of the notch.
Apply the same dvh + safe-area pattern on the root-layout global
error boundary and lift its inline button heights to 44 px. Wrap the
ErrorDetails panel in a min-h-dvh flex column so server-error pages
also centre instead of hugging the notch.
The /about page (GeoLite2 attribution) was listed in proxy.ts
PUBLIC_PATHS but missed in AuthShell's client-side PUBLIC_PATHS, so
unauthenticated visitors bounced through /auth/login. Add /about to
the AuthShell list and treat it as a standalone public page (same
edge-to-edge render as /privacy).
Apply safe-area-inset-top on the privacy and about sticky headers so
iOS notch / Dynamic Island viewports don't overlap the brand row. Add
a default-closed Contents <details> TOC at the top of the privacy
policy and wrap every HealthKit identifier <code> with break-all so
long camelCase strings (heartRateVariabilitySDNN,
environmentalAudioExposure) wrap inside narrow viewports.
CF-19 follow-up: the endpoint catalogue, active-token table, and
revoked-token table on `/settings/api` each gain a `md:hidden`
card-list partner so phones never have to horizontal-scroll the
tabular layout. Desktop tables stay verbatim behind `hidden md:block`.
The active-token cards expose a full-width revoke button that clears
the 44pt tap-target floor.
Final surface sweep for the v1.4.27 tap-target work: the chart band
overlay, the error-details action row, and the public-page header
links.
- `health-chart.tsx`: band overlay used to inset `right: 18px` while
the underlying ComposedChart margin is `right: 8`, so the colour
band drifted left of the plotted line by 10 px on every viewport.
Pin the overlay to the chart's actual right edge.
- `error-details.tsx`: drop `size="sm"` from the retry, copy, and
report-issue buttons so they sit at the default 40 px primitive
height — clears the WCAG 2.5.5 floor on touch.
- `auth/login/page.tsx`: back-to-passkey toggle wraps in `inline-flex
min-h-11 w-full` so the form-mode swap is reachable on touch.
- `privacy/page.tsx` and `about/page.tsx`: header HealthLog + Sign-in
links wrap in `inline-flex min-h-11` so the public-page chrome
clears the floor.
Lift the GLP-1 tile range strip + tab pills, the getting-started
checklist dismiss buttons, and the settings reorder + source-ladder
arrow buttons so every interactive element clears the 44 px WCAG
2.5.5 floor.
- `glp1-tile.tsx`: range-strip radio buttons gain `inline-flex
min-h-11 items-center justify-center px-3` so each preset sits at
44 px; the tab pills (level / weight) lift the same way.
- `getting-started-checklist.tsx`: dismiss-all CTA drops `size="sm"`
so it sits at the default 40 px primitive height; per-row dismiss
`X` button wraps in `inline-flex h-11 w-11`.
- `dashboard-layout-section.tsx`: reorder up + down arrows `h-5 w-5`
→ `size-11`; column-header spacer widens to `w-11` to keep the
layout grid aligned.
- `sources-section.tsx`: metric source ladder + device-type ladder
up/down arrows stack vertically on `<sm` so each button keeps the
`h-11 w-11` floor without crowding the row; sit side-by-side again
from `sm:` up to preserve the desktop layout.
`measurements.filterByType` shipped in en + de with the
v1.4.27 measurement-list aria-label wiring; the locale-integrity
test failed because the four English-fallback locales (es, fr, it,
pl) had not been backfilled. Add the placeholder English string so
each locale file is key-parity with `messages/en.json` again; real
translations follow in the v1.4.28 locale sweep.
- New `<Popover>` primitive (Radix-backed, Dracula surface tokens)
mirrors the existing `<Tooltip>` wrapper. Tooltips do not
tap-toggle reliably on touch; popovers do.
- The Coach composer info-icon swaps off `<Tooltip>` onto
`<Popover>` so the long-form "Enter to send, Shift+Enter for a new
line" copy is reachable via a tap on mobile while the keyboard
hover-preview semantic is preserved on desktop. The `aria-label`
on the trigger keeps screen-reader users announced.
- The composer textarea now carries `enterKeyHint="send"`,
`autoCapitalize="sentences"`, and an opt-in `autoFocusOnOpen` prop.
The drawer flips that prop on when it mounts the fallback
composer, so the textarea claims the cursor the first time the
drawer opens; subsequent re-renders of an already-open drawer do
not steal focus back.
- Coach evidence `<details>` is now controlled by local state. The
summary carries an accurate `aria-expanded` that flips in
lock-step with the disclosure; the native Enter / Space toggle
semantics are preserved.
Lift the icon-button and pagination controls on the list + form
surfaces under `/measurements` and `/medications` so every touch
target clears the 44 px WCAG 2.5.5 floor.
- `measurement-list.tsx`: mobile-row edit + delete buttons `size-8`
→ `size-11`; pagination chevrons `size="sm"` → `size="icon"
size-11` with translated `aria-label`; edit-dialog kebab
`h-9 w-9` → `size-11`; `DeleteButton` default `h-8 w-8` →
`size-11`. Empty-state CTAs drop `size="sm"` so they sit at the
default 40 px primitive height.
- `measurement-form.tsx`: reset kebab `h-9 w-9` → `size-11`.
- `intake-history-list.tsx`: desktop + mobile per-row edit + delete
buttons `size-8` → `size-11`; pagination chevrons → `size-11`
icon-buttons; edit-dialog kebab → `size-11`; shared `DeleteButton`
in this file → `size-11`.
- `SideEffectsSection.tsx`: per-entry delete `h-7 w-7` → `size-11`.
- `inventory-section.tsx`: live-pen "mark as in use" / "mark as used
up" pills drop `size="sm"`/`h-7` so they sit at `min-h-11`; past-pen
delete `h-6 w-6` → `size-11` icon-button.
Adds `measurements.previousPage` / `measurements.nextPage` and
`medications.previousPage` / `medications.nextPage` translation keys
so the pagination icon-buttons carry accessible labels in every
locale.
Bring the auth flows to parity with the rest of the v1.4.27 form
sweep so screen-reader users hear the form-level error banner tied
to the offending field and the mobile keyboard surfaces the right
on-screen affordance per step.
Login: email field flips to `inputMode="email"` +
`enterKeyHint="next"` and disables capitalize / spell-check;
password field carries `enterKeyHint="go"`. Both inputs reference
the error banner via `aria-describedby` and the banner itself gains
`aria-live="polite"`.
Register: same pattern across email, username, and the new-password
field. The submit key on password is `"go"` to fire the create-
account submit on virtual-keyboard return.
Records the five MB1 commits, the `<ResponsiveSheet>` API, every
migrated form mount, two race-conditioned commit-message
mis-attributions (`bd7cb938`, `44554729`), and the optional
sticky-CTA pattern deferral.
Bring the same `inputMode`, `enterKeyHint`, `autoComplete`, and
`spellCheck` defaults to the settings and admin surfaces:
- Account: profile email gets `autoComplete="email"` +
`enterKeyHint="next"`, the disabled username carries
`autoComplete="username"` so the password manager can still bind
the credential, and the height field hints the decimal keyboard.
- Telegram card: bot token and chat ID both opt out of autofill,
disable spell-check + autocapitalize, and lead the chained next →
done keyboard order.
- Integrations: Withings client-id / secret and the moodLog URL +
API key share the off-spell + no-capitalize secret-input contract
and chain into the test / save action via `enterKeyHint`.
- Notifications anchors: each card wrapper carries `scroll-mt-28`
so direct anchor navigation to `#telegram`, `#ntfy`, `#web-push`
lands the section header below the safe-area sticky header
instead of under it.
- Thresholds editor: min/max numeric inputs derive `inputMode`
("numeric" for integer ACTIVITY_STEPS, "decimal" elsewhere) and
chain next → done.
- Admin reminders: the late-window and missed-window minute inputs
pin `inputMode="numeric"` and the next → done keyboard order.
The primitive `<SheetContent>` close-X is an absolutely positioned
`opacity-70 rounded-xs` button that clashes with the rest of the
sheet's in-header chrome. Hide it via `showCloseButton={false}` and
render a matching ghost-icon `<SheetClose>` inline in the header
so the close affordance shares the same baseline, ghost variant,
and 44 px tap floor as the coach-drawer header pattern. The
settings-sheet test mock picks up a `SheetClose` passthrough so the
existing skeleton + form assertions keep working.
The mobile history + sources tray triggers used to mount as absolute
chevron buttons over the message thread, so they (a) overlapped the
first message bubble, (b) sat at a 28 px tap target (`h-7`), and (c)
relied on `backdrop-blur` to stay legible — which still failed on
busy threads.
Lift them into a sub-header strip above the thread: a flex row with
two ghost buttons at `min-h-11`. The strip itself carries `xl:hidden`
since both rails are inline at xl+; the history trigger keeps its own
`lg:hidden` because the history rail mounts inline already at lg+.
While the rail surface is in scope, drop the `opacity-0
group-hover:opacity-100` reveal on the history-rail per-row delete
button — touch surfaces have no hover, so the affordance was
invisible on mobile. The button now sits at `size-11` always-visible;
the confirming state still styles via `data-confirming`.
Test re-baselined to assert the new strip wrapper + `min-h-11` floor.
Pair every required field with `enterKeyHint`, `aria-required`,
`aria-invalid`, and `aria-describedby` so the form-level error banner
gets announced the moment validation fails, and so each input
surfaces the right virtual-keyboard hint (next, done) when the user
moves through the form.
- Measurement form (single + BP triple) and the inline edit dialog
pin `aria-describedby` to the alert region, add `enterKeyHint` on
every numeric and notes input, and pass the filter Select an
explicit `aria-label`.
- Medication form gets the chained `enterKeyHint` flow on name →
dose → unit → doses-per-pen, switches the schedule window inputs
to `type="time"`, and forces `text-base` on the dense schedule
cells so iOS Safari does not auto-zoom the field on focus.
- Intake create + edit dialogs reference the dialog-local error
banner via `aria-describedby`.
- Inventory add-pen dialog wires the same error descriptor and adds
`enterKeyHint` to the date and doses-total fields plus
`autoCapitalize="sentences"` on the notes textarea.
- Side-effect log notes textarea gets `enterKeyHint="done"` and
`autoCapitalize="sentences"`.
- Mood form pins the timestamp input to the error banner and lifts
the tags input to `autoCapitalize="none"` so colour-coded chips do
not get title-cased.
Adds the `measurements.filterByType` translation in de + en.
Lift every interactive element on the insights hero + advisor + coach
chrome to clear the 44 px WCAG 2.5.5 floor on touch.
- `hero-strip.tsx`: drop `size="sm"` from the action buttons (weekly
report, ask the Coach, weekly banner read/share/export). The default
primitive height now sits at 40 px so the explicit `sm` was the only
thing keeping these below the floor.
- `suggested-prompts.tsx`: chip floor `min-h-9` → `min-h-11`.
- `insight-advisor-card.tsx`: regenerate icon buttons drop their
`h-7 w-7` override so the `size="icon"` primitive default (40 px)
takes over; legacy-payload CTAs drop their `size="sm"`/`h-7` overrides.
- `coach-drawer.tsx`: window pill `h-7` → `h-11`; the three header
icon buttons (new chat, settings, close) `size-9` → `size-11`.
- `coach-input.tsx`: composer hint icon trigger `h-7 w-7` → `h-11
w-11`. The Tooltip → Popover swap is owned by MB3 — distinct lines.
- `message-thread.tsx`: thumbs feedback row `py-0.5 text-[11px]` →
`min-h-11 py-1.5 text-xs`. Same surface row, padding does the lift.
Swaps the medication create/edit, intake create/edit, side-effect
log, and inventory add-pen mounts from the centred `<Dialog>` to
the shared `<ResponsiveSheet>` primitive so the same surfaces flip
to a bottom-anchored sheet on narrow viewports. The "log this
thing" mental model — measurement, mood, medication, intake,
side-effect, inventory — now lands on a consistent mobile shape.
Configuration surfaces (IntakeImport, ApiEndpoint, PhaseConfig)
stay centred per Decision G; the dialog primitive's new
`max-h-[calc(100dvh-2rem)]` default keeps them inside the viewport
on short / soft-keyboard screens without the previous per-callsite
`max-h-[90vh]` ceiling.
Move `<PasswordInput>` from `src/components/settings/password-input.tsx`
to `src/components/ui/password-input.tsx` so the auth surfaces and
every settings card that masks a secret consume one primitive. The
two surfaces previously diverged on toggle markup and aria-label
wiring; the new home in `ui/` keeps them in lock-step.
While the primitive moves, lift the icon-only show/hide toggle to a
44 px hit area per WCAG 2.5.5 — `inline-flex h-11 w-11` wrapper with
the eye icon centred inside. The input itself gets `pr-12` so user
input never collides with the toggle glyph.
Consumer imports updated in:
- `src/components/settings/account-section.tsx`
- `src/components/settings/ai-section.tsx`
- `src/components/settings/integrations-section.tsx`
- `src/components/settings/ntfy-card.tsx`
- `src/components/settings/telegram-card.tsx`
- `src/components/admin/_shared.tsx`
Surface the right on-screen keyboard on iOS Safari and Android Chrome
without forcing every call site to repeat the attribute. The primitive
maps `type="number"` to `inputMode="decimal"` (most numeric fields in
HealthLog accept decimals — kg, mmol/L, sleep hours), `type="email"`
to `"email"`, `type="url"` to `"url"`, `type="tel"` to `"tel"`, and
`type="search"` to `"search"`. Integer-only call sites (steps, doses
per pen, reminder minutes) still opt into `inputMode="numeric"`
explicitly and the override wins.
Cover the derivation with seven additional unit tests on top of the
three existing autoComplete contracts; the suite now pins every
mapping plus the override path.
Move `<PasswordInput>` from `src/components/settings/password-input.tsx`
to `src/components/ui/password-input.tsx` so the auth surfaces (login
and register) and every settings card that masks a secret consume one
primitive. The two surfaces previously diverged on toggle markup and
aria-label wiring; the new home in `ui/` keeps them in lock-step.
While the primitive moves, lift the icon-only show/hide toggle to a
44 px hit area per WCAG 2.5.5 — `inline-flex h-11 w-11` wrapper with
the eye icon centred inside. The input itself gets `pr-12` so user
input never collides with the toggle glyph.
Consumer imports updated in:
- `src/components/settings/account-section.tsx`
- `src/components/settings/ai-section.tsx`
- `src/components/settings/integrations-section.tsx`
- `src/components/settings/ntfy-card.tsx`
- `src/components/settings/telegram-card.tsx`
- `src/components/admin/_shared.tsx`
The `<DialogContent>` close-X tap-target lift to `min-h-9 min-w-9`
landed in the parallel `ResponsiveSheet` commit because both buckets
share the same `dialog.tsx` file. See Decision I — 36 px is the
intentional exception for fixed icon-only close affordances.
Shared `<ResponsiveSheet>` flips between a bottom `<Sheet>` on narrow
viewports and the existing centred `<Dialog>` at `md+`, with a
sticky-pinned footer slot on the Sheet branch so primary CTAs stay
reachable when the keyboard collapses the viewport. Picks the
breakpoint via a new SSR-safe `useIsMobile` hook (`matchMedia`-driven,
default `false` on the server so SSR markup always matches the
desktop branch). Adds a `max-h-[calc(100dvh-2rem)] overflow-y-auto`
default to `<DialogContent>` so the existing centred-dialog forms
also scroll inside the viewport on short / soft-keyboard mobile
screens instead of spilling off-screen. Smoke suite locks both
branches, the header sr-only escape hatch, sticky-vs-flow footer
shape, and controlled-state plumbing.
Move primitive default heights up so any consumer that does not opt
into a specific size already meets the 40-44 px target-size minimum
without per-callsite overrides.
- `<Button>` default → `h-10` (40 px), `lg` → `h-11` (44 px), `icon`
→ `size-10`, `icon-lg` → `size-11`. `sm` and `xs` stay compact for
explicit-dense contexts.
- `<Input>` default → `h-10`.
- `<Select>` trigger default → `h-10`.
- `<DropdownMenuItem>` → `min-h-11` so menu rows always clear the
44 px floor on touch.
No same-line collisions with MB1 (`dialog.tsx` max-h cap) or MB3
(`input.tsx` inputMode derivation) — distinct touch zones.
Per-commit subjects, deliverable-vs-actual verification matrix for
every fix-plan item, the orphan-endpoint grep table that grounds the
five-endpoint v1.4.28 defer, and the seven recorded deviations from
the original fix-plan scope.
Audits the chart-tick timezone contract called out in BL-P4-2.
`sleep-stage-stacked-bar`, `mood-chart`, `health-chart`, and
`medication-compliance-chart` already anchor every day-key
construction at `Date.UTC(y, m - 1, d)` and route tick rendering
through the timezone-aware formatter pair (`tzFmt.date` / `tzFmt.dateShort`),
so they audit clean.
The compliance heatmap was the outlier: the day cells walked the
weekday and month-boundary axes with `getDay()` and `getMonth()`,
which read in the SSR server's local timezone while the dateKey
above used `toISOString().slice(0, 10)` (UTC). An SSR pass running
on a non-Berlin host would land the Monday alignment and the month
markers one position off relative to the cells they sit under.
Swapped to the UTC accessor pair so all three coordinates (dateKey,
weekday row, month-marker label) read off the same clock.
The tooltip date formatter also parsed the day-key against local
time; pinned to `T00:00:00Z` + the UTC accessor trio for the same
reason.
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.
The /api/audit-log GET handler has zero runtime callers in src/, tests/,
scripts/, or docs/ — verified via grep against the route path plus the
auditLog model name. The admin counterpart at /api/admin/audit-log is
the surface every consumer reads from. Drops the orphan route file
outright per the v1.4.27 fix-plan Section 4 Decision A.
The five admin / monitoring endpoints flagged for the scope-
maximization sweep all carry a README + CHANGELOG documentation
reference, which the fix-plan classes as a consumer for the
defer-on-tie rule:
/api/admin/ai-settings — README L362–363, CHANGELOG L752
/api/admin/backup/test — README L368, CHANGELOG L752
/api/admin/status-overview — README L367, CHANGELOG L753
/api/monitoring/glitchtip/test — README L381
/api/monitoring/umami/test — README L382
All five defer to v1.4.28 for a maintainer call — either wire each to
a real consumer or drop the route + README mention together. Seeds
the v1.4.28 backlog entry with the per-endpoint grep findings so the
decision is one read away.
Also seeds the v1.4.28 backlog with the missing `insights.coach.window.lastYear`
i18n key across the six locale bundles — owned by the i18n bucket.
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.
Adds `GET /api/workouts` as a paginated read surface that runs every
fetch through `pickCanonicalWorkout()` so a duplicated workout that
Apple Health and Withings both publish collapses to the canonical
row before reaching the client. The picker stays a pure function;
the route owns the query window, the fetch multiplier that keeps the
page full after dedup, and the meta envelope that surfaces the
dedup ratio.
Collapses the batch-route route-attach lookup from `Promise.all` of
`tx.workout.findFirst` per manual workout to a single batched
`findMany` keyed by the deterministic `(source, startedAt, sportType)`
tuple. For a 100-row batch this cuts the per-batch round-trip count
from 1 + N to 2 while preserving the most-recent createdAt tie-break
through in-memory grouping.
Adds `__tests__/canonical-dedup.test.ts` covering twin collapse,
proximity-window separation, sport-type isolation, and the limit
clamp.
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.
Captures the i18n cleanup pass: 154 dead-key paths retired across
six locales (~924 strings), 38 new keys added for B1/B3/B4/B5
component-side inserts, locale-native date format ordering for
FR/ES/IT/PL, drift-guard test pinning the GLP-1 / carrier / empty-
state / notification / personal-record key sets, mood-label
shared module landing BL-P6-11.
Also lands the four probe scripts (.planning/v1427-b6-*.mjs) used
to enumerate dead keys and build the new key inserts, sibling to
the existing .planning/v1422-w1a-*.mjs audit artifacts.
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.
W10 design-review carry-over items. M7 lifts the medication-form
day-of-week buttons, the more-horizontal trigger, and the schedule
label-input gaps to the 44 px / space-y-2 standard used by every
other settings surface. L1 pairs every animate-spin/pulse/bounce
with motion-reduce:animate-none across the component tree so users
with prefers-reduced-motion get a still surface. L3 adds the
aria-controls + panel-id pairing to the inventory + coach-evidence
<details> blocks for parity with the Health Score provenance
accordion. L4 inserts a sr-only h4 per therapy-timeline entry so
screen-reader users can jump between medication blocks.
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.
Sweeps 154 dead keys per locale bundle (~924 strings across six
locales) flagged by the v1.4.27 dead-key audit. Drops the
insights.*Status* trio (loading / noKey / unavailable) across six
metrics, the legacy `insights.aiInsights` / `dashboard.insightsPreview`
roots that B1 replaced with the gated tab strip, the
`insights.healthScore.askCoach` / `coachPrompt` pair retired with the
inline button, the orphan onboarding.v2.* stub set, plus the
ad-hoc `medications.glp1.drug.*.class` and `notifications.eventPersonalRecord*`
strings that lost their callers in v1.4.25/W17.
Also drops the stale prefill-format test under
`health-score-card.test.tsx` that asserted the now-removed coachPrompt
key shape — the negative-assertion test for the retired inline button
stays in place.
The locale-integrity test already validates the new shape: no
re-baselining needed because the test asserts parity + no-empties
rather than a key-count threshold.
Sub-headings in the settings + admin section trees were mixing
font-medium and font-semibold; the password-change dialog used a
1.5-unit label-input gap while every other Settings form runs at 2.
Standardise on font-semibold for every h2 and h3, the card-internal
cadence to space-y-4 on the AI provider and thresholds-editor cards,
and lift the password-dialog field gaps to space-y-2.
- Three R3a contributor reports (B3 GeoIP+ASN, B4 Coach+Insights,
B5 Notifications) — B1 and B2 already committed theirs.
- v1.4.27 mobile-capability audit and fix plan inserted between
R3b and R4: seven parallel auditors per surface area, a single
consolidator, then a parallel fix-pass against the consolidated
bucket list.
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.
Two test-contract fixes for fallout from concurrent R3a contributors:
- hero-strip.test.tsx — collapses the two onAskCoach forwarding tests
into a single regression guard that asserts the inline Health Score
card Ask-the-Coach button stays retired and the hero strip's
action-row button carries the affordance.
- telegram-test-locale.test.ts — the route's POST handler takes zero
arguments (apiHandler wraps an async () => function). Drops the
Request argument from all seven call sites.
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}
The /insights hero strip already renders briefing.paragraph as its
subtitle directly above the Daily Briefing card; the card then mounted
the same paragraph a second time inside its CardContent before the
key-findings list. The user read the same string twice within 200 px.
Drop the leading paragraph render from <DailyBriefing>; the card now
opens directly on the structured key-findings list (the part the hero
subtitle cannot surface). The empty-state branch keeps its 'Generate
briefing' CTA so the populated → empty flow remains intact.
Rebalance the Health Score card so it reads as a co-equal column of
the hero rather than a small inset:
- lg:w-[260px] → lg:w-[360px] xl:w-[400px]
- score number text-4xl → text-5xl sm:text-6xl
- progress bar h-1.5 → h-2
- inline Ask-the-Coach button retires; the hero strip's existing
action covers it. onAskCoach stays on the prop signature so callers
don't break.
Fold in BL-P4-9 L2: disclaimer text-[10px] → text-[11px] (the 10 px
size was borderline against the 12 px mobile floor flagged in the
W10 design review).
Tests updated: daily-briefing test asserts the paragraph slot is
absent in the populated branch; health-score-card tests pin the new
no-button render across English and German locales.
Adds a `carrierShortLabel()` helper in `_shared.tsx` that folds the
verbose GeoLite2 `autonomous_system_organization` strings down to
the glanceable DACH short labels Marc asked for: "Telekom",
"Vodafone", "1&1", "O2". Unknown organisations fall through to the
raw string so the chip stays informative.
`LoginOverviewSection` now renders the short label as a smaller
chip below the existing auth-provider chip, gated on
`entry.carrier !== null` so private-IP and offline-miss rows keep
the legacy single-chip layout.
The CSV export grows a matching `carrier` column between `location`
and `provider`. The API selector pulls the new `asn` + `carrier`
fields. New translation keys (`admin.carrier`, `admin.carrierUnknown`)
are referenced in code; bucket B6 lands the JSON entries.
The composer rendered the keyboard-shortcut hint ("Enter to send,
Shift+Enter for new line") as a prose `<span>` directly under the
textarea. Combined with the disclaimer and the send button, the
footer ate ~140 px of vertical room for what is essentially a single-
sentence affordance.
Replace the `<span>` with an `Info` icon wrapped in a Radix `<Tooltip>`
sat left of the send button. The existing `composerHint` translation
surfaces both as the trigger's `aria-label` (so screen-readers still
hear it on focus) and as the tooltip body for sighted users on hover.
The slot `data-slot="coach-input-hint"` stays so consumers can still
target the affordance.
The assistant bubble's evidence `<details>` mounted open whenever the
user had `coachPrefs.showEvidenceByDefault = true`, which surfaced the
raw measurement values (e.g. "Blutdruck letzte 30 Tage: 226") under
every reply. The disclosure is now always collapsed; the user expands
by click. The persisted pref stays in the schema for backward
compatibility but no UI reads or writes it, so the value is dead-code-
tolerated for one release and can be dropped in the next migration.
Drops the `evidenceDefaultOpen` prop wiring from `<MessageThread>` and
the corresponding `<Switch>` in the Coach settings sheet. Adjusts the
sheet test that previously asserted the toggle was rendered.
The faint dracula-green left-seam on the outer wrapper was decoration
without a semantic tie to the schedule rows beside it. The Syringe-icon
in the title row already carries the 'active therapy' signal at a
glance.
Replace the <dl> grid with a soft-green pill bar that groups Last- and
Next-Injection as one cohesive 'therapy schedule' unit. Each pill keeps
its data-slot anchor (glp1-tile-last / glp1-tile-next) so existing
tests stay in lockstep; new test asserts the data-slot='glp1-tile-schedule'
wrapper renders and the border-l-dracula-green/60 + border-l-2 classes
no longer appear on the outer wrapper.
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.
The GLP-1 tile previously rendered a single weight chart — a downstream
proxy for the dose-response. The maintainer-flagged headline is the
estimated drug-level curve because it surfaces the sawtooth shape of
the weekly cycle directly.
Add a two-tab segmented control (Drug-Level default / Weight) inside the
chart pane and a 7d / 30d / 90d / All range strip above. The Drug-Level
tab mounts <DrugLevelChart compact …>, which now drops its outer card
wrapper and renders at 160 px instead of 240 px when 'compact' is set,
so the dashboard tile owns the card surface. Range selections thread
through a new windowHoursBefore prop on DrugLevelChart; the sample step
scales 6/12/24/48 h with window length so 'All' mode does not paint
thousands of points. The standalone surface at /medications/[id]/history
keeps its full 21-day default and full-card chrome — both consumers
share one implementation.
New translation keys referenced (B6 sweep picks up):
- dashboard.glp1.tabLevel
- dashboard.glp1.tabWeight
- dashboard.glp1.tabsAria
- dashboard.glp1.rangeStripLabel
- dashboard.glp1.levelUnavailable
- dashboard.glp1.weightUnavailable
Test coverage extended: glp1-tile test asserts tab strip + range strip
DOM, drug-level default selection, level-unavailable hint when the
medication has no id; the existing DrugLevelChart test suite continues
to pin the standalone-surface contract.
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.
Several call-sites composed notification bodies in English or hardcoded
German without consulting User.locale. Swap each surface to a
translator-aware path:
- src/app/api/internal/deploy-webhook/route.ts uses
dispatchLocalisedNotification per admin, reading
notifications.admin.deployFailedTitle and
notifications.admin.deployFailedBody with param substitution.
- src/app/api/admin/notifications/test/route.ts resolves the admin
locale once at the top of the handler, then composes the test
notification via getServerTranslator. This route talks to the
senders directly because it needs per-channel success/error
feedback for the UI, so it stays off the dispatcher.
- src/app/api/settings/telegram/test/route.ts has the same shape for
the per-user path; it reads the user locale and translates
notifications.user.telegramTestBody before sending.
- src/app/api/admin/notifications/reminder-check/route.ts uses
dispatchLocalisedNotification with per-row recipient locale, so the
affected user (not the admin running the check) sees the message
in their language.
Reminder-worker is already localised via getPhaseMessage; no change.
src/lib/integrations/status.ts is out of scope and left for a later
symmetry-sweep.
Tests:
- src/app/api/settings/__tests__/telegram-test-locale.test.ts covers
de, fr, null, unsupported locale paths plus rate-limit,
missing-token, and send-failed branches.
- src/app/api/internal/deploy-webhook/__tests__/route.test.ts swaps
its admin-alert assertion from dispatchNotification to
dispatchLocalisedNotification.
New translation keys (B6 picks up the JSON edits):
- notifications.admin.deployFailedTitle
- notifications.admin.deployFailedBody
- notifications.admin.testNotificationTitle
- notifications.admin.testNotificationBody
- notifications.admin.reminderCheckMissedTitle
- notifications.admin.reminderCheckMissedBody
- notifications.admin.reminderCheckOverdueTitle
- notifications.admin.reminderCheckOverdueBody
- notifications.user.telegramTestBody
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.*
Lists the four B2 commits, the scope of each, the translation-key
plumbing left for the localisation pass, why the optional Playwright
timing test was skipped, and the deviations from the original
fix-plan dispatch.
Replaces the centred `<Loader2 /> Loading` line in
`<ThresholdsEditorSection>` and `<SourcesSection>` with a skeleton
list whose row count matches the loaded UI (14 rows each — one per
`METRIC_ORDER` entry and one per `SOURCE_PRIORITY_METRIC_KEYS` entry
respectively). The skeleton rows reuse the live UI's container
classes (`border-border space-y-3 rounded-lg border p-4` on
Thresholds; `border-border bg-background/30 space-y-2 rounded-md
border p-3` on Sources) so the post-fetch swap lands in place. The
`<Skeleton>` primitive's `motion-reduce:animate-none` covers the
reduced-motion preference. Combined with the `min-h` shell reserve
from the previous commit, this removes the page-height jump that
landed under the sticky sidebar when these two sections finished
loading.
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.
MoodChart rendered at 280 px while every sibling chart on the dashboard
card row (weight, BP, pulse, body fat, sleep, steps, medication
compliance) renders at 240 px. The 40-px outlier broke vertical rhythm
when scrolling past the trend strip — the row swelled when it hit the
mood card.
Consume CHART_HEIGHT_PX from the shared constants module so the empty
state and the populated chart now stride with the rest of the row. Bump
the populated-chart override from 280 to 240 in the same edit.
Adds `min-h-[calc(100dvh-12rem)]` to the `<main>` element in both
`<SettingsShell>` and `<AdminShell>`. The sticky 220 px sidebar is
anchored to the page scroll; previously the grid's row height tracked
the section's content height, so swapping a short spinner for a long
list (Thresholds: 14 rows; Sources: 14 metric tiles) jumped the page
height under the sidebar and shifted the active link off-screen. The
12 rem reserve covers the global header (5 rem) + section header
(~5 rem) + a small buffer, so the spinner-to-content transition no
longer changes the viewport height. Short pages (`/settings/about`)
still render short because `min-h` does not clamp tall content.
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.
The picker row sits between two `space-y-4` (16 px) form rows, so the
old `gap-2` (8 px) between the select and the detect button read as a
tighter rhythm than the rest of the profile form. `gap-3` (12 px) keeps
the detect button visually attached to the select (it is a hugged
action) while no longer breaking the cadence the surrounding rows set.
Moves the language select into the same `grid sm:grid-cols-2` row as
date of birth and drops the standalone language wrapper plus its
`sm:max-w-xs` clamp. The profile form now carries a uniform two-cell
rhythm from top to bottom instead of alternating between paired and
single-cell rows. Updates the mobile-consistency e2e assertion to match
the new contract: dob + language MUST share a grid row.
Consolidates the six R1 audit reports (R1.1 dashboard, R1.2 settings/admin,
R1.3 coach/insights, R1.4 notifications, R1.5 iOS offline-first research,
R1.6 backlog sweep) plus the 17 backlog items pulled forward into a single
fix-surface plan with seven touch-disjoint buckets, a file-touch collision
matrix with zero same-line collisions and four sequenced edits, and a
Round 3 dispatch sequence (5 parallel + 2 sequenced). Decides /api/audit-log
to DELETE as an orphan endpoint, parks all four iOS server-side preps
(syncVersion, deletedAt, GET /api/sync/state, ETag) to v1.4.28, and adds
22-offline-first-architecture.md to the iOS handoff doc-pack carrying R1.5's
research forward for the iOS-side reader.
Logs the production-alarm hotfix shipped in `fec02aa` under a new
"Resolved by v1.4.26" section so the backlog stays honest about what
landed on develop after the v1.4.25 tag.
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.
Replace the seed skeleton with the final navigation map for the
21-file doc-pack.
Inventory: 21 rows pinned against the actual filenames + final
token estimates (total ~89400 tokens / ~57000 words).
Question → File map: 38 real entries mapping concrete iOS-Claude
questions to file + section anchors. Covers source-priority,
MeasurementType enum, MDR boundary, APNs flow, Withings OAuth,
HealthKit batch ingest, Coach SSE, Health Score, PR detection,
migrations 0051-0060, OpenAPI hard-flip gate, error codes,
Idempotency-Key, rate-limiting, PROMPT_VERSION,
RESEARCH_MODE_DISCLAIMER_VERSION, TanStack Query, chart hex literals,
locale keys, dev server, Simulator API token, v1.4.26 inventory.
Reading-orders-by-goal: 16 scenarios with file lists + estimated
cumulative tokens. Cold-start, tag-a-release, new endpoint, Coach
feature, Apple Health sync, workout ingest, GLP-1 work (critical
research-first), pre-PR gate, screen design, Settings → Sources,
Health Score tile, Withings reconnect, onboarding wizard, CI setup.
Stop-here-markers index: per-file summary of what each file's STOP
HERE block (or inline warnings) covers, so iOS-Claude can lookup
and exit.
TL;DR refresh: 2 sentences pinning v1.4.25 stack + v1.5 = iOS +
deeper Apple Health.
Cross-link audit: every <file>.md reference in every sibling now
resolves to a real file. 16 broken references found + patched in
the prior commit.
Sibling docs (00..20 except README) referenced 16 file names that don't
exist in the final pack — names like 10-coach-pipeline.md, 12-ai-coach.md,
15-withings-bridge.md, 17-personal-records.md, 18-onboarding.md,
19-deploy-runtime.md, 02-architecture.md, 03-system-overview.md,
10-architecture.md, 12-ai-providers.md, 13-medical-content.md,
09-ui-state.md. Every one mapped to a real file in the final pack;
patched all cross-links in:
- 00-philosophy.md (STOP HERE table)
- 01-repo-tour.md (deploy link + STOP HERE table)
- 02-server-architecture.md (6 Detail-in references + STOP HERE table)
- 03-api-contracts.md (STOP HERE table)
- 04-data-model.md (prerequisites + What is NOT in this file)
- 05-auth-flows.md (What is NOT in this file)
- 06-ios-responsibilities.md (prerequisites)
- 07-server-responsibilities.md (prerequisites)
- 08-locked-contracts.md (What is NOT in this file)
- 11-web-ui-tour.md (prerequisites)
- 12-design-system.md (prerequisites)
- 13-state-management.md (prerequisites)
- 14-coach-mental-model.md (prerequisites)
- 17-error-handling.md (What is NOT in this file)
- 18-pattern-cookbook.md (prerequisites)
- 19-i18n-system.md (prerequisites)
- 20-glossary.md (multiple Coach + provider references)
Every cross-link in the doc-pack now resolves to an existing file or to
a section heading inside an existing file.
The remaining two files of D. 06-ios-responsibilities.md enumerates the five
domains the iOS app owns (HealthKit, Keychain, APNs, offline cache, deep
links) plus the MDR-critical 'don't cross this line' warnings.
07-server-responsibilities.md is the inverse — 22 domains the server
already handles, so iOS Claude knows what NOT to reinvent.
Three of D's five files. The Coach + Insights + Health-Score surfaces are
server-resident; iOS Claude must consume, not reimplement. Documents the
layered prompt builder, GROUND RULES 1-15 (including W4d/W19c safety
contracts that hold the EU MDR line), the safety-contract matrix loader,
cache-invalidation rules, source-attribution provenance, and the
deterministic asOf discipline introduced in v1.4.25 Fix-G.
Rewrites the v1.4.25 CHANGELOG section to cover the ~90 commits since
v1.4.24 across onboarding rebuild, GLP-1 medication tracking end-to-end
(drug knowledge, Research Mode with MDR acknowledgment dialog,
estimated drug-level chart, side-effect taxonomy, cadence + compliance,
EMA titration ladder, pen-and-vial inventory with a 30-day clock),
Personal Records detection worker + badge + push opt-in, workout batch
ingest, Withings Activity + Sleep v2 syncs, EMA drug knowledge layer,
log-redaction path-segment rule that closes the Withings webhook
secret leak, GLP-1 convenience endpoint hardening, native first-party
Coach + insights system prompts across six locales with a refusal-probe
matrix, OpenAPI drift-gate hard-fail flip, 380 dead i18n keys removed,
shared helpers + section wrapper consolidations, dashboard top-tile
polish, design polish to WCAG 2.5.5 + AA contrast.
Updates the test count footer to the current 3828 unit / 344 files /
~170 integration / 9 new migrations (0051-0059) + 1 hardening (0060).
Adds the refusal-probe matrix metrics (1800+ assertions x 15 GROUND
RULES x 6 locales x 20+ paraphrasings).
Replaces the stale "Deferred to v1.4.26" section. Items that already
shipped in Wave 4-5 (OpenAPI hard-flip, onboarding rebuild, native
FR/ES/IT/PL Coach prompts, PR detection worker, workout ingest,
Withings Activity + Sleep v2) are out of the deferred list and into
Added / Changed / Fixed / Security / Refactor. The remaining v1.4.26
backlog reads accurately.
Closes the W21 reconcile pass. Captures the six commits, the 49 new
test assertions, three documented deviations (inventory-section
keeping its details shell, medication-form parseDose left alone,
apiError signature broadening), and final-gate evidence.
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.
- advanced-section: drops the local ResearchModeStatus interface in
favour of the shared DTO, replaces the nested ternary that built the
toggle's status caption with a researchModeStatusLabel helper
threaded through the shared gate-state classifier.
- WelcomeCarousel / GoalsChipPicker / SourceCardGrid / BaselineForm:
import the shared readError helper instead of the four hand-rolled
copies — the request-failed fallback shape now evolves in one place.
- BaselineForm: swap the gender native <select> for the
shadcn-radix Select component so the picker matches the rest of the
form library (focus rings, keyboard nav, theme tokens).
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.
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.
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.
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.
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.
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.
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.
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.
`<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.
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.
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.
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.
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.
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.
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).
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
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.
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.
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.
Commits, scope, test counts, gate results, deviations, and the
medication-detail-page layout map handoff for W19e (reminders /
cadence / compliance) and W19f (titration ladder).
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.
W19d sub-wave — structured GLP-1 side-effect logbook derived from
EMA EPAR §4.8 ("Undesirable effects"). 21 entries rolled up into 5
surface categories (GI / METABOLIC / INJECTION_SITE / COGNITIVE /
GLP1_SPECIFIC) with 1-5 Likert severity per row.
Composite index on (user_id, medication_id, occurred_at) for the
detail-page timeline read; a second (user_id, occurred_at) index
serves the Coach snapshot aggregation. Severity is bounded with a
DB-level CHECK constraint, defence in depth against client drift.
Notes column is unbounded TEXT, application-layer capped to 280
chars in the Zod schema introduced in the follow-up commit.
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.
Records the three commits, scope delivered, and the contracts
W19c-Frontend needs:
- RESEARCH_MODE_DISCLAIMER_VERSION import path,
- computeOneCompartment / shotPhaseAt signatures,
- drug-id enum + look-up helper,
- GET/POST/DELETE API contract for the Settings toggle,
- chart-wiring rule that hides the curve when persisted
acknowledgment version drifts behind the server-side constant.
Also notes the two-compartment math deferred to v1.5 and the Ka
textbook-approximation fallback used for non-tirzepatide drugs.
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.
Three columns gate the opt-in GLP-1 PK research-view chart behind an
undismissable MDR-disclaimer dialog (research §2.3, §11):
research_mode_enabled — master switch, default false.
research_mode_acknowledged_at — timestamp of the latest ack.
research_mode_acknowledged_version — copy version the user read.
Versioning the acknowledgment lets the Settings UI re-prompt when
the disclaimer copy drifts ahead of the persisted value, so a copy
update cannot quietly keep the chart visible to a user who has not
read the new wording.
Migration 0058 uses ADD COLUMN IF NOT EXISTS to mirror 0057's
idempotent posture.
Five commits landed for the onboarding rebuild scaffold: schema
migration, OnboardingShell primitive, nested step routes, progress
API, and 132 new i18n entries across six locales. Documents the
deviation from the flat-file locale layout the spec assumed, the
left-over content work for the W14b-Content agent, and the test
counts (45 directly-touched tests, full suite green at 3460 passed).
New keys (under the existing `onboarding` namespace):
shell.{back,skip,next,step1of4..step4of4,welcomeTitle,welcomeBody}
goals.{title,body,placeholder}
source.{title,body,placeholder}
baseline.{title,body,placeholder}
done.{title,body,returnCta}
EN is the production reference; FR / ES / IT / PL mirror the EN
intent. DE strings are neutral hand-off placeholders pending Marc's
hand-curated copy in a later pass — every value is real text so the
no-empty-values and no-TODO locale-integrity guards keep passing.
Capture root-cause analysis and fix narrative for the two e2e specs
that were failing on PR #168's latest CI run — Recharts 3.x DOM shift
hitting charts-mobile.spec.ts, and a brittle literal AI-prose
assertion in dashboard.spec.ts replaced with the structural insight-
summary slot.
The /insights mother page no longer fetches the `/api/insights/general-status`
endpoint — InsightAdvisorCard reads `/api/insights/generate` (a POST
that doubles as cache-aware read), gated by `/api/insights/comprehensive`
returning a non-null payload. The old test mocked a dead endpoint, hit
the EmptyState CTA path, and asserted on a literal AI prose substring
that the renderer never produces verbatim — twice brittle.
Swap the mocks to the real endpoints with a minimal-but-valid
`InsightResult` payload, then assert on the populated card's stable
structural hook `[data-slot="insight-summary"]`. The hook is set by
`InsightAdvisorCard` on the populated-body branch only, so the
assertion still proves "the card mounts its summary when the payload
arrives" — the actual contract under test — without coupling to AI
prose that varies per provider and cache state by design.
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.
Recharts 3.x relocated x-axis tick labels out of `.recharts-xAxis` into
a sibling ZIndex container `.recharts-xAxis-tick-labels`. The old
selector `.recharts-xAxis .recharts-cartesian-axis-tick text` matched
nothing under the new DOM, so the wait timed out and the spec failed
on chromium-mobile. Switch both the readiness wait and the per-wrapper
count to the new container, scope counting to `.recharts-wrapper` so
each chart contributes once, and drop empty axes from the tally.
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.
Persists the highest step a user reached in the rebuilt /onboarding flow
so closing the tab mid-flow no longer wipes progress. Nullable Int
default 0; encoding mirrors the new wizard:
0 welcome / 1 goals / 2 source / 3 baseline / 4 done
Migration is idempotent (ADD COLUMN IF NOT EXISTS) so it tolerates
hand-applied dev-DB state without diverging from the migration history.
Bundle 51 .planning/ artifacts written by the Round-3 wave agents and the
W10 reviewer pass that were never staged: phase reports for W3/W5b/W6/W7d/W7e/W8/W8b/W8c/W8e/W11/W11a/W11b/W14a/W14c/W16a-c/W17bc/W19a/W19b/W20a + Fix-G/H,
plus research backgrounders for Apple Health, GLP-1, Withings, Health-Score
provenance, source-priority two-axis, W10 reviewer outputs, and others.
Historical record only - no production-code or schema changes.
Session 2 hit ~78% context across orchestrator + both W14b-Foundation
and W19c sub-agents. Both paused before any destructive work. This
handoff captures everything that landed, what's in-flight, what's
remaining, and the recommended dispatch order for Session 3.
PR #168 stays as Draft. develop accumulates further commits in Session
3 until Marc's UAT signoff at W23.
The v1.4.25 W11a refactor moved the workflow off `docker/metadata-action`
as the single source of the image-ref string and started passing
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}` directly into the
`outputs:` of `docker/build-push-action`. `IMAGE_NAME` comes from
`github.repository` (`MBombeck/HealthLog`), and GHCR rejects pushes
with any uppercase character in the repo segment, so both per-platform
builds failed with `invalid reference format`. Resolve the lowercase
form once at the top of each job via `${VAR,,}` bash expansion and
reuse it across `metadata-action`, `build-push-action`, and the
`buildx imagetools` calls in the merge stage.
The W17b/c sleep-stage migration widened the measurement unique
index to (user_id, type, measured_at, source, sleep_stage) with
NULLs treated as not-distinct, so three same-instant APPLE_HEALTH
weight rows now collide on insert. Derive a stable per-externalId
minute offset and stagger the seeded `measuredAt` accordingly so
the deletion-by-externalId contract is exercised without piggy-
backing on dedup behaviour the route doesn't care about.
W5 added `defaultWindow` to the coach-prefs schema with `"allTime"`
as its default, so the PUT handler now returns the canonical
defaulted shape even when the caller omits the field. Update the
integration round-trip to expect the defaulted form on both the
response envelope and the persisted row.
The hard-coded "2026-05-14T13:00:00.000Z" reading was due to fall out
of the snapshot's 14-day `recent` window two weeks after the suite
landed, deterministically breaking the assertion. Anchor the instant
to "today minus 2 days at 13:00 UTC" so it always sits inside the
window, then derive the expected Auckland / Berlin day-keys from
that instant. Keeps the test asserting the same tz-divergence
invariant without baking a calendar date into the suite.
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.
Adds a dedicated parity test for the safety-contract matrix that
complements the existing i18n locale-integrity test:
- Every EN ground rule must have a non-empty translation in DE /
FR / ES / IT / PL.
- No non-EN locale may carry a body identical to its EN counterpart
(verbatim-EN = placeholder bug).
- Sentinel literals (KEYVALUES, END, snapshot token, example tag
open/close) are IDENTICAL across all six locales.
- GLP-1 brand list is IDENTICAL across all six locales — international
brand registry, never translate.
- JSON contract enums (severity / sourceWindow / sourceMetric / etc)
are IDENTICAL across all six locales — parser keys.
- Every parser_critical rule declares at least one must_contain
token (defence-in-depth — a rule that loses its pin would silently
bypass the refusal-probe).
- Every locale exports a non-empty defer-to-clinician phrase list
and a non-empty reply-language directive.
While the parity test was being wired, the dailybriefing schema
ground rule gained must_contain tokens (dailyBriefing / keyFindings /
sourceWindow / sourceMetric) since it is parser-critical.
Wires the top-row Dashboard tiles (Weight, BP-sys, BP-dia, Pulse, Body
Fat, Mood, Sleep, Steps, VO2 max, BP in Target) to locale-specific
abbreviated heading keys (`dashboard.*Short`) so the heading row
always fits a single line at narrow viewports.
The Romance + Slavic locales pick the clinical compact forms used in
medical practice — FR/IT/ES use `TA` / `PA` for blood pressure,
PL uses `RR` (Riva-Rocci); body-fat shortens to `KF` in DE and
`GC` / `TG` / `TT` in the other non-EN locales. EN and the
already-short DE labels (Puls, Stimmung, Schlaf, Waga) keep their
familiar wording. The corresponding i18n keys landed in messages/
across all six locales via the same touchpoint.
The full `dashboard.bloodPressureSys` / `bodyFat` / `pulse`
labels stay in place for the Settings → Dashboard layout picker and
the chart-row titles below the tile strip — only the cramped tile
heading row reaches for the short form.
New test module exercises pickCanonicalSourceRows with the realistic
W17b/c shape: same user reporting both WITHINGS rows (from the new
activity / sleep sync routines) and APPLE_HEALTH rows (from the v1.5
iOS passthrough) for the same day.
Coverage:
- Default Marc-directive priority picks APPLE_HEALTH for every
cumulative metric (steps, active energy, distance, flights)
- Same default applies to sleep — even when both sources captured
the same set of per-stage rows from Migration 0055
- Withings stays the fallback when Apple Health has no rows that
day (today's v1.4.25 reality; v1.5 may flip it per-user)
- User-level overrides re-prioritise the ladder as documented
- Activity rows (sleepStage NULL) and sleep rows (sleepStage non-null)
pick independently — Migration 0055's per-stage uniqueness doesn't
leak into the picker's source axis.
Updates the banner notice across all six locales to be explicit that
the AI-initial locales (FR / ES / IT / PL) are AI-drafted including
the Coach's safety-critical instructions, and that automated
validation guards them in CI. The CTA reframes from "help out" to
"report on GitHub" so users with a regression know the
contribution path is bug reporting, not editorial rewrites.
Pins the new wording in the banner test so a future copy regression
cannot quietly soften the disclosure. Banner gating (renders only on
non-maintained locales, SSR-safe) and the GitHub-issue CTA target
URL remain unchanged from W9e.
Two new pg-boss queues join the existing reminder-worker orchestrator:
withings-activity-sync — cron '0 * * * *' (hourly at :00)
withings-sleep-sync — cron '15 * * * *' (hourly at :15, offset)
Each queue's handler dispatches per-user: when the payload carries
`userId` (webhook-triggered) only that user syncs; when `userId` is
absent (cron-triggered) the worker iterates every WithingsConnection.
Per-user failures log as warnings so one parked-at-reauth connection
doesn't starve the rest on a cron tick.
The webhook handler now reads the `appli` form / JSON field and
dispatches:
appli=16 → enqueue withings-activity-sync (W17b)
appli=44 → enqueue withings-sleep-sync (W17c)
appli=1/2/4 or missing → legacy syncUserMeasurements (unchanged)
When pg-boss is unavailable (dev server without the worker process)
the handler falls back to inline sync so dev iteration isn't blocked
on a separate worker process.
The 15-minute cadence offset between activity (:00) and sleep (:15)
keeps the two crons out of lockstep with the existing measure cron at
:00, smoothing the load against the Withings 120-req/min/app cap.
Replaces the W9e EN-body-plus-footer plumbing for FR / ES / IT / PL
with native Coach and Insights system prompts assembled from the
safety-contract matrix. Each locale carries hand-curated persona,
day-level, evidence-block and few-shot sections in its medical
register (vouvoiement / usted / Lei / Pan-Pani) while the parser
contract tokens — KEYVALUES sentinels, GLP-1 brand names, severity /
sourceWindow / sourceMetric / timeRange enums — stay verbatim across
every locale.
The DE and EN bodies are unchanged: DE has two years of clause-by-
clause Marc review and remains the calibration reference; EN is the
schema source-of-truth. If the matrix loader fails for any reason
the dispatcher falls back to the previous EN-body-plus-footer path
so the surface fails open rather than empty.
Also exports OUT_OF_SCOPE_REFUSAL_FR / ES / IT / PL constants
mirroring the EN / DE shape, sourced from the matrix's
out_of_scope_refusal.summary field so they cannot drift from the
prompt body that teaches the model to emit them.
New `syncUserSleep` routine pulls per-segment sleep data from
POST /v2/sleep?action=get and writes one Measurement row per stage
segment, tagged with the appropriate SleepStage enum:
state 0 (awake) → AWAKE
state 1 (light sleep) → CORE (HealthKit-aligned NREM 1+2)
state 2 (deep sleep) → DEEP
state 3 (REM) → REM
state 4 (synthetic) → ignored
The legacy ASLEEP and IN_BED enum slots stay reserved for HealthKit
ingest (pre-iOS-16 `asleepUnspecified` and iOS-16+ `inBed`).
ScanWatch nights with no REM segment simply omit the row rather than
synthesising an all-zeros entry — Withings drops the field, the writer
never sees it.
Idempotency rides on Migration 0055's five-column composite — a re-sync
of the same night UPDATEs every segment row in place rather than
inserting duplicates, even when Withings re-aggregates the night with
adjusted segment boundaries.
Sleep durations stored as canonical minutes (seconds ÷ 60, rounded) so
re-syncs don't flip values on floating-point noise.
New `syncUserActivity` routine pulls daily aggregates from
POST /v2/measure?action=getactivity and writes one Measurement row per
(date, metric) for three fields:
steps → ACTIVITY_STEPS (count)
distance → WALKING_RUNNING_DISTANCE (metres)
calories → ACTIVE_ENERGY_BURNED (kcal)
`elevation`, soft/moderate/intense/active duration and
`totalcalories` are deliberately skipped — no clean MeasurementType
counterpart today; revisit when v1.5 ships the iOS workout passthrough.
Rows anchor measuredAt at the day's 23:59:59 UTC end-of-day so the
Berlin-timezone analytics aggregator buckets them into the correct
local day even across DST shifts. Idempotency rides on the W17b
composite (Migration 0055, NULLS NOT DISTINCT) — repeat syncs of the
same date update value in place rather than inserting duplicates.
Park behaviour mirrors syncUserMeasurements: connections at
error_reauth short-circuit without making the upstream call.
Marc cannot review the FR/ES/IT/PL safety-critical translations, so
automated coverage is the only safety net. The probe walks the full
14×6 ground-rule × locale grid with 20+ adversarial paraphrasings per
contract — direct asks, indirect asks, role-play attempts,
system-prompt-injection tries, and multilingual code-switches.
The test is structural: each (rule, locale, paraphrasing) triple
assembles a synthetic system-prompt + user-turn pair and asserts the
locale's rule body survives prompt assembly verbatim. Parser-critical
contracts (zero hallucination, evidence-block sentinels, GLP-1 dose
refusal, out-of-scope refusal, severity enums) additionally assert
every must_contain token is preserved.
GLP-1 dose refusal — the highest-patient-risk contract — runs a
heavier per-locale check: for every adversarial probe the assembled
context must list every GLP-1 brand AND include at least one
defer-to-clinician phrase token from the locale's allow-list. Total
~3000 structural assertions across 273 test cases.
WITHINGS_NOTIFY_APPLIS grows from [1, 2, 4] to [1, 2, 4, 16, 44]:
appli=16 (Activity) and appli=44 (Sleep v2) join the webhook-primary
set so the new sync routines fire in seconds rather than waiting for
the hourly cron fallback.
setupWebhook iterates the new list unchanged — Withings has no batch
subscribe endpoint, so every appli is its own POST. The two new
categories require the user.activity OAuth scope from W5d; legacy
connections that never reconnected return 503/293 on those subscribes,
which setupWebhook already logs as a per-appli warning while keeping
the remaining subscriptions live.
Adds six locale-keyed YAML files plus a Zod-validated loader so the
Coach and Insights system prompts can pull their 14 ground rules from
a single matrix instead of carrying parallel hand-edited bodies. The
matrix marks each rule with surface (coach / insights / both) and
parser_critical so downstream tests can distinguish stylistic from
safety-load-bearing clauses. GLP-1 brand names, evidence-block
sentinels and JSON contract enums are pinned to identical values
across every locale.
The German body is derived from the existing two-year hand-curated
COACH_PROMPT_DE / SYSTEM_PROMPT_DE wording. French / Spanish / Italian
/ Polish bodies are AI-drafted into the project's formal medical
register (vouvoiement / usted / Lei / Pan-Pani). The refusal-probe
test landing in a follow-up commit covers each ground rule x locale
with 20+ adversarial paraphrasings.
The Withings Sleep v2 sync writes one Measurement row per stage segment
(AWAKE | CORE | DEEP | REM) for the same night, all sharing the legacy
four-column composite (user_id, type=SLEEP_DURATION, measured_at,
source=WITHINGS). Migration 0055 adds sleep_stage as the fifth axis and
recreates the unique index with NULLS NOT DISTINCT so non-sleep rows
(sleep_stage IS NULL) still dedup on the first four columns — same
effective semantics as before for every meastype other than SLEEP_DURATION.
Prisma's generated compound input requires non-null fields, so the
Withings sync's upsert is reshaped as findFirst + create/update for the
non-sleep path (sleepStage is always NULL there). The new five-column
upsert key is the canonical path for the Sleep v2 ingest landing in W17c.
Register the new typed workout batch ingest endpoint in the OpenAPI
route table so the W14a hard-flip CI gate stays green:
- Annotate createBatchWorkoutSchema with .meta() so it lands as a
named component (CreateBatchWorkoutRequest).
- Define WorkoutBatchEntryResult + WorkoutBatchResponse — the per-
entry status envelope the iOS sync cursor decodes against.
- Register POST /api/workouts/batch with the 200 / 400 / 413 / 401 /
422 / 429 response surface so the Swift DTO generator sees the
full contract.
- Tagged under Measurements rather than a new tag so the iOS
client's sync surface stays in one section.
Regenerated docs/api/openapi.yaml via pnpm openapi:generate;
pnpm openapi:check confirms the spec is in sync with the
underlying schemas.
Unit + integration coverage for POST /api/workouts/batch.
Unit (database-free, mocks the Prisma layer):
- 401 on unauthenticated calls
- 413 when Content-Length exceeds the 5 MB ceiling, with the
documented workout.batch.payload_too_large error code
- 429 when the rate-limit gate blocks the call
- 400 when the workouts array exceeds the 100-entry cap
- 400 when the Zod schema rejects the payload
- 400 when a route exceeds the 20 000-point cap
- Fresh entries marked inserted
- Pre-existing entries flagged duplicate without re-inserting
- Race-reconciliation downgrades inserted statuses to duplicate
when createMany.count is below the attempted-row count
- WorkoutRoute rows attached by FK after the workout createMany
Integration (real Postgres in a testcontainer):
- Happy-path single workout with nested route lands as a Workout
row + WorkoutRoute row + 1:1 FK + computed durationSec
- 100-workout batch with mixed routes lands all 100 workouts and
exactly the expected number of route rows
- Over-cap batch (101 workouts) returns 400 with
workout.batch.too_large
- Over-cap route (20 001 points) returns 400 with
workout.batch.invalid
- Re-posted batch surfaces as duplicates without inserting twice
- Idempotency-Key replays the cached envelope and sets the
X-Idempotent-Replay header
- Pre-seeded rate-limit row triggers 429 and writes nothing
- Narrow-scope Bearer token admitted on a route that declares no
required permission (the W10 fix-C correctness fix the iOS
narrow-scope sync depends on)
Concurrent-write integration coverage that validates the W10 fix-C
reconciliation pattern carries over correctly to the
/api/workouts/batch endpoint:
- Two batches with overlapping (userId, source, externalId) tuples
posted in parallel resolve to exactly one DB row per tuple (the
composite unique index enforces it; createMany.skipDuplicates
absorbs duplicate-key conflicts).
- Each response body's per-entry envelope sums to its own
aggregate counts — the invariant the iOS sync cursor relies on
to checkpoint correctly.
- Aggregate inserted counts across both responses equal the row
count actually present, cross-validating that createMany.count
is the truth source rather than the older
'stored vs not-stored' diff (which was a no-op under standard
Postgres semantics because every attempted row IS in the DB
after the call, written by whichever batch won the race).
- A two-batch shared-key fixture asserts the shared tuple lands
exactly once across both responses.
Also adds workouts and workout_routes to the integration suite's
truncate list so beforeEach() actually clears them between tests
rather than relying on CASCADE from users.
Single-source-of-truth gate between the GLP-1 knowledge layer and the
research file (`.planning/research/glp1-feature-inspiration.md` §1 + §2.6).
Two layers:
1) Hard pins on every cited numeric — half-life, Ka, CL, F,
bioavailability, titration ladder, post-opening window — against
the EMA EPAR / psp4.13099 values. A silent mutation in the TS
module surfaces here before reaching the Coach or inventory
surfaces.
2) Soft check that the research file still carries the underlying
citations (EMA EPAR URLs, Schneck/Urva DOI, the N7 retatrutide
exclusion clause). A research-file revision that drops a citation
is caught immediately.
30 tests, all green. Future Coach grounding (deferred to W14c) can
trust the values it reads.
First write endpoint for the W8d Workout + WorkoutRoute tables.
Mirrors the v1.4.25 W10-hardened /api/measurements/batch in every
cross-cutting concern so the iOS sync engine and the deferred
Withings activity sync (W17b) can re-use the same retry / cursor
plumbing:
- requireAuth() — cookie + Bearer; narrow-scope-token safe per
the W10 fix-C contract (a route that declares no scope admits
any authenticated token, wildcard or otherwise).
- withIdempotency — Idempotency-Key replays the cached envelope
on retry (24h window).
- checkRateLimit at 60 batches/min/user — 100 workouts/batch
ceiling × 60/min = 6 000 workouts/min headroom, orders of
magnitude past any healthy sync.
- Content-Length pre-flight at 5 MB — anything larger returns
413 with a structured error code so the client falls back to
one-workout-per-call.
- Per-entry status (inserted | duplicate | skipped) so the iOS
sync cursor can checkpoint accurately.
Idempotency contract:
- HTTP-level via Idempotency-Key.
- Per-entry via @@unique([userId, source, externalId]) — Prisma
createMany skipDuplicates absorbs duplicate-key conflicts.
Race reconciliation mirrors the v1.4.25 W10 fix for measurements
batch: under contention, createMany.count is the source of truth
for how many rows THIS request wrote, and we downgrade enough
per-entry inserted statuses to duplicate so the envelope sums
stay consistent with the aggregate counts. The iOS sync cursor
advances past both statuses identically.
Two-step write: createMany the workouts, then look up the freshly
written rows by composite key and createMany the routes by FK.
The 1:1 WorkoutRoute.workoutId unique index means route inserts
are also idempotent on re-submit.
Add medications.glp1.drug.* namespace with localized INN + drug-class
labels for tirzepatide, semaglutide, liraglutide, dulaglutide, and
exenatide, plus medications.glp1.source.{ema,psp4} citation strings.
Each locale uses the regional spelling convention (DE Tirzepatid,
ES Tirzepatida, PL Tyrzepatyd, …) — scientific names are written
naturally for each market rather than transliterated. Drug-class
labels follow the local pharmacology vocabulary.
All 6 locale files validate; i18n-locale-integrity green.
Static read-only catalog covering the five EMA-approved GLP-1 / GIP-GLP-1
receptor agonists (tirzepatide, semaglutide, liraglutide, dulaglutide,
exenatide). Each record carries pharmacology, titration, storage, pen
form-factor, EMA §4.8 adverse-reaction frequencies, contraindications,
warnings, and a direct EMA EPAR citation.
Tirzepatide PK parameters are pinned to Schneck & Urva 2024
(DOI 10.1002/psp4.13099) — half-life 5.0 d, Ka 0.0373 h⁻¹, CL 0.0329 L/h
per 70 kg, F 0.80, two-compartment model.
One-to-many brand mapping (Mounjaro+Zepbound → tirzepatide,
Ozempic+Wegovy+Rybelsus → semaglutide, etc.) plus per-brand route
overrides for the only mixed-route entry (Rybelsus oral, others SC).
Retatrutide deliberately omitted — no EMA approval as of 2026-05.
32 unit tests cover shape integrity, parameter pinning, brand-route
consistency, ascending titration ladders, storage window sanity, and
N7 retatrutide-exclusion.
Lock the W16b ingest contract on the W8d-authored workout schema:
- Cap route geometries at 20 000 points (covers any normal run /
ride / hike; rejects pathological payloads that would otherwise
blow the 5 MB request-body ceiling on a single workout).
- Enforce the parallel-array invariant via .superRefine() — when
route.sampleTimestamps is present its length must equal
route.geometry.coordinates.length. A desynced pair silently
poisons downstream analytics (per-sample HR / speed against the
wrong coordinate), so we hard-fail at parse time.
- Add createBatchWorkoutSchema mirroring the createBatchMeasurement
shape: { workouts: createWorkoutSchema.array().min(1).max(100) }.
Cap is an order of magnitude tighter than the 500-entry
measurements batch because each workout may carry a 20 000-point
route geometry; 100 covers a cold-start HealthKit backfill on a
healthy multi-year history.
Schema tests grow from 16 → 20 cases. The /api/workouts/batch
endpoint and Swift DTO generator both consume this contract.
W10 senior-dev review flagged that workouts will collide once v1.5
lands the iOS HealthKit passthrough: a single ScanWatch run becomes
two rows — one from HKWorkout, one from the Withings Activity API.
The Measurement table solved the symmetric problem via
pickCanonicalSourceRows; workouts need a parallel helper before the
"recent workouts" tile can show one row per workout instead of one
per source.
This commit ships the scaffolding only. The helper:
- Clusters workouts whose start timestamps land within
proximityMinutes of each other (default 5 minutes — covers the
HealthKit passthrough lag plus a clock-skew buffer) AND share a
sport type, so a Walking + Running burst stays distinct.
- Walks a per-user source ladder (default
APPLE_HEALTH > WITHINGS > MANUAL > IMPORT — see file-level rationale)
and keeps the first source present in each cluster.
- Is deterministic on input order plus a lexicographic id
tiebreaker so the same fixture always produces the same canonical
list.
- Falls through cleanly when none of the ladder entries are
present in a cluster (legacy / future source values).
NOT yet wired into any query path. The wiring lands alongside the
workout ingest endpoint (W16b) or with the v1.5 iOS rollout,
whichever ships first. The Workout model already carries the
TODO(v1.5 iOS) breadcrumb in prisma/schema.prisma; this commit
delivers the helper that breadcrumb points at.
Unit tests cover the three brief-mandated fixtures (two-source same
workout, two-source different workouts close in time, single source
pass-through) plus the 5-minute proximity boundary, custom-ladder
override, empty input, and determinism guards.
W8d shipped the opt-in VO2 max trend tile on the dashboard; this is
the deeper Insights surface. Renders one HealthChart for the trend
plus a compact stat strip (latest / min / max / 30-day average with
the comparison delta vs. the prior period) on /insights/puls because
VO2 max sits in Apple Health's Heart category — cardio fitness, not
its own root concept.
Implementation notes:
- vo2Max joins CHART_OVERLAY_KEYS so the per-chart cog persists its
overlay-prefs independently from the pulse chart sitting directly
above it on the same page.
- The card stays mounted at zero samples so a brand-new account sees
the "no data yet" hint rather than a missing surface.
- Compare-delta caption only renders when the user has a comparison
baseline active AND the prior-period bucket has data; otherwise the
slot is suppressed.
- HealthChart is dynamic-imported with the same skeleton placeholder
the trends row uses so Recharts stays out of the Insights bundle.
Translations land in all six locales; FR / ES / IT / PL carry English
placeholders matching the v1.4.25 convention. RTL test exercises the
stat-strip slots, the empty-state branch, the compare-delta gates, and
the German label rendering.
Move the Withings webhook shared secret from `?secret=…` to a path
segment (`/api/withings/webhook/<secret>`) so it never lands in the
reverse-proxy `query_string` access-log column or GlitchTip URL/
breadcrumb capture surface (W10 finding M-2).
Withings has no facility for custom HTTP headers on outgoing
notifications and never signs the body — every Notify.subscribe call
accepts only action/callbackurl/appli/client_id/nonce/signature, so
the callback URL is the only authenticity surface a subscriber
controls. Path-segment binding is the largest practical shift away
from loggable query strings that Withings supports end-to-end.
The legacy `/api/withings/webhook` route stays alive for one release
cycle (removal target: v1.4.27) so existing subscriptions keep
delivering. The query-string path now emits a deprecation breadcrumb
on every hit. Active users re-subscribe organically when they reconnect
for the v1.4.25 `user.activity` OAuth-scope upgrade.
Tests: 8 new for the path-segment route (valid / invalid / missing /
unset-env / prefix-match / rate-limited / HEAD / GET), 3 new for
`getWithingsWebhookCallbackUrl()`. Existing legacy-route tests
continue to pass over the refactored shared helper.
Adds 32 HKQuantity / HKCategory identifiers to HK_QUANTITY_TYPE_DEFERRED
so the batch ingest endpoint logs a "deferred, not unknown" signal when
the iOS app passes a sample type HealthLog hasn't surfaced yet. None
currently have a HealthLog MeasurementType counterpart; mapping them
through requires the enum extension to land first (separate wave).
Buckets covered, with iOS version they shipped in:
- Cardiovascular clinical (iOS 16): AFib burden, peripheral perfusion
- Mobility (iOS 14/15): walking steadiness, falls, steadiness event
- Pulmonary clinical (iOS 17): FEV1, FVC, peak expiratory flow,
inhaler usage
- Other quantity types: insulin delivery, UV exposure, electrodermal
activity, blood-alcohol content, legacy Nike fuel
- Heart-rhythm event flags (iOS 9+/17 refresh): low/high heart-rate
events, irregular rhythm, low cardio fitness event
- Audio-exposure event flags + environmental sound reduction
- Behavioural: handwashing, toothbrushing
- Reproductive / fertility / pregnancy: contraceptive, lactation,
pregnancy, pregnancy/progesterone test, sexual activity, sleep
changes, persistent intermenstrual bleeding, prolonged / irregular
/ infrequent menstrual cycles
Tests gain explicit coverage that every long-tail identifier sits in
the deferred set and that no identifier is duplicated.
The Security & Quality workflow ran the OpenAPI drift check with
`continue-on-error: true` since v1.4.23 while the Zod registry caught
up with the hand-maintained legacy spec. Coverage is now complete and
the v1.5 iOS Swift codegen needs the contract pinned, so the gate
flips to hard-fail: any route signature change without a regenerated
`docs/api/openapi.yaml` blocks the merge.
The CI error message points at `pnpm openapi:generate` and the
registry under src/lib/openapi/ so the fix is self-evident.
The Zod registry picked up additional route schemas during the v1.4.25
wave (Apple Health DTOs, GLP-1 injection sites, dashboard token slots).
Regenerate the committed spec so the upcoming hard-fail drift gate has
a clean baseline.
moveDeviceType now takes bucket: string | null where null means the
global ladder. The function maps null to the canonical "default" key
once at the top of the body and reads + writes that key uniformly,
removing the two equal-shape if branches and the magic-string check
that drove them. Callers pass null instead of "__default__".
Builds the base output object once with the four shared fields and
appends sleepStage only when the mapping carries a sleep-stage table.
Drops one duplicated object literal and one return path; the function
keeps the same null-on-bad-input semantics for unmapped, invalid date,
missing stage, and unknown stage cases.
The derived map mirrored APPLE_HEALTH_TYPE_MAP one key for one key
with the value narrowed to the measurementType field. Every caller can
read that via APPLE_HEALTH_TYPE_MAP[id]?.measurementType — no external
consumer is depending on the compact view today. Drops the redundant
constant plus the mirror-check test block that only existed to keep
the two tables in sync.
COMPONENT_ORDER moves to module scope so the provenance row order is
allocated once per process. The asOf date formatter is memoised on the
locale via useMemo so the per-row map no longer constructs a fresh
Intl.DateTimeFormat instance for each row. The defensive try/catch
around new Date() drops at the same time — the constructor doesn't
throw on bad ISO input, the Number.isNaN(getTime()) guard already
catches every failure mode.
Three independent listings of the same seven sub-page slugs collapse
to one. The keys of SUB_PAGE_METRIC drive both the type union and the
array — adding a sub-page becomes a single-line change in one record
instead of touching three declarations that must stay in lockstep.
Fix-C's release-readiness flag about i18n-locale-integrity drift on
`insights.sleep.headlineCaptionSuffix` reflected a transient mid-session
snapshot — commit e14466e already shipped native translations for all
six locales and the integrity test is green (26/26). Append a follow-up
note to the W10 reconcile-A design report so the audit trail is complete.
Drops the inline override-then-default-then-constant lookup ladder
inside pickCanonicalSourceRows and routes the resolution back through
getDeviceTypeLadder, the single helper that already encodes that
order. The picker keeps its per-bucket cache; only the resolution
itself moves out. Future changes to the fallback rule land in one
place instead of two.
Both PUT endpoints persist values into User.*Json columns that
shape downstream analytics output (source-priority drives the
aggregator's source pick on every read; doctor-report-prefs gates
PDF section inclusion including the mood-data toggle). Neither
wrote an audit-log entry, so a silent compromise or unintended
client write was invisible.
Mirror the timezone-route pattern: capture the previous shape,
write the new value, then `auditLog` with previous + next +
client IP. Adds the missing source-priority route test alongside
the new audit case in the doctor-report-prefs suite.
The previous race-reconciliation block compared post-insert rows in
the DB against the rows we attempted to insert, downgrading any
"inserted-but-not-stored" entries to "duplicate". Under standard
Postgres semantics that branch never fires: `skipDuplicates`
absorbs duplicate-key conflicts but the row is still present in the
table (written by the other batch that won the race). So every
attempted row was "stored" and the loop ran as a no-op, leaving
the per-entry envelope inconsistent with the aggregate counts when
two batches collide. The v1.5 iOS sync cursor depends on the
envelope sums matching the aggregate counts to checkpoint
correctly.
Pragmatic fix: trust `createMany.count` for the aggregate
`insertedCount` and downgrade exactly `toInsert.length -
insertedCount` per-entry "inserted" statuses to "duplicate". The
post-fix loop no longer touches `insertedCount` (it was already
correct from createMany) so the count cannot drift negative. Order
doesn't matter for the client — both statuses checkpoint past the
row and the DB ends up with the same single-copy state.
Concurrent-write integration test asserts the three invariants the
iOS sync cursor relies on: per-entry sums equal aggregate counts,
aggregate counts stay non-negative, and the DB row count matches
the combined `inserted` total.
- GLP-1 medication card streak flame: `text-orange-400` →
`text-dracula-orange`. The hardcoded Tailwind shade was skewing
warm against the rest of the card.
- /insights/medikamente compliance counters: `text-green-500` /
`text-orange-500` / `text-red-500` → `text-dracula-green` /
`-orange` / `-red`. Same drift in three sibling spans on one
surface; replace as a set.
- Side-effect affordance: `Smile` icon read as a positive
affordance for a *negative* logging surface. Switch to
`Stethoscope` so the icon matches the clinical context.
- Health Score componentBp: FR `BP` → `TA`, IT `BP` → `PA`,
ES `BP` → `PA`, PL `BP` → `RR` — the native compact forms
used in clinical practice. DE "Blutdruck" + EN "BP" unchanged.
- Therapy-timeline `<li>` key: array index → composite
(`kind-date-medicationName`). Prevents DOM-reuse bugs when a
dose change is logged mid-window and reorders the list.
- Therapy-timeline inline icons: `aria-hidden="true"` on the
`<Pill>` injection glyph and the dose-arrow `<ArrowUp>` /
`<ArrowDown>` so screen readers stop announcing "image"
mid-sentence.
- Medication-form Dialog tap targets: the schedule-row dropdown
trigger (`h-7 w-7`) and the New-schedule button + the four
schedule inputs (`h-8`) promoted to `h-11` so the dense
schedule grid honours the 44px floor. The labels stay compact;
the controls grow.
The publish workflow now builds each architecture on its native
GitHub-hosted runner (ubuntu-latest for amd64, ubuntu-24.04-arm for
arm64) and stitches the two digests into a single multi-arch manifest
via 'docker buildx imagetools create' in a final merge job. Consumers
pull one tag and Docker auto-selects the correct platform — Apple
Silicon Macs get a native arm64 image with no Rosetta translation, the
existing x86_64 Coolify hosts (apps-01, edge-01) keep getting the
amd64 variant, and arm64-based hosting (Graviton, Ampere) is supported
out of the box.
The v1.4.16 C5 attempt at multi-arch on a single amd64 runner
deterministically SIGILL-crashed Next 15's static export under
qemu-arm64 user-mode emulation; v8's optimising tier emits CPU
instructions qemu cannot reliably translate. ubuntu-24.04-arm is a
standard GitHub-hosted arm64 runner, free for public repositories and
native — no qemu in the loop.
GHA cache scopes are now per-platform AND per-ref, so amd64 / arm64
caches stay isolated from each other and a tag build at the same SHA
as a main build does not contend on writes. The Coolify deploy webhook
moves to the merge job so it fires once, only after the manifest is
final.
`Measurement.deviceType` exists on the row, was added by the W8c
batch ingest path, but the single-entry `createMeasurementSchema`
omitted it — every single-row POST silently dropped the device tag
even when the client supplied one. Manual rows tagged with watch /
scale / ring metadata lost their attribution at the schema boundary
and showed up as unsourced.
Declare `deviceType` on the schema (optional, nullable, 1–32 chars),
thread it through both single-entry and array-body POST handlers,
and add three RTL cases (present, null, omitted) so the parsed
shape stays observable.
Extends the W8.3 44px touch-target rule and the W8e accordion ARIA
contract to surfaces that shipped without them:
- Insights tab-strip pills: pills are primary navigation; promote to
`min-h-11` so a thumb tap on "Stimmung" cannot bleed into the
neighbour two pixels below.
- Injection-site picker: raise the invisible hit-target radius from
11 to 12 SVG units so the rendered diameter (\~25.6 CSS px)
clears the WCAG 2.5.8 Level AA 24×24 floor. The abdomen left/right
pair at \Δx=24 caps the upper bound — larger circles would
collide.
- Source-priority reorder arrows (per-metric + device-type axes):
promote four `h-7 w-7` icon buttons to `h-11 w-11`.
- Sub-page heading: replace the bare `focus-visible:outline-none`
with the same ring vocabulary used by insights pills + the Coach
drawer so sighted-keyboard users get a visible indicator on
programmatic focus.
- Source-priority per-metric + device-type accordions: pair
`aria-expanded` with `aria-controls` + matching `id` on the
panel, mirroring the W8e Provenance contract.
- Maintainership banner dismiss button: 22×22 hit zone replaced by a
44×44 inline-flex button that keeps the same icon size.
- Injection-site picker group: `aria-label` was leaking the
cadence-preset string; introduce
`medications.injectionSitePickerAriaLabel` and translate it
across all six locales.
- Body-outline SVG: hardcoded English "Body outline" replaced by
`medications.injectionSiteBodyOutlineAriaLabel` with native
translations in DE/FR/IT/ES/PL.
The Apple Health batch endpoint accepted 500-row payloads with no
per-user write cap. A leaked wildcard iOS token could sustain
unbounded inserts (500 rows × N batches/sec) and degrade the
database for every other user on the host.
Gate the write path with the shared `checkRateLimit` helper at
60 batches/min/user — generous for legitimate iOS sync (the
HealthKit observer coalesces well below one batch/minute on a
quiet device, and 60/min × 500 rows = 30 000 rows/min headroom)
and tight enough that a misbehaving client bottoms out within a
minute. Returns 429 with an unambiguous message so the iOS
client can back off and retry rather than assume the row landed.
Integration test exercises the over-limit path by pre-seeding the
`rate_limits` counter at the cap.
Update existing call-shape assertions to include the new `take: 100`
default and add three cases for the W10 pagination contract:
- explicit `?limit=25` passes through verbatim
- `?limit=999999` clamps to the project-wide ceiling (500)
- garbage `?limit=abc` falls back to the default without 400-ing
The plateau-detector prompt builder (`buildGlp1PlateauPrompt`) and the
Coach snapshot builder (`buildGlp1SnapshotBlock`) both interpolated
`Medication.name`, `MedicationDoseChange.doseUnit`, and
`MedicationDoseChange.note` directly into LLM user prompts. A malicious
medication entry (e.g. "ozempic\nSYSTEM: override GROUND RULE 14") could
inject a new instruction header that the model reads as overriding the
dose-prescription guardrail — a patient-safety regression.
Route every user-controlled string through the existing
`sanitizeForPrompt` helper (same pattern already used by the BP and
compliance status builders). Control sequences, prompt-injection
patterns, and oversized payloads are stripped at the interpolation
boundary; numeric fields stay as-is because the schema layer already
constrains them.
Adds RTL coverage for both builders: malicious input is neutralised,
normal input passes through unchanged.
The GET route was an unbounded findMany. Today the detection worker
isn't shipped so every user sees an empty array, but a power user with
multi-year Apple Health history could accumulate 50+ PRs per metric ×
14 PR-trackable metrics once the worker lands.
Default 100, max 500 — matches `listMeasurementsSchema`'s ceiling so
every ingest-and-read endpoint on the route surface shares one upper
bound. Garbage values silently clamp to the default rather than 400-ing,
keeping the same defence-in-depth posture the `metricType` parse uses.
The v1.5 iOS app's query path is now bounded from day one.
The Workout uniqueness key is (userId, source, externalId). The same
workout ingested from MANUAL plus the iOS HealthKit passthrough lands
two rows because `source` differs — Postgres NULL-distinct semantics
keep manual workouts unique among themselves, but the cross-source
case has no canonical-picker layer yet.
Measurements already solved the symmetric problem via the W8c
canonical-source picker. Workouts need the same shape before the
dashboard "recent workouts" tile can show one row per workout instead
of one per source. Schema-only release today (W8d), and there is no
iOS client yet, so the smell is latent — track it inline on the model
so the v1.5 iOS sprint picks it up.
Two reconcile fixes on parseSourcePriority(), one observability and one
invariant guard:
- Emit a wide-event tag when the persisted JSON fails the Zod schema
so a future schema-tightening regression that silently nukes a
user's saved ladder surfaces in ops dashboards. annotate() is a
no-op outside a request context, so static settings-page renders
and unit tests stay side-effect-free.
- Deep-freeze the resolved blob. The top-level flat keys share their
backing arrays with `metricPriority` via the spread, so a caller
mutating `resolved.metricPriority.weight = […]` would silently
desync the two views. Freezing every ladder + the containers + the
resolved root surfaces the mutation at runtime in strict mode.
The W7 per-user-tz sweep threaded `userTz` through every aggregator on
the analytics route except `berlinIsoWeekday()` — the module-level
`Intl.DateTimeFormat` stayed pinned to `Europe/Berlin`. A user in
`Pacific/Auckland` whose 23:30 weight reading lands at 12:30 the same
day in Berlin had their Monday-local reading bucketed under Sunday's
weekday column. Pearson/ANOVA on the wrong weekday.
Replace the pinned formatter with a per-tz memoised lookup, rename the
helper to `isoWeekdayInTz()`, and feed `userTz` through the call site.
Also rename the misleading `dateFromBerlinKey()` → `dateFromDayKey()`
since the keys it consumes are already anchored to the user's tz
upstream.
Integration test covers the non-Berlin path: 28 weight readings at a
UTC instant where the Berlin and Auckland weekdays disagree; the
correlator reaches the gate with every seeded row counted, confirming
the helper used the user's tz to bucket.
The 'per night' caption beneath the sleep headline was branching on
`locale === "de"` and falling through to English for FR/IT/ES/PL.
Move the string into `insights.sleep.headlineCaptionSuffix` with
native translations (de: pro Nacht, fr: par nuit, it: per notte,
es: por noche, pl: na noc) and resolve it through `t()`.
The previous `requireAuth()` policy 403'd every narrow-scope Bearer token
on routes that did not declare a required permission. The intent was to
keep wildcard tokens from acting as session-equivalents on sensitive
routes that never gated on scope, but the side effect blocked four
W8d routes (`/api/measurements/by-external-ids`, `/api/personal-records`,
`/api/medications/[id]/glp1`, `/api/dashboard/glp1`) for the v1.5 iOS app.
Restate the contract: a route that does not declare a `requiredPermission`
is content with any authenticated token; a route that declares one gates
on it. Narrow-scope tokens still 403 against scoped routes they don't
list. Add four cases to the Bearer-auth test suite covering all token-
× route-scope combinations.
The W8c picker pinned the device-type ladder to `pickedRows[0].type` for
the entire bucket and walked one ladder for every row. The inline
comment promised per-row resolution against the cached ladder map; the
implementation never delivered it. Today's only caller — the
`SLEEP_DURATION` aggregation in `/api/analytics` — feeds single-type
rows so the gap is latent. Future callers (Coach evidence rollup,
doctor-PDF section, correlations engine) that batch multiple
MeasurementTypes through one picker call would have tested non-matching
rows against the wrong ladder.
Group present device-types per row-type, resolve each type's ladder
from the cache, pick the winning device-type independently per type.
Per-type fall-through covers the case where the user's custom ladder
doesn't enumerate any device-type present for a given row-type.
Cover the multi-type path with two new tests:
- per-row ladder resolution preserves each type's winner
- per-type fall-through when a custom ladder is unreachable
CartesianGrid and axis labels were rendering un-themed because
`stroke="hsl(var(--border))"` evaluates to `hsl(#44475a)` —
invalid CSS — and the SVG fell back to currentColor. The Dracula
tokens are already hex, so the bare `var(--border)` /
`var(--muted-foreground)` form is the correct contract.
The components list above the accordion already paints a top
border; a second border on the accordion wrapper made the divider
stride read visually heavy. Drop it so the accordion sits flush
under the four sub-bars.
Pins every key the W8e accordion renders to the locale-integrity
guard. Mirrors the measurement-list-meta enum-coverage pattern:
if a future regression drops a key or adds a new source token
without a matching locale entry, the test fails fast across all
six shipped locales.
Pairs the panel <section> with the toggle button via id +
aria-labelledby so screen readers announce 'Driven by' as the
panel's accessible name. The toggle already advertises
aria-expanded + aria-controls; the labelledby pairing closes the
loop the WAI accordion pattern recommends.
The drift-guard test that pinned strict attribute order on the
panel id now tolerates additional ARIA attributes between id and
data-slot via a flexible regex.
Adds a tap-to-expand 'Driven by' panel under the four component
sub-bars inside <HealthScoreCard>. Each row pairs the existing
value bar with a second weight-share bar tinted in the Coach
provenance accent plus a source pill (manual / withings /
appleHealth / mixed / none). Rows sort by effective weight
descending so the biggest contributor leads; null components sink
to the bottom and dim to 50 % opacity.
A 'Provisional' header badge fires when fewer than half of the
configured inputs carry data, and a role=status 'mixed sources'
banner surfaces above the rows whenever at least one component
blends multiple ingest paths.
i18n keys land under insights.healthScore.provenance.* across all
six locales (en / de / fr / es / it / pl with formal register).
The locale-integrity drift-guard auto-discovers the new keys.
14 new RTL-style SSR tests pin the contract: aria-expanded toggle,
aria-controls/id pairing, sort order, source-pill localisation,
mixed banner gating, empty-state dimming, provisional threshold,
asOf tooltip forwarding, and footnote rendering.
Extends HealthScoreComponentDetail with a source pill (manual /
withings / appleHealth / mixed / none) and an asOf ISO timestamp.
The analytics route folds the persisted MeasurementSource enum from
weight + BP rows into the new attribution slot; mood + compliance
stay manual until later ingest paths land. Backward compat is
preserved — callers that don't pass attribution still get a valid
result with manual defaults for present components.
Captures the five-sub-task wave that locks the Apple Health server
contract before the v1.5 iOS-Swift session: migrations 0052 / 0053 /
0054, the mapping extension, the personal-records skeleton, and the
opt-in VO2 max tile. Restates the WORKOUT_ROUTE drop rationale and
lists the items deferred to v1.4.26+.
Adds the VO2 max strip tile to /, matching the existing
secondary-metric convention (sleep / steps / glucose / oxygen
saturation are all default-invisible + opt-in via Settings →
Dashboard).
Wire-up:
- src/lib/dashboard-layout.ts: vo2Max appended to
DASHBOARD_WIDGET_IDS and DEFAULT_DASHBOARD_LAYOUT.widgets with
visible=false / tileVisible=false. The /api/dashboard/widgets
Zod enum is derived from the same array so the toggle is wired
automatically.
- src/app/page.tsx: vo2Summary from data?.summaries?.VO2_MAX
(auto-populated by /api/analytics; the route iterates the full
measurementTypeEnum.options list, no backend change needed),
hasVo2 data-floor gate, showVo2Tile combined gate, and the
TrendCard push next to showStepsTile. directionSentiment is
up-good (higher VO2 max is better) and the unit string runs
through the t() helper so the iOS app session can localise it.
- src/components/settings/dashboard-layout-section.tsx: vo2Max
label key added to WIDGET_LABEL_KEYS so the Settings → Dashboard
toggle row renders the localized label.
- messages/{en,de,fr,es,it,pl}.json: dashboard.vo2Max + vo2MaxUnit
keys. "VO₂ max" is universal across all six locales (scientific
abbreviation, no translation needed); the unit string is
consistent SI notation.
No chart-row card — that's an explicit v1.5 deferral per the W8d
outline §6.4. The tile lights up the moment the user accumulates any
VO2 sample (Withings ingest or the upcoming iOS Health passthrough).
Schema-only release of the personal-records feature. Lands now so the
v1.5 iOS-Swift app can build its query path against the GET
/api/personal-records contract from day one; the detection worker
that populates the rows is explicitly out of v1.4.25 scope and
will land in v1.4.26 or v1.5.
Schema (prisma/migrations/0054_personal_record):
- PersonalRecordDirection enum (MAX | MIN) stored on the row so
the future detection worker doesn't re-resolve direction at
query time.
- personal_records table: userId FK (cascade), metricType
(MeasurementType enum), nullable metricSlot for sport-specific
PRs, direction NOT NULL, value + unit + achievedAt, optional
sourceMeasurementId (SET NULL on delete so PRs survive the
underlying measurement), source provenance, externalId.
- Composite unique (userId, metricType, metricSlot, achievedAt)
for idempotent worker re-runs.
- Index (userId, metricType, value) for the "current PR" read
pattern (ORDER BY value DESC LIMIT 1 / ASC LIMIT 1).
Direction helper (src/lib/personal-records/pr-direction.ts):
- getPRDirection(MeasurementType) -> MAX | MIN | null. Exhaustive
switch covering all 28 canonical enum values:
MAX — steps, active energy, flights, distance, VO2 max, HRV,
total body water, bone mass, muscle mass, time in daylight.
MIN — resting HR, body fat, fat mass, visceral fat, vascular age,
pulse-wave velocity, audio exposure (env + headphone).
null — BP, glucose, body / skin temperature, pulse, SpO2, weight,
sleep duration, fat-free mass (homeostatic, clinical-edge,
or direction-ambiguous metrics; the v1.4.26 worker can
enrich weight from User.thresholdsJson when it ships).
- isPRTrackable() convenience predicate for the future worker.
API surface (src/app/api/personal-records/route.ts):
- GET returns { data: PersonalRecord[], error: null } (canonical
apiSuccess envelope).
- Optional ?metricType=X filter, validated against
measurementTypeEnum; unknown values drop silently so a
loose-typed caller never 400s.
- No write endpoint, no detection worker — explicitly v1.4.26
scope.
Tests (drift-guard + route):
- pr-direction: every MeasurementType returns one of MAX / MIN /
null; per-direction bucket membership pinned.
- route: 401 unauth, empty response, valid filter passthrough,
unknown-filter graceful drop, seeded-record round-trip.
Locks the workout-ingest contract before the v1.5 iOS-Swift session.
v1.4.25 ships no UI surface for workouts beyond the VO2-max tile
landing in W8d.5; the table layout is in place so the iOS DTO can
be generated against it and the v1.4.26 XML / Strava workers can
target the same shape.
Schema highlights (full rationale in
.planning/research/w8d-implementation-outline.md §4):
- Typed columns for every hot-path metric (sport, duration, energy,
distance, HR avg/max/min, steps, elevation, pause). HKWorkout-
shaped 1:1, so the iOS DTO needs zero glue. Withings v2/measure
getworkouts maps cleanly modulo `hr_zone_0..3` (-> metadata).
- Open `metadata Json?` blob for the long tail (HK metadata bag,
Withings HR-zone breakdown, device bundle id).
- `sportType` is TEXT, not a Postgres enum — Apple alone carries
70+ HKWorkoutActivityType values and the tail only grows. Adding
a new sport is a Zod refinement, never a Postgres migration.
- Composite unique `(userId, source, externalId)` for ingest dedup;
Postgres NULL-distinct semantics keep manual entries non-colliding.
- WorkoutRoute is 1:1 with Workout, FK on workoutId with cascade
delete. Geometry is GeoJSON LineString in JSONB (no PostGIS
dependency — v1.4.20 set the same precedent for correlations).
Per-sample timestamps + speed/HR ride in `sample_timestamps` and
stay parallel to the coordinate array.
New module src/lib/validations/workout.ts:
- workoutSportTypeEnum: 20-member roster covering ≥98% of typical
workouts in the open-source corpora referenced in the outline.
- geoJsonLineStringSchema: RFC 7946 §3.1.4 with optional altitude
component; ≥2 points, lon ∈ [-180, 180], lat ∈ [-90, 90].
- workoutRouteSamplesSchema: parallel-array timestamp + speed + HR.
- createWorkoutSchema: full HKWorkout-shaped payload Zod for the
eventual POST /api/measurements/batch?kind=workout endpoint.
13 unit tests cover the happy path (Apple Health full payload), the
minimal-payload defaults, sport-type rejection, GeoJSON validation
(Point rejected, single-point LineString rejected, OOB coordinates
rejected), and route-sample edge cases. No ingest route ships in
v1.4.25 — the schema is the contract.
Records the two release-blockers resolved against the W9e translation
wave before tagging v1.4.25:
- Fix 1 (parseLocaleFromAcceptLanguage regression): no-op — the W7d
agent flagged a stale failure that the W9e wave itself had already
closed by adding a dedicated FR/ES/IT/PL prefix-match test block.
- Fix 2 (missing comparison.toggleHint): bundled into 6a7da4b which
restored the key across all six locales alongside the audio-exposure
/ time-in-daylight enum backfill.
All quality gates clean — full suite 2583 passed (1 skipped), above
the post-W9e baseline of 2577.
Extends src/lib/measurements/apple-health-mapping.ts with the W8d
mapping surface the v1.5 iOS-Swift session needs locked before it
starts:
- Three new APPLE_HEALTH_TYPE_MAP entries —
HKQuantityTypeIdentifierEnvironmentalAudioExposure,
HeadphoneAudioExposure, TimeInDaylight — wired to the matching
MeasurementType values landed in Migration 0052.
- HK_QUANTITY_TYPE_TO_MEASUREMENT: a compact derived view of the
same data keyed by HK identifier so a caller that only needs the
enum value can skip instantiating the full mapping record (XML
import worker, iOS DTO routing).
- HK_QUANTITY_TYPE_DEFERRED: the curated "we know about it, we are
not mapping it yet" set. Covers iOS-17/18 running form, cycling,
paddle / row / ski distances + speeds, sleep apnea, scored
assessments (GAD-7 / PHQ-9), nutrition, mental-state, FHIR
clinical, ECG, pregnancy / cycle. Each block carries its planned
release window inline.
File-header attribution credits the two open-source projects whose
identifier lists were consulted as data —
k0rventen/apple-health-grafana (MIT) and dogsheep/healthkit-to-sqlite
(Apache-2.0); only the lookup strings were ported, not code.
Test additions
(src/lib/measurements/__tests__/apple-health-mapping.test.ts):
- exhaustiveness — every MeasurementType outside the
Withings-only set has an HK counterpart;
- dedup — no identifier appears in both the mapping table and the
deferred set;
- identity-conversion check for the three new entries;
- HK_QUANTITY_TYPE_TO_MEASUREMENT key + value parity with the full
map;
- deferred-set sanity (every entry starts with a real HK prefix).
W8d.1 follow-up — the first commit landed the schema + code surface but
the messages/*.json edits did not persist through the editor sync. This
commit completes the locale parity:
- typeAudioExposureEnv / typeAudioExposureHeadphone / typeTimeInDaylight
across en (translated), de (translated), and fr / es / it / pl
(English placeholders matching the existing convention for the W5d
additions; will be filled by the locale build script on the next
pass)
- comparison.toggleHint backfilled to it / pl which were missing the
key the other four locales already carry — pre-existing drift that
surfaced in the same diff, included here so the locale-integrity
drift-guard test runs green
No code change. The drift-guard tests
(measurement-list-meta + i18n-locale-integrity) now pass.
Adds three new enum values that close the iOS-17/18 HealthKit coverage
gap before the v1.5 iOS-Swift session locks against Postgres:
- AUDIO_EXPOSURE_ENV (dBA, environmental noise)
- AUDIO_EXPOSURE_HEADPHONE (dBA, AirPods listening)
- TIME_IN_DAYLIGHT (minutes/day, iOS 17+)
Wire-up touches the canonical surfaces:
- prisma/schema.prisma: MeasurementType + comment block
- src/lib/validations/measurement.ts: enum + unitMap + VALUE_RANGES
- src/lib/__tests__/measurement-type-enum-coverage.test.ts: 25 -> 28
expected types; new entries added to PDF_VITAL_EXCLUSIONS under the
existing v1.5 clinical-layout gate
- src/components/measurements/measurement-list-meta.ts: i18n keys,
Volume2 / Headphones / Sun icons, palette assignments
- src/lib/insights/chart-tokens.ts: ALLOWED_CHART_TOKENS allow-list
plus orphan-enum stripper coverage so the LLM cannot leak raw
upper-snake names into rendered prose
- messages/{en,de,fr,es,it,pl}.json: typeAudioExposureEnv,
typeAudioExposureHeadphone, typeTimeInDaylight (en/de translated;
fr/es/it/pl carry English placeholders matching their existing
convention for the W5d additions)
The task brief originally listed a fourth value (WORKOUT_ROUTE) as a
sentinel slot. Dropped here because workouts are first-class entities
landing in Migration 0053 with their own table (Workout + WorkoutRoute);
HealthKit also separates HKWorkout from HKQuantitySample. A reserved
sentinel that no real Measurement row will ever take would be a dead
slot for analytics, doctor-PDF allow-lists, and chart registries.
scripts/i18n/build-locale.py walks messages/en.json and emits the four
AI-initial locale bundles (FR / ES / IT / PL) via a layered translation
strategy: full-path overrides for highly contextual strings, a
common-vocab lookup keyed on the lowercase EN value, placeholder
protection that splits {interpolation} tokens and <tag> markers out
before translation and re-glues them verbatim afterwards, and a
fallback to the English value for long-tail prose the script can't
cover. Re-running the script on a fresh en.json keeps the four
locale bundles in parity automatically — handy when new keys land
between releases.
The phase report (.planning/phase-W9e-v1425-translations-report.md)
captures the per-locale vocabulary calls (Apple-Health / Withings
register checks for each language), the system-prompt routing
decision (EN body + REPLY LANGUAGE footer rather than four native
~500-line bodies, to preserve every safety contract verbatim), and
the deferred polish for v1.4.26 (hand-review prose for the highest-
traffic surfaces, native FR/ES/IT/PL prefs strings in
buildPrefsPrefix, locale-native date formats).
Next 16's dev server passes a synthetic placeholder (not a real
NextRequest instance) as the first argument to route handlers
declared `export const dynamic = "force-static"`. NextRequest's
`method`, `url`, and `headers` getters all read a private `#state`
field; invoking those getters with the wrong `this` binding crashes
with the V8 native error:
TypeError: Cannot read private member #state from an object whose
class did not declare it
at Reflect.get (<anonymous>)
at wrapped (src/lib/api-handler.ts:56:23)
This 500s `/api/version` on every request (the only `force-static`
route in the app today) and would 500 any future force-static route
that anyone adds.
Wrap every direct NextRequest property access inside the apiHandler
wrapper in a `safeRequestProp` helper that swallows the private-field
error and returns a sensible fallback (empty string for `url`, "GET"
for `method`, `null` for header lookups). The fallbacks let logging
instrumentation continue to attach `path` / `method` / `user_agent`
fields when they are available and silently degrade when they are
not — instrumentation must never crash a route handler.
Verification: `curl -sI /api/version` -> HTTP/1.1 200 OK, body
contains the package.json version (was 500). Dynamic routes
(`/auth/login`, `/api/health`) still serve correctly. Affected
tests still pass (vitest src/lib/api-handler + src/app/api/version
+ src/components/targets = 31/31).
Add the four AI-initial locale bundles (FR / ES / IT / PL) and a
small dismissible MaintainershipBanner strip that paints at the top
of the auth shell whenever the active locale falls outside the
maintainer-curated set (currently DE + EN). The banner names the
AI-initial provenance and links to a GitHub translation-feedback
template so users can contribute fixes back. Dismissal persists
per-locale in localStorage so dismissing the FR banner does not
hide the IT banner once the user switches language.
The bundles are generated by scripts/i18n/build-locale.py — a layered
walk of messages/en.json that applies a vocabulary lookup keyed on
the lowercase EN value, preserves every {interpolation} placeholder
and <tag> marker verbatim, and falls through to the English value
for long-tail prose the script can't translate. The banner advertises
this status so users know what they are looking at. All four locale
files pass the i18n-locale-integrity test verbatim — full key
parity to en.json, no empty values, no TODO/FIXME placeholders,
no value-equals-last-segment matches.
Foundation work bundled with the banner so the build never lands in
a half-wired state:
- locales array extended to ["de","en","fr","es","it","pl"] with
matching localeLabels + MAINTAINED_LOCALES set + isMaintainedLocale
helper in src/lib/i18n/config.ts.
- INTL_LOCALE_MAP and parseLocaleFromAcceptLanguage in
format-locale.ts gained the four new tags so SSR locale resolution
and Intl number/date formatting work end-to-end.
- i18n/context.tsx and i18n/server-translator.ts import the four new
JSON bundles alongside DE + EN.
- Coach (src/lib/ai/coach/system-prompt.ts) and the strict insights
generator (src/lib/ai/prompts/insight-generator.ts) ship the EN
system prompt with a one-line REPLY LANGUAGE footer for the four
AI-initial locales. The DE branch keeps its hand-curated body
verbatim. Routing through the EN system prompt preserves every
safety contract (zero hallucinations, no dose-prescription,
evidence-block sentinel format) without re-validating four new
language bodies.
- Five locale-parameter signatures (no-key-fallbacks, glp1-plateau,
time-window-format, target-prompts, medication-form) widened
from "de" | "en" to the canonical Locale type. Helpers that
ship DE+EN copy route non-DE locales to the EN body, mirroring
the same fallback chain the JSON bundles use.
- OpenGraph alternateLocale on the root layout lists the four new
BCP-47 tags so social-link previews advertise the broader
language coverage.
Tests:
- 4 new SSR tests pin the locale gate (EN/DE hidden, FR/ES/IT/PL
visible by default, dismissed flag honoured) and the re-exported
isMaintainedLocale helper.
- parseLocaleFromAcceptLanguage gains a FR/ES/IT/PL coverage block
alongside the existing DE / EN / unknown branches.
- The existing i18n-locale-integrity suite auto-discovers each new
messages/<locale>.json and runs the 25-sub-test parity + value-
quality battery against it — all six locales pass.
LocalePicker is data-driven (settings and onboarding both iterate
the locales array) so promoting the array to 6 entries grows both
dropdowns without per-call-site changes. The PDF doctor-report
renderer is t()-driven for every label, so the four new JSON
bundles give that renderer FR/ES/IT/PL output without touching
doctor-report-pdf-core.ts.
Tailwind v4's content scanner walks the entire project root by default
and emits a CSS rule for every class-shaped token it finds — including
literal class fragments quoted inside .planning report markdown and
docs/ design specs. The bracketed CSS-variable arbitrary-value form
combined with an opacity modifier (e.g. `bg-[var(--name)]/12`)
generates an escaped selector whose `\(...\)\/N` token sequence
crashes Turbopack's CSS parser with `Unexpected token Delim('.')`,
500-ing every page that imports globals.css.
The crash:
./src/app/globals.css:1853:27
Parsing CSS source code failed
1852 | .bg-\[var\(...\)\]\/12 {
> 1853 | background-color: var(...);
| ^
Three coordinated changes:
- Add `@source not "../../.planning"` and `@source not "../../docs"`
to globals.css so the scanner skips planning + docs trees. Stops
fixture-style class names in research/audit reports from poisoning
the generated CSS.
- Migrate `bg-[var(--dracula-*)]/N` / `ring-[var(--dracula-*)]/N` in
target-card.tsx to the Tailwind v4 parenthesised shorthand
`bg-(--dracula-*)/N` / `ring-(--dracula-*)/N`. The shorthand emits
a clean selector that LightningCSS parses cleanly. Per the Tailwind
v4 upgrade guide, the bracketed form is deprecated for CSS
variables.
- Replace `shadow-[0_0_0_1px_var(--dracula-green)]/20` in
consistency-strip.tsx with `shadow-[0_0_0_1px_color-mix(in_oklab,var(--dracula-green)_20%,transparent)]`.
The arbitrary shadow with opacity modifier hits the same parser
issue; encoding the alpha directly in the value via color-mix
removes the modifier and keeps the visual identical.
Verification: `pnpm dev --port 3030` then `curl -sI /auth/login` ->
HTTP/1.1 200 OK (was 500). Zero CSS parse errors in the dev server
log. Affected tests (targets + api-handler + api/version) still pass
(31/31).
Lock the GET /api/analytics wire-up to the new two-axis picker with a
fixture-driven test that drives a real Postgres testcontainer + the
production handler. Four scenarios on the same Berlin day:
1. Plain default user (single source, no override) — picker passes
through; v1.4.25-today behaviour stays a regression sentinel.
2. Per-metric override (sleep: [WITHINGS, APPLE_HEALTH]) — flips
the default APPLE_HEALTH-first ladder; Withings row wins.
3. Per-device default (no user override) — Apple Watch row beats
iPhone row under same source on default ladder (watch first).
4. User flips device ladder to [phone, watch] — iPhone row wins.
Wire-up: analytics' fetchMeasurementSeriesChunked now selects
deviceType + type on every chunk so the picker can read both axes.
ChunkedRow gains two fields; all existing aggregation paths continue
to compile and the integration suite stays green (6/6 passing across
analytics-sleep-stages.test.ts + source-priority-two-axis.test.ts).
Unit tests already cover the picker's internal edge cases (45 unit
across helper + algorithm); this file is the end-to-end guard.
Add an optional deviceType field to the POST /api/measurements/batch
payload schema. The iOS client maps each sample's HKDevice.model to a
canonical device class (watch | band | ring | phone | scale | other |
unknown); the server stores it on a new Measurement.device_type
column. The cross-source canonical picker (W8c) reads this column to
break Apple-Watch-vs-iPhone ties when the same source contributed
multiple devices for the same day.
Backward-compatible end-to-end:
- Schema field is optional; legacy iOS builds keep working unchanged.
- DB column is nullable; the migration is an additive ALTER TABLE
(Migration 0051, next free per v1.4.25 handoff section 4).
- The picker treats null as 'unknown' and only uses it as a
tiebreaker when a ranked device-type coexists in the bucket; with
a single source + null deviceType the row passes through as today.
OpenAPI: deviceType is documented on AppleHealthBatchEntry with
guidance on the HKDevice.model mapping. Batch endpoint description
gains a W8c note. iOS-Claude can lock the field name + enum from the
spec without re-reading source.
Tests: 45/45 source-priority tests pass; the batch route's existing
integration tests are untouched by the schema addition.
Pre-existing lint failure unrelated to this change:
src/components/i18n/maintainership-banner.tsx triggers a
react-hooks/set-state-in-effect warning; the file is currently
untracked and lives outside this Wave's scope.
Extend Settings → Sources with two collapsed-by-default expanders below
the existing global ladder (Marc no-split directive — one screen, three
vertically-stacked sections):
1. Per-metric overrides — surfaces every metric whose ladder has been
edited away from the default; each row reveals a one-click reset
so a single metric can roll back without touching the rest.
2. Device-type priority — the new W8c axis. Default ladder uses the
open-wearables-compatible 'watch beats phone beats scale' order;
up/down arrows + 'Reset device-types' keep the same interaction
vocabulary as the existing source ladder.
Save round-trips the W8c canonical shape (metricPriority +
deviceTypePriority); the server's parseSourcePriority already echoes
the resolved shape back so the optimistic update is a setQueryData.
Reset PUTs an empty body so the server returns constant defaults.
i18n: 11 new keys under settings.sections.sources.* (en + de). German
prefers Umlaute (Uhr, Geräte-Typ-Reihenfolge, individuelle Anpassungen).
Tests: settings-shell.test.tsx already gates the navigation
ordering — keeps passing (12/12). No new component tests yet; the
integration coverage in 8c.5 exercises the new shape end-to-end.
The `/api/insights/general-status` route was deleted as part of W3f
(commit edae569) when the general-status panel migrated into the
combined comprehensive insights payload. The matching query-key factory
+ its integrity test assertion were missed in that cleanup pass.
The test now pins `insightsBpStatus` instead — the locale-in-status-key
invariant remains covered.
The wrapper composed `<ComplianceHeatmap />` and `<ComplianceLineChart />`
into a single panel, but no surface ever rendered the wrapper —
analytics pages mount the two child charts directly. The children stay
intact; only the dead wrapper is removed.
The component was exported but never imported anywhere — only the file
itself contained the three references (the props interface, the
function declaration, and the type annotation). Likely an early scaffold
for the v1.4 medication-intake redesign that was superseded by the
intake-card layout before any caller wired it in.
No tests existed for this component, so the delete is single-file.
`<HeroStrip>` (v1.4.20 phase B1) replaced this component on every
surface. Last production import was retired with the dashboard rewrite
and only the deprecated source + its self-test remained. Comment
markers in `hero-strip.tsx` already note the migration.
Pre-existing typecheck failures in the working tree (locale enum drift
from concurrent W9e i18n work in medication-card / medication-form /
target-card) are unrelated to this delete and stay as-is.
After picking the canonical source for a day, the picker now consults a
device-type ladder and keeps ONLY the rows from the top-ranked device-
type present in the day's bucket. Rationale: Apple Watch + iPhone +
Withings scale all stream steps into HealthKit; summing the three
triple-counts the daily total. The watch is the most reliable signal,
so the picker keeps the watch rows and drops phone/scale.
Lookup order (high to low): deviceTypePriority[metricType] (per-metric
override) > deviceTypePriority.default (user default) >
DEFAULT_DEVICE_TYPE_PRIORITY (constant). When no device-type info is
present (legacy NULL rows / pre-W8c data), the second axis is a no-op
— every row in the bucket passes through. Custom ladders that don't
enumerate the bucket's device-types also fall through unfiltered, so
the picker never silently drops data.
SourcePickerRow gains two optional fields (deviceType, type). Both
default-null so existing call sites continue to compile.
Tests: 18 cases pass (8 single-axis legacy + 10 new W8c two-axis).
Covers per-metric override, custom ladder, legacy NULL fallback,
preference of known over unknown, and the W8c-over-W5e shape merge.
Layer two new optional keys on top of the existing User.sourcePriorityJson
shape — metricPriority (nested per-metric ladder, canonical going forward)
and deviceTypePriority (default ladder plus per-metric overrides). The
W5e flat top-level metric keys stay accepted as a backward-compat alias
so existing rows keep working without a DB migration or forced reconfig.
parseSourcePriority() now returns a fully-resolved shape where every
metric ladder is defaulted and the device-type axis defaults to the
documented DEFAULT_DEVICE_TYPE_PRIORITY (watch first). Two helpers
(getSourceLadder, getDeviceTypeLadder) centralise the lookup so the
picker, the UI and downstream call sites share one path.
Tests: 30 unit tests pass (up from 19). Covers both axes' parse,
fallback chain (W8c nested > W5e flat > defaults), enum guards, and
the two helper functions.
Pre-existing typecheck failures unrelated to this change:
src/lib/i18n/* and a handful of consumers expect a 'de' | 'en' locale
union but i18n config declares the 6-locale superset.
Settings and Admin pages render inside AuthShell, whose inner main
already applies `mx-auto max-w-[76.8rem] px-4 py-6 md:px-6`. Both
shells then re-applied `px-4 py-6 md:px-6 md:py-8` on their own
container, which produced visibly more top/bottom whitespace on
/settings/* and /admin/* than on /, /measurements, /medications,
/mood, /insights, /coach, and /notifications.
Drop the redundant padding from SettingsShell and AdminShell so the
visual top-of-page baseline matches the rest of the app. The
`mx-auto w-full max-w-screen-xl` wrapper stays for the wider
content column that these two surfaces opt into. Mobile chip-strip
`-mx-4` still cancels AuthShell's horizontal padding correctly.
Pixel-5 (393 px) audit flagged four primary navigation surfaces that
were below the 44 px touch-target floor:
- TopBar user-menu trigger and login link: text-only flex with
py-1.5 measured ~30 px tall. Added min-h-11 min-w-11 with a
rounded hit zone so the trigger lands on the WCAG floor without
visibly altering the icon's position.
- SettingsShell mobile section chip-strip and AdminShell mobile
section chip-strip: rounded-full chips with py-1.5 were ~30 px
tall. The mobile chip-strips are the primary navigation surface
for /settings and /admin on touch devices, so min-h-11 was added
to both.
- medication-form schedule interval picker (1W / 2W / 3W / 4W):
h-8 native buttons inside a 4-col grid; min-h-11 brings them up
to the floor without changing column width.
Also normalised the medications page-root spacing from space-y-5 to
space-y-6 to match dashboard / measurements / mood / notifications.
The Coach-Feedback admin section previously rendered four distinct top-
level structures across its loading / error / empty / data branches.
The loading branch was a thin flex-row stub without the heading, the
error branch was a p-4 alert with no card chrome at all, and only the
empty + data branches mounted the Sparkles icon + 'Coach Feedback'
title inside the bg-card border-border rounded-xl border p-6 wrapper.
The result: when the user clicked into /admin/coach-feedback the
section first measured at ~52 px (loading) and then snapped down to
its full height once the query resolved, shifting everything below it.
Mirror the canonical pattern used by SystemStatusSection: render the
outer card + heading unconditionally, and switch only the body between
spinner / alert / empty / table. The header now sits at a constant
y-offset from the section root regardless of fetch state.
Adds a regression test that asserts the 'Coach Feedback' heading and
the bg-card rounded-xl outer chrome are present in every render state.
Both heading variants (empty-state at line 126, header at line 139) now
follow the convention used by 21 other Settings sections: a Bell icon
(text-primary h-5 w-5) preceding the h2 in a flex container with
gap-2 items-center. Brings the only two violators in
src/components/settings/* into alignment with the icon-heading pattern
established in telegram-card.tsx, ntfy-card.tsx, web-push-card.tsx.
The 7 W5d additions (fat-free mass, fat mass, muscle mass, skin
temperature, pulse-wave velocity, vascular age, visceral fat) landed
in measurementTypeEnum but the per-type lookup maps and AI-chart
allowlist were never extended. Six drift-guard tests caught the gap.
Adds icons (Dumbbell for muscle; family-consistent reuse for the
rest), chart-bucket colors (mass → chart-1, fat/temp → chart-4,
cardio → chart-3, pulse-derived → chart-5), AI chart-token allowlist
entries, and orphan-enum strip list entries so raw enum identifiers
can never leak through prose. Doctor-report PDF holds the 7 new
types under the same v1.5 gate as the v1.4.23 Apple Health metrics
until clinical layout + reference ranges are agreed.
Baseline: 2537 → 2543 passing tests; typecheck clean.
Previous Claude session reached 79% context. Handoff for the new
session to pick up W8 → W13 cleanly without re-reading the full
phase-report trail. Lists exactly what's landed (all 23 waves
inventoried), what's still open (W8/W9e/W10/W11/W12/W13), the
critical Marc-Voice + branch-model + i18n conventions, the
in-flight test failures to expect, and the verbatim greeting for
the new session.
7 conflict files resolved by taking the upstream/HEAD side (which carries
the W4d/W5/W6c/W3f feature additions). Stashed-side changes that were
genuine new features re-land in subsequent commits if any were lost.
This sweeps up the loose state the W4e+W5e+W5d combined agent left
mid-stash. Resolves develop's working tree for clean push.
Closes the second half of the deferred phase-W4d test gap. The
picker is a controlled SVG with 8 click-targets wrapped in
invisible 22px circles carrying role=button + aria-label +
tabIndex=0 (WCAG 2.5.5 minimum-tap-target + keyboard-reachable).
The nextInjectionSite() helper return is dashed-ring annotated
over the recommended target.
Coverage (12 tests):
- All 8 InjectionSite enum values render as click-targets
- Active site gets the fill-primary highlight
- Recommended next-site is dashed-ring annotated
- onChange(site) fires for every enum value
- Keyboard navigation: tabindex=0 on every target
- ARIA labels EN + DE smoke (Umlaute round-trip)
- aria-pressed=true on the selected site, false on the others
- Empty history → recommender defaults to ABDOMEN_LEFT
- Caption swap between selection line and recommendation hint
- SVG body outline + role=group wrapper
No production code changes.
Closes the deferred component-level test gap from phase W4d
(`.planning/phase-W4d-v1425-glp1-full-report.md` section 5).
Renders the Glp1MedicationCard via renderToStaticMarkup with a
seeded QueryClientProvider so the useQuery calls resolve
synchronously, then asserts on the produced SSR string.
Coverage (13 tests):
- GLP-1 variant renders when treatmentClass === "GLP1"
- Default MedicationCard back-compat when treatmentClass is null
- Drug name + dose joined with the middle-dot separator
- Last + next injection lines with localised prefixes
- Dose-history <details> disclosure (closed by default)
- Injection-site rotation hint (last + recommended)
- Pen-inventory line + low-stock badge
- Side-effect quick-log button hands off the medication object
- AI-disabled state pin (no Coach hand-off button today)
- Inactive paused-since state
- German locale parity with Umlaute round-trip
No production code changes.
v1.4.25 W5d. The Withings Activity / Sleep endpoints sit behind the
`user.activity` OAuth scope which v1.4.24-and-earlier connections
never requested. This commit ships the scope plumbing + reconnect
surface so existing users can opt in to step / active energy /
distance / floors-climbed ingest with a single click.
- Schema: `WithingsConnection.scope` nullable column. NULL =
legacy connection that's never re-authed since v1.4.25.
- Migration 0050 — additive column add.
- Client: `WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity"`
+ `parseWithingsScope` / `hasActivityScope` helpers.
- Callback: persists `scope: WITHINGS_OAUTH_SCOPE` on every
successful exchange, so reconnecting upgrades the row.
- Status route: exposes `scope` + `hasActivityScope` so the UI
can render the reconnect banner conditionally.
- Settings → Integrations: paint a reconnect banner when the
user has an active connection AND `hasActivityScope === false`.
Click → /api/withings/connect → standard OAuth flow.
- i18n: EN + DE strings for the banner +
`measurements.types.{fatFreeMass,fatMass,muscleMass,
skinTemperature,pulseWaveVelocity,vascularAge,visceralFat}`.
- 5 new unit tests covering the scope helpers.
Note: the Activity sync routine itself
(`POST /v2/measure?action=getactivity`) lands in v1.4.26. Shipping the
scope upgrade now means users only reconnect once instead of twice.
Cross-source dedup protection is already in place via the W5e
foundation (`pickCanonicalSourceRows`): once Activity sync lands AND
a user also has Apple Health passthrough (v1.5), the per-day picker
prevents double-counting cumulative metrics from two sources.
Records the four polish-bar deliverables (per-card target-edit cog,
comparison-overlay grey-out, orphan general-status endpoint cleanup,
per-night sleep-stages stacked bar) with their commit SHAs,
file lists, verification results, and notes on the parallel-agent
commit-message collision that swept Win 1 into d74aa20.
Marc directive 2026-05-14 — the /insights/schlaf chart now shows one
bar per night with DEEP / REM / CORE / AWAKE / IN_BED / ASLEEP stacked,
matching Apple Health's sleep tab. The 30-day average-composition
read is preserved as a fallback when the API doesn't yet ship
perNight (legacy clients during rollout).
Plumbing:
- src/app/api/analytics/route.ts: computeSleepStageBreakdown() also
builds a per-Berlin-tz-day perNight array, sorted ascending. Empty
days drop out — the chart treats them as gaps.
- src/components/insights/sleep-stage-stacked-bar.tsx: rewrites
the visualisation. Default window = 7d; 14d + 30d toggles in the
card header (same min-h-11 touch targets as the dashboard range
tabs). Y-axis renders hours, tooltips show per-stage minutes +
percent + nightly total.
- Mobile-first: window-toggle pills reflow under the title on <sm
screens, sit inline on sm+. Bars use Recharts vertical layout
(per the directive — no horizontal reflow on mobile, just tighter
XAxis labels via preserveStartEnd).
i18n: no new keys — reuses insights.sleep.stages.* + the existing
compositionTitle / compositionSubtitle / compositionAriaLabel slots.
Tests: 4 new cases under sleep-stage-stacked-bar.test.tsx cover
- 7d default + three toggle buttons rendering
- empty-state (perNight=[] AND stages={} → 'sleep-stage-empty' slot)
- legacy fallback (no perNight → aggregate row still renders)
- sparse perNight (gaps don't crash the chart)
Existing 4 tests (title + aria + de locale + unknown stage key)
keep passing after perNight prop addition.
Seven new enum values for v1.4.25 W5d. Source: Withings developer
guide — Data API → All Available Health Data — cross-referenced
against `.planning/research/withings-api-coverage.md` §3.
- FAT_FREE_MASS (meastype 5) — Body+, Body Cardio, Body Comp, Body Scan
- FAT_MASS (meastype 8) — same devices; mass form of BODY_FAT
- MUSCLE_MASS (meastype 76) — Body+ family
- SKIN_TEMPERATURE (meastype 73) — ScanWatch dermal reading. DISTINCT
from BODY_TEMPERATURE: surface temps run ~32 °C, core ~37 °C, so
they cannot share a bucket without corrupting analytics.
- PULSE_WAVE_VELOCITY (meastype 91) — Body Cardio / Body Scan;
indicator of arterial stiffness
- VASCULAR_AGE (meastype 155) — Body Scan; composite of PWV + age
- VISCERAL_FAT (meastype 170) — Body Comp / Body Scan; Withings'
1–12 rating scale (not a percent)
Migration 0049 is additive only — every value is unconditional, no
backfill needed. The Withings client picks these values up once the
meastype → enum mapping lands (separate commit in this phase).
Unit map + plausibility ranges updated in `src/lib/validations/
measurement.ts` so the create-measurement validator accepts the new
types end-to-end.
Final-pass hygiene for the W4d GLP-1 integration:
- `buildGlp1SnapshotBlock()` and `detectGlp1Plateau()` short-circuit
to `null` when the test environment mocks prisma partially (only
`measurement` / `moodEntry` mocked, `medication` undefined). Matches
the production behaviour for accounts without an active GLP-1 row.
- DE locale parity: adds the matching German string for
`insights.subPage.therapyTimelineDescription` that landed without
its de.json counterpart.
- Drops unused `locale` destructure on the GLP-1 medication card and
the dead `DailyValueRow` interface in the snapshot helper.
- Regenerated `docs/api/openapi.yaml` so the new GLP-1 endpoints
(/api/medications/[id]/glp1, /api/insights/glp1-timeline) appear
in the spec.
Phase 10 of W4d GLP-1 full integration. The doctor-report PDF gains a
dedicated "GLP-1 therapy" section when the patient has at least one
active GLP-1 medication and the existing compliance toggle is on. The
section lists what a prescribing clinician needs at every follow-up:
- Weight delta over the report window (start → end → delta)
- Per-medication block: drug name, current dose + start date,
compliance count and rate, full titration history table
(date | dose | note)
- Side-effect frequency table (last N days, counts per tag) when the
mood section is also enabled (mood is the data source)
Generic accounts (no GLP-1 medication) get `glp1: null` from the
aggregator and the PDF skips the entire section, so existing reports
stay byte-identical.
Aggregator extension lives in `doctor-report-data.ts` — eager-loads
`doseChanges` + the most recent intake event on every active
medication (cheap, indexed); GLP-1 specifics only assemble when at
least one GLP-1 row exists. The field is optional on the type to
preserve compatibility with existing test fixtures.
DE + EN copy for the report section lives under `doctorReport.glp1*`.
Marc directive 2026-05-14 — when a chart has a comparisonBaseline
selected but the prior period (lastMonth / lastYear) carries zero
overlay-able rows, the chart settings cog now shows the comparison
buttons in a visibly disabled state (opacity-50, aria-disabled, native
title tooltip, and a sub-label hint) instead of silently rendering
nothing. The 'none' button always stays enabled — it's the escape
hatch.
Plumbing:
- chart-overlay-controls.tsx: new `hasComparisonData` prop (defaults
to true so legacy callers keep their behaviour). The popover body is
extracted as `<ChartOverlayControlsBody>` so unit tests can render
it without Radix's portalled popover stripping it out of the SSR
output.
- health-chart.tsx + mood-chart.tsx: thread the already-computed
`hasComparisonData` memo (line 768 / 457) into the controls.
- medication-compliance-chart.tsx: passes `hasComparisonData={false}`
unconditionally because the compliance heatmap never paints a
prior-period overlay (already documented in the existing N/A
caption block).
Tests: 5 new cases under chart-overlay-controls.test.tsx cover the
normal / greyed / none-baseline / locale / default-prop branches.
The pre-W3f assertion suite (3 tests) keeps passing unchanged.
i18n: appended chart.overlay.controls.comparisonUnavailable to both
locales — no key restructure.
Phase 9 of W4d GLP-1 full integration. New `detectGlp1Plateau()`
helper flags the clinical plateau pattern for GLP-1 receptor agonists:
≥21 days on a stable dose AND weight delta within ±0.5 kg over that
same window. When the rule fires, a SYSTEM CONTEXT block is appended
to the insight-generation user prompt with the named drug, current
dose, days-on-dose, and weight delta — and instructs the model to
emit a glp1_plateau keyFinding framed observationally.
GROUND RULE 14 (insight prompt) keeps the safety contract: the
finding NAMES the plateau and the conversation it should prompt
("worth mentioning at the next visit"), but NEVER recommends a dose
change. The plateau is detection, not prescription.
Detector returns `null` when no GLP-1 medication is active OR no
plateau condition is met, so 99% of users pay zero token cost and
never see a plateau-shaped section in their briefing.
The schema gains "glp1_plateau" in the keyFinding.sourceMetric enum
(landed earlier this phase) and the daily-briefing icon mapping
inherits the Pill icon for visual consistency with other
medication findings.
The `windowStart` / `windowEnd` columns on `medication_schedules` were
the last hold-outs using the camelCase identifier from 0001_init.sql;
every other column on the table already follows the snake_case
convention. Adding `@map("window_start")` / `@map("window_end")` aligns
the SQL identifier without changing the Prisma client surface — every
read/write site continues to use the TypeScript-side `windowStart` /
`windowEnd` names verbatim.
Migration 0047 renames the columns in place — no data movement, no
index churn, no FK changes. Forward-only.
Phase 8 of W4d GLP-1 full integration. New TherapyTimeline component
mounts on /insights/medikamente below the compliance grid. Renders a
chronological merge of every GLP-1-relevant event the user has logged:
dose changes (with up/down arrow against the previous step),
injections (with optional site), inventory events, and side-effect
days collapsed from MoodEntry tags.
Self-hides for users without a GLP-1 medication (the API returns
`hasGlp1: false`), so /insights/medikamente keeps its v1.4.24 look
for everyone else.
New GET /api/insights/glp1-timeline aggregates the data. Caps at 60
entries by default (configurable to 200 via ?limit) to keep the list
readable; older events surface via the existing intake-history page.
DE + EN copy for the surface lives under insights.therapyTimeline.
The medikamente sub-page description gains a one-liner explaining
when the timeline appears.
The HTTP route had no remaining UI callers — the v1.4.16 polish replaced
the <InsightStatusCard> on the /insights overview with the polished
<InsightAdvisorCard>, and the per-metric sub-pages each consume their
own dedicated endpoint (/api/insights/{weight,bmi,pulse,blood-pressure,
medication-compliance}-status). The lib helper generateGeneralStatusForUser
remains in src/lib/insights/general-status.ts because the reminder
worker still pre-warms the daily cache for users opted in to the
briefing notification (src/lib/jobs/reminder-worker.ts).
Verification:
- grep confirms zero callers under src/ outside the route file itself
- the route was not registered in src/lib/openapi/routes.ts and
docs/api/openapi.yaml carries no general-status path
- pnpm typecheck remains clean
Adds a 'What it is' section between the badges and the existing 'Why
HealthLog?' paragraph so a stranger landing on the repo sees the
product proposition in under 30 seconds without scrolling. The section
gives a two-paragraph product description and a direct CTA to the
live demo, addressing the marketing-checklist gap.
Also reframes the Quick Start lead from 'Plan ~5 minutes' to
'3 minutes from git clone to a working install' so the install-time
promise lives next to the install instructions.
Phase 5 of W4d GLP-1 full integration. The body-map picker is the
visual surface for the 8-zone InjectionSite enum that landed with
migration 0046. Stylized front-of-body SVG outline with 8 click-target
dots — head/neck/torso/arms/thighs for orientation, abdomen
quadrants centred on the navel reference per Eli Lilly's IFU rotation
zones, plus thighs and upper arms.
UI behaviour:
- Active site fills with the primary colour
- Recommended next site shows a dashed primary-coloured ring
- Each <button> hit area is 22px radius so the target meets WCAG 2.5.5
even though the visible dot is 12px
`nextInjectionSite()` is a pure function that maximises the average
Euclidean distance from the candidate to the last N=4 picks, with a
recency penalty for sites used most recently. Untouched sites beat
stale ones via the recency-bonus tie-break. Empty history returns
ABDOMEN_LEFT as a sensible first-time default (the card always has
something to point to).
Unit tests cover: never-recommends-most-recent invariant, sensible
choice after a left-cluster sequence, rotation through unused sites,
i18n key shape, coordinate-table completeness.
Renders a dedicated tile for users on a GLP-1 receptor agonist
(Mounjaro, Ozempic, Wegovy, Zepbound, …) — the new W4d schema enum
`Medication.treatmentClass = GLP1` is the discriminator. Tile self-
hides when no active GLP-1 med exists so accounts without one see no
visual change.
The tile surfaces:
- Drug name + current dose ("Mounjaro 7.5mg") — sourced from the
`MedicationDoseChange` titration history (latest row wins), so a
Mounjaro user titrating up 2.5 → 5 → 7.5 → 10 mg sees their
current step, not the starting dose.
- Last injection date + weekday ("Sun, 05/10")
- Next injection date + weekday + countdown
("Sun, 05/17 (in 3 days)") — predicted from the existing schedule
cadence
- Weight delta since starting the therapy ("−4.2 kg since start"
/ "−4,2 kg seit Beginn") — green chip on loss, orange on gain,
muted minus on flat (within ±0.1 kg)
- A compact weight chart with vertical injection-day markers via
the new `<HealthChart verticalMarkers={…} />` prop
New server endpoint `GET /api/dashboard/glp1` aggregates the data:
the heavy lifting (current dose / last + next injection / titration
history / inventory math) is delegated to the existing
`buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
the dashboard tile read from the same shape. The route adds the UI-
facing extras (weight series since first dose-change, current vs
start, injection date keys for the markers) on top.
Mount: dashboard mother page (`src/app/page.tsx`) mounts the tile
below the AI insights preview and above the chart row — high enough
that a Mounjaro user sees their dose-response without scrolling past
BP / pulse, but below the AI preview which stays the scroll-stop
hero anchor.
Constraint respected (Marc directive 2026-05-14): the chart lives on
the Dashboard tile and the Insights /medikamente sub-page only; the
Medications page itself stays chart-free.
i18n keys: dashboard.glp1.{title, lastInjection, nextInjection,
weightDelta, inDays, empty.title, empty.description} in EN + DE.
Tests pin 7 scenarios: hidden when no active GLP-1, hidden when meds
array is empty, skeleton during fetch, full render for a Mounjaro 7.5
mg weekly profile, latest titration step wins (10 mg, not 2.5 mg),
fallback to name-only when no dose history exists, German copy under
the de locale.
Phase 4 of W4d GLP-1 full integration. New `Glp1MedicationCard`
variant renders inside the existing medications grid alongside generic
cards — same Card shell, same compliance bars, same primary intake
actions. The GLP-1-specific rows sit between the dose line and the
compliance bars so the user reads top-down: drug → injection state →
adherence.
Marc directive 2026-05-14: NO chart on the medication card. The card
stays text-rich:
- Headline: drug name + current dose ("Mounjaro · 7.5 mg")
- Last injection line with optional site ("Last: Mo May 12 · Abdomen,
lower left")
- Next injection line with day count ("Next: Mon, May 19 (in 5 days)")
- Rotation hint ("Last site: Abdomen left · Recommended:
Abdomen right") — only when the last site differs from the
recommended next site
- Pen-inventory line with low-stock badge when dosesPerUnit is set
- Collapsible "Dose history" details block listing every
MedicationDoseChange row
- "Log side effect" quick-button alongside Taken / Skipped
Routing: the medications page reads `treatmentClass === "GLP1"` and
dispatches to the variant. Existing meds (treatmentClass = "GENERIC",
the migration 0046 default) render the unchanged generic card.
Companion pieces:
- `injection-sites.ts` — the 8-site enum + `nextInjectionSite()`
recommender that maximises Euclidean distance to recent picks and
beats stale sites in the tie-break.
- `/api/medications/[id]/glp1` — GLP-1 details endpoint feeding the
card (dose changes + recent intakes + running inventory math).
POST creates a dose-change or inventory event.
`coach.budget.exceeded` (per-user daily token cap, returned as JSON
429) now surfaces the dedicated "Daily limit reached; resets at 00:00
UTC." copy. `coach.provider.rate_limited` (every provider in the
fallback chain landed on 429, streamed as an SSE error frame) surfaces
"Provider temporarily rate-limited; retry in ~5 min." Other provider
failures keep the generic copy. The chat route emits the new
rate-limited code; the send hook decodes the JSON 429 envelope's
structured `error` field so the drawer can route the bubble copy.
Phase 3 of W4d GLP-1 full integration. The form gains a small
"Treatment class" selector (GENERIC | GLP-1 injection) sitting under
the existing clinical-category dropdown. Picking GLP-1 reveals two
helpers that stay hidden for every other medication:
- "Once weekly on …" preset — a single-tap weekday row that writes
daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals
the advanced block so the user can still tune the window. The last
weekday picked persists in localStorage so a follow-up med starts on
the same day.
- "Doses per pen / vial" optional input that powers the inventory math
(Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
pen = 1). Leaving it blank keeps inventory tracking off.
The wire schema gains optional `treatmentClass` and `dosesPerUnit`
fields. Both default sensibly (GENERIC + null) so the v1.4.24 client
keeps working untouched.
Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not
crowd the form. The new affordances live behind the treatment-class
toggle — for every other med the form is byte-identical to v1.4.24.
The composer starts at a single-line height (matching the disclaimer
text on the left of the row) and grows with content up to six lines.
Past that point the textarea scrolls internally so the rest of the
drawer never moves. Implementation is the standard scrollHeight
measurement — reset to "auto", read scrollHeight, clamp to
[lineHeight + padding, 6 × lineHeight + padding]. The pure
`computeAutoGrowHeight` helper backs the effect for unit testing.
Adds an optional `verticalMarkers` prop to `<HealthChart>` so the
v1.4.25 GLP-1 dashboard tile + the Insights /medikamente sub-page can
paint dashed vertical reference lines at each recorded injection day.
Each marker = `{ date: 'YYYY-MM-DD', label?, color? }`. Defaults to
the strip-tile green (`#50fa7b`) so a row of markers reads as
"injection cadence" at a glance; callers can override per marker.
Rendering:
- Recharts `<ReferenceLine>` with `strokeDasharray='3 3'`,
`strokeOpacity=0.55`, no label by default (inline labels would
crowd a once-weekly cadence over a 90-day window).
- A small filled `<ReferenceDot>` (r=3) at the y-axis minimum so
the markers also read along the x-axis baseline.
- Off-window markers silently drop via `ifOverflow='discard'`,
matching the existing storyboard-annotation contract.
The marker math is factored into a pure helper
`resolveVerticalMarkerPositions` so the unit suite can pin behaviour
without mounting Recharts. Day-keys are matched by EXACT string
comparison against `chartData[i].date` (no ±7-day snap like the
storyboard annotations) — drawing a marker on a day the chart has no
data point reads as a rendering bug rather than as orientation.
Tests pin: empty input, undefined input, single in-window match,
multi-marker order, caller-supplied color/label overrides, off-window
drop, sparse weekly-bucket exact-day matching.
The mic was rendered + disabled with a "voice arrives with iOS"
tooltip, drawing taps that did nothing. Voice input remains scheduled
for the iOS client (v1.5); the web composer no longer surfaces a
placeholder. Frees the row for the Enter-to-send hint to breathe.
All three header actions (new chat, settings, close) now share the
same ghost / size-icon / 36-px hit-target shape with a 16-px icon and
identical color treatment. The Sheet's default absolute-positioned
close X is gone; the inline close button sits on the same baseline as
the others so the cluster reads as a single set.
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
Phase 2a of W4d GLP-1 full integration. New `buildGlp1SnapshotBlock()`
helper reads every active medication classified as GLP1 (treatmentClass
column from migration 0046) and assembles a compact block the snapshot
ships under `weeklyContext.glp1` — drug display name + generic active
ingredient, current dose + weeks-on-dose, chronological titration
history, weekly cadence (parsed from MedicationSchedule.daysOfWeek),
last + next injection date, last injection site, pen inventory math,
and side-effect tag counts from the last 14 days of MoodEntry rows.
The block is omitted entirely for users with no GLP-1 medications (the
helper short-circuits to null after one indexed Prisma lookup), so
web-only generic accounts pay zero token cost.
Marc directive 2026-05-14: the Coach must say "your Mounjaro 7.5 mg"
instead of "your medication" when the user has named the drug. The
snapshot is the load-bearing surface for that — the system-prompt
guardrail (GROUND RULE 9) lands in the follow-up commit and forbids
any dose-prescriptive use of this data.
setupWebhook now subscribes to appli=1 (weight + composition),
appli=2 (temperature), and appli=4 (pressure family: BP, pulse, SpO2)
in one pass. The previous implementation only registered appli=1, so a
new BP reading from a BPM cuff sat for up to an hour waiting for the
hourly poll fallback instead of arriving via push within seconds.
A failure on one category is logged and we proceed with the rest —
losing one webhook is strictly better than rolling back all three.
The disconnect path mirrors the same iteration so cleanup unsubscribes
every category we ever subscribed to. Webhook handler already
dispatches `syncUserMeasurements` regardless of appli, so no change
needed on the receiver side.
Exports `WITHINGS_NOTIFY_APPLIS` as the single source of truth so the
unit test and the disconnect route stay in lockstep with setupWebhook.
The ScanWatch family reports VO2 max under meastype 123 in
mL/(kg·min), which matches HealthLog's canonical DB unit verbatim
(no conversion needed beyond the standard value × 10^unit exponent
decode). Targets the VO2_MAX enum slot added in v1.4.23 for the
Apple Health passthrough.
Older Withings firmware and some partner SDK examples report SpO2
under meastype 35 instead of 54. Mapping both into OXYGEN_SATURATION
prevents a quiet drop on legacy devices and is compatible with mixed
fleets (the existing meastype 54 path stays in place).
Current-gen Withings Thermo and ScanWatch series report core body
temperature under meastype 71 (the legacy meastype 12 stays in place
for first-gen Thermo). Both decode through the same value × 10^unit
path and target the BODY_TEMPERATURE enum slot.
Skin temperature (meastype 73) stays out until v1.4.26 ships a
SKIN_TEMPERATURE enum — surface temps (~32 °C) and core temps (~37 °C)
must not share a rollup or correlation analytics will collapse.
Pins the comparison-overlay shift across the edges Marc flagged worry
about. The existing `shiftDailySeriesForward` implementation is correct
— integer-day forward addition, no DST surprises because every input
timestamp is anchored at UTC-noon (well clear of the 02:00-03:00 wall-
clock jump). No production fix needed; this is a regression-floor
suite so a future refactor cannot silently change the contract.
Pinned scenarios:
- DST forward shift (Mar 31 2024 Berlin spring-forward) — calendar
day preserved
- DST backward shift (Oct 27 2024 Berlin fall-back) — calendar day
preserved
- Leap-year alignment — Feb 29 2024 + 365 days lands on Feb 28 2025
(the contract is "365 days ago", not "same calendar date last
year")
- Insufficient prior data (60 days history + `lastYear` request) →
overlay renders empty so the chart drops the comparison cleanly
without rendering a "kein Vergleich" placeholder (per Marc directive
2026-05-14)
- Partial prior-period coverage flags `hasComparisonData = true`
- Tile-caption math (`averageValue` + `computeComparisonDelta`)
returns null cleanly when the prior window is empty
First-gen Withings Thermo (WBT01) reports body temperature under
meastype 12. The current-gen Thermo ships meastype 71 (next commit).
Both decode through the same `value * 10^unit` path the client already
applies, and target the `BODY_TEMPERATURE` enum slot we added in v1.4.23
for the Apple Health passthrough.
Adds `src/lib/withings/__tests__/client.test.ts` with a synthetic
`getmeas` payload covering the exponent decode, unknown-meastype skip,
and two-decimal rounding. Re-exports MEASURE_TYPE_MAP so the test can
assert the mapping directly without importing internals.
Phase 1 of W4d GLP-1 full integration. Forward-only additive migration: a
new `medication_category` enum (GENERIC | GLP1) lands as
`medications.treatment_class` (defaults to GENERIC so existing rows render
exactly as before). `medications.doses_per_unit` lets pen/vial-tracked
meds carry their dose count. `medication_intake_events.injection_site`
captures the 8-zone body-map pick — nullable; non-GLP-1 meds never set
it. `medication_dose_changes` holds titration history (Mounjaro 2.5 → 5
→ 7.5 → 10 → 12.5 → 15 mg per Eli Lilly's PI). `medication_inventory_events`
is the append-only ledger backing the low-stock dashboard caption.
Naming nuance: the new column is `treatmentClass`, not `category`, because
the existing `medication_categories` side-table already owns the
`category` slot in the API response (clinical taxonomy:
BLOOD_PRESSURE / VITAMIN / ...). Two orthogonal concepts, two columns.
The Settings → Dashboard sub-section's Save button silently 422'd once
the user had touched any per-chart overlay popover. Root cause was a
Zod v4 semantics change: `z.record(z.enum(KEYS), ...)` now requires
every enum key to be present in the input, where Zod v3 treated the
same shape as a partial record. Real-world Save payloads only carry
the chart keys the user actually toggled, so every Settings → Dashboard
Save attempt with one or two `chartOverlayPrefs` entries failed with
`expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
the toast surfaced "Layout konnte nicht gespeichert werden".
Fix uses `z.partialRecord(z.enum(KEYS), ...)` which preserves the
original v1.4.18 intent — per-chart opt-in overlay prefs, missing keys
filled by the resolver. The inner object now also accepts the
`comparisonBaseline` field so a Settings save preserves the per-chart
comparison toggle the user set via the chart-card popover (otherwise
Zod's default strip behaviour wiped it on every save).
New integration suite pins:
- partial chartOverlayPrefs round-trips
- per-chart comparisonBaseline survives a Settings save
- empty chartOverlayPrefs still persists (fresh accounts)
- widget toggle changes land in the DB
Adds src/lib/withings/mapping.md as the single source of truth for the
meastype → MeasurementType table that the client.ts MEASURE_TYPE_MAP
mirrors, and .planning/research/withings-api-coverage.md as the full
audit of the Withings public API surface (Measure, Sleep, Heart,
Activity, Workouts, User, Notify) against HealthLog's ingestion.
The audit scores each gap on user-value / effort / schema-impact and
slots additions into v1.4.25 (cheap mappings), v1.4.26 (sleep + activity
sync, body composition expansion) and v1.5 (ECG, AFib, workouts).
Each row in "What I'm looking at" / "Worauf bezieht sich das?" was
prefixed with `kv.label` — the same framing the disclosure heading
already supplies. The label is gone; rows lead with the value + unit
and keep the window as a parenthetical tail.
The send hook now surfaces the user's message as an optimistic bubble
the moment send fires, so the chronological render order matches the
user's mental model: user message → "Thinking…" placeholder →
assistant reply. The optimistic copy drops as soon as the persisted
twin lands via the invalidate-refetch the SSE `done` frame triggers.
Up to v1.4.24 the snapshot's day-key and weekday labels ran through
utcDayKey() / utcWeekday() / isoWeekKey(), so a 23:50 Pacific/Auckland
reading was filed under the next UTC day and the Coach replied with
the wrong calendar. The pipeline now reads User.timezone (alongside
the existing coachPrefsJson read so the read budget is unchanged) and
threads it through every bucketing helper (dailyMeans, bucketWeekly,
buildDailyValueRows, buildDailyBpRows) plus the medication-compliance
day-roll-up and the Apple Health additive blocks.
The prompt-budget calculator (src/lib/ai/coach/budget.ts) is unaffected.
The ledger still uses UTC midnight for spend roll-over because the
operator's LLM bill cycles on UTC; only the SNAPSHOT itself shifts to
the user's calendar. The provenance counts are row counts, not day
counts, so no provenance drift either.
Integration test pins the boundary: a 13:00 UTC May 14 reading (=
01:00 NZST May 15) appears under "2026-05-15" in the Coach snapshot
for a Pacific/Auckland user. The same instant in Berlin would land on
May 14; the test asserts the snapshot does NOT bucket there.
Existing 88-test coach unit suite stays green (the mocked user row
returns no timezone, so the snapshot falls back to Europe/Berlin and
the v1.4.24 byte-shape holds).
The W7 timezone agent committed an openapi/routes.ts change without
running pnpm openapi:generate. This regeneration brings the committed
yaml back in sync (cosmetic whitespace normalisation on multi-line
descriptions — no schema changes from this run).
Adds the labels the v1.4.25 W4 sub-pages reference:
- insights.navOverview / insights.navSleep — two new tab pills on
the strip ("Overview" + "Sleep" / "Übersicht" + "Schlaf").
- insights.subPage.{blutdruck,gewicht,puls,stimmung,medikamente,bmi}
Description — one-line scaffold under each sub-page heading,
matching the Apple Health metric-page convention.
- insights.subPage.stimmungEmpty{Title,Description,Action} +
medikamenteEmpty{Title,Description,Action} — CTAs into /mood and
/medications when the corresponding sub-page has zero data.
- insights.sleep.* — title, description, headline caption, stage
labels (deep/REM/core/asleep/awake/inBed), composition title +
subtitle + ARIA label, plus an empty-state group pointing at
/settings/devices for the v1.5 Apple Health sync teaser.
i18n-locale-integrity.test.ts keeps the EN + DE key set in lock-step;
no changes there because the new keys land identically in both files.
After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.
What's gone from the page (now lives on the matching sub-page):
- Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
pulse/mood/meds/bmi) with their target-zone + value-band wiring.
- Per-metric status-card queries (blood-pressure-status,
weight-status, pulse-status, bmi-status, mood-status,
medication-compliance-status).
- All the per-section <InsightStatusCard> + scatter-correlation
cards (BP × continuity, weight × BP, mood × BP, mood × weight).
- The bulky helper functions (getBloodPressureSectionStatus,
getWeightSectionStatus, …, classifyRangeValue) and their imports
(getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
- The inline <InsightsSectionNav> mount — superseded by the strip
that <InsightsLayoutShell> now mounts.
What stays here:
- HeroStrip (greeting, weekly-report banner, Health Score panel).
- DailyBriefing (the AI morning paragraph).
- CorrelationRow (cross-metric hypotheses).
- TrendsRow (BP / weight / mood mini-charts above each annotation).
- InsightAdvisorCard (the rich recommendations grid).
- CoachDrawer — mother-page-only, per Marc's directive.
The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.
trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.
The v1.4.23 schema added the SleepStage enum + Measurement.sleepStage
column for HealthKit per-stage rows, but the UI never surfaced them.
This commit adds the missing surface.
Components:
- <SleepDurationChart>: thin wrapper around <HealthChart> with
chartKey="sleep" so the per-chart cog (trend / target / compare)
works the same way as the dashboard charts.
- <SleepStageStackedBar>: horizontal Recharts stacked bar covering
the trailing-30-day stage distribution (DEEP / REM / CORE /
ASLEEP / AWAKE / IN_BED). Uses the /api/analytics sleepStages
payload that v1.4.23 W2 already exposes; no new endpoint.
- <SleepOverview>: composition that arranges the headline
"X h Y min per night" card, the stage-composition stacked bar,
and the duration trend chart. Empty state when the user has zero
sleep rows points at /settings/devices with a v1.5 Apple Health
sync teaser.
The sub-page itself (src/app/insights/schlaf/page.tsx) is trivial —
<SubPageShell> + <SleepOverview>. All data-fetching + empty-state
handling stays inside the components so a future v1.4.26 refinement
can iterate without touching the route.
Tests: <SleepStageStackedBar> covers the i18n titles (EN + DE), the
ARIA label that announces the nights count, and resilience against
unknown stage keys.
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).
Contract:
- Body: { externalIds: string[] } with min 0, max 500 entries.
- Auth: requireAuth() — session cookie or bearer.
- Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
failure — matches the ingest path's symmetric tolerance).
- Over-cap: 422 with errorCode "measurement.delete.too_large" so
the iOS client can surface a specific diagnostic.
Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.
OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
i18n-locale-integrity now auto-discovers every messages/<locale>.json
instead of hardcoding en + de. When W9e lands fr/es/it/pl, the parity +
no-empty + no-TODO + no-duplicate-keys checks extend automatically.
fallback-chain.test.tsx documents the three-level resolver contract
(active locale → EN → raw key). Level 2 stays as a skipped contract
note since the integrity test makes it unreachable at runtime — every
key is guaranteed present in every locale before CI accepts.
Carves the v1.4.24 monolithic /insights page into routed sub-pages
under /insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi}.
Each sub-page renders its metric chart with the per-chart cog
(chartKey from CHART_OVERLAY_KEYS) and the existing AI status card;
empty states surface a CTA where the chart can't compute (BMI without
height, mood with zero entries, medication adherence without a tracked
medication).
The sticky tab strip moves into src/app/insights/layout.tsx via the
new <InsightsLayoutShell>, which also owns the advisor query so the
regenerate affordance stays reachable from every Insights surface.
The tab strip itself is now route-aware (<Link> + usePathname()) and
exposes an "Overview" pill for the mother page. The CoachDrawer stays
in the mother-page body only — per Marc's directive, navigating to a
sub-page must unmount the drawer.
Shared infrastructure:
- src/lib/insights/sub-page-metric.ts: slug → MeasurementType[] map
+ SUB_PAGE_SLUGS canonical order.
- src/hooks/use-insights-layout-prefs.ts: reader for the dashboard-
layout payload so every sub-page picks up the comparisonBaseline
without re-fetching.
- src/components/insights/sub-page-shell.tsx: title + optional badge
+ focusable h1 (a11y — screen-reader focus restoration on tab
navigation).
- src/components/insights/insights-layout-shell.tsx: the layout's
client shell that mounts the strip + threads the regenerate handler.
The 7th sub-page (/insights/schlaf) ships in the follow-up W4c commit
together with its sleep-stage chart components.
Documents the EN/DE maintained tier vs FR/ES/IT/PL AI-initial community-
maintained tier. Includes the PR workflow for fixing an existing locale,
adding a new one, a translator style guide, and a forward reference to
the self-hosted Weblate plan tracked for v1.6.
Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.
Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.
The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.
Tests: 109 chart-component tests stay green.
`/admin/login-overview` is the only card on its dedicated route since
v1.5 phase-4b. The Collapse/Expand toggle inherited from the v1.4
shared-admin page therefore hid the entire page contents on click,
serving no purpose beyond clutter. Drop the `expanded` state, the
header toggle button, and the `expanded && (…)` body wrapper; the
section always renders. The two queries lose their `enabled:
expanded` gate (a no-op now that they always run). The
`settings.collapse` / `settings.expand` i18n keys stay so future
surfaces can opt back in without re-introducing the strings.
A regression test mirrors the `api-token-no-collapse` pattern: no
"Collapse"/"Expand"/"Einklappen"/"Ausklappen" buttons, no
`aria-expanded` on the section heading, and the filter pills paint
unconditionally in SSR.
The legacy export emitted snake_case English headers and raw UTC
timestamps which Excel/LibreOffice misread once the `Z` suffix was
stripped (issue #167 territory) and forced admins to mentally map
`actor_username` → "Marc" every time they triaged a row. The export
now mirrors the on-screen column order — `timestamp → user → IP →
location → provider → outcome` — with `action` + `details`
appended so the full audit payload still rides along. Headers are
translated through the active locale; timestamps go through
`formatInUserTz(..., "iso-with-offset")` so they round-trip
correctly in any spreadsheet; `toCSV` now treats `\r` as an escape
trigger alongside `\n,"` so Windows-newline detail strings stay on
their row.
`buildAuditLogCsvRecords` and `auditLogCsvHeaderLabels` live in
`_shared.tsx` so the column-order/escape contract is pinned by a
pure unit test independent of the React render.
Closes the loop on the "Metric Pressure_Sys" leak Marc reported on
the Einschätzungen surface 2026-05-14. The stripChartTokens regex
extension cleans up render-time output, but the model was free to
keep emitting raw enum identifiers turn after turn. This commit
teaches the generator to stop emitting them in the first place.
Insight-generator (DE + EN) gets GROUND RULE 13:
- Lists every banned identifier verbatim — Pressure_Sys,
BLOOD_PRESSURE_SYS, PULSE_BPM, MOOD_SCORE,
MEDICATION_COMPLIANCE_PCT, plus the v1.4.23 Apple Health surface
(HEART_RATE_VARIABILITY, RESTING_HEART_RATE,
ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED,
WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE,
SLEEP_DURATION).
- Enumerates the exact user-facing fields the ban applies to —
summary, recommendations[].text, findings, dailyBriefing,
trendAnnotations.*, weeklyReport.*, storyboardAnnotations.
- Carves out the contract identifiers the parser reads
(metricSource.type, sourceMetric, trendAnnotations keys) so the
JSON contract stays intact — the ban applies ONLY to prose.
- Documents the natural-language labels to use instead ("your
systolic", "your weight", "your medication adherence", …).
Coach system-prompt (DE + EN) gets the matching GROUND RULE 8 with
the same banned list, the same prose-only carve-out, and an extra
clarification that evidence-block labels stay human-readable
("avg30 systolic", NOT "BLOOD_PRESSURE_SYS_AVG_30") while the
window slot keeps its documented contract vocabulary.
PROMPT_VERSION 4.23.0 → 4.24.0. The rule is genuinely new (neither
prompt previously mentioned the enum identifiers in any form), so
the bump reflects the additive behavioural change. Existing pin
assertions in coach-prompt-v423.test.ts + coach/system-prompt.test.ts
loosen from `/^4\.23\.\d+$/` to `4.{>=23}.x` so future additive
ratchets don't force a hostile test rewrite each time.
New unit tests pin GROUND RULE 13 + 8 by the banned-identifier
strings + the contract-carve-out wording so a future copy-paste
regression can't silently drop either rule.
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.
The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).
Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
The audit log records *how* each login happened in the action name
(`auth.login.passkey`, `auth.bearer.success`, …) but the table
collapsed all of that into a single Aktion label. Admins now see
the credential type as a separate column with a lucide glyph
(KeyRound / Fingerprint / Cpu / Globe) so password-vs-passkey-vs-API
token attempts are distinguishable at a glance — and the column
maps cleanly into the CSV export.
The mapping itself (`providerForAction`) lives in `_shared.tsx`
alongside `useAuthActionLabels` so any later audit-feed consumer
picks up the same i18n + icon contract; a regression test pins
every action → provider edge and every provider → icon edge.
The v1.4.22 stripper only ran on the InsightAdvisorCard summary,
findings, primaryRecommendation, and the RecommendationCard top-line
rec text. Every other AI-prose surface bypassed it entirely, so the
v1.4.25 W5b regex extension (capitalised Metric + orphan enum
patterns) silently could not help them.
Wire stripChartTokens() through every render path that pipes AI
output to the user:
- DailyBriefing — paragraph, finding.headline, finding.detail
- WeeklyReport — summary, goingWell[], worthWatching[], tips[],
dataQualityNotes
- TrendAnnotation — annotation sentence
- RecommendationCard — rationale rows (dataWindow, comparedTo,
deviation) inside the expanded RationaleCard
- InsightsCardPreview — dashboard mini-card rec text
- Coach message-thread — assistant bubble content (streaming +
persisted). User bubbles stay untouched on purpose: their content
is user-typed, not AI-emitted.
User bubbles, evidence-block key/value rows (constrained to the
sentinel grammar by the Coach prompt), and the deterministic
server-generated correlation interpretations stay raw — the leak
surface is AI prose, not those structured fields.
The Standort column existed but rendered right-aligned next to a
right-aligned IP and timestamp, which pushed it into the gutter on
wide audit tables and let admins overlook it entirely. Left-align
the IP and Location cells (timestamp keeps the right edge as the
sort anchor) and prefix the Standort header with a MapPin glyph so
the column is a first-class entry instead of a forgotten trailer.
Marc reported a raw "Metric Pressure_Sys" token leak on /insights
2026-05-14. The v1.4.22 stripper only matched the canonical
`metric:<TYPE>` colon form; the model emitted a capitalised "Metric"
prefix followed by a Pascal/snake-cased identifier and the substring
sailed through to the rendered prose unchanged.
Extend `stripChartTokens()` with two additional matchers:
- `Metric <Word>` capitalised two-word phrase (catches the exact
pattern Marc saw, plus PascalCase variants and the v1.4.23
Apple-Health additions like `HEART_RATE_VARIABILITY`).
- Fixed-list orphan upper-snake-case enum identifiers
(`BLOOD_PRESSURE_SYS`, `PULSE_BPM`, `MOOD_SCORE`,
`MEDICATION_COMPLIANCE_PCT`, plus the v1.4.23 Apple Health enum
surface: `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`,
`SLEEP_DURATION`).
The orphan-enum list is fixed (not a generic `[A-Z_]+`) so single
capitalised words like `WEIGHT` or `MOOD` stay legible in normal
prose — only the suffixed/database-style enum names get cleaved.
Lowercase "metric" inside legitimate English prose ("Each metric
tells part of the story") is also left untouched.
Tests: 14 new cases covering every documented leak pattern plus two
defence-in-depth assertions (combined leaks; German "Metrik" safe).
Doctor-report is the most user-facing export — give it its own row above
the 4-card 2×2 grid so it reads as the primary action. Mobile keeps the
single-column stack since every card is already full-width there.
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.
Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:
• targets.summary.{weekTitle,weekTitleAll,streakHighlight}
• targets.card.{lastMet,streak}
• targets.consistency.{ariaLabel,daysInRange,daysLogged,
dotInRange,dotNearRange,dotOutOfRange,
dotNotLogged,insufficientData,relativeDay.*}
• targets.relativeDay.{today,yesterday,daysAgo}
• targets.coach.cta
• targets.mood.stability.{stable,variable,highlyVariable}
Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.
Verification:
- pnpm typecheck: clean
- pnpm test: 2325 / 2325 passing
- pnpm lint: clean
- i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
The drawer mount + per-card CTA handoff was wired during Phase 5
when the page.tsx was rewritten around the extracted primitives.
This commit lands the integration tests + prompt-builder tests that
pin the contract:
• targets-coach-mount.test.tsx — three branches:
1. AI provider configured → per-card "Ask Coach" CTAs render
2. No AI provider → CTAs hide entirely (no broken-button state)
3. Chain query still loading → same as #2 (don't render a CTA
whose click could 404)
• target-prompts.test.ts — six cases:
1. EN BP template threads current value + range + cadence + streak
2. DE BP template threads the same values with German copy
3. Streak fragment is omitted when streakDays < 3
4. MOOD_STABILITY uses the verbal label inline ("stable" /
"variable" / "highly variable") not the raw σ
5. Unknown target types fall back to a general EN template
6. DE fallback localises the question shape
• coachScopeForTarget — four cases:
1. WEIGHT + BMI map to ["weight"]
2. BP + BP-in-target map to ["bp"]
3. MOOD_SCORE + MOOD_STABILITY map to ["mood"]
4. Unmapped targets return [] so the drawer falls back to defaults
PROMPT_VERSION stays at 4.23.0 per Marc's prompt-version rules:
the assistant-facing system prompt does not change; only the user-
side prefill does. Confirm in the W3e reconcile if a future change
makes a prefill load-bearing on the model's behaviour.
Per Marc's "mobile is co-equal with desktop" directive, every
breakpoint gets a deliberate layout — no afterthought reflow:
• default (<640px): grid-cols-1, full-width cards, gap-4. The
per-card Coach CTA spans full width (justify-center) so the
touch target is generous; the source link sits beneath
right-aligned.
• sm (640-1023px): grid-cols-2, gap-4. Card footer reverts to
horizontal — Coach CTA left, source link right.
• lg (1024px+): grid-cols-3, gap-6. Three cards per row matches
the dashboard / insights rhythm and lets the user see all six
core targets above the fold on a 1440px viewport.
Card internals also reflow:
• Headline row: icon + label stack with status pill below on
mobile (sm:flex-row sm:justify-between), inline on sm+.
• Footer row: flex-col items-stretch on mobile,
sm:flex-row sm:items-center sm:justify-between on sm+.
New `targets-responsive.test.tsx` pins the breakpoint classes so a
future refactor can't quietly collapse the grid back to v1.4.22's
sm:grid-cols-2 only layout.
Verified by inspection (Tailwind class audit). A Playwright sweep
across 375 / 768 / 1280 / 1440 viewports is queued for the W3e
verification phase.
The card used to render the raw σ value ("0.42 σ") as the big headline
number. Most users cannot interpret a standard deviation; the W3e
research found this was the single biggest "what does this mean?"
friction point in the v1.4.22 page.
Replace with a three-state verbal label keyed by the
`moodStabilityLabel(sigma)` helper landed in the previous phase
(thresholds: < 1.0 stable, < 2.0 variable, ≥ 2.0 highly variable).
Translations under `targets.mood.stability.{stable,variable,highlyVariable}`
in EN + DE.
The raw σ value stays one hover away — the headline carries a
tooltip surfacing "σ = 0.42" so power users keep the precise number
without forcing every visitor to decode it. Headline also gets a
`capitalize` class so the lowercase i18n string ("stable" / "stabil")
renders sentence-cased.
Replaces the inline 790-line page.tsx composition with the W3e
primitives extracted in the previous phase. The page is now ~270
lines of orchestration:
- TanStack-Query pulls the redesigned targets payload (with the
v1.4.25 consistency + summary additions).
- A second query reads /api/insights/provider-chain to derive
`aiEnabled = chainStatus?.activeProvider != null`. The flag threads
down into every <TargetCard>, which uses it to gate the per-card
Coach CTA — no broken-button state when the user has no provider
configured.
- A new `useCoachHandoff()` hook (src/hooks/use-coach-handoff.ts)
owns the drawer's open + prefill + scope triple. Per-card buttons
call `askCoach()`; the hook flips state and the drawer mounts
inline at the page level.
- Cards now render in a fixed order (BP / BP-in-target / WEIGHT /
PULSE / BMI / MOOD / COMPLIANCE / SLEEP / BODY_FAT / STEPS) — Marc
directive: stable visual hierarchy beats status-sorting because
the user's eye lands on the same metric in the same place every
visit.
- Source-citation URLs stay on the page (declarative routing) and
thread into <TargetCard> via the `sourceLink` prop.
The old inline TargetCard / RangeBar / TrendIcon / status-category
map are deleted along with the dead `Badge` + lucide imports.
Existing targets-i18n + targets-spacing tests updated to mock the
new useQueryClient + useMutation surfaces that the embedded
<CoachDrawer> needs at SSR; both suites still assert the same
visual contracts and pass.
Wave 2 deliverables also folded into this commit:
- pageSummary mounts via the W3e header above the grid
- consistency strip renders only when insufficientData === false
- recency caption ("last met goal: 2 days ago") renders only when
lastMetGoalAt !== null
- streak chip ("4-day streak") renders only when streakDays ≥ 3
Adds a one-line summary above the card grid that surfaces the
account-level state at a glance:
"4 of 6 targets met this week" / "All 6 targets met this week ✓"
When every target hits the bar we surface a calm green check mark; no
animation, no celebration. Per Marc's "muss nicht so sein wie die KI
Sache" — the summary stays scannable and quieter than the v1.4.20
Insights hero strip.
A streak highlight chip ("5-day streak: Blood pressure") renders to
the right when one metric carries a current ≥ 3 day streak. Resolves
via the existing targets.label.{TYPE} i18n map so the metric name
respects the user's locale.
The new <TargetsSummaryHeader> primitive lives under
src/components/targets/ alongside the rest of the W3e extraction.
4 tests cover partial / all-met / streak / empty branches.
The /targets page composed everything inline in a 790-line page.tsx.
Break it into focused primitives under src/components/targets/ so the
W3e redesign can iterate per-card without growing the page file:
- target-card.tsx — the redesigned card composition. Headline + verbal
status pill, big-number row with inline trend chevron, range bar,
consistency strip, recency + streak chips (both conditional on real
data), per-card Coach CTA gated by aiEnabled, source link footer.
MOOD_STABILITY swaps the raw σ headline for a verbal label via the
moodStabilityLabel helper. Fixed card order is owned by the page;
this component is presentational.
- range-bar.tsx — extracted intact from page.tsx. Same behaviour, same
data-slot. Now carries its own test suite.
- consistency-strip.tsx — NEW. 7 dots (one per Berlin-tz day), each
filled / amber / red / hollow per the day's band classification.
Caption flips between "X of 7 in range" and "X of 7 logged" when
cadence is the more informative signal. Accessible: <ul role="list">
with per-dot aria-labels seeded from i18n. No empty-state copy
inside the strip — Marc directive: hide entirely when insufficient.
- target-coach-button.tsx — NEW. Per-card "Ask Coach about this" CTA
that returns null when aiEnabled is false. No greyed-out button,
no tooltip placeholder — the gate is total per Marc's
"no broken-button state" directive.
Supporting helpers (each unit-tested):
- src/lib/targets/mood-stability-label.ts — pure σ → verbal-label
threshold logic (< 1.0 stable, < 2.0 variable, ≥ 2.0
highlyVariable). Thresholds documented in the helper and confirmed
in the W3e plan.
- src/lib/ai/coach/target-prompts.ts — 8 metric × 2 locale templates
for the per-card Coach prefill. Falls back to a general template for
glucose contexts. PROMPT_VERSION unchanged (the assistant-facing
prompt does not shift; only user-side prefills do).
- src/lib/ai/coach/target-scope.ts — per-target CoachScope.sources
mapping. Derived metrics (BMI → weight, MOOD_STABILITY → mood) point
at the underlying source; unmapped targets return an empty array
so the drawer falls back to defaults.
i18n keys added: targets.consistency.* (cap + dot aria + relative day
+ insufficient-data), targets.card.{lastMet,streak}, targets.relativeDay.*,
targets.summary.{weekTitle,weekTitleAll,streakHighlight},
targets.coach.cta, targets.mood.stability.* — all EN + DE.
Tests: 17 cases across 5 suites (consistency-strip render variants,
target-coach-button gate branches, range-bar zone painting,
target-card full-data + insufficient-data + aiEnabled-off +
MOOD_STABILITY verbal-headline, mood-stability-label thresholds).
Page.tsx still uses the old inline composition for now — Phase 5
wires it to the new components.
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:
- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
week. The client renders 7 dots, one per day, filled when the day's
mean reading landed in the green band, hollow when logged but
out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
target band, or null when no in-range day in the last 30. Surfaces
the maintainer-tester case the W5 reconcile flagged (today lands
in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
OR < 1 day logged in 7d. The client hides the strip + percentage
when set so we never surface a misleading "0 of 7" caption.
Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.
Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).
Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
mood) — classify the day's mean against the same green band the
RangeBar paints. Orange band reuses the pulse / glucose
configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
≥ 50% = near, otherwise out. Days with zero scheduled doses
return null so they don't break the streak.
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.
Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
in phase 3)
v1.4.25 W7 phase 7 — proposal §6.4 test strategy applied.
Seven cases cover the full per-user-tz pipeline against the
integration testcontainer:
1. CSV export for an Auckland user emits '+12:00' on measuredAt,
never the bare 'Z'. This is the issue #167 fix from the user's
end-to-end perspective: an Auckland user's exported spreadsheet
carries unambiguous local time.
2. CSV export for an Asia/Tokyo user emits '+09:00' (no DST in
Japan, deterministic offset year-round).
3. CSV export with no userTz (legacy library callers — admin tools,
canonical backup-on-disk) keeps the 'Z' suffix. Protects the
backward-compatibility path.
4. PUT /api/auth/me/timezone writes the new zone, the user row
reflects it, AND the in-process resolver cache returns the new
value immediately (the route invalidates on write — proves the
evict path works without waiting 60s for the TTL to roll).
5. PUT /api/auth/me/timezone rejects an invalid IANA zone with
422 and does not touch the column.
6. POST /api/auth/register captures a browser-detected zone from
the signup payload onto User.timezone.
7. POST /api/auth/register falls back to Europe/Berlin when the
payload's zone is invalid. The 'admin server default'
intermediate fallback layer is exercised by the unit suite —
the integration test runs against a testcontainer whose
migration history doesn't carry every AppSettings column,
so setting a non-NULL default would error before the route
runs. Documented inline.
The Coach snapshot's UTC bucketing (proposal §3 symptom 9) is
intentionally NOT asserted — that surface is deferred to a
follow-up wave because the snapshot's day/week bucketing pipeline
is a separate refactor that touches the prompt budget calculator.
v1.4.25 W7 phase 6 — proposal §4 Option C as default-value layer.
The registration form now reads
Intl.DateTimeFormat().resolvedOptions().timeZone and sends it with
the signup payload. The server:
1. Validates the value against Intl.supportedValuesOf via
isValidTimezone().
2. On any invalid value (or absence), resolves the admin-
configured server default (AppSettings.defaultUserTimezone) and
uses that.
3. On no admin default, the resolver falls back to the
'Europe/Berlin' string, which is also the User.timezone column
default — so the worst case still produces the legacy
pre-v1.4.25 behaviour.
The Zod registerSchema gains an optional 'timezone' string field
capped at 64 chars (matches the longest IANA canonical zone
identifier plus headroom). Audit-log entry now carries the resolved
zone so an operator can spot a Tokyo user mis-attributed to Berlin
if browser detection fails.
No new tests — the existing register-route integration coverage is
thin, and the v1.4.25 W7 phase 7 integration test in
Pacific/Auckland covers the full signup → measurement → export
round-trip end-to-end.
`pnpm format:check` flagged two files touched in the previous W3 +
W3b commits — `src/components/insights/insights-tab-strip.tsx` (the
new tab strip) and `src/lib/charts/x-axis-density.ts` (the rebuilt
tick-density helper). Prettier normalised the Tailwind class-name
ordering and the multi-line function signature; no behaviour change.
v1.4.25 W7 phase 5 — proposal §5 admin half.
GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
- empty string clears the column (resolver falls back)
- non-empty must pass isValidTimezone(); otherwise 422
- successful writes invalidate the server-default resolver cache
so the registration handler sees the new value on the next
signup without waiting 60s for the TTL
The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'
Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
v1.4.25 W7 phase 4 — proposal §5.
Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.
Form save uses Promise.all over two PUTs:
- /api/auth/profile (email, height, dob, gender)
- /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.
The new route PUT /api/auth/me/timezone:
- requireAuth gates the cookie + Bearer paths
- 422 on any string the runtime's Intl.DateTimeFormat rejects
- 200 on success, returns the persisted value
- audits 'user.timezone.update' with previous + next
- invalidates the resolver cache so the next read sees the new
value within one tick instead of waiting 60s for the TTL
i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'
Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
Marc reported the German "Einnahmetreue" component label visibly
overlapping the band-coloured component-value pill at the right of
the row on `lg+` viewports. The card's `lg:w-[220px]` width left only
~64px for the label column after the progress bar (`flex-1`) and the
right-side value pill (`w-8`) consumed the rest — fine for short
labels like "BP" / "Mood" but cropped for the longer German strings.
Two coordinated changes:
- Card width: `lg:w-[220px]` → `lg:w-[260px]` (~18 % wider on `lg+`).
- Label column: `w-16` (64px) → `w-24` (96px) with a `truncate`
fallback for any future localisation that pushes the label past
96px.
Net result: the label column has ~50 % more pixel budget than before,
which puts "Einnahmetreue" comfortably inside its slot. The card's
internal grid (label / bar / value) preserves the same gap rhythm so
the visual proportions stay familiar.
Two coupled fixes pinned by Marc on the v1.4.25 W3 walkthrough:
1. Trends row equal-height
The three trend cards (BP / Weight / Mood) on /insights sit in a
`grid-cols-1 md:grid-cols-3` grid. The `min-h-[300px]` floor
guarantees a sensible mobile height, but the AI annotation prose
varies card-to-card on desktop, leaving the tallest card pushing
only its own row member down — siblings stayed short. Added
`md:auto-rows-fr md:items-stretch` on the grid container and
`h-full` on each direct child so the row pins every card to the
tallest member's height.
2. Mood chart gridline density parity
The mood chart's YAxis pins ticks to [1,2,3,4,5] (mood scale). By
default Recharts syncs CartesianGrid coordinates with YAxis ticks,
so the mini variant painted five horizontal lines while the
BP/Weight/Pulse minis painted six (their auto-generated YAxis
ticks land on ~six bands in typical health ranges). Added a
`horizontalCoordinatesGenerator` that emits six evenly-spaced
lines inside the plot area so the trends row reads as a single
rhythm.
Both changes are presentational — no chart data shape changes, no
behaviour changes, no API touched.
v1.4.25 W7 phase 3.4 — proposal §3 symptom 10.
The schema patterns on every 'format: date-time' field already
accept both 'Z' and '±HH:MM' suffixes (the Zod sources use
{ offset: true }), so the wire contract is technically correct. The
discoverability problem is that consumers reading the spec assume
'date-time' implies UTC.
Adds an explicit 'Date-time contract' paragraph to the API
description naming the convention: server responses emit the
requesting user's offset when a user context is present (exports,
doctor-report PDF), background-job-authored payloads emit 'Z',
clients must accept both.
Regenerated docs/api/openapi.yaml via pnpm openapi:generate; check
passes.
v1.4.25 W7 phase 3.3 — closes proposal §3 symptom 8.
renderDoctorReportPdfBytes now accepts an optional userTz in its
DoctorReportRenderOptions. The PDF route resolves the patient's
stored timezone via the v1.4.25 W7 resolver and threads it through
makeFormatters so date/time rows render in the user's zone.
A US patient generating a report at home in Boston now sees Eastern
time rows, not Berlin. Existing Berlin-region users see no
behaviour change — the User.timezone default is Europe/Berlin and
the formatter falls back when userTz is empty.
Client-side caller (settings dialog) is untouched in this commit — a
follow-up wave wires the auth-context value through the dialog
component.
Marc directive 2026-05-14: the page-level regenerate affordance has to
stay reachable while the user is reading the per-metric cards. Up to
v1.4.24 it lived inside the hero band's action row, which scrolled
away as soon as the user moved past the briefing.
What changed:
- New `src/components/insights/insights-tab-strip.tsx` carries the
six metric pills + the regenerate icon button. The strip is
`sticky top-0` with `bg-background/95 backdrop-blur` so it lives
above every section as the user scrolls.
- Right slot: a 44×44 icon-only `<RefreshCw>` button with
`aria-label={t("insights.regenerateAnalysis")}` and a sonner
success toast keyed to the falling edge of `regenerating`
(rising-edge guard via a ref so the toast only fires when an
in-flight regen finishes, not on every render).
- The hero strip's `onRegenerate` / `regenerating` props are gone;
its action row is now Weekly-report + Ask-the-Coach only. The
JSDoc comment block points to the new tab strip for the
regenerate affordance.
- `src/app/insights/page.tsx` mounts `<InsightsTabStrip
onRegenerate={advisor.regenerate} regenerating={...} />` and
drops the inline `InsightsSectionNav` function (its logic moved
into the new component verbatim).
- Two new translation keys added under the `insights.*` namespace:
`regenerateAnalysis` ("Analyse neu starten" / "Re-run analysis")
drives the aria-label + title; `regenerateSuccess` ("Analyse
wurde neu erstellt" / "Analysis refreshed") drives the toast.
The existing hero-strip test that pinned the regenerate slot was
flipped — the slot must NOT render under any prop combination. The
insights-polish test that asserted `<HeroStrip onRegenerate={advisor.regenerate}>`
was rewritten to assert the wiring is on `<InsightsTabStrip>` and that
the `<HeroStrip>` JSX block carries no `onRegenerate` prop.
v1.4.25 W7 phase 3.2 — extends the shared locale formatter to accept
an optional second argument carrying the per-user display timezone.
makeFormatters(locale) — unchanged, still renders in Europe/Berlin.
makeFormatters(locale, userTz) — renders in the user's zone.
The empty-string and undefined cases both fall back to
DISPLAY_TIMEZONE so admin tools, the legacy client wrapper in
src/lib/format.ts, and audit-log viewers keep behaving identically.
Three tests cover Asia/Tokyo wall-clock + date roll-over and the
empty-string fallback. No production callers yet — wire-up happens
in the per-surface commits that follow (doctor-report PDF, Coach
snapshot, chart axes).
Two carryover failures fixed in this session before the rest of W2
closes:
- tests/integration/coach-prefs.test.ts NextRequest URL mock
(v1.4.22 carryover, deferred from v1.4.23 + v1.4.24)
- e2e/chart-overlay-controls.spec.ts portalled-toggle viewport
+ e2e/charts-mobile.spec.ts Pixel-5 axis-tick visibility
Report covers root causes, file paths + line ranges, verification
exit codes for the integration suite, and the local-environment
limitation that blocked e2e verification (Node 25 vs CI's Node 22
prerender regression). Final e2e verification will arrive from the
next CI run on develop.
v1.4.25 W7 phase 3.1 — closes the export half of issue #167.
CSV/JSON exports previously emitted timestamps via .toISOString(),
producing 2026-05-15T09:00:00.000Z. Excel and LibreOffice drop the
trailing Z silently and render the bare datetime in the viewer's
locale, so a Warsaw user reads UTC as local time — 11:00 entries
display as 09:00 in their spreadsheet.
Export helpers now accept an optional userTz argument and emit
ISO-8601 with the user's UTC offset
(2026-05-15T11:00:00+02:00). The offset survives the round-trip and
the on-screen value is unambiguous regardless of the spreadsheet's
defaults. Routes resolve the user's stored timezone via the v1.4.25
W7 phase 2 resolver and thread it through formatMeasurements /
formatIntakeEvents / formatMoodEntries.
Backward-compatible: userTz is optional. When omitted (admin tools,
canonical-backup-on-disk shape) the helpers fall back to
.toISOString(), keeping the legacy contract for the JSON full-backup
route that the upload-restore validator depends on.
Surfaces touched:
- src/lib/export.ts (3 formatters + helper)
- src/app/api/export/measurements/route.ts
- src/app/api/export/medications/route.ts (intake history block)
- src/app/api/export/mood/route.ts
- src/app/api/export/route.ts (legacy aggregate route)
Tests: 5 new cases in src/lib/__tests__/export.test.ts covering
Berlin / Auckland / New_York offsets, the no-userTz fallback, and
the mood-entry case where the stored YYYY-MM-DD date column stays
unchanged (see proposal §6.3 for the read-side rationale).
Two specs have been red on main since the v1.4.20 hero rewrite, spamming
the GitHub Actions failure mailer on every push:
1. chart-overlay-controls.spec.ts — the test opens the dashboard's chart
overlay popover and toggles 'target range' on. The Radix dropdown
content portals to the document body, so on the 393×851 Pixel 5 and
near the bottom of the 1280×720 desktop viewport the toggle's bbox
lands below the fold. `click({force:true})` still asserts the bbox
intersects the viewport and refuses to fire, and
`scrollIntoViewIfNeeded` on the portalled content is a no-op because
the portal sits outside the page scroll container. Switching to
`dispatchEvent('click')` synthesises a click directly on the element
without any actionability or viewport check; Radix Switch's native
click handler fires `onCheckedChange` and the PUT lands as expected.
2. charts-mobile.spec.ts — the 30-day-window tick-count assertion times
out waiting for `.recharts-xAxis .recharts-cartesian-axis-tick text`
to be visible. Recharts does paint the <text> ticks into the SVG, but
the chart card on Pixel-5 is taller than the 851-px viewport, so the
bottom-anchored x-axis labels stay below the fold even after
`scrollIntoViewIfNeeded` brings the card's top into view. The
downstream `evaluate` only needs the ticks attached to the DOM (it
counts them via querySelectorAll), so `state: 'attached'` is both
sufficient and viewport-tolerant.
Neither fix changes the assertion semantics — both still exercise the
same user-visible behaviour against the same selectors. The CI mailer
should fall silent after the next push.
v1.4.25 W7 phase 2 — adds src/lib/tz/resolver.ts with:
- resolveUserTimezone(userId) reads User.timezone with a 60s
process-level cache and falls back to the server default.
- resolveServerDefaultTimezone() reads AppSettings.defaultUserTimezone
with the same TTL, falling back to 'Europe/Berlin'.
- detectBrowserTimezone() — client-only Intl helper.
- formatInUserTz(date, tz, shape) emits ISO-8601-with-offset (for
exports — issue #167 fix), wall-clock (for chart axis labels),
datetime (for PDFs / notifications), and date-only (parallel to
berlinDayKey but parameterised).
- userDayKey(date, tz) — day-bucket helper parallel to
berlinDayKey() but user-scoped.
- isValidTimezone() / listSupportedTimezones() for write-path
validation and the picker UI.
29-test suite covers cache hit/miss, fallback chain, invalid tz
handling, DST (Berlin Jan = +01:00, May = +02:00), Pacific/Auckland
(UTC+12), America/New_York (UTC-04 in May), and the Berlin-vs-Auckland
midnight round-trip that makes the per-user feature visible.
The /insights page rendered both <InsightAdvisorCard> (the polished
severity-ordered recommendations grid + per-rec rationale + confidence
meter + medical-citation footnotes shipped in v1.4.16 phase D
reconcile) AND <InsightStatusCard title={t("insights.generalStatusTitle")}>
(the v1.4.15 text-only summary). The page comment at the advisor
wiring acknowledged the status card "was intended to be replaced"
during D-reconcile but the status mount was never removed, so users
saw two cards saying the same thing — one labelled
"KI-Gesundheitsanalyse" and one labelled "Allgemeiner
Gesundheitszustand".
Removed:
- The <section id="section-general"> block + its <InsightStatusCard>.
- The `generalStatus` useQuery + the unused `GeneralStatusData`
interface.
- The `generalStatus?.updatedAt` entry from freshestUpdatedAt(); the
other six per-section caches still feed the hero's "Generated …"
caption.
- The "section-general" entry from the sticky pill nav (it had
nothing left to scroll to).
Kept:
- The /api/insights/general-status route + the underlying
generateGeneralStatusForUser lib — the reminder worker still
pre-warms the daily general-status cache for users opted in to the
briefing notification. Added a JSDoc @deprecated note on the route
pointing new callers at <InsightAdvisorCard> /
useInsightsAdvisorQuery().
- The insights.generalStatusTitle + insights.generalStatusBadge.*
translation keys — they're still referenced by the per-section
badge labels (insights.generalStatusBadge.good / .watch /
.critical map traffic-light states on every metric section, not
just the dropped general one).
v1.4.25 W7 phase 1 — adds AppSettings.defaultUserTimezone for the
per-user-timezone feature (Option B from
.planning/feature-user-timezone.md §4).
User.timezone already exists in the schema as a NOT NULL column with
'Europe/Berlin' default — it is the canonical per-user display zone
and needs no change. The only new piece is the server-wide default
for new accounts that don't carry a browser-detected zone in the
registration payload. Nullable so the resolver can fall back to
'Europe/Berlin' on every existing instance without an admin touching
anything.
Forward-only ALTER TABLE ADD COLUMN, no NOT NULL, no rename, no drop.
The two failing cases in tests/integration/coach-prefs.test.ts were invoking
the route's GET handler with zero arguments — `(GET as () => Promise<Response>)()`.
The apiHandler wrapper at src/lib/api-handler.ts:46-47 reads args[0].url to
build the Wide Event `http.path`, so the call threw
`TypeError: Cannot read properties of undefined (reading 'url')` before the
route body ever ran. Other integration tests already pass a real Request to
GET (see analytics-health-score, chart-overlay-prefs); aligning this test to
the same pattern restores parity with production where Next.js always
invokes route handlers with a Request.
The api-handler wrapper itself was left untouched — defending against a
caller passing undefined would mask the same bug in any future test.
v1.4.24 (security + accessibility hardening) shipped directly on main as
a hotfix-style release. Back-merging the two main-only commits
(ca03c60 chore(release): v1.4.24, 604009d per-user timezone proposal)
into develop so the v1.4.25 work continues on the standard branch model.
# Conflicts:
# .planning/ROADMAP.md
STATE ticks all Wave 7 sub-items: pre-release verify, format + planning
+ openapi sweep commits, version bump + CHANGELOG, release-merge, tag,
GHCR build, Coolify deploy (via host-side retag fallback after the
auto-deploy path skipped on missing secrets and the :latest digest
cache pinned to v1.4.22), /api/version=1.4.23, smoke, GH release,
docs + landing sync, release brief, v1.5 P1 plan refresh cross-link.
ROADMAP moves the v1.4.23 milestone into the Previous-milestone slot
(Waves 1-7 all done) and scaffolds v1.4.24 at the top with the
deferred items from v1423-backlog: Pearson incomplete-beta, OpenAPI
drift gate flip, settings-cog UX consolidation, NextRequest URL mock
fix, security MED + LOW cluster, S-05 simplify.
Release brief at docs/audit/v1423-summary.md mirrors the v1421-summary
structure: lead, live state, smoke, wave-by-wave overview, iOS
readiness statement with the 13 DTO open questions cross-link, branch
model, carry-overs, strategic next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Close the v1.4.43 mobile-UI audit L3 finding: six call-sites were each maintaining their own copy-pasted className with the `text-base sm:text-sm` iOS-zoom defence, drift risk on every edit. Extract a shadcn-style `<Textarea>` primitive parallel to `<Input>`, migrate the affected call-sites, lock the contract behind unit tests.
## What landed
### New primitive: `src/components/ui/textarea.tsx`
Mirror of `<Input>` for multi-line input. Baked-in defaults:
- **iOS zoom defence** — `text-base sm:text-sm` (16 px on mobile, 14 px on `sm+`). Safari zooms the viewport on focus when the rendered font is below 16 px and yanks the keyboard up; this defends every consumer without each site having to repeat the dance.
- **WCAG 2.5.5 tap-target floor** — `min-h-11 sm:min-h-9` (44 px on touch, 36 px on pointer). Lets `rows={1}` consumers stay above the tap-target floor without manually overriding.
- **`autoCapitalize="sentences"`** — free-text fields read as prose on iOS / Android by default. Caller overrides with `"none"` for code / JSON / IDs.
- **`spellCheck={true}`** — same reasoning; caller opts out for structured input.
- **`autoComplete="off"` + `data-lpignore` / `data-1p-ignore`** — same posture as `<Input>`: HealthLog is a health-data app, so password managers don't get to paste the user's saved password into a free-text note that we then persist server-side.
- **`forwardRef`** so consumers can attach refs (auto-grow, focus management, …).
- **`data-slot="textarea"`** for the shadcn convention.
- **`cn(…)` composition** — caller className wins via tailwind-merge, defaults still apply.
Plus the standard focus / aria-invalid / disabled treatment that mirrors `<Input>`.
### New tests: `src/components/ui/__tests__/textarea.test.tsx`
11 tests, all green. Locks the contract:
- iOS zoom defence class present (`text-base` + `sm:text-sm`).
| `src/components/admin/feedback-inbox-section.tsx:498` | -3 (comment + comment + 211-char className) | Admin internal-note textarea; primitive defaults are a 1:1 fit. |
**Total**: ~16 lines of copy-paste removed; **852 chars** of repeated className strings eliminated.
## Out of scope (intentional)
- **`src/components/insights/coach-panel/coach-input.tsx:188`** — included in the audit's "6+" count, but it's a fundamentally different design (auto-grow composer card wrapped in an outer "fake input" div with its own border + focus-within ring + Dracula-purple ring on focus, transparent textarea bg, custom `max-h-[9.5rem]` cap with internal overflow). Migrating it would require nullifying nearly every primitive default (border, bg, focus-ring, min-h, padding, shadow) — net worse than the inline pattern. Left as-is.
-`<Input>` modifications — separate concern per scope.
## Quality gates
-`pnpm typecheck` — green.
-`pnpm lint` on the touched files — green (one unrelated pre-existing warning in a withings test file remains).
-`pnpm test src/components/ui/__tests__/textarea.test.tsx` — 11/11 green.
-`pnpm test src/components/ui/__tests__/ src/components/medications/__tests__/SideEffectsSection.test.tsx` — 43/43 green; no regression in the suites that touch the migrated files.
-`npx prettier --check` on the touched files — clean after a `--write` pass.
-`npx knip` — clean (no unused exports / files).
- Full `pnpm test` — 5101 / 5104 pass; the 2 failing tests (`/api/dashboard/summary` idempotency + `/api/medications/intake` double-mint) are confirmed pre-existing (fail on origin/develop unchanged, verified via `git stash`). Not in this scope.
## Branch
`worktree-agent-a135dd75a71c47561` — branched from `origin/develop` (HEAD `690cfaf0`).
Net: +169 LOC primitive + tests, -14 LOC inline copy-paste, with the audit's drift-risk surface area now closed behind a single contract + test file.
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.