Commit Graph
1691 Commits
Author SHA1 Message Date
Marc-André Bombeck 266e45ffa4 perf(mood-analytics): consume rollup tier instead of unbounded findMany
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.
2026-05-18 12:22:38 +02:00
Marc-André Bombeck 823764a914 perf(analytics): cap live-fallback findMany at trailing 90 days
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.
2026-05-18 12:22:11 +02:00
Marc-André Bombeck 8763b3aaa7 perf(summaries-slice): wire long-window slice to monthly rollup buckets
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).
2026-05-18 12:20:14 +02:00
Marc-André Bombeck 586def916c test(rollup-read-wmy): granularity-routing and parity coverage
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.
2026-05-18 12:18:00 +02:00
Marc-André Bombeck 597906f8ae feat(rollup-read-wmy): WEEK / MONTH / YEAR reader helpers
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.
2026-05-18 12:17:52 +02:00
Marc-André Bombeck d77db7460a perf(dashboard-summary): consume rollup sum_value for cumulative metrics
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.
2026-05-18 12:17:38 +02:00
Marc-André Bombeck 03c91c7a3c perf(measurements-groupby-day): cumulative path reads sum_value
`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.
2026-05-18 12:17:28 +02:00
Marc-André Bombeck b2a5f2c410 feat(rollups): populate sum_value alongside mean/count in every fold
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.
2026-05-18 12:17:21 +02:00
Marc-André Bombeck 6742b4f90b feat(mood-rollups): per-write hook and reader helpers
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.
2026-05-18 12:16:30 +02:00
Marc-André Bombeck 0d33f91d01 feat(rollups): schema scaffolding for mood + medication-compliance + cumulative-sum tiers
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.
2026-05-18 12:10:02 +02:00
Marc-André Bombeck 2fd344ed2d Merge remote-tracking branch 'origin/main' into develop 2026-05-18 12:06:28 +02:00
Marc-André Bombeck 8a8150d2bf perf(analytics): fast-path gates per-type only
`isFullyCovered(coverage) && specificTypes` poisoned every fast-
path with a single unrelated uncovered measurement type. v1.4.38.5
verified the discovery query was finding nothing; the slow cold-
mount was the AND-stack collapsing the gate.

- correlations-fast-path: gates on SYS + PULSE + WEIGHT only.
- bp-in-target-fast-path: gates on SYS + DIA only.
- health-score-fast-path: gates on WEIGHT only.

Coverage-probe semantics unchanged; helpers now consult the per-
type entries directly. Power-user cold-mount on /api/analytics
estimated 30-75 s → ~3-5 s.
v1.4.38.8
2026-05-18 11:06:24 +02:00
Marc-André Bombeck 9cc5ececa7 chore(release): v1.4.38.7
Rollup recompute observability + admin trigger.

- POST /api/admin/rollups/recompute synchronously folds one user or
  re-runs the boot-discovery loop on demand. Admin-gated.
- slim_summaries live-fallback now annotates `fallback_reason` and
  `missing_types[]` so the operator can map a slow cold-mount to
  the type missing DAY-bucket coverage without DB shell access.
- Boot-backfill discovery now logs every result, including the
  silent "no users to backfill" case — the previous shape made it
  impossible to tell "discovery ran cleanly + found nothing" apart
  from "discovery never executed".

See CHANGELOG.md.
v1.4.38.7
2026-05-18 06:27:02 +02:00
Marc-André Bombeck 80b1fcb8d0 fix(rollups): boot-backfill discovery SQL uses bucket_start as NULL sentinel
v1.4.38.5 referenced `r."id" IS NULL` on the LEFT JOIN — but
`measurement_rollups` has a composite primary key
`(user_id, type, granularity, bucket_start)` and no surrogate `id`
column. Postgres rejected the query with `column r.id does not
exist` (SQL state 42703); the worker boot swallowed the error per
the helper's best-effort contract and the queue stayed empty.

Net effect: v1.4.38.5 deployed cleanly but delivered none of its
promised fast-path recovery. Marc's account stayed on the live
aggregator.

The fix uses `r."bucket_start" IS NULL` — any column from the
unmatched right side of the LEFT JOIN serves the "no matching row"
filter, and `bucket_start` is part of the composite PK so the
planner already has the column. Bumps to v1.4.38.6.
v1.4.38.6
2026-05-18 06:15:56 +02:00
Marc-André Bombeck c0ce12673e Merge remote-tracking branch 'origin/main' into develop 2026-05-18 06:07:46 +02:00
Marc-André Bombeck 9c13cba3f7 chore(release): v1.4.38.5
Analytics fast-path restored on long-running accounts. The boot-time
rollup-backfill discovery query was matching only ZERO-rollup users;
any account that ever folded silently stranded the moment a brand-
new measurement type landed. Discovery now widens to per-type
missing coverage so the boot-backfill queue picks these accounts up
on the next deploy and folds them back into the fast-path.
v1.4.38.5
2026-05-18 06:07:14 +02:00
Marc-André Bombeck ca96fb94a3 chore(release): v1.4.38.5
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.
2026-05-18 06:06:57 +02:00
Marc-André Bombeck 93d639c746 perf(rollups): boot-backfill discovery widens to per-type missing coverage
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.
2026-05-18 06:06:35 +02:00
Marc-André Bombeck e1528952bc docs(planning): v1.4.38.4 marathon closure addendum
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.
2026-05-18 05:58:24 +02:00
Marc-André Bombeck 96e7442bab Merge remote-tracking branch 'origin/main' into develop 2026-05-18 05:51:45 +02:00
Marc-André Bombeck 0c21d4c616 chore(release): bump package.json to 1.4.38.4
Mirrors the v1.4.38.4 tag (the previous release commit only carried
the CHANGELOG entry because the editor lost the version-line edit on
a merge race). `NEXT_PUBLIC_APP_VERSION` derives from this string at
build time, so the running shell's self-heal poll would otherwise
keep firing against itself.
v1.4.38.4
2026-05-18 05:50:16 +02:00
Marc-André Bombeck d0f8084fb2 chore(release): v1.4.38.4
Self-healing stale-shell after every deploy. New `<VersionPoller>`
client component proactively polls `/api/version` and triggers an
SW-unregister + cache wipe + hard reload when the server moves
ahead of the running shell. SW `CACHE_VERSION` now keys to the
release tag so the precached chunk graph from the previous deploy
is dropped on next SW install.

See CHANGELOG.md.
2026-05-18 05:49:49 +02:00
Marc-André Bombeck 34a8f081ef feat(app): self-heal the stale-shell post-deploy paper-cut
The v1.4.38.3 `AppError` boundary reloads once when a stale shell
trips `ChunkLoadError`, but the reload only fires AFTER the user
hits a navigation that lazy-loads a missing chunk. Users sitting on
the dashboard or insights overview ride out the staleness until
their next click; some never click and the cached shell silently
drifts further out of date with every deploy.

Three layered fixes so the running app heals itself instead:

- `public/sw.js` now keys both `STATIC_CACHE` and `PAGE_CACHE` to
  the app release tag (`v1.4.38.4`). The `activate` step already
  evicts any cache name outside the current pair, so bumping the
  string at release time guarantees the precached root HTML and the
  precached `/_next/static/*` chunks from the previous deploy are
  dropped on the next SW install.
- `next.config.ts` injects `NEXT_PUBLIC_APP_VERSION` from
  `package.json` into the client bundle so the running shell knows
  which release it was built against.
- New `<VersionPoller>` client component mounts inside `<Providers>`
  and polls `/api/version` every 60 s. If the live version moves
  ahead of the shell-baked version it unregisters every active SW,
  wipes CacheStorage, and `window.location.reload()`s.
  `sessionStorage` gates the reload to once per session so a
  mid-deploy webhook race that briefly returns a stale image cannot
  loop the page.
2026-05-18 05:49:24 +02:00
Marc-André Bombeck a06cde7731 docs(planning): v1.4.38.1-.2-.3 iOS-coord closure + marathon addendum
- 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.
2026-05-18 00:07:48 +02:00
Marc-André Bombeck 2c5e3beb66 chore(merge): back-merge main into develop after v1.4.38.3
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.
2026-05-17 23:57:27 +02:00
Marc-André Bombeck a16bb4b753 chore(release): v1.4.38.3
CI green-up + chunk-load auto-recover. Closes the three pre-existing
red checks that had been failing on every push since v1.4.38 landed
(TODO marker in product code, rollup integration test racing the
fire-and-forget rollup populator, e2e drift after the v1.4.37
doctor-report hero refactor + the dashboard touch-target gate),
plus a new `AppError` branch that auto-reloads the SPA when the
cached shell hits a stale chunk after a deploy.

See CHANGELOG.md.
v1.4.38.3
2026-05-17 23:56:39 +02:00
Marc-André Bombeck 82241063f6 fix(app-error): auto-recover from stale-shell ChunkLoadError
After a deploy the cached SPA shell still references the old chunk
filenames. Next.js fetches them, the new server 404s the request,
and the user sees the generic error surface — typically with no
context other than "Failed to load chunk". The fix on the user's
side is a hard reload; before this commit they had to discover it
themselves.

`AppError` now detects the chunk-load family of errors
(`ChunkLoadError`, `Loading chunk`, `Failed to load chunk`,
`Failed to fetch dynamically imported module`) and triggers a single
`window.location.reload()`. `sessionStorage` gates the auto-reload
to once per session so an unrecoverable error doesn't loop into a
reload storm. Browsers in strict-privacy mode fall through to the
error UI cleanly.
2026-05-17 23:56:04 +02:00
Marc-André Bombeck 7c0eee788e a11y(achievements): lift the dashboard "View all" link to a 44 px tap target
The `RecentAchievementsCard` "View all" link in the dashboard card
header was 46×16 px. The mobile-viewport e2e check fails at the
WCAG-recommended ≥44 px touch-target floor and the existing project
contract pins the same number. `inline-flex min-h-11 items-center`
keeps the visual styling identical while satisfying the floor.
2026-05-17 23:55:58 +02:00
Marc-André Bombeck fa8a62513a test(e2e): include unit + source on the mocked measurement row
`measurement-flow.spec.ts` mocks `/api/measurements` and echoes the
just-posted body back as the list response. The `Measurement` shape
the list page consumes requires `unit` and `source`; the mock
omitted both, the list render crashed before painting the row, and
the test's `expect.poll(...)` for "78.4" timed out.
2026-05-17 23:55:55 +02:00
Marc-André Bombeck 6ea1c77532 test(e2e): point doctor-report specs at the v1.4.37 hero-card testids
v1.4.37 lifted the doctor-report card from the export-tile grid to a
hero block on the export page; the test-ids renamed from
`export-card-doctor-report` / `export-action-doctor-report` to
`export-hero-doctor-report` / `export-hero-doctor-report-action`.
The Playwright suite still targeted the old ids and timed out on
every run since. Updates the two affected specs.
2026-05-17 23:55:52 +02:00
Marc-André Bombeck 61208da6ec test(rollups): force synchronous recompute before reading the aggregate
The v1.4.37.1 read-path hotfix changed `ensureUserRollupsFresh` from
an awaited populator to a fire-and-forget. On a cold testcontainer
the integration test inserts measurements and immediately reads
through `buildComprehensiveAggregate`, which now no longer waits for
the rollup-write path to land — the coverage probe sees no buckets
and the `dailyByType` branch returns an empty object.

Calls `recomputeUserRollups(user.id)` explicitly in the test setup
so the rollup-driven branch is exercised deterministically. Matches
the production warm-cache contract (the test was always asserting
the rollup-fed shape, just no longer racing against it).
2026-05-17 23:55:44 +02:00
Marc-André Bombeck 9da5b7022e chore(correlations): drop TODO marker on the degraded sentinel
The v1.4.38 commit `41506c7a` added a `TODO(v1.5):` marker on the
correlations-fast-path `degraded` sentinel. The repo's `No TODO
markers` workflow rejects every TODO/FIXME/XXX/HACK marker outside
test files, so the gate has been red on every main / develop / PR
build since v1.4.38 landed. Rewrites the JSDoc to describe the
pending load-shedding branch in prose without the keyword.
2026-05-17 23:55:19 +02:00
Marc-André Bombeck d224811a34 chore(release): v1.4.38.2
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 (es/fr/it/pl) to English.
- Dedup ledger committed before delivery — a transient APNs blip
  silently nuked the day's nudge and blocked retries.
- DST-unsafe arithmetic drifted the local-22:00 baseline by an hour
  twice a year for both the mood-reminder push and the medication
  iOS-snooze action.
- APNs payload forwarded the Telegram `replyMarkup` inline-keyboard
  object and other channel-specific extras to Apple's servers and
  the iOS notification handler.
- No Settings UI for the per-user opt-in flag — the feature was
  unreachable for end users regardless of the other fixes.

v1.4.38.2 closes every item above plus adds the missing
`MoodReminderCard` Switch, a 90-day retention sweep for the dispatch
ledger, and a per-user `try` wrapper so one bad row cannot abort the
22:00 tick.

See CHANGELOG.md.
v1.4.38.2
2026-05-17 23:46:09 +02:00
Marc-André Bombeck fb16264cea chore(release): v1.4.38.2
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.
2026-05-17 23:45:42 +02:00
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