Commit Graph
283 Commits
Author SHA1 Message Date
Marc-André Bombeck f8de4b05fd perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan
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)
2026-05-21 11:52:09 +02:00
Marc-André Bombeck 691443e264 test(query-keys): pin factory enforcement and dedup behaviour
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.
2026-05-21 11:50:37 +02:00
Marc-André BombeckandClaude Opus 4.7 a3b0dc920b test(consent): pin endpoint behaviour and append-only invariant
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>
2026-05-21 11:49:22 +02:00
Marc-André BombeckandClaude Opus 4.7 d6809e3d54 feat(consent): POST + GET + DELETE endpoints at /api/consent/ai
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>
2026-05-21 11:49:11 +02:00
Marc-André Bombeck 3cacfcf94c feat(dashboard): per-tile Suspense boundaries for progressive paint
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.
2026-05-21 11:48:10 +02:00
Marc-André Bombeck 128d863763 test(privacy): render assertions for the GDPR-required sections
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.
2026-05-21 11:47:42 +02:00
Marc-André Bombeck c79d91736c feat(privacy): public privacy policy page at /privacy with DE + EN content
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.
2026-05-21 11:46:50 +02:00
Marc-André Bombeck 97920f0f59 fix(measurements-route,series,timeline): exclude soft-deleted in list reads
`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.
2026-05-21 11:45:26 +02:00
Marc-André Bombeck 1bcaae4764 fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries
`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.
2026-05-21 11:44:00 +02:00
Marc-André BombeckandClaude Opus 4.7 8187d54969 feat(apns): time-sensitive interruption-level for medication reminders
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>
2026-05-21 11:43:41 +02:00
Marc-André Bombeck 433874e390 test(analytics,db): pin concurrency cap and pool ceiling
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).
2026-05-21 11:39:54 +02:00
Marc-André Bombeck 8d6f1cf8d8 feat(aasa): serve apple-app-site-association at the well-known path
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).
2026-05-21 11:38:52 +02:00
Marc-André Bombeck 96b2ee4c1c perf(analytics): bound 15-way Promise.all to 4 concurrent type-fetches
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.
2026-05-21 11:37:40 +02:00
Marc-André Bombeck db2c70a78d test(medications): pin skipDuplicates flag on intake-route backfill
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.
2026-05-21 11:23:17 +02:00
Marc-André Bombeck 341483de3f fix(medications): swallow concurrent-projection duplicates in intake backfill
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.
2026-05-21 11:19:04 +02:00
Marc-André Bombeck 7c0a722309 fix(medications): backfill today-intakes inside dashboard summary route
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.
2026-05-21 11:17:48 +02:00
Marc-André Bombeck 10e658f66d fix(medications): project daily-schedule intakes for today's window
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).
2026-05-21 10:59:25 +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 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 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 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 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 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 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 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 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 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 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 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 9cc5ececa7 chore(release): v1.4.38.7
Rollup recompute observability + admin trigger.

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

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

`AppError` now detects the chunk-load family of errors
(`ChunkLoadError`, `Loading chunk`, `Failed to load chunk`,
`Failed to fetch dynamically imported module`) and triggers a single
`window.location.reload()`. `sessionStorage` gates the auto-reload
to once per session so an unrecoverable error doesn't loop into a
reload storm. Browsers in strict-privacy mode fall through to the
error UI cleanly.
2026-05-17 23:56:04 +02:00
Marc-André Bombeck d224811a34 chore(release): v1.4.38.2
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped:

- Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English.
- Dedup ledger committed before delivery — a transient APNs blip
  silently nuked the day's nudge and blocked retries.
- DST-unsafe arithmetic drifted the local-22:00 baseline by an hour
  twice a year for both the mood-reminder push and the medication
  iOS-snooze action.
- APNs payload forwarded the Telegram `replyMarkup` inline-keyboard
  object and other channel-specific extras to Apple's servers and
  the iOS notification handler.
- No Settings UI for the per-user opt-in flag — the feature was
  unreachable for end users regardless of the other fixes.

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

