Commit Graph
1657 Commits
Author SHA1 Message Date
Marc-André Bombeck 91feb2de3f docs(planning): v0.5.4 server-coord report and six-axis QA findings
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.
2026-05-17 23:44:59 +02:00
Marc-André Bombeck 96fc7fbda2 feat(settings): mood-reminder Settings toggle and profile API field
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.
2026-05-17 23:44:18 +02:00
Marc-André Bombeck e1a5c639a5 fix(mood-reminder): six-locale resolver, ledger-after-delivery, DST-safe reminders
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.
2026-05-17 23:44:07 +02:00
Marc-André Bombeck 1bb2afd829 feat(notifications): bubble dispatch outcome and whitelist iOS metadata
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.
2026-05-17 23:43:50 +02:00
Marc-André Bombeck 9cf10d5cf0 fix(i18n): restore missing apostrophe in fr mood-reminder body
`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.
2026-05-17 23:43:37 +02:00
Marc-André Bombeck d3643f7c19 Merge remote-tracking branch 'origin/main' into develop 2026-05-17 23:22:42 +02:00
Marc-André Bombeck c4f2e0bc51 chore(release): v1.4.38.1
iOS v0.5.4 push-notification coordination — APNs category for
med-reminder action-buttons + optional MOOD_REMINDER daily event.
Additive only, migration 0069 idempotent.

See CHANGELOG.md.
v1.4.38.1
2026-05-17 23:13:20 +02:00
Marc-André Bombeck 07574480c9 chore(merge): back-merge main into develop after PR #190 (v0.5.4 APNs)
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.
2026-05-17 23:10:32 +02:00
Marc-André Bombeck 4049c6c724 feat(notifications): APNs category + MOOD_REMINDER event for iOS v0.5.4 (#190)
* 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.
2026-05-17 23:08:17 +02:00
Marc-André Bombeck 8912bf14f9 docs(planning): v1.4.38 perf-verify + audit-marathon closure addendum
- 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.
2026-05-17 19:18:56 +02:00
Marc-André Bombeck a550031a84 chore(release): v1.4.38
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.
v1.4.38
2026-05-17 19:07:03 +02:00
Marc-André Bombeck 7cdfe3feef chore(release): v1.4.38
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.
2026-05-17 19:06:30 +02:00
Marc-André Bombeck 3d21a22ee2 docs(planning): v1.4.38 wave reports + QA findings + v1.4.39 backlog
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).
2026-05-17 19:03:04 +02:00
Marc-André Bombeck 3fe09fe0f5 test(auth): pin invalid-timezone rejection on profile update
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.
2026-05-17 19:02:53 +02:00
Marc-André Bombeck 24379721d1 fix(auth): validate User.timezone against IANA zone list on profile update
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.
2026-05-17 19:02:44 +02:00
Marc-André Bombeck 679fcd2d08 fix(i18n): restore placeholders in medications cluster (es/fr/it/pl)
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".
2026-05-17 19:02:35 +02:00
dependabot[bot] fe64f66c7f chore(deps-dev): Bump @playwright/test from 1.59.1 to 1.60.0 (#189)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.59.1 to 1.60.0.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 16:03:18 +00:00
dependabot[bot] 55469fa56a chore(deps): Bump @tanstack/react-query from 5.100.9 to 5.100.10 (#188)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.100.9 to 5.100.10.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/HEAD/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:58:54 +00:00
dependabot[bot] 64b787762a chore(deps-dev): Bump @types/node from 25.6.2 to 25.8.0 (#186)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.2 to 25.8.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:57:03 +00:00
dependabot[bot] 895a705c42 chore(deps): Bump @aws-sdk/client-s3 from 3.1045.0 to 3.1048.0 (#184)
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1045.0 to 3.1048.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1048.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1048.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:55:37 +00:00
dependabot[bot] 965fc0f2d2 chore(deps): Bump docker/build-push-action from 6 to 7 (#181)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 15:54:11 +00:00
Marc-André BombeckandClaude Opus 4.7 ee82105323 docs(planning): W-E v1.4.38 i18n full-localization wave report
Captures the Option-A scope (T1 mandatory + T2 high-value medical
copy across es/fr/it/pl), per-namespace key counts, coverage delta
(27% → 60-63% per locale, +3.4k translations), commit log,
quality-gate results, placeholder preservation, residual EN-fallback
surface (admin / settings / achievements left as T3), and per-locale
domain-term notes for native-speaker review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:42:06 +02:00
Marc-André BombeckandClaude Opus 4.7 d95820ab27 i18n(medications): translate GLP-1 cluster and schedule controls into es/fr/it/pl
Localises the GLP-1-specific UI plus the surrounding schedule controls:
treatment class picker (generic vs GLP-1 injection), GLP-1 weekly
preset, doses-per-pen tracker, last/next injection captions, injection
site picker (abdomen quadrants, thighs, upper arms), side-effect quick-
tag chips (nausea, constipation, diarrhea, fatigue, appetite loss,
heartburn, headache), schedule editor (new / remove / interval / every-
N-weeks / advanced toggle), and reminder phase config (green / yellow /
orange / red bands, percentages, before/after-end captions, reset and
save toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:40:36 +02:00
Marc-André BombeckandClaude Opus 4.7 9bd67564f3 i18n(insights): translate sleep, sub-pages, coach settings, and relative-time copy into es/fr/it/pl
Localises the remaining insights surface: coach settings (tone, verbosity,
exclude, context, evidence-by-default, default-window, feedback chips,
daily limit, provider rate-limit), relative-time captions
(justNow / minutesAgo / hoursAgo / daysAgo), sub-page descriptions
(blood pressure, weight, mood, medications, BMI, therapy timeline),
sleep panel (stages: deep, REM, core, asleep, awake, in-bed, plus
composition title and empty state), correlations / compliance shortcuts,
status-badge watch/critical, assessment title, and the cached chip.
Placeholders {count}, {nights}, {suffix} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:38:32 +02:00
Marc-André BombeckandClaude Opus 4.7 7f490af7c2 i18n(targets): translate medical target ranges and status labels into es/fr/it/pl
Localises the targets page: BMI / blood-pressure / pulse / sleep /
body-fat / activity-steps / mood status bands (WHO and ESH-aligned),
target-range editing dialog (description, derived hint, bounds, save
toasts), consistency strip (in-range / near / out / not-logged dots +
relative-day captions), streak highlights, glucose targets per context,
profile-incomplete prompts, and the Telegram reminder button labels.
ICU placeholders {value}, {unit}, {delta}, {min}, {max}, {count},
{metric}, {when} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:35:01 +02:00
Marc-André Bombeck f442977733 docs(planning): W-B v1.4.38 robustness sweep wave report 2026-05-17 17:34:16 +02:00
Marc-André BombeckandClaude Opus 4.7 11256935f7 i18n(doctor-report): translate PDF medical report strings into es/fr/it/pl
Localises the doctor-report PDF surface: cover-page metadata, vitals
table headings (parameter / current / period mean / n), BP classification
(ESH 2023 bands: optimal / normal / high-normal / hypertension grades),
BMI classification (WHO underweight → obesity grade III), mood scale,
glucose reference ranges (ADA 2024 / DDG, fasting / postprandial /
random / bedtime), GLP-1 weight summary, compliance table, section-
picker dialog (date range, practice name, presets, validation errors).
ICU placeholders {start}, {end}, {delta}, {label}, {avg}, {unit},
{rangeMin}, {rangeMax}, {class} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:31:06 +02:00
Marc-André BombeckandClaude Opus 4.7 b776daf546 i18n(insights): translate hero, recommendation, health-score, daily-briefing, coach into es/fr/it/pl
Localises the user-visible insights surface: hero finding banners,
recommendation card (rationale, confidence bands, feedback chips),
health-score panel (label, delta-vs-last-week, provenance sources,
disclaimer), suggested-prompts cluster, daily-briefing empty/loading
states, therapy-timeline GLP-1 strings, vo2-max stats, trend-annotation
confidence labels, correlation-row predefined hypotheses, and the coach
surface (refusal copy, error states, composer, history drawer, sources
panel, metric dictionary). Skips the deep coach settings (verbosity,
exclude, context, evidence-by-default, default-window) for a later pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:29:18 +02:00
Marc-André Bombeck 2177898aa2 refactor(bp-in-target): rename private dayKey to bucketDayKey
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).
2026-05-17 17:27:09 +02:00
Marc-André Bombeck 41506c7afd chore(correlations): TODO(v1.5) on the degraded sentinel
`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.
2026-05-17 17:26:23 +02:00
Marc-André Bombeck bee09bb75a fix(bp-in-target): calendar-aware priorYear read window
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.
2026-05-17 17:25:42 +02:00
Marc-André BombeckandClaude Opus 4.7 03952deb41 i18n(medications): translate status chips, categories, weekdays, and intake-history into es/fr/it/pl
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>
2026-05-17 17:25:14 +02:00
Marc-André Bombeck bf1b7b1604 chore(drain): per-user COMPLETE log line for the cumulative drain
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.
2026-05-17 17:24:52 +02:00
Marc-André Bombeck f2f1e5f036 fix(dashboard): share medications cache between checklist and quick-add
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.
2026-05-17 17:24:08 +02:00
Marc-André Bombeck 01cc1f78cd test(medications): drift-guard between category enum and label-key map
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.
2026-05-17 17:23:30 +02:00
Marc-André Bombeck 14de24c15d fix(api-response): strict IP validation via node:net.isIP
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.
2026-05-17 17:23:02 +02:00
Marc-André BombeckandClaude Opus 4.7 bc2bc65335 i18n(shell): translate onboarding, getting-started, bug-report, telegram into es/fr/it/pl
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>
2026-05-17 17:22:50 +02:00
Marc-André BombeckandClaude Opus 4.7 0cc9f55a4c perf(insights): consolidated BP sys+dia read and per-sub-query timings
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>
2026-05-17 17:22:15 +02:00
Marc-André Bombeck 6cbdad5f58 perf(rollups): parallel WEEK/MONTH/YEAR enqueue on measurement write
`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.
2026-05-17 17:22:10 +02:00
Marc-André Bombeck 4e284235fc chore(rollups): per-userId in-flight dedup for ensureUserRollupsFresh
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.
2026-05-17 17:21:47 +02:00
Marc-André BombeckandClaude Opus 4.7 a8c16dc3b5 docs(planning): W-D v1.4.38 UX polish wave report
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:21:25 +02:00
Marc-André Bombeck 5ecc315225 test(coach): make route-inventory gate check skip pure-comment lines
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.
2026-05-17 17:21:19 +02:00
Marc-André Bombeck a86c6cf478 fix(analytics): derive daysAgo from cached lastSeenAt per request
`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.
2026-05-17 17:20:19 +02:00
Marc-André BombeckandClaude Opus 4.7 ae637ce844 i18n(charts): translate chart legends, thresholds, and comparison copy into es/fr/it/pl
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>
2026-05-17 17:19:55 +02:00
Marc-André BombeckandClaude Opus 4.7 bc1f384e5c ui(buttons): tighten min-h floors on Arztbericht and dismiss CTAs
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>
2026-05-17 17:19:09 +02:00
Marc-André Bombeck 1440c77396 fix(measurements): refine drill-down limit to 1000 in the validator
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.
2026-05-17 17:18:11 +02:00
Marc-André Bombeck 1eb0038914 test(coach): discovery test pins requireAssistantSurface on every coach insights route
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.
2026-05-17 17:18:04 +02:00
Marc-André BombeckandClaude Opus 4.7 1e9bde7b60 i18n(shell): translate auth, nav, password-strength, and trend-hints into es/fr/it/pl
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>
2026-05-17 17:17:33 +02:00
Marc-André BombeckandClaude Opus 4.7 31895fcea9 a11y(medications): pair window-status colour with a Lucide glyph
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>
2026-05-17 17:17:24 +02:00
Marc-André BombeckandClaude Opus 4.7 eba7d6aa73 perf(dashboard): rollup + DISTINCT ON read for the iOS summary route
`/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>
2026-05-17 17:17:14 +02:00