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.
* feat(notifications): set APNs category for med-reminders
Unblocks iOS HealthLog v0.5.3 which registers a UNNotificationCategory
named MEDICATION_REMINDER with three actions (Take / Snooze 15 min /
Skip). Without aps.category on the push payload iOS renders the
notification as a plain alert, so the action-button row never
materialises on the lock-screen.
- ApnsPayload gains optional `category` + `mutableContent` fields.
The sender writes `category` through to node-apn's setter (a
forward-compat cast wraps the d.ts gap in node-apn 8.1).
- sendViaApns automatically forwards `payload.eventType` as the
category so every event-type that lands here surfaces as a
matching iOS category identifier without further plumbing. Future
event-types that the iOS app doesn't register fall back to a plain
alert — iOS silently ignores unknown categories.
- mutableContent=1 is set by default so an iOS Notification Service
Extension can hook the payload later without a server-side change.
- New unit tests pin aps.category=MEDICATION_REMINDER on the dispatcher
path, aps.category=MOOD_REMINDER for the new event-type, and the
explicit-category override on the lower-level sendApnsPush entry.
* feat(prisma): add moodReminderEnabled + MoodReminderDispatch ledger
Schema foundation for the v0.5.4 daily mood-reminder push:
- `users.mood_reminder_enabled` (Boolean, default false): per-user
opt-in flag. Default off because mood capture is an emotionally
loaded surface — a fresh-install user must explicitly toggle the
preference in Settings before the server starts nudging them.
- `mood_reminder_dispatches` (new table, unique on userId+date):
idempotency ledger that records exactly one nudge per user per
local date. Doubles as the race-guard for the every-15-min cron
re-ticking inside the same 22:00 window — the unique constraint
makes parallel workers safe even though the worker boots at
localConcurrency=1.
Migration is additive: existing users stay on the opt-out path
byte-identical to v1.4.38 behaviour. Uses IF NOT EXISTS guards
matching the idempotent pattern from 0061 / 0068.
* feat(notifications): add MOOD_REMINDER event type with daily 22:00 cron
Second half of the v0.5.4 ios-coord patch. Now that the schema can
remember "user opted in" and "we already nudged today", wire the
worker side:
- types.ts: extend EVENT_TYPES with MOOD_REMINDER. Default off on
the per-event policy as a defence-in-depth gate behind the
per-user moodReminderEnabled flag.
- mood-reminder.ts: pure dispatcher module with three pinned-down
entry points so the unit tests don't have to spin up pg-boss:
* `evaluateMoodReminderWindow` — boundary contract for
"21:59 → skip, 22:00 → fire, 23:00 → skip" across any IANA tz.
* `buildMoodReminderPayload` — locale-aware title/body
("Stimmung erfassen" / "Wie geht es dir heute?" for DE,
"Log your mood" / "How are you feeling today?" for EN; falls
back to the app default for null/unknown locales).
* `runMoodReminderTick` — orchestrator that pulls opted-in
users, applies the window + already-logged + already-
dispatched filters, reserves the dedup row, and only then
calls the dispatcher. The P2002 unique-constraint violation
is caught and reclassified as "already dispatched" so a
lost race doesn't pollute the wide-event error stream.
- reminder-worker.ts: registers the `mood-reminder-check` queue
with a 15-min cron, mirroring the medication-reminder cadence so
any user's 22:00 boundary is caught within 15 minutes of wall-
clock without one-cron-per-zone. localConcurrency=1 keeps a
worker from racing itself. Also enriches the existing med-
reminder dispatch with a `scheduledAt` ISO string so the iOS
notification handler can pin "snooze 15 min" against the actual
schedule slot rather than the wall-clock moment APNs delivered.
- messages/{de,en,es,fr,it,pl}.json: new `moodReminders.dailyTitle`
+ `moodReminders.dailyBody` keys for all six locales.
- mood-reminder.test.ts: 13 unit tests covering opt-in gating,
window boundaries (21:59 / 22:00 / 23:00 + non-Berlin DST),
logged-today skip, ledger-already-exists skip, lost-race P2002
handling, and the multi-user multi-locale fan-out.
- 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.
- /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.
See CHANGELOG.md for the full set.
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>