Commit Graph
1730 Commits
Author SHA1 Message Date
Marc-André Bombeck 96c68fb492 fix(measurements): preserve decimal precision on non-grouped list rows
The v1.4.37 W7c collapsed list view passed every value through
`fmt.integer`, which kept the per-day step / activity aggregates
correct but silently truncated single weight / body-fat /
body-temperature readings — 78.4 kg rendered as "78 kg" on the
desktop table and the mobile card grid.

Grouped rows (`isGrouped === true`, the daily aggregate for
cumulative HK types) keep `fmt.integer` because the underlying
readings are integer-only by definition. Non-grouped single readings
now use `fmt.number(value)` which honours up to three fraction
digits without forcing a minimum, so 78 stays "78" and 78.4 renders
as "78.4" / "78,4" depending on the active UI locale.
2026-05-21 10:30:24 +02:00
Marc-André Bombeck 730028590b fix(dashboard): slim/thick merge falls through on empty slim resolve
The v1.4.39.2 dashboard split fired two parallel useAnalyticsQuery
calls and merged them with `slim?.summaries ?? thick?.summaries`.
JavaScript treats an empty `{}` as truthy, so a zero-data slim
resolve short-circuited the thick fallback even when thick carried
the full per-type payload — the tile strip painted blank and the
chart row never mounted. Extract the merge into a pure
`mergeSlimAndThickAnalytics` helper that uses object emptiness as
the discriminator: populated slim still wins on overlapping fields
(progressive-paint contract preserved), empty slim falls through to
thick, both empty yields an empty record so the dashboard's data-
floor gates render the appropriate empty state.

Eight new behaviour-level unit tests cover the empty-slim, slim-
only, thick-only, both-empty, and `lastSeenByType` precedence cases.
The earlier source-text snapshot test in
`dashboard-analytics-split.test.ts` did not detect the empty-object
short-circuit; the pure-helper extraction is what makes the
regression directly testable.
2026-05-21 10:30:17 +02:00
Marc-André Bombeck 85d3c9a531 Merge tag 'v1.4.39.2' into develop 2026-05-21 10:04:15 +02:00
Marc-André Bombeck d665a5db42 chore(release): v1.4.39.2 — per-request rollup convergence and progressive dashboard paint
v1.4.39.1 wired the rollup write hook into the Withings sync, /api/import,
and admin-restore paths and widened the boot-time backfill discovery to
per-(user, type, day). Two follow-ups Marc surfaced from the post-deploy
trace remained: the dashboard chart still painted its `< 3 daily points`
empty-state on the 30-point range for accounts whose historic rollup
partition had not yet been folded, and the cold-mount UX waterfall left
every per-type tile waiting on the heavy /api/analytics envelope.

### Fixed

- GET /api/measurements?source=rollup now reconciles the rollup table
  against the live measurements table when the read returns suspiciously
  few rows. A request that lands while the boot backfill is still folding
  the historic partition would previously short-circuit on the sparse
  rollup (`rollupRows.length > 0`) and never reconcile with the live
  table. The route now probes
  `COUNT(DISTINCT date_trunc('day', measured_at))` against measurements
  for the requested window when the rollup carries fewer than three
  rows on a window of at least seven days; if the live table holds more
  distinct days than the rollup, the route folds the
  (user, type, DAY, [from, to]) partition inline via
  `recomputeUserRollups` and re-reads. The chart paints the full window
  on the same request without paying the cost on subsequent requests.
  The probe is gated on rollup sparsity so covered-tenant hot paths
  stay single-read.

### Changed

- Dashboard analytics fan-out split across two parallel queries. The
  per-type tile strip now reads from the slim `?slice=summaries`
  branch and paints as soon as the slim slice resolves; the
  BD-Zielbereich and glucose tiles stream in from the thick branch
  afterwards. Pre-fix the single thick-slice query blocked every
  per-type tile until the heavy fan-out resolved, so the mood and
  medication tiles (separate routes) painted first and every other
  tile arrived as one delayed burst. Both queries share the
  `caches.analytics` 60 s LRU server-side so warm hits stay free.

