v1.4.41 W-DELETED-2 — the doctor-report-data aggregator (shared by
/api/doctor-report JSON + /api/doctor-report/pdf server render) and the
section-availability probe now filter deletedAt: null so deleted rows
never appear in the rendered PDF or trigger an empty section toggle.
Three structurally-distinct `interface AnalyticsData {…}` declarations
shared a name across `src/app/page.tsx` (dashboard), `src/app/insights/
page.tsx` (insights mother), and `src/components/onboarding/
getting-started-checklist.tsx` (checklist). The collision made the
shapes un-discoverable from any one site and forced every iOS-contract
addition to land in three places.
Hoist all three into `src/types/analytics.ts` as named exports:
`DashboardAnalyticsData`, `InsightsAnalyticsData`, `ChecklistAnalytics
Data`. Each call site re-imports its shape under the original alias to
keep downstream code readable; the slim `SubPageAnalyticsData` that was
already there stays put. We deliberately keep the four shapes distinct
(rather than collapsing to one with everything optional) so call sites
can't cross-read fields they shouldn't reach for — closes v1.4.39 org-
audit recommendation #2.
v1.4.41 W-DELETED-2 — full-backup JSON, per-type CSV, and the legacy
/api/export endpoint now scope reads to deletedAt: null so iOS undo
or pending-sync rows never reach the user's downloaded file or the
admin restore round-trip.
Resolves the single component → route-handler import in the codebase
(`components/admin/backups-section.tsx ← app/api/admin/backups/route.ts`)
flagged by the v1.4.39 org audit (recommendation #2). The DTOs now live
in `src/types/backups.ts`; the route handler keeps owning the HTTP
contract and re-imports the shared types from there.
The recompute helper swallows errors internally today, but the contract
is fragile — any future refactor that lets a throw escape would turn
the intake POST or dashboard summary GET into a 5xx for a strictly
best-effort hook. Wrap both call sites in `Promise.allSettled` so the
fail-soft contract is explicit at the call site, not buried in the
helper.
Closes specialist H1 from the v1.4.40 QA round.
The audit-H4 regex still pinned the pre-W-INFRA Thread 2 form
`useMemo(..., [user?.timezone])` while the production code lifts
`user?.timezone` to a `userTimezone` local one line above the
`useMemo` so the dep array stays stable. Update the test fixture to
match the new dependency form.
Closes senior C1 from the v1.4.40 QA round.
- src/lib/medications/compliance-rollups.ts → src/lib/rollups/medication-compliance-rollups.ts
- co-located test follows the source file
10 importers updated covering the dashboard summary, every intake CRUD
route (single + bulk + import + Telegram webhook), the ingest endpoint,
the admin backups restore path, and the reminder worker job.
Closes the rollup umbrella restructure — every persistent-rollup
producer + reader now lives under src/lib/rollups/. Pure relocation
across all five batches: zero behaviour or exported-surface change.
- src/lib/mood/rollups.ts → src/lib/rollups/mood-rollups.ts
- co-located test follows the source file
17 importers updated from @/lib/mood/rollups to @/lib/rollups/mood-rollups,
covering the mood-entries CRUD routes, the moodlog webhook, the import
restore path, the insights comprehensive + targets routes, the analytics
+ features paths, and the reminder worker + moodlog sync job consumers.
Pure relocation.
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:
- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
src/lib/rollups/__tests__/measurement-rollups.test.ts
Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.
Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
The useMemo introduced for the timezone-aware greeting in the W-RSC
per-tile Suspense pass listed [user?.timezone] as its dep array. React
Compiler's preserve-manual-memoization rule rejected the literal
optional-chain expression because the inferred dep is the wider 'user'
object — a less-specific source than the declared property access — so
the compiler refused to optimize the surrounding 1 400-line component
body.
Lift userTimezone into a local before the memo so the inferred dep and
the declared dep agree. No behaviour change; the memo still recomputes
only when the user's timezone string changes.
The three Measurement reads in the analytics route — glucose-by-context,
sleep-stage breakdown, and the fetchMeasurementSeriesChunked paged
helper that backs the per-type summarize loop and the BP-Zielbereich
windowing — were still pulling soft-deleted rows. W-DELETED skipped this
file assuming the W-POOL pool / p-limit pass covered it, but that pass
only addressed concurrency, not the deletedAt predicate.
Adds deletedAt: null to all three findMany clauses so the route shares
the same soft-delete contract as every other analytics read path. iOS
soft-delete unblocked.
The glucose UI only converts canonical mg/dL into mmol/L for display
via convertGlucose; the reverse helper had no caller outside its own
test. The Berlin-only day-key helper was already superseded by
userDayKey(date, tz) in v1.4.38 for every read path that consumed it;
its remaining presence as a tested module was dead weight.
Both routes were superseded by their /api/admin/monitoring/{umami,glitchtip}-test
counterparts which are the variants the admin UI actually fetches.
The duplicated routes had no callers anywhere in the source tree.
The GET handler had no fetch caller and the queryKeys.medicationIntakeSummary
entry was never consumed in any useQuery. Strip the route file, the
factory entry, the medicationDependentKeys reference, and an outdated
quick-add doc comment.
Mirrors the intake-route call site (v1.4.40 close of v1.4.39.4 hook
gap). `projectAndReadTodaysIntakes` runs the same idempotent
projection helper the intake route uses; without this hook the
compliance rollup stayed pre-projection on the second call site too.
Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` after the bulk `createMany`, best-effort
inside the helper's try/catch so a populator hiccup doesn't surface
as a 5xx on the dashboard summary.
Closes the v1.4.39.4 hook gap surfaced by the QA reconcile pass. The
`scope=today` branch's bulk `createMany` mints fresh
`(medicationId, scheduledFor)` PENDING rows when the morning
projection lands; without this hook the compliance rollup for the
affected `(user, medication, day)` tuples stays at its pre-projection
`scheduled` count, inflating the apparent compliance % until the user
actually logs against the new row.
Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` (coalesced by Set) so a 5-meds-3-times-a-
day projection drops 15 createMany rows down to ≤3 recompute round-
trips. Best-effort: errors stay caught inside the helper so a
populator hiccup doesn't surface as a 5xx on the intake request.
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day
`prisma.moodEntry.findMany` block fed two downstream consumers:
`dailyMoodEntries` for mood × metric correlations, and `moodSummary`
for the dashboard mood block. Both read at a per-day resolution.
Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads
one row per calendar day instead of one per entry. Coverage-fallback
runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day
mean DataPoints into `summarize()` on both branches, matching the
rollup-tier convention shipped in v1.4.39 (QA UX-H1).
Two parity tests added to `__tests__/route.test.ts`: rollup fast path
skips the raw findMany, and the coverage-fallback runs the live walk
exactly once when rollups are empty.
Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db).
The mood block at the bottom of the route used to run an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` on every mount —
pulling every mood the user had ever logged into Node only to bucket-
average them in JS. The `findMany({distinct:['type']})` for 'absolute
latest measurement per type' carried no `measuredAt >=` floor either,
so on a 347 k-row tenant Postgres still had to sort the full set
before the driver applied DISTINCT ON (Prisma's `distinct` doesn't
compile to PG's `DISTINCT ON`).
This commit:
- swaps the mood findMany for `readMoodDayRollups(userId, since)`
plus a single `findFirst` for the latest score; coverage-fallback
runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39)
- adds a 365-day floor to the latest-ever-per-type read so the
driver-side distinct walk stays bounded
- pins all three behaviours in `__tests__/route.test.ts` (4 new
tests: rollup fast path, coverage-fallback bounded walk, no-mood
branch, distinct floor present)
Three test suites land together:
1. queryKeys factory contracts for the four new v1.4.40 entries
(`authMe`, `userThresholds`, `chartData`, `dashboardMedicationCompliance`)
— pin the byte-stable shapes so a future rename of any literal
lands a matching test failure rather than silently breaking cache
layout.
2. `measurementDependentKeys` / `medicationDependentKeys` bundle
membership — `["chart-data"]` and
`["dashboard-medication-compliance"]` prefixes now ride the
bundles so a mutation evicts every per-chart and per-compliance
cache in one pass (audit-C2 + audit-L4).
3. Factory-bypass guard — walks `src/components/charts`,
`src/app/page.tsx` and `src/hooks/use-auth.ts` and fails if a
literal `queryKey: [...]` slipped past the factory. Acts as a
lint-style enforcement check in the absence of a custom ESLint
rule. The guarded directory list is intentionally scoped tight to
the files this wave migrated; a follow-up wave can extend it as
it routes the remaining admin/settings/medications surfaces
through the factory.
Plus a dashboard Suspense-boundary structural test that pins the
per-tile `<Suspense fallback={...}>` wrappers, the module-scope
`DASHBOARD_QUERY_OPTS` hoist, and the `useMemo` around the hour-of-day
derivation. A refactor that drops any of those three structural
guarantees lands red CI rather than silently regressing the
streaming-composition contract.
All 16 + 5 new assertions green; tests 4565 → 4586.
Cases covered:
- POST 401 unauth, 400 invalid kind, 400 non-ISO signedAt, 400 oversize
artefact (>64 KB), 200 inserts row + strips artefact from response.
- GET 401, 400 invalid kind, null when nothing granted, latest row by
kind, full keyspace projection when ?kind is omitted.
- DELETE 401, flips revokedAt on the latest active row, idempotent
no-op when nothing to revoke, master toggle revokes per-kind.
The headline test is the append-only invariant: a revoke + re-grant
cycle leaves the original row in place (with revokedAt set), mints a
fresh row, and a subsequent GET points at the re-grant — never at the
revoked predecessor. Prisma's `delete` is asserted to never be called
on a receipt, which is the legal-team guarantee the audit trail relies
on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the iOS onboarding + Settings "AI deaktivieren" flow to the
persistence layer landed in the previous commit:
- POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists
a fresh receipt, returns id + serialised row (artefact stripped from
the response so the signed token doesn't echo over network paths it
doesn't need to cross).
- GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for
the given kind. Omit ?kind to get the full three-kind keyspace
keyed by kind, with null slots for kinds the user hasn't granted.
- DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the
latest active row. Idempotent: a no-op returns 200 with null receipt
rather than 404 so the iOS toggle can hammer the endpoint without
spurious error states. Omit ?kind for the master "deaktivieren alle
KI-Funktionen" sweep across every kind.
Both grant + revoke write to auditLog so the trail is reconstructible
even if the receipts table is rebuilt from a backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap every chart row cell and every tile-strip cell in a per-cell
`<Suspense>` boundary so the dashboard composition reads as a grid of
independently suspending islands rather than a flat tree that shares
loading semantics (audit-H2 + brief C1). The dynamic-loaded charts
(`HealthChartDynamic`, `MoodChart`, `MedicationComplianceChart`) already
mount their own `<ChartSkeleton>` while the JS chunk resolves; the
Suspense layer lifts the same skeleton to a streaming-compatible
boundary so a future migration to `useSuspenseQuery` automatically
buckets each chart's loading state to its own cell without a further
composition pass.
Bundled with two audit-cited polishes that share the same touch
surface: hoist `DASHBOARD_QUERY_OPTS` to module scope so the option
object reference stays stable across renders (audit-M2), and memoise
the `getHourForTimeZone` call so the `Intl.DateTimeFormat`
instantiation only re-runs when the user's timezone changes
(audit-H4). Both are micro-wins individually but the page renders
~720 lines of JSX per pass, so a stable per-render allocation budget
keeps Recharts re-render cost bounded.
No visible behaviour change in the current cold-mount waterfall —
every chart still suspends on its own client-fetch — but the
structural boundary is the prerequisite for the v1.4.41 RSC migration
that hoists slim-analytics fetching to the server.
Track the v1.4.40 W-PRIVACY bilingual rewrite of /privacy. The test
suite now pins:
- The bilingual H1 ("Datenschutzerklaerung / Privacy Policy") so a
redesign cannot silently regress to a monolingual title that fails
the German DPA review.
- Eleven sections each carry a `data-slot="privacy-section-de"` body
plus a collapsible `data-slot="privacy-section-en"` translation,
with `lang="de"` / `lang="en"` boundaries so screen readers switch
pronunciation correctly.
- The HealthKit data-flow boundary (POST /api/measurements over
TLS 1.3).
- The AI off-device transit guarantees: off by default, never
includes raw HealthKit identifiers, named providers (Anthropic,
OpenAI).
- Consent-receipt endpoint GET /api/account/consents with the 5-year
retention window.
- Encryption disclosure: TLS 1.3, HSTS, certificate pinning + the DE
"Zertifikat-Pinning" mirror.
- Server-location + operator framing (Germany, Hetzner, "operator").
- Retention defaults: 5-year measurements, 90-day audit, 30-day APNs.
- GDPR Art. 17 deletion route names "Konto loeschen", "Delete
account", "User.delete", "onDelete: Cascade" verbatim.
- Operator contact mailto:mbombeck@gmail.com + GitHub-issue fallback,
with the v1.4.20 PII rule still enforced (no "Marc-Andre" or
free-text "Bombeck <name>" anywhere in the body).
Bumps POLICY_VERSION constant to 1.4.40 + LAST_UPDATED to 2026-05-18.
Convert the English-only v1.4.26 privacy page to a bilingual document
that an Apple US App-Store reviewer and a German DPA inspector can both
read without locale switching. Each numbered section opens with the
German body (visible by default) and ships a collapsible English
translation directly underneath. The H1 and TOC carry both languages.
GDPR / DSGVO content additions on top of the v1.4.26 spine:
- HealthKit data flow disclosed explicitly: HK sample to iOS app to
POST /api/measurements over HTTPS, with no third-party transit.
- AI off-device transit boundary spelled out: TLS 1.3, off by default,
never includes raw HealthKit identifiers, per-provider opt-in.
- Consent receipt persistence linked to GET /api/account/consents with
the 5-year retention window.
- Encryption layer named explicitly: TLS 1.3, HSTS, certificate
pinning on iOS (Zertifikat-Pinning in the DE body).
- Server location framed as operator in Germany on Hetzner; no
third-party hosting beyond the AI providers listed in section 4.
- Retention defaults table: rolling 5-year measurement window, 90-day
audit logs, 14-day server access logs, 30-day failed APNs.
- GDPR Art. 17 deletion route named: Settings -> Daten -> Konto
loeschen with the User.delete + onDelete: Cascade contract.
- Operator contact via mbombeck@gmail.com with the GDPR subject-line
convention plus the GitHub-issue fallback channel.
- DSGVO legal-basis citations attached per data category (Art. 6 (1)
(b) and Art. 9 (2) (a) GDPR).
Bumped POLICY_VERSION to 1.4.40, LAST_UPDATED to 2026-05-18. Static
render kept (force-static, no revalidate) so the legal document does
not depend on JavaScript to display its non-default-locale text to a
reviewer.
`GET /api/measurements` has five distinct read shapes (per-day
drill-down, group-by-day, rollup-source coverage-fallback probe + fold
re-read, live `date_trunc` aggregate, default findMany + count). Each
relied on a shared `where` builder or a local `findMany` clause; none
filtered `deletedAt`. The `GET /api/measurements/series` adapter for
iOS read BP-SYS / BP-DIA / single-type series without the filter too.
Pin `deletedAt: null` on the shared `where` so the count + raw findMany
+ group-by-day mode all inherit it; add the filter to the dayKey
drill-down findMany, the coverage-fallback day-count probe, and the
live `date_trunc` aggregate; thread the filter through the three series
findMany calls. The `source=rollup` branch reads from
`measurement_rollups` so the writer-side filter covers it. No
`measurements/timeline` route exists yet — the file-set name was
prospective.
`latestIn7d` and `streakDays` are raw SQL against `measurements`; the
`allTime` aggregate is `prisma.measurement.groupBy`. All three folded
tombstoned rows into the tile-strip's latest value, the all-time count
+ last-seen-at metadata, and the measurement-streak day count. The
`sparkBuckets` query reads from `measurement_rollups` so the writer-side
filter covers it.
Add `m."deleted_at" IS NULL` to the two raw queries and `deletedAt: null`
to the Prisma groupBy.
Medication reminders are the one notification category the user has
explicitly opted in to. iOS 15+ supports `aps.interruption-level:
time-sensitive` to break through Focus modes per category, gated on
a per-app toggle the user grants on install. Wire it for
MEDICATION_REMINDER only — every other event (mood nudges, anomaly
alerts, PRs, system messages) stays on the iOS default `active`
level so Focus modes including Sleep keep silencing them as the user
expects.
Pairs with `apns-priority: 10` for time-sensitive deliveries per
Apple's push best-practices doc; without the priority pairing APNs
may downgrade delivery and silently lose the Focus-bypass intent.
Pinned by 8 new unit tests across `apns.test.ts` — one positive case
for MEDICATION_REMINDER and a parameterised negative covering all
six other event-types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regression nets for the W-POOL changes shipped in the prior two
commits:
* `src/lib/__tests__/db.test.ts` — assert `getPoolMax()` returns 20 by
default and honours `DATABASE_POOL_MAX`, with a final assertion that
pins the floor at 20 so a future refactor that drops the explicit
`max:` config can't silently revert to the library default of 10.
* `src/app/api/analytics/__tests__/route.test.ts` — instrument the
per-type `findMany` mock with inflight counters and assert peak
concurrency never exceeds `ANALYTICS_TYPE_FETCH_CONCURRENCY`. The
wall-clock window also catches both an unbounded `Promise.all`
regression (would finish too fast) and an accidental serialisation
(would finish too slow).
Populate applinks.details with the iOS App ID and the "*" path matcher so
Universal Links resolve to the HealthLog app on every URL. The existing
webcredentials entry stays in place; both now share a single AASA_APP_ID
constant to keep passkey origin-pairing aligned with deep linking.
Unblocks iOS PB30 (App-Store submission).
Wrap the per-type fan-out in `buildAnalyticsResponse` with a p-limit(4)
gate. The empirical v1.4.39 cold-mount trace
(`.planning/round-v1439-empirical-trace.md` §B1) showed thick
/api/analytics holding ≥8 of the default-10 pg.Pool slots for 6.5 s on a
power-user account, starving every other dashboard query. The cap keeps
the same total work but holds ≤4 pool slots for this branch at any
moment, leaving the remaining slots free for the parallel chart-tile
reads. Thick analytics is already 60 s cache-wrapped so the small
absolute wall-clock cost on a single cold mount is offset by the
chart-tile burst paying ~5 s less of pool-wait time on every mount
thereafter.
The W-SERVER-FIX-2 race-guard work added `skipDuplicates: true` to the
intake route's projection-backfill createMany alongside the schema
@@unique([userId, medicationId, scheduledFor, source]) constraint. The
existing unit test asserted the row shape but not the flag, so a
regression that drops `skipDuplicates: true` while keeping the unique
constraint would surface as a 5xx in prod (constraint violation
bubbles through) without breaking the suite.
Adds the flag to the `toMatchObject` assertion so the defense-in-depth
contract stays pinned next to the row shape it pairs with. Matches the
equivalent assertion the dashboard summary test already carries.
PR #191 added a today-window backfill to /api/medications/intake. The
v1.4.39.4 dashboard symmetry fix wires the exact same projection into
/api/dashboard/summary, which means two parallel routes can now race
the same (userId, medicationId, scheduledFor, REMINDER) row between
their existence probes and createMany calls. The @@unique on
(userId, medicationId, scheduledFor, source) is the structural
backstop; without skipDuplicates: true the loser of that race would
surface a constraint violation as a 5xx.
Pair the createMany with skipDuplicates: true so Postgres swallows the
rejected rows and both routes still return 2xx. Matches the same flag
the dashboard route ships with.
Dashboard tile feeding the iOS Compliance ring + Erfassen-sheet preview
pulls from /api/dashboard/summary, not /api/medications/intake. Without
the same projection-then-backfill the intake route received in PR #191,
the dashboard endpoint returned [] for daily meds (daysOfWeek = null),
keeping the iOS Dashboard tile in its "Heute nichts geplant" empty
state even after the intake route fix shipped.
Re-uses expandTodayIntakes(schedules, now, tz) + an idempotent
createMany with skipDuplicates: true so a concurrent intake-route hit
can't race a duplicate row in before the existence probe converges.
Two tests added: backfill-mints-once + idempotent-on-second-pass.
Schedule-expansion never minted today's intake rows for medications
with `daysOfWeek: null` until the reminder worker entered the RED
phase at the end of the dose window. The database stores `null` to
mean "every day" (the `MedicationSchedule.daysOfWeek` column carries
that annotation in `prisma/schema.prisma`), so daily meds spent the
whole morning with zero scheduled rows. Endpoint
`/api/medications/intake?scope=today` returned `[]` even when two
active daily meds existed; iOS Dashboard tile + Erfassen sheet both
fell to "Heute nichts geplant" / "Keine offene Einnahme" end-to-end.
Adds `expandTodayIntakes` to `src/lib/medication-schedule.ts` — a
pure projection that re-uses the existing `parseScheduleRecurrence`
(empty array == every day) and respects explicit weekday filters
when present. The `scope=today` route projects active schedules
through the helper, then idempotently backfills any missing rows
into `MedicationIntakeEvent` so the iOS POST update path keeps the
existing intakeId contract.
Tests:
- `medication-schedule.test.ts` gains eight cases covering
`daysOfWeek: null`, `""`, `"0,1,2,3,4,5,6"`, explicit-weekday
excludes-today / includes-today, the operator's Ramipril +
Metformin fixture, DST-correct `scheduledFor` anchoring at
`windowStart` in Europe/Berlin, and multi-week cadence skip.
- `intake/route.test.ts` gains two cases: backfill mints the
missing row + does-not-double-mint when the row already exists.
Full vitest suite stays green (4672 passed / 1 skipped).
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.
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.
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
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.
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.
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.
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.
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`.
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.
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.
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.
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.
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.
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.