Adds the persistence layer for the AI consent audit trail required by
App-Store Guideline 5.1.2(i) and GDPR Art. 7:
- src/lib/validations/consent.ts — Zod schemas with a three-kind
discriminator (ai_full / ai_insights_only / ai_coach) and a 64 KB cap
on the opaque artefact so a misbehaving client can't fill the audit
table with multi-megabyte rows.
- src/lib/consent/receipts.ts — append-only helpers: createReceipt,
latestActiveReceipt, latestActiveReceiptsByKind, revokeLatest.
Revoke flips a single field on the existing row; a re-grant always
mints a fresh row, keeping the historical chain intact for legal
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins the v1.4.40 W-DELETED contract end-to-end against the real
Postgres testcontainer. Three contracts:
1. The slim summaries slice's narrow aggregate + DISTINCT ON latest
reads exclude tombstoned rows. Fixture parks a 99.0 sentinel
row that would explode min/max if leaked.
2. The dashboard summary route's per-type groupBy + latestIn7d
DISTINCT ON ignore the tombstone — the WEIGHT tile's
`latestValue` shows the live row, not the tombstoned 99.0.
3. `recomputeBucketsForMeasurement` produces a DAY bucket with
count=2 (not 3) when one of three same-day rows is soft-
deleted, and the bucket mean reflects the live two rows only.
Also pins the Coach snapshot's measurement read against the same
filter — tombstoned rows must never reach the AI prompt where they
would surface as live data the user thinks they removed.
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.
Pins the SB-5 (apns time-sensitive interruption-level) and SB-6
(/api/notifications/status events map) contracts.
Note: the SB-6 route + test changes landed inside an earlier
`fix(dashboard-summary)` commit (1bcaae47) due to a cross-wave
pre-commit-hook race that auto-folded my staged changes; this
commit ships the wave report + records the proper SB-5 + SB-6
intent in the log for the release-notes sweep to pick up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`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.
Pre-fix the dashboard mounted two separate consumers against the same
`/api/mood/analytics` endpoint: `app/page.tsx:291` keyed on
`queryKeys.moodAnalytics()` (`["mood-analytics"]`) and the chart at
`mood-chart.tsx:316` keyed on a bare `["mood-chart-data"]` literal.
TanStack treats different keys as separate cache slots, so a cold
dashboard mount fired two requests against the same endpoint — wasting
one HTTP round trip, one pg pool slot, and ~22 KB of payload per cold
mount (empirical-trace #3). Re-key the chart to `queryKeys.moodAnalytics()`
so TanStack dedups the request; align the chart's `staleTime` to the
dashboard's 60 s window so a route swing back to the dashboard within
a minute stays a free cache hit.
The chart's own subscription becomes the cache writer for any insights
/ mood sub-page that mounts it without the dashboard parent having
fetched first. No payload-shape change — the chart already typed its
response as `MoodAnalyticsData` which matches the dashboard's
`{ entries, summary }` shape byte-for-byte.
`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>
The route's central analytics aggregator runs eight `FROM measurements`
clauses (window_stats CTE + main aggregate + DISTINCT ON latest +
firstAt probe, mirrored across the rollup-fresh and cold-fallback
branches) plus one `findMany` for the BP raw rows. None filtered on
`deleted_at`, so the Coach/Insights snapshot would see tombstoned
readings in every windowed avg, slope, anomaly count, and latest
attribution.
Add `AND m."deleted_at" IS NULL` to the eight raw SQL filters and
`deletedAt: null` to `fetchBpRawRows`. The `measurement_rollups`
findMany branch already rides the writer-side filter.
Every chunked findMany walk in the three analytics fast-paths fed the
raw `measurements` table through Prisma without a `deletedAt` filter.
The correlation pairing, BP-in-target windowing, and Health Score
weight / BP source-attribution would each fold tombstoned rows once
iOS soft-deletes its first reading.
Add `deletedAt: null` to:
- `fetchSeriesChunked` in `correlations-fast-path.ts`
- `fetchSeriesChunked` in `bp-in-target-fast-path.ts`
- the WEIGHT source attribution + WEIGHT live-fallback + BP_SYS
source attribution reads in `health-score-fast-path.ts`
The DAY-bucket reads from `measurement_rollups` are unaffected — they
ride the writer-side filter landed earlier in the chain.
The four `$queryRaw` blocks in `computeFromRollups` and
`computeFromLiveAggregate` (narrow aggregate + DISTINCT ON latest,
both fast and cold paths) folded every row regardless of
`deleted_at`. The DAY-bucket rollup aggregate is unaffected because
its source table is already filtered by the writer.
Add `AND m."deleted_at" IS NULL` to every `FROM measurements m`
WHERE clause so the slim slice's avg7/avg30/slope/r2 plus the
per-type latest never reflect a row the user soft-deleted.
`probeRollupCoverage` discovers the set of types the user has logged
by selecting `DISTINCT "type" FROM measurements`. A type whose every
row has been tombstoned would still surface, force the read path to
demand rollup coverage for it, and silently push the request onto the
live aggregator branch.
Add `"deleted_at" IS NULL` so the discovery only returns types the
user can still see. The rollup-read modules (`rollup-read`,
`rollup-read-wmy`, `rollup-read-cumulative`) read from
`measurement_rollups` which is already populated from the filtered
writer in the previous commit, so they need no additional filter.
The `runRollupAggregate` SQL folded every row in `measurements`
regardless of `deleted_at`. Once iOS soft-deletes its first reading
through the by-external-ids path, the tombstoned value would still
contribute to the bucket count / mean / min / max, leaving dashboards
and analytics showing data the user thinks they removed.
Add `AND m."deleted_at" IS NULL` to both branches of `runRollupAggregate`
plus the watermark probes in `isRollupFresh` and `ensureUserRollupsFreshImpl`
so a stale `updatedAt` on a tombstoned row no longer keeps the rollup
flagged as stale forever. The boot-time backfill discovery is widened
the same way so deleted-only `(user, type, day)` tuples never enqueue.
Defense-in-depth: every reader will pick up the filter in follow-up
commits — this lands the writer-side cut so newly-folded buckets are
already clean.
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).
Five assertions on the route handler:
- 200 on an unauthenticated GET.
- Content-Type is exactly application/json with no charset parameter
(Apple's swcd/aasa-validator refuse a charset and the failure mode
is silent — Universal Links just stop firing).
- Cache-Control allows public CDN mirroring.
- JSON shape matches the SB-4 spec exactly.
- applinks App ID matches webcredentials App ID so a rotated
Team ID never half-lands.
Includes the v1.4.40 phase report.
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).
The v1.4.39 empirical cold-mount trace
(`.planning/round-v1439-empirical-trace.md` §B2) flagged the library
default of 10 concurrent connections as the hard ceiling that thick
/api/analytics was monopolising. With the parallel W-POOL p-limit(4)
cap on the analytics fan-out, a 20-slot pool leaves ≥16 free slots for
every other dashboard query regardless of how many measurement types
analytics is mid-walk over. Postgres' stock `max_connections` of 100
gives ample headroom for ≤5 concurrent power-users at this ceiling.
Env-overridable via `DATABASE_POOL_MAX` so operators with a smaller
server-side `max_connections` can dial down without a code change.
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.
Prereq for SB-10 (POST /api/consent/ai). Append-only per-user audit
trail of AI-processing consent grants. The iOS app posts a fresh row
whenever the user opts in to off-device AI flows during onboarding or
via Settings; a revoke + re-grant cycle mints fresh rows so the
timeline reads chronologically and the latest non-revoked row per
(user, kind) is the source of truth for "is AI active right now".
Backs both App-Store Guideline 5.1.2(i) ("data-collection consent must
be verifiable") and the GDPR Art. 7 documentation burden. The server
persists the raw signed artefact (PDF receipt or signed JWT — caller's
choice) without parsing it; the audit trail is the storage itself plus
the `signed_at` wall-clock the client recorded.
Migration 0074 is additive only; IF NOT EXISTS guards mirror 0067 /
0070 / 0071. No table rewrite, no breaking change.
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.
Five planning artefacts produced during the v1.4.39 review pass:
frontend, ghosts (dead-code drift), infra-db, organization, and a
cross-cutting empirical trace. Kept under .planning/ rather than the
versioned docs site because they are working notes scoped to a single
release window.
Migration 0073 adds a UNIQUE constraint on
(user_id, medication_id, scheduled_for, source) for
medication_intake_events. Pre-fix the table only had an INDEX on those
three columns, so the two newly-projecting routes — the intake route
(PR #191) and the dashboard summary route (v1.4.39.4) — could pass
their existence probes simultaneously and mint the same REMINDER row
twice when the iOS app hydrates both endpoints in parallel.
`source` is in the key so the reminder worker's REMINDER row doesn't
collide with manual user inserts (WEB / TELEGRAM / IMPORT) on the
same slot — distinct intake sources at the same scheduled instant are
a legitimate shape (Telegram-bot logged take + a Web-UI correction on
the same dose).
The legacy (user_id, medication_id, scheduled_for) index stays in
place because most reads filter on those three columns without
caring about source. The new constraint becomes the structural
backstop; the createMany skipDuplicates: true flag added in the two
routes is the defense-in-depth layer.
Additive only. DO $$ … EXCEPTION-on-duplicate guard mirrors 0067 /
0071 so re-runs are idempotent.
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 post-deploy CI for v1.4.39.2 surfaced eight e2e failures across
chromium-desktop and chromium-mobile (chart-overlay-controls,
dashboard, charts-mobile, measurement-flow) that all routed through
two narrow regressions and one durable test-fixture gap.
### Fixed
- Dashboard slim/thick merge no longer blanks the tile strip when the
slim slice resolves with an empty summaries record. The v1.4.39.2
inline `slim?.summaries ?? thick?.summaries` short-circuited on
JavaScript's truthy-empty-object even when thick carried the full
payload. The merge moved to 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 leaves
the dashboard's data-floor gates to render the appropriate empty
state. Eight new behaviour-level unit tests cover the edge cases.
- `MeasurementList` non-grouped rows now preserve decimal precision
on both the desktop table and the mobile card grid. 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 / temperature readings — 78.4 kg
rendered as "78 kg". Grouped rows (cumulative HK type day
aggregates) stay on `fmt.integer` because their readings are
integer-only; non-grouped rows now use `fmt.number` which honours
up to three fraction digits without forcing a minimum.
### Changed
- Playwright route mocks for `/api/analytics` migrate from the
literal `**/api/analytics` string glob to the regex form
`/\/api\/analytics(\?|$)/` across every authenticated spec. The
string glob does not match the sliced URL form
`/api/analytics?slice=summaries` the v1.4.39.2 dashboard split
fires alongside the thick request, so the slim request fell
through to the real route on the seeded test user that returns
empty summaries. Ten specs migrate: dashboard,
chart-overlay-controls, measurement-flow, charts-mobile, a11y,
v1427-responsive-sheet, v1427-coach-launch,
v1427-measurements-add-param, v1427-insights-empty-state,
onboarding-tour-passthrough. The regex form is the durable
alignment with the v1.4.37 W-CI lesson that already swept
`onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`.
### Notes
- No schema migration. Runtime + test-fixture-only changes.
- `pnpm test --run` green at 4662 / 4663 (1 long-standing skip).
- Full Playwright suite green at 116 passing / 36 skipped / 152 total
on both `chromium-desktop` and `chromium-mobile` profiles, against
the production Next.js build. Closes every failure from CI run
26213226723.
Closes the eight e2e CI failures left behind by v1.4.39.2 across the
chromium-desktop and chromium-mobile profiles. Two underlying bugs +
one durable test-fixture migration:
- Dashboard slim/thick merge no longer short-circuits when the slim
slice resolves with an empty summaries record. The merge moved to
a pure `mergeSlimAndThickAnalytics` helper with object-emptiness
as the discriminator and eight new behaviour-level unit tests.
- `MeasurementList` non-grouped rows now preserve decimal precision
(`fmt.number` instead of `fmt.integer`) so a 78.4 kg reading stops
truncating to "78 kg". Grouped daily aggregates for cumulative HK
types stay on `fmt.integer` because their readings are integer-
only by definition.
- Playwright route mocks for `/api/analytics` migrate to the regex
form across every authenticated spec so the slim and thick slice
URLs intercept under one handler, matching the v1.4.37 W-CI lesson.
No schema migration; runtime + test-fixture-only.
Playwright's literal `**/api/analytics` string glob does not match
the sliced URL form `/api/analytics?slice=summaries` the v1.4.39.2
dashboard split fires alongside the thick request. The slim request
fell through to the real route on every authenticated spec, and
because the seeded e2e user carries no measurements, the real slim
slice returned empty summaries — exactly the case the v1.4.39.3
production merge fix already guards against, but the regex form is
the durable alignment with the v1.4.37 W-CI lesson that already
swept `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`.
Ten specs migrate: dashboard, chart-overlay-controls,
measurement-flow, charts-mobile, a11y, v1427-responsive-sheet,
v1427-coach-launch, v1427-measurements-add-param,
v1427-insights-empty-state, onboarding-tour-passthrough. Every spec
now uses `/\/api\/analytics(\?|$)/` so the slim AND thick slice URLs
intercept under one route handler.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.