### Notes

- No schema migration. Runtime-only fix on top of the v1.4.39.1
  write-path hooks.
- pnpm test --run green at 4654 / 4655 (1 long-standing skip);
  targeted suites (src/app/api/measurements, src/lib/measurements,
  src/app/__tests__) all clean.
- The inline rollup fold cost is bounded by the requested window and
  one measurement type. For the dashboard chart's 30-day BP_SYS window
  that is a single 30-row upsert against the composite primary key —
  well inside the request budget.
v1.4.39.2
2026-05-21 09:56:24 +02:00
Marc-André Bombeck 77e05d491f chore(release): v1.4.39.2
Per-request rollup convergence on coverage miss + progressive
dashboard paint via slim/thick analytics split. Closes the two
post-deploy follow-ups Marc surfaced from the v1.4.39.1 trace.
2026-05-21 09:55:37 +02:00
Marc-André Bombeck 3126e4cff2 perf(dashboard): split analytics fan-out across slim and thick queries
Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the
thick `/api/analytics` envelope. Every per-type tile on the dashboard
strip waited on the heavy fan-out before paint. Mood and medication
tiles arrived first (separate routes with their own dedicated 60 s LRU
caches); every other tile then arrived as one delayed burst once the
full slice landed. Marc reported the "blocked-then-burst" pattern as
"etwas nervig" in the v1.4.39.1 post-deploy trace.

The dashboard now mounts two `useAnalyticsQuery` calls in parallel:
one for `?slice=summaries` (per-type tile strip + last-seen freshness
map) and one for the thick envelope (BD-Zielbereich + glucose tiles).
The tile strip paints as soon as the slim slice resolves; the
BD-Zielbereich and glucose tiles stream in afterwards from the thick
slice. TanStack's parallel-mounting keeps the network fan-out flat,
and both queries share the `caches.analytics` 60 s LRU server-side so
warm hits stay free.

The `data` object the rest of the dashboard consumes stays shape-
stable: a `useMemo` merges the two query results with slim winning on
overlapping fields (slim and thick compute `summaries` /
`lastSeenByType` from the same SQL helpers, so the values agree). The
thick-only `bpInTargetPct*` and `glucoseByContext` fields read from
the thick slice and surface as `null` / `undefined` until the thick
query resolves; the BD-Zielbereich tile's data-floor gate already
suppresses the tile cleanly in that interim state.

A text-anchored test pins both query mounts so a future refactor cannot
silently collapse the dashboard back onto the thick-only path.
2026-05-21 09:55:15 +02:00
Marc-André Bombeck f8a1319e94 fix(measurements-route): reconcile rollup against live table on coverage miss
v1.4.39.1 wired the rollup write hook into the previously-bypassed
Withings sync, /api/import, and admin-restore paths, and widened the
boot-time backfill discovery to per-(user, type, day). Together those
changes ensure new writes and post-deploy restarts converge the rollup
tier for every measurement type the user has logged. They do NOT,
however, guarantee convergence inside a single request when the boot
backfill has not yet finished folding the user's historic rows: a chart
query lands first, the rollup table still carries 2 rows for a 30-day
BP_SYS window, and the route's pre-fix "rollup wins on length > 0"
short-circuit returns the sparse rollup. The chart trips its `< 3 daily
points` empty-state even though `measurements` holds the full 30 days
(Marc reported the regression from the v1.4.39.1 post-deploy trace).

This commit closes the gap. When the rollup row count is suspiciously
low for the requested window (>= 7 days asked and < 3 rollup rows
present), the route now probes `COUNT(DISTINCT date_trunc('day', ...))`
against `measurements` for the same window. If the live table holds
strictly more days than the rollup, the route folds the
(user, type, DAY, [from, to]) partition inline via
`recomputeUserRollups` and re-reads the rollup so the request returns
the converged rows. The chart paints the full window on the same
request without paying the cost on subsequent requests because the
inline fold leaves the rollup partition converged.

