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.
The hotfix landed in 57795418; package.json was missed in that
commit because the squash-merge attempt collided with the
working tree. Pure version bump so docker-publish keys on
v1.4.37.2.