See CHANGELOG.md.
2026-05-17 23:46:09 +02:00
Marc-André Bombeck a550031a84 chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
2026-05-17 19:07:03 +02:00
Marc-André Bombeck 98e078d359 v1.4.37.1 — event-loop unblock on the read path
Hotfix on top of v1.4.37. The post-deploy verify caught
/api/analytics cold-mount hits of ~62 s and parallel /api/health
checks of ~58 s on the live instance, far above the v1.4.37
1.5-3 s perf claim. Root cause: the synchronous
ensureUserRollupsFresh(userId) call at the top of each heavy
read path folds the trailing 90-day DAY window when the rollup
watermark trails the newest measurement. On a power-user account
whose iOS step samples push the watermark every few minutes,
each cache miss synchronously paid the full refresh cost on a
Node worker that also serves the rest of the request fan-out
(including /api/health, /api/version, and concurrent iOS calls),
and the event loop was blocked for the duration of the
recompute.

Convert the three call sites (analytics route, comprehensive
aggregator, summaries slice) to fire-and-forget. The downstream
probeRollupCoverage gate falls back to live SQL on partial
coverage so correctness is preserved; the only user-observable
change is one cache window of staleness on the very first
request after a new measurement landed.
2026-05-17 16:37:51 +02:00
Marc-André Bombeck 88028fa082 v1.4.37 — final web polish before iOS focus
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every
Coach affordance behind a single feature gate with an invariant
test pin, lifts the medication-detail Mounjaro card to byte-
equivalent parity with Ramipril, brings the Insights overview
hero row to height parity, hardens getClientIp behind a new
TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card
on Settings -> Export, adds the Medikamenteneinnahme quick-add
overlay to the dashboard, drops the timezone override toggle for
a silent browser-zone auto-seed, and fixes the v1.4.36 red CI
workflows. Documentation refreshed across all three repos with
five new architecture diagrams in the Dracula palette. Unit suite
4354 -> 4486 passing; typecheck + lint clean.
2026-05-17 15:36:38 +02:00
Marc-André Bombeck 0e2a99f8e7 v1.4.36 — perf, charts, AI payload trim, UX punch
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
2026-05-17 13:22:51 +02:00
Marc-André Bombeck 8dc9edb65e v1.4.35 — persistent measurement rollups + partial read-swap
Foundation release for the persistent measurement_rollups cache tier.
Adds the additive table + enum, populates it from every measurement
write hook, lands the Apple-Health-import end-of-job backfill, and
ships the parity-checked read-swap on two reader surfaces.

The comprehensive insights aggregator and the slim analytics
summaries slice now source count / min / max / mean per type from the
DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets. A defensive parity check against the
live aggregate's COUNT(*) falls back to live SQL when divergence is
detected.

Includes the new scripts/backfill-rollups.ts CLI for the one-shot
fold of historical measurements into all four granularities.

Tests: 4280 unit + 228 integration. tsc + lint clean. Migration
0067_v1434_measurement_rollups is idempotent under re-run.
2026-05-17 09:55:33 +02:00
Marc-André Bombeck 9715831f82 v1.4.34.5 — audit follow-on: critical-path tests + iOS textarea zoom
Follow-on to v1.4.34.4.

Tests:
  24 new integration tests across 5 files closing the highest
  critical-path gaps the test-coverage audit flagged:
    - auth-password-change.test.ts pins the v1.4.34.3+
      destroyAllSessions three-transport revocation contract.
    - admin-reset-password.test.ts mirrors the same shape for
      admin resets.
    - withings-oauth.test.ts covers connect / callback / disconnect
      + state-token replay rejection.
    - passkey-register.test.ts covers register-options +
      register-verify + tampered-attestation + challenge single-use.
    - cache-invalidation-coverage.test.ts grids the IW-G
      invalidation matrix across 5 write surfaces.
  Integration suite: 197/50 → 222/55. tsc + lint clean.

Mobile:
  Five textareas no longer trigger iOS Safari zoom-on-focus. The
  Coach composer, bug-report description, admin feedback note,
  medication-side-effects notes, and medication-intake JSON
  import all shipped text-sm (14 px). iOS Safari zooms whenever
  an input renders below 16 px. Switched each to
  `text-base sm:text-sm` so mobile clears the floor while desktop
  keeps the compact look.