The probe is gated on rollup sparsity so the covered-tenant hot path
stays a single indexed read; the inline fold is bounded by the window
and one measurement type so it stays small (a 30-day BP_SYS window is
one 30-row upsert against the composite primary key). The fallback
surface annotates `meta.coverage_fallback: true` on the response so
ops can spot the convergence path in production wide-events.

Four new tests pin the four shapes:
- under-coverage triggers the inline fold + re-read
- covered window keeps the single-read hot path
- short window (< 7 days) skips the probe entirely
- live + rollup agree (genuine sparsity) returns the sparse rollup
2026-05-21 09:54:59 +02:00
Marc-André Bombeck 50b74be208 docs(planning): v1.4.39.1 closure row 2026-05-21 09:12:57 +02:00
Marc-André Bombeck 8ce9642640 Merge remote-tracking branch 'origin/main' into develop 2026-05-21 09:11:57 +02:00
Marc-André Bombeck 4122c79d66 chore(release): v1.4.39.1 — rollup tier catches Withings + import + admin-restore write paths
Hotfix on top of v1.4.39. Three measurement write paths (Withings
sync, `/api/import`, admin backup restore) were bypassing the
persistent rollup tier's write hook, so the dashboard chart's
`source=rollup` fast-path painted "Noch nicht genug Daten" on the
30 / 90-point range for accounts whose recent data only came through
those paths. The 7-day window kept rendering because the chart's raw
fetch never consults the rollup tier.

Closes the read-coverage gap by wiring `recomputeBucketsForMeasurement`
into every remaining ingest path and re-anchors the boot-time backfill
discovery to per-day coverage so already-deployed instances re-converge
on the next worker boot. The legacy `sum_value IS NULL` branch stays
on the same UNION so the v1.4.39 column-backfill continues working.

Also re-anchors one date-bound test in the medications-intake route
suite so the green CI run no longer depends on the wall clock.

Tests 4647 → 4648 passing; one long-standing skip unchanged.
v1.4.39.1
2026-05-21 09:03:33 +02:00
Marc-André Bombeck cb8d800667 chore(release): v1.4.39.1
Hotfix on top of v1.4.39. Three measurement write paths (Withings
sync, `/api/import`, admin backup restore) were bypassing the
persistent rollup tier's write hook, so the dashboard chart's
`source=rollup` fast-path painted the "Noch nicht genug Daten"
empty-state at the 30 / 90 range on accounts whose recent data only
came through those paths. The 7-day window kept rendering because the
chart's raw fetch never consults the rollup tier.

This release wires the write hook into every remaining ingest path,
re-anchors the boot-time backfill discovery to per-day coverage so
already-deployed instances re-converge on the next worker boot, and
re-anchors one date-bound test in the medications-intake route suite
so the green CI run no longer depends on the wall clock.

Tests 4647 → 4648 passing; one long-standing skip unchanged.
2026-05-21 09:02:17 +02:00
Marc-André Bombeck e73661fef6 test(medications-intake-route): anchor seed row on runtime today
The v1.4.39 W-MED route test hard-coded a `2026-05-18` rollup row and
expected it to land at `body.data[body.data.length - 1]` (today). The
test ran green on the day the marathon shipped, then started failing
on every subsequent day because the trailing-7-day window drifted off
the seed. Deriving the seed key from `new Date()` keeps the assertion
date-stable across the calendar without weakening the parity check.
2026-05-21 09:02:06 +02:00
Marc-André Bombeck 3b4a5bc35a fix(rollups): boot-backfill discovery anchors on per-(user, type, day)
The pre-fix boot-time discovery used a `(user, type)` LEFT JOIN
against `measurement_rollups`: an account surfaced only when the
ENTIRE type had zero rollup rows. Once any single day for that type
had a row (e.g. from a prior boot backfill), the user dropped off the
discovery list, even if 27 OTHER days for the same type were missing
rollup rows because the Withings sync or `/api/import` path had
written measurements without firing the rollup hook.

Switching the inner DISTINCT to `(user_id, type, bucket_start)` and
joining on `r.bucket_start = mt.bucket_start` surfaces every per-day
gap. Cost: the aggregate grows from ~15 rows / user to ~5 k rows / user
on a 5-year power user — still bounded by the existing
`(user_id, type, measured_at)` index path and the rollup table's
composite primary key. The legacy `sum_value IS NULL` branch stays
on the same UNION so v1.4.39 write-tier converges continue working.

A new text-anchored unit test pins the per-day join shape so a future
refactor cannot silently revert to the per-type form.
2026-05-21 09:01:59 +02:00
Marc-André Bombeck a2b0ab19e5 fix(rollups): fold measurement tier on /api/import + admin backup restore
Both ingest paths wrote into `measurements` without firing the rollup
write hook, leaving the dashboard chart's `source=rollup` fast-path
blind to the imported days. The mood / medication-compliance rollup
tiers already had post-batch hooks on these routes; the measurement
tier had been the gap.

- `/api/import`: tracks every touched `(type, measuredAt)` written
  during the batch, collapses to distinct `(type, day)` via
  `collapseToTypeDayKeys`, and folds once at the end. A 10 000-row
  CSV restore now pays at most ~N (type, day) recomputes rather than
  10 000 per-row hooks.
- Admin backup restore: the transaction wipes the user's
  `measurement_rollups` partition alongside the existing
  `mood_entry_rollups` wipe, and the post-transaction step kicks a
  `recomputeUserRollups(ownerId)` so the restored dataset paints
  fresh tiles instead of carrying the previous owner's daily means
  forward.
- Best-effort posture preserved in both paths: a populator hiccup
  surfaces via `annotate` but never undoes the restore / import.

Test coverage extends the existing `/api/import` route suite with a
regression that pins the per-`(type, day)` collapse and a duplicate-
write case (`P2002`) that proves the hook is skipped when nothing
was actually written.
2026-05-21 09:01:46 +02:00
Marc-André Bombeck ad22fa2e9d fix(withings-sync): fold persistent rollup tier on every touched (type, day)
Three Withings ingest paths wrote measurements without ever firing the
rollup write hook: `syncUserMeasurements`, `syncUserActivity`, and
`syncUserSleep`. Once the dashboard chart's `source=rollup` fast-path
landed in v1.4.36 the under-counting started biting Withings-only
metrics on the 30 / 90-day range — Marc's BP / pulse charts painted
the empty-state at 30 even though the live `measurements` table held
the days. The 7-day window kept reading raw rows so the empty-state
only appeared above the chart's `< 3 daily points` threshold.

Each sync now collects every `(type, measuredAt)` it touched, collapses
to distinct `(type, dayStart)` via `collapseToTypeDayKeys`, and calls
`recomputeBucketsForMeasurement` once per pair after the row writes.
The per-day collapse keeps a 30-day catch-up bounded — at most ~30
DAY recomputes per sync rather than one per row. Best-effort: a
populator hiccup never fails the user's sync.

- Tests pin the hook call count on activity (6 calls for 2 days × 3
  metrics, 3 calls when the dates collapse) and sleep (one per night
  even when each night has multiple stage segments), plus a populator-
  failure path that proves the sync still returns its imported count.
- Reproducer verified: stashing the sync-activity hook makes the new
  test red on develop; restoring it greens.
2026-05-21 09:01:35 +02:00
Marc-André Bombeck 4d895eeab0 Merge remote-tracking branch 'origin/main' into develop 2026-05-21 07:50:38 +02:00
Marc-André Bombeck 805e495731 docs(planning): v1.4.39 closure rows and post-deploy perf-verify
Closes the audit-marathon closure series with rows for v1.4.38.5
through v1.4.38.8 (the four-hotfix chain that landed between v1.4.38.4
and v1.4.39) plus v1.4.39 itself.