2026-05-17 00:53:03 +02:00
Marc-André Bombeck f5962497d6 v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.

Security
  - Password rotation now wipes API tokens + refresh tokens in the
    same `$transaction` as session revocation. A stolen iOS Bearer no
    longer survives a user-initiated password change or an admin reset.
  - `/.well-known/` proxy bypass tightened to an explicit-allowlist
    `Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
  - HSTS gains `preload`. Withings host removed from the global CSP
    and gated to the integration's own routes.
  - `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
    mismatch + exposes a tagged helper for tighter universal buckets.
  - Service worker rejects off-origin notification-click URLs. Offline
    fallback body switched to a locale-neutral icon. Manifest gains
    `scope`, `id`, `display_override`.

UX + accessibility
  - i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
    announcement, and the notifications-section breadcrumb.
  - Focus rings on bottom-nav, the More overflow button, and the
    mobile top-bar user menu.
  - Tap-target floor: Input / NativeSelect / Select / DateTimeInput
    switch to `h-11 sm:h-10`.
  - Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
  - List-page subtitles visible on mobile.
  - Insights empty-state CTA vocabulary normalised so the link opens
    the right measurement form (was silently failing for
    BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).

Mobile-deep
  - `viewport.themeColor` reacts to light/dark mode.
  - TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
    notched iPhones.
  - Sonner toaster switches to `theme="system"`.