Adds round-v1439-perf-verify.md anchored on the live deploy
(commit hash d84922cd, deploy uuid ujgsxpa46esyzybjldgxjeox) and the
public-surface 10x burst median 86 ms. Authenticated heavy-endpoint
verification awaits the first browser session against the live deploy.
2026-05-21 07:50:32 +02:00
Marc-André Bombeck d84922cdc3 chore(release): v1.4.39 — mood, medication-compliance, and cumulative-sum rollup tiers
Squashed merge of the v1.4.39 wave work from develop. The release
extends the v1.4.38 fast-path posture to two more endpoints that
still walked the source table on every cold mount and folds a
cumulative `sum_value` column into the existing rollup tier so step /
flight / distance / daylight / active-energy sparklines no longer
re-derive their daily totals in Node.

Raw measurements / mood_entries / medication_intake_events tables are
byte-unchanged. Three new tiers (mood_entry_rollups,
medication_compliance_rollups, measurement_rollups.sum_value) are
derived caches that self-heal via boot-time backfill on first reach.

Migrations 0070, 0071, 0072 are additive only. The sum_value column
add is catalog-only DDL on PostgreSQL 11+ — no table rewrite, writes
resume in milliseconds. No environment-variable change. No API
contract break. No iOS contract change.

Expected wins (live perf-verify rides the post-deploy window):
- /api/mood/analytics cold 12.7 s -> ~200 ms
- /api/medications/intake?scope=compliance cold 3.2 s -> ~200 ms
- /api/dashboard/summary cumulative sparkline ~500 ms -> ~300 ms
- /api/analytics live-fallback row cap 347 k -> ~5 k (425 d cap
  preserves the year-ago baseline tile)

Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean.

See CHANGELOG.md for the full v1.4.39 entry.
v1.4.39
2026-05-21 07:39:10 +02:00
Marc-André Bombeck 4d6a6126b0 chore(release): v1.4.39
Mood, medication-compliance, and cumulative-sum rollup tiers extend
the v1.4.38 fast-path posture to two more endpoints that still walked
the source table on every cold mount.

Raw measurements / mood_entries / medication_intake_events tables are
byte-unchanged. Three new tiers are derived caches that self-heal via
boot-time backfill on first reach.

Expected wins:
- /api/mood/analytics 12.7 s -> ~200 ms
- /api/medications/intake?scope=compliance 3.2 s -> ~200 ms
- /api/dashboard/summary ~500 ms -> ~300 ms
- /api/analytics live-fallback 347 k rows -> ~5 k rows (425 d cap)

Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean.
2026-05-21 07:38:31 +02:00
Marc-André Bombeck 96e4cc06f1 test(medications-intake-route): align mocks with v1.4.39 coverage-probe shape
QA F-H-01 (v1.4.39) tightened `hasMedicationComplianceCoverage` to a
`$queryRaw` aggregate; the route test mocks were still calling
`prisma.medicationComplianceRollup.findFirst`. Switch the mocks to the
new probe shape — `{ rolled_days, event_days }` — and add a
`$executeRaw` mock for the atomic upsert path that QA F-H-02 introduced.
The covered-vs-coverage-miss cases pin the rollup-tier and live-fallback
branches under the new probe semantics.
2026-05-18 13:01:23 +02:00
Marc-André Bombeck 5329943e09 docs(planning): v1.4.40 backlog captures deferred QA findings
Six QA reviewers (senior / security / specialist / ux / product-lead /
simplifier) ran on the v1.4.39 diff; their finding docs land here for
traceability. The v1.4.40 backlog captures every Medium + Low the
reconcile deliberately deferred — F-SEC-M-02 RLS (v1.5), F-M-01
long-window consumer (v1.5), F-M-02 unused tx parameter, F-M-03 partial
index post-convergence, F-M-05 RollupBucketRow consolidation, F-M-06
per-agent worktree process note, UX-M1-3 compliance + mood polish,
Specialist-M01 UNION arm cleanup, Simplifier dead-exports + duplicate
tz helpers, plus low-severity observability polish. UX-M4 is marked
obsolete (Specialist-H2 widen closed it).
2026-05-18 13:00:01 +02:00
Marc-André Bombeck 4e1f2d4694 refactor(dashboard-summary): flatten nested ternary per CLAUDE.md
QA Simplifier (v1.4.39): the cumulative-vs-spot sparkline branch in
the `sparkByType` loop was a nested ternary
(`useSum ? (row.sum_value !== null ? sum : mean * count) : mean`).
CLAUDE.md bans nested ternaries; the fix hoists the cumulative branch
into a named local so the outer ternary is single-level.
2026-05-18 12:58:45 +02:00
Marc-André Bombeck 12486e257c test(analytics-bp-paged): re-anchor seed to current wall clock
QA F-M-04 (v1.4.39): the wall-clock-anchored seed planted rows from
`nowMs` backwards, packing 6 000 rows × 15 min = 62.5 days starting
from "right now". Once the route's live-fallback cap is in effect,
the seed slides past the cap as time passes; under the old 90-day
cap the failure threshold was 28 days post-seed. Re-anchor the youngest
row to `nowMs - 30 * 86_400_000` so the seed always lives ~30-92 days
ago regardless of when CI runs the test.
2026-05-18 12:58:10 +02:00
Marc-André Bombeck 6ec2d499fb fix(medication-compliance): scope coverage-miss backfill enqueue to caller user
QA F-SEC-M-01 (v1.4.39): the route's coverage-miss fallback was firing
`enqueueBootTimeMedicationComplianceBackfill` on every authenticated
hit, running a cluster-wide `LEFT JOIN medication_intake_events x
medication_compliance_rollups` discovery scan in the request path. On a
multi-tenant cluster with millions of intake events the planner walked
all of them per request — a soft DoS amplifier. Split into two helpers:
`enqueueUserMedicationComplianceBackfill(userId)` for the request path
(one targeted enqueue, no discovery scan) and the existing cluster-wide
discovery stays for the worker-boot path. Mirrors the mood-rollup
`ensureUserMoodRollupsFresh` posture. Pinned with a unit test that
asserts the user-scoped helper never issues `$queryRaw`.
2026-05-18 12:57:25 +02:00
Marc-André Bombeck aacfbf5b61 perf(analytics): widen live-fallback cap to 425 days to preserve year-ago summaries
QA Specialist-H2 (v1.4.39): the original 90-day cap on the per-type
live-fallback loop in `/api/analytics` silently nulled `summarize().avg30LastYear`
(year-ago tile) on every fall-through path because the window stopped at
day 90 and the year-ago aggregate reads days 365-395. Widened the cap to
425 days (365 + 30 for the year-ago bucket span + 30 buffer) so the
year-over-year comparison tile stays populated on cold mounts. The slim
slice and rollup-backed comprehensive aggregator already operate on
tighter windows so this only affects the legacy live-fallback path the
fast-path coverage gate misses. Adjusted the W-SINCE test that pinned
the 90-day cap and updated the route's `meta.analytics.bp_aggregate.live_since`
annotation rationale.
2026-05-18 12:55:32 +02:00
Marc-André Bombeck ff4e5faf5f test(mood-rollups): pin DST fall-back Berlin midnight slip
QA Specialist-H1 (v1.4.39): the wave-report noon-anchored parity tests
miss the DST-boundary case where the UTC-anchored rollup day-key and the
user's local wall-clock day-key diverge. Pinned with a route-parity test
that logs at `2025-10-25T23:30:00Z` (Berlin local `2025-10-26` 00:30,
post fall-back) and asserts the rollup tier emits `2025-10-25` —
documents the UTC-anchor trade-off the route's header comment now spells
out. v1.5 per-user-tz bucketing closes the gap; until then this is the
contract.
2026-05-18 12:53:43 +02:00
Marc-André Bombeck 3c1a50cbb2 fix(mood-analytics): pre-aggregate live fallback to match rollup-path summarize semantics
QA UX-H1 (v1.4.39): the rollup fast-path feeds `summarize()` one DataPoint
per calendar day (the rollup's `mean`); the live-fallback used to pass every
raw entry, which silently shifted `summary.count / latest / min / max / mean
/ avg7 / avg30 / slope30` on multi-entry days. Pre-aggregating the
live-fallback through `aggregateDailyAverages` before `summarize()` keeps
both branches byte-identical on multi-entry days too. Pinned with a
two-entries-on-the-same-day parity test that compares the live-fallback
and rollup-tier summary fields directly.
2026-05-18 12:53:01 +02:00
Marc-André Bombeck 362f87d4c4 fix(rollups): atomic upsert closes mood + compliance DAY race
QA F-H-02 (v1.4.39): the read-aggregate-then-upsert pattern in the mood
and medication-compliance DAY recomputes could interleave concurrent
writes for the same (user, day) as A-SELECT -> B-SELECT -> B-UPSERT
(correct N) -> A-UPSERT (stale N-1), leaving a stale row until the next
write. Replaced both with a single SQL INSERT ... SELECT ... ON CONFLICT
DO UPDATE that re-aggregates inside the upsert subquery so each statement
commits with a snapshot taken after the prior commit released its row
lock. A paired DELETE statement handles the "all entries removed" case
in the same flow, gated on NOT EXISTS so it cannot clobber a row the
INSERT just wrote. Pinned both helpers with a Promise.all race test that
asserts both callers go through the atomic path and never fall back to
the legacy read-aggregate-upsert.
2026-05-18 12:51:31 +02:00
Marc-André Bombeck 5e45791771 fix(medication-compliance): tighten coverage probe to count rolled days vs expected
QA F-H-01 (v1.4.39): the previous "any row exists" probe would short-circuit
to the rollup path while only the first few days of a multi-medication
account had been backfilled, exposing zero-filled tiles for the un-rolled
days until the boot fold completed. The fix compares the count of DISTINCT
rolled days against the count of DISTINCT days that hold an intake event in
the trailing window via a single SQL aggregate; partial coverage now falls
through to the legacy aggregator until the fold catches up. Pinned with a
new unit test covering the partial-coverage case alongside the zero-events
and full-coverage cases.
2026-05-18 12:46:16 +02:00
Marc-André Bombeck bbcb09e879 docs(planning): W-WMY v1.4.39 phase report 2026-05-18 12:32:34 +02:00
Marc-André Bombeck 4deaa18fd5 docs(planning): W-MED v1.4.39 phase report 2026-05-18 12:32:01 +02:00
Marc-André Bombeck 8e29c3ab54 feat(medication-compliance-rollups): boot-time backfill queue
Registers `medication-compliance-full-backfill` on pg-boss alongside
the existing `rollup-full-backfill` and `mood-rollup-full-backfill`
queues. The worker boot fires a discovery query that enqueues one
full-fold per user with intake events but no rollup coverage —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. The reminder worker's
RED-phase mint path also wires the per-write rollup hook so freshly
minted `takenAt:null` rows bump the per-day `scheduled` count before
any read sees the user's compliance tile. Legacy accounts pre-dating
the v1.4.39 deploy auto-converge on the persistent tier without
operator action.
2026-05-18 12:30:49 +02:00
Marc-André Bombeck 7657b848f0 test(medication-compliance-rollups): hook, reader, and tz parity
Unit-covers the writer's `(scheduled, taken, skipped)` upsert, the
zero-event delete, idempotency across repeated invocations, the
tz-anchored UTC window math, and the Berlin vs Los Angeles bucket
parity for an instant that lands on different local days. Reader
tests pin the trailing-window zero-fill plus the per-medication
fold-to-day. Boot-backfill discovery tests pin the singleton-keyed
enqueue. Route tests extend the existing harness with the new
coverage probe — rollup-tier read on coverage, live fallback on
miss — without touching the byte-stable response shape.
2026-05-18 12:30:35 +02:00
Marc-André Bombeck dfdf6be598 docs(planning): W-MOOD v1.4.39 phase report 2026-05-18 12:30:33 +02:00
Marc-André Bombeck 227eb69b63 perf(medications-intake): consume compliance rollup tier
Swaps `/api/medications/intake?scope=compliance` onto the rollup
reader behind a coverage probe — a user with intake events but no
rollup rows still gets a correct response from the legacy live
aggregator and triggers the boot backfill in the background so the
next request lands on the rollup path. Every write site that mutates
`MedicationIntakeEvent` (POST + PUT + DELETE + per-medication import,
bulk batch, telegram callbacks, external API ingest, admin restore)
now fires `recomputeMedicationComplianceForEvent` for the affected
user-day. Bulk + import collapse the per-row hooks to one recompute
per distinct `(medicationId, dayKey)` so a 500-row ingest still costs
a bounded fold. Purge wipes the rollup partition alongside the
intake-event deleteMany. Eliminates the 3.2 s cold mount the
v1.4.38 perf analysis attributed to the unbounded event findMany.
2026-05-18 12:30:25 +02:00
Marc-André Bombeck 83d96a9817 feat(medication-compliance-rollups): writer and reader helpers
Materialises the persistent medication-compliance rollup tier behind
`MedicationComplianceRollup`. Per-`(userId, medicationId, day)` row
where `day` is `YYYY-MM-DD` anchored to the user's IANA timezone.
`recomputeMedicationComplianceForDay` upserts the row from the events
inside the local-day UTC window; `readMedicationCompliance` returns a
zero-filled trailing-window bucket array matching the legacy
`buildComplianceBuckets` shape so consumer swaps are byte-stable.
`hasMedicationComplianceCoverage` powers the route's coverage probe
and `recomputeUserMedicationCompliance` is the full-fold the worker
boot backfill calls.
2026-05-18 12:30:00 +02:00
Marc-André Bombeck 87d682d40b test(mood-rollups): writer, reader, and route parity coverage
Unit tests pin the per-write hook contract (DAY upsert + WEEK/MONTH/
YEAR enqueue + delete-on-empty + idempotency under re-run), the
warm-up helper (no-op on fresh / dedup on concurrent / annotate on
populator failure), and the boot-time discovery enqueue. Route
tests pin the parity contract: identical entries + summary shape
between the rollup fast path and the live-fallback walk, plus the
LRU warm-read invariant.
2026-05-18 12:28:58 +02:00
Marc-André Bombeck d15850d526 feat(mood-rollups): boot-time backfill queue for legacy accounts
Registers mood-rollup-recompute (per-bucket WEEK/MONTH/YEAR fold)
and mood-rollup-full-backfill (boot discovery) onto pg-boss. The
worker boot now fires a discovery query that enqueues one full-fold
job per user with mood entries but zero mood_entry_rollups rows —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. Legacy accounts
pre-dating the v1.4.39 deploy auto-converge on the persistent tier
without operator action.
2026-05-18 12:25:45 +02:00
Marc-André Bombeck 1c6264dfd3 docs(planning): W-SUM v1.4.39 phase report
Captures the cumulative-metric sum_value rollup work — writer +
boot-backfill discovery widening + consumer swaps in
`/api/measurements?groupBy=day` and `/api/dashboard/summary` +
new `rollup-read-cumulative.ts` helpers. Documents the
`/api/analytics` A2 skip-cumulative deferral to v1.4.40 (file
owned by W-SINCE in this phase) and the per-source W8c deferral
to v1.5. Surfaces the cross-agent commit-message drift recurrence
for the marathon retrospective.
2026-05-18 12:23:31 +02:00
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