Code quality
  - `SESSION_SECRET` dropped — the env var was documented as required
    but never read by code. Three secrets, not four. Self-hosters
    upgrading can delete the existing line.
  - `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
  - `db-compat.ts` gains a header docstring naming it the
    schema-bootstrap path.
  - Passkey response types adopted from `@simplewebauthn/server`;
    `any` + eslint-disable pragmas drop out.
  - Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.

Documentation + discoverability
  - README rework: How-it-compares matrix, Apple Health + AI Coach
    promoted to standalone Key-Features bullets, status block, latest
    release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
  - openGraph + twitter cards now carry `images` and a
    `summary_large_image` Twitter card.
  - GitHub repo metadata: description tightened from 290 to 156
    chars + keyword-front, `homepageUrl` flipped from a personal
    tenant to the demo site, six generic / PII-adjacent topics
    swapped for six high-intent ones.
  - Five new user-facing docs: `docs/self-hosting/getting-started.md`,
    `docs/self-hosting/reverse-proxy.md`,
    `docs/integrations/apple-health.md`,
    `docs/integrations/withings.md`,
    `docs/integrations/ai-providers.md`.
  - `docs/README.md` clarifies the docs tree as internal playbooks
    vs the user-facing `docs.healthlog.dev` site.
2026-05-17 00:39:58 +02:00
Marc-André Bombeck 2ef7f04e89 v1.4.34.3 — remove the dashboard Coach CTA
Per maintainer directive — the v1.4.34 IW-B "Frag den Coach" sparkles
button sat next to the "Hinzufügen" dropdown on the dashboard hero
and read as visual noise rather than a useful surface. Remove it; the
Coach drawer remains reachable from the /insights tree (its original
home), and the auth-shell `<CoachLaunchProvider>` + drawer mount stay
intact so any future page that wants its own opt-in CTA can call
`askCoach()` without churn.

The hook usages this leaves orphaned in `src/app/page.tsx`:
  - `useCoachLaunch` (only consumer was the CTA)
  - `useFeatureFlags` (only gate was `featureFlags.coach`)
  - `Sparkles` icon import

All drop out cleanly.

First release that lets v1.4.34.2's `pull_policy: always` show its
worth — the deploy mechanic should pick up the new image without a
manual host-side retag fallback.
2026-05-17 00:06:18 +02:00
Marc-André Bombeck f6ca89b963 v1.4.34.1 — insights cold-mount perf hotfix + scatter-card sizing
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
every cold load against accounts populated by the v1.4.34 Apple Health
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
blood-pressure classification, target adherence, and four Pearson
correlations from the same pile of rows. On a 100 000+ row account the
route consumed one of the 20 pool connections for the whole 29 s, which
cascaded sibling endpoints into Cloudflare "no available server" 503s.
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
Recharts warning on first paint because its dynamic-mount lacked a
non-zero floor before aspect-ratio resolved.

Changed:
  - /api/insights/comprehensive now reads through a SQL-side aggregator
    (src/lib/insights/comprehensive-aggregator.ts) grouping by metric
    type with Postgres-native AVG / MIN / MAX / REGR_SLOPE / REGR_R2 on
    the measurements (user_id, type, measured_at) index path. Returns
    the same DataSummary-shaped per-type bundle the legacy route
    stitched together; the medication-compliance block keeps its
    bounded Prisma reads. Wrapped through the server cache keyed on
    `${userId}|comprehensive` (60 s TTL). Cold path drops from ~29 s
    to a few hundred ms against fixture data matching the production
    volume that triggered the bug; warm path resolves in single-digit
    milliseconds.
  - Cache-wrap the five remaining read routes the v1.4.34 server-cache
    blueprint left staged but unwired: /api/mood/analytics,
    /api/workouts, /api/bugreport/status, /api/medications,
    /api/dashboard/widgets. The Cloudflare 503 cascade on
    /api/measurements?aggregate=daily resolves as a downstream
    consequence — once the comprehensive route stops monopolising the
    pool, the origin accepts new connections fast enough that
    Cloudflare's circuit-breaker no longer trips.
  - /api/dashboard/chart-overlay-prefs PUT now invalidates the new
    dashboard-widgets cache so the partial-update path doesn't leave
    stale layout state in the cache for up to TTL.

Fixed:
  - Scatter-correlation card no longer logs the Recharts
    `width(-1)/height(-1)` warning on first mount. The wrapper now
    carries a min-h-[180px] floor that matches the loading skeleton so
    ResponsiveContainer always measures a non-zero parent before
    aspect-ratio settles.
2026-05-16 22:26:59 +02:00
Marc-André Bombeck affede31a4 v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.

### Added

- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
  (`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
  LRU shape into a reusable per-route layer with TTL expiry,
  capacity-bounded LRU eviction, single-flight read coalescing, and
  per-instance hit / miss / eviction / stampede counters. Wires the
  three hottest dashboard reads — `/api/analytics` (slim + default),
  `/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
  — and bolts per-user invalidation onto every measurement / mood /
  medication / workout / dashboard-widget write endpoint so the next
  read paints fresh data instead of waiting out the TTL. Every cache
  hit / miss surfaces on the active wide event as
  `cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
  carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
  `POST /api/import/apple-health-export` (synchronous multipart
  upload, asynchronous ingest via a dedicated pg-boss
  `apple-health-import` worker, per-`MeasurementType` ingestion
  stats). Streams the upload straight to disk so a multi-gigabyte
  export never lands in V8 heap, hashes the bytes inline for
  content-based idempotency, and unpacks `apple_health_export/export.xml`
  with a hand-rolled ZIP central-directory walker that handles Zip64
  for archives past the 4 GB barrier. The parser folds every
  `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
  into the existing `Measurement` and `Workout` row shapes — spot
  rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
  `sample:<sha256>` fallback), cumulative HK types collapsed into
  one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
  the iOS daily-aggregation convention. Live per-stage progress
  surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
  An admin variant at `POST /api/admin/import-apple-health-export`
  imports on behalf of a target user (cookie-only `requireAdmin()`
  gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
  the per-upload state machine (`queued | unpacking | parsing |
  upserting | done | failed`), the content-hash for idempotency
  short-circuits, and the per-`MeasurementType` ingestion counters
  the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
  (`?slice=summaries`) and default slices return a per-type
  `{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
  mounts to a new `tileStaleDays()` helper so an "X days / weeks /
  months ago" hint paints under any per-metric tile whose last
  sample crosses the 7-day floor. Six locales gained additive
  week / month plural keys; Polish uses the genitive-plural form
  that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
  existing "Hinzufügen" / "Add" launches the Coach drawer directly
  from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
  the insights layout to the auth shell so the drawer is reachable
  from every authed route; the floating action button stays scoped
  to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
  centralises the three previous consumers — recent-achievements
  card, the `/achievements` mother page, and the unlock notifier —
  onto one TanStack queryKey + cache slot so dashboard cold mount
  fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
  `src/lib/http/cache-headers.ts` module exports
  `NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
  `SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
  presets land at the framework level via a `next.config.ts`
  `headers()` rule that stamps the bfcache-friendly directive on
  every authed HTML response, restoring Chromium bfcache eligibility
  for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
  "top client chunks by parsed size" signal Turbopack dropped from
  `next build`; reads `.next/analyze/client.json` (written by
  `@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
  and prints a sorted table plus totals.

### Changed

- **Settings sidebar: "Sources" folded into "Targets & Sources".**
  Per-metric threshold ranges and per-metric source priority now
  share one `/settings/thresholds` page so the same metric's
  threshold and device-source preference sit together instead of
  one sidebar entry away. `/settings/sources` keeps a
  `permanentRedirect` so external bookmarks and docs links follow
  through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
  parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
  Temperature, Active Energy) hide behind one parent pill that
  opens a popover sub-list. Parent-pill active state mirrors the
  URL so spatial orientation survives the collapse; each sub-page
  keeps its own URL and bookmark resolves unchanged. Strip
  footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
  inspection captured the section-1 / section-2 duplicates that
  have accumulated under apps-01 since v1.3.1. Audit pinned at
  `.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
  deletes performed (operator action).

### Fixed

- **Compliance classifier: early intakes count as compliant.** The
  classifier picked up a dedicated `early` bucket (window-start
  minus three hours through window-start), the `on_time` post-window
  tolerance widened from one hour to three hours, and the `late`
  band sits between the new `on_time` ceiling and the configurable
  `lateMinutes` knob. Heatmap consumers route the new bucket
  through the compliant path; the dedicated `early` counter rides
  on `DailyComplianceEntry` for downstream consumers that want to
  distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
  `looksClassifierBug` fallthrough is gone now that the underlying
  classifier widening removes the every-dose-very-late mode the
  fallback covered.

### Performance

- **`/api/gamification/achievements` consumer collapse.** Two of
  three previous consumers shared the same TanStack queryKey
  literal; the third carried a per-user discriminator so the cache
  treated it as a fresh slot. Dashboard cold mount fired the
  endpoint twice. Collapsing onto the shared hook trims that to a
  single request; with the new server-side cache slot warm, the
  duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
  `docker-publish.yml` workflow lost its `push.branches: [main]`
  trigger; the `:latest` raw-tag enable rule moved onto the tag
  ref so each release produces exactly one multi-arch build that
  refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
  `outputFileTracingExcludes` block that narrows the Turbopack
  tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
  the trace-report no longer warns on every dependent route file.

### Refactor

- **Dashboard analytics consumers.** The two cards that previously
  decided their own freshness copy now lean on the typed
  `tileStaleDays()` helper and the additive `lastSeenByType` field
  on the analytics response. Mood and BD-Zielbereich tiles keep the
  default `null` because they have no underlying per-type freshness
  signal.

### Accessibility

- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
  The new "Ask the coach" pill carries `min-h-11` on mobile and
  the matching `sm:min-h-9` desktop floor so the touch target
  stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
  tech.** Parent pill carries `aria-current="page"` whenever the
  current URL matches one of its child vital sub-pages, plus a
  `data-slot="insights-tab-strip-group"` hook for visual-regression
  testing.

### Internal

- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
  its 12-sample 50 ms poll loop for a single Playwright
  auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
  1-2 ms race window between the analytics-pending shell and
  `useAuth().user` resolving collapses to a single retry slot.
  `mobile-viewport` dropped `nav a[href]` from the touch-target
  sweep (the bottom-nav owns its own WCAG spec) and gated the
  44-px floor on `matchMedia('(min-width: 640px)').matches === false`
  so the Pixel 5 viewport never tripped into the `sm:` tier during
  WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
  an `annotate` callback that lands two keys on the wide event:
  `cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
  `cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
  can grep `cache.analytics.outcome` over any time window to
  compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
  handler serves a direct 200 on every fronting origin; Apple's
  CDN has ingested matching bodies for all three domains.

### Web freeze

v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
2026-05-16 21:25:26 +02:00
Marc-André Bombeck 7bef199039 v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.

Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.

Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.

The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
2026-05-16 19:46:43 +02:00