Commit Graph
389 Commits
Author SHA1 Message Date
Marc-André Bombeck 01ae3b542a feat(rollups): v1.4.35.1 — auto-converge rollups on worker boot
Removes the operator step that was implicit in the v1.4.35 foundation.
Self-hosters no longer need to run the backfill script after upgrade.

New `rollup-full-backfill` queue + handler folds one user per job at
serial concurrency. `enqueueBootTimeRollupBackfill` runs once at
`startReminderWorker` boot, discovers accounts with measurements but
zero rollup coverage, and enqueues one full-fold job per uncovered
user with a singleton key so fast restarts don't pile up duplicates.

Best-effort: discovery errors surface through the helper's return
value and are logged via workerLog; the worker boot never fails
because the backfill missed.

Tests: 4280 → 4285 unit + 228 → 230 integration. tsc + lint clean.
2026-05-17 11:28:21 +02:00
Marc-André Bombeck 67ccd55398 feat(rollups): v1.4.35 — partial read-swap onto measurement_rollups
Switches two reader surfaces onto the persistent rollup table for the
linearly-composable stats. The comprehensive insights aggregator and
the slim analytics summaries slice now source count / min / max / mean
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: covers the cold-mount
edge case where the rollup populator is mid-flight, and degrades
cleanly on the slim slice's all-time window for accounts that never
ran the explicit backfill.

Three new integration cases pin the parity contract end-to-end
against a real Postgres testcontainer: the aggregator's
count / min / max / mean matches live SQL byte-for-byte, dailyByType
matches a parallel date_trunc('day', ...) GROUP BY query, and a
freshly-written measurement is reflected on the next read.

tests: 4249 → 4280 unit + 222 → 228 integration. tsc + lint clean.
2026-05-17 09:53:41 +02:00
Marc-André Bombeck 9872081d0e feat(rollups): MeasurementRollup foundation — populator + write hooks + warm-on-read
WIP toward v1.4.35. Lands the persistent rollup foundation from the
v1.4.34 cache-aggregation research §9. Reads still served by the
v1.4.34.1 live-SQL aggregator — the parity-safe read-swap
(count/min/max/mean + dailyByType from buckets, slope/r2/sd stay on
live aggregation) ships in a follow-up commit so v1.4.35 is the
single release that flips both foundation + read-swap.

Schema:
  - new MeasurementRollup model with composite PK
    (userId, type, granularity, bucketStart) and a descending
    bucketStart index. RollupGranularity enum (DAY/WEEK/MONTH/YEAR).
  - Prisma migration 0067_v1434_measurement_rollups is additive:
    CREATE TYPE / CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT
    EXISTS / ADD CONSTRAINT FK. No ALTER on existing tables.
  - db-compat schema-bootstrap path mirrored.

Populator (src/lib/measurements/rollups.ts):
  - recomputeBucketsForMeasurement: sync DAY; WEEK/MONTH/YEAR via
    pg-boss (singleton-keyed).
  - recomputeUserRollups: bounded full-range, used by Apple Health
    import + backfill.
  - ensureUserRollupsFresh: cheap stale-detection + bounded DAY-90d
    recompute. Error-swallowing.
  - SQL via $queryRawUnsafe + strict enum whitelist for date_trunc
    unit (Postgres 42803 forbids binding the unit literal).

Write hooks on every measurement-mutating endpoint; each wrapped
in try/catch so a populator hiccup never fails the write.

Apple Health import worker recomputes the full range once at
completion (per-row hooks would multiply cost).

Backfill (scripts/backfill-rollups.ts): idempotent, single-user
serial, single-digit minutes for a 100k-row account.

Tests: +14 unit, +3 integration. setup.ts.truncateAllTables
extends to the new table.

Read-swap follow-up:
  - comprehensive-aggregator + analytics/route + summaries-slice
    each call ensureUserRollupsFresh as a read side-effect (warms
    the table). The response stays on the live-SQL aggregator so
    byte-shape parity with v1.4.34.5 is preserved.
  - Next commit lands the partial read-swap: count/min/max/mean
    and dailyByType from buckets (math-identical compose), while
    slope/r2/sd stay on indexed live aggregation. Together they
    ship as v1.4.35.

Pre-shipping state — do NOT tag v1.4.35 from this commit alone.
2026-05-17 09:32:30 +02:00
Marc-André Bombeck 999fc257ae code-quality: drop dead SESSION_SECRET, add toJson helper, document db-compat, type passkey responses, lock zero-TODO
Five hotfix-ready findings from the v1.4.34.3 code-quality audit.

SESSION_SECRET drop (F-3):
  The env var was required by `docker-entrypoint.sh`, listed in
  `.env.example`, `docker-compose.yml`, `CONTRIBUTING.md`, and the
  scaling guide — but `grep -rn SESSION_SECRET src/` returned
  nothing. Sessions are server-stored in `prisma.session` keyed by
  the cookie value; the secret signed nothing. Every self-hoster
  carried a ceremonial 64-char hex string that the app ignored.
  Removed everywhere except the README (separate batch). Each fresh
  install now generates 3 secrets, not 4.

toJson helper (F-9):
  Seven sites cast Prisma JSON writes via `value as unknown as
  Prisma.InputJsonValue`. Each call re-invented the same escape
  hatch. Replaced with one helper in `src/lib/db.ts`:
  `export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
  Migrated all 7 call sites: `auth/me/doctor-report-prefs`,
  `dashboard/chart-overlay-prefs`, `dashboard/widgets`,
  `integrations/healthkit`, and 4 sites in
  `apple-health-import-worker`. The helper has a one-line WHY
  comment + a unit test pinning the cast shape stays stable.

db-compat docstring (F-10):
  `src/lib/db-compat.ts` looks like accumulated backwards-compat
  cruft on first read. It's actually the schema-bootstrap path that
  runs ALTER-TABLE-IF-NOT-EXISTS so a fresh container syncs without
  `prisma migrate deploy`. Added a header docstring naming the role
  and pointing at `prisma/migrations/` as the canonical history.

Passkey types (F-11):
  `src/lib/auth/passkey.ts:103,169` used `response: any` with
  per-line `eslint-disable`. Imported `RegistrationResponseJSON` and
  `AuthenticationResponseJSON` from `@simplewebauthn/types` and
  dropped the disables.

Zero-TODO CI gate (F-15):
  The audit confirmed product code has zero TODO / FIXME / XXX /
  HACK markers — genuinely rare and worth preserving. Added a tiny
  GitHub Actions workflow at `.github/workflows/no-todo-markers.yml`
  that fails the build on any future re-introduction. Test files
  are excluded; product code only.
2026-05-17 00:34:21 +02:00
Marc-André Bombeck 6885703505 security: HSTS preload + Withings CSP gating + SW URL trust + manifest hygiene
HSTS gains `; preload` so the domain becomes eligible for the
Chromium preload list. Withings CSP host moves from global to a
path-gated entry (mirrors the existing AI-host gating) — closes a
DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns
once per process when `TRUST_PROXY_HOPS` and the XFF chain length
disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so
future callers can route to a tighter universal rate-limit bucket
when the chain looks tampered with.

Service worker now resolves push-notification payload URLs against
`self.location.origin` and rejects off-origin redirects (falls back
to `/`). The German-only offline fallback HTML body was replaced
with a language-neutral icon + 'Offline' wordmark so EN / FR / ES /
IT / PL users don't see German on a cold offline boot. Manifest
gained `"scope": "/"`, `"id": "/?source=pwa"`, and
`"display_override": ["standalone"]` so future sub-path deploys
don't accidentally inherit root scope and install-prompt heuristics
anchor on a stable id.

+16 new test cases across two new files covering HSTS preload, CSP
gating on/off, the once-per-process warning, the tagged helper's 4
shape branches, SW same-origin guard, offline-locale-neutrality, and
manifest hygiene. Existing proxy/SW suites stay green.
2026-05-17 00:33:34 +02:00
Marc-André Bombeck 58c5416bc9 security: destroyAllSessions wipes API tokens + refresh tokens; tighten well-known proxy bypass
Two findings from the v1.4.34.3 security audit, both with the same
shape — every authenticated surface a user owns should rotate when a
credential-rotation path fires, and unfamiliar discovery sub-paths
must not inherit "no auth" status by default.

destroyAllSessions:
  Previously only ran `prisma.session.deleteMany`, leaving every
  long-lived `ApiToken` row (Bearer credentials issued via
  /settings/api-tokens) and every native-client `RefreshToken`
  intact. A stolen iOS Bearer kept working after the user rotated
  their password or after an admin reset; only the browser cookie
  flipped. Wraps the cleanup in a $transaction that flips
  `apiToken.revoked = true` and `refreshToken.revokedAt = now()` in
  the same atomic block so the audit trail survives. Both lookups at
  `src/lib/auth/api-token.ts` and `src/lib/auth/refresh-token.ts`
  already filter on these columns; no second code path needed.

  Same destroyAllSessions call already runs from:
    - POST /api/auth/password (user-initiated change)
    - POST /api/admin/users/[id]/reset-password (admin reset)
    - DELETE /api/settings/account (account deletion)

  All three paths now wipe every transport in one shot.

Well-known proxy bypass:
  `src/proxy.ts` PUBLIC_PATHS carried "/.well-known/" with a prefix
  match — a future route at `/.well-known/foo` would inherit "no
  auth" status silently. Tighten to an exact-match Set
  (WELL_KNOWN_PUBLIC_PATHS) seeded with the one path we actually
  serve (`apple-app-site-association`). New routes must be added
  explicitly.

  Also drops the stale `/api/auth/codex/callback` PUBLIC_PATHS
  entry — that route doesn't exist in the codebase (the Codex
  OAuth flow uses `/api/auth/codex/{device-poll,device-start,
  disconnect}` instead).

Tests:
  - New destroyAllSessions assertion in src/lib/auth/__tests__/
    session.test.ts pins the three updateMany/deleteMany call
    shapes inside a single $transaction.
  - Existing proxy well-known + privacy + onboarding redirect
    suites stay green.
2026-05-17 00:21:44 +02:00
Marc-André Bombeck 49e599b04e perf(insights): SQL-aggregator + cache for comprehensive route
The /api/insights/comprehensive handler used to walk every measurement
of every type from the trailing 90-day window into JS, then ran
summarize(), BMI, BP classification, target adherence, and four Pearson
correlations against the same pile of rows. Post Apple Health import the
unbounded findMany routinely returned 100 000+ rows, dominated request
time (~29 s in a production HAR capture), and starved the connection
pool — sibling endpoints cascaded into Cloudflare 503s.

Replace with a SQL-side aggregator in
src/lib/insights/comprehensive-aggregator.ts:

  - Per-type DataSummary built via $queryRaw with AVG / MIN / MAX /
    STDDEV_POP / REGR_SLOPE / REGR_R2 on the
    measurements (user_id, type, measured_at) index. Anomaly count uses
    STDDEV_POP to match the JS divisor (n, not n-1) and the same
    round-then-threshold ordering as summarize().
  - DISTINCT ON (type) for the latest raw value per type.
  - date_trunc('day') daily means for correlation pairings.
  - Two bounded findMany for BP sys/dia raw rows so the 5-minute
    pairByTimestamp contract for bpPctInTarget survives byte-identical.
  - One conditional MIN(measured_at) for dataSpanDays.

Route handler split into a buildComprehensiveResponse() helper wrapped
through caches.analytics (60 s TTL) keyed on `${userId}|comprehensive`.
The 60 s TTL converts every consumer within the window — page mount,
Coach drawer, recommendations grid — to a Map lookup; cold path drops
to a few hundred ms against fixture data matching the production volume
that triggered the bug.

One sanctioned semantic divergence: weight × BP correlation switches
from pairByTimestamp(24 h tolerance) to a daily-key join. The directive
authorised the swap for parity with the daily-mean shape mood × X
correlations already used; consumers comparing exact r/n against legacy
output may observe small drift.

Unit + integration tests cover the aggregator shape, cache hit / miss
counters, wide-event annotations, and end-to-end envelope parity.
2026-05-16 22:24:41 +02:00
Marc-André Bombeck a5dd8e1d1a feat(cache): server-side LRU primitive + per-user invalidation helpers
Extends the v1.4.33 Coach snapshot LRU shape into a reusable
ServerCache<T> with TTL expiry, capacity-bounded LRU eviction,
single-flight read coalescing, prefix delete, and per-instance
hit/miss/eviction/stampede counters. Adds five invalidation helpers
keyed by the per-user prefix scheme so write endpoints can flush every
cache that may reflect their changed rows in a single call.

The cached() wrapper attaches cache.<name>.outcome + cache.<name>.key_hash
to the active wide event so production logs carry the hit ratio without
leaking the userId portion of the cache key.

Hand-rolled to keep the dependency surface at zero — same recipe as the
snapshot cache the dashboard already relies on. Process-local; multi-
instance staleness is bounded by TTL, the Redis migration path stays a
future option.
2026-05-16 20:55:05 +02:00
Marc-André Bombeck bcca7664a5 refactor(insights): collapse five wave-A vital pills under a Vitals parent
The Insights tab strip carried fourteen pills end-to-end after the
v1.4.32 wave-A HealthKit additions: Overview + thirteen metrics.
On a `<sm` viewport that meant Overview + roughly two-and-a-half
metric pills before the right-edge fade hid the rest.

The five wave-A entries (HRV, Resting HR, Oxygen, Body Temperature,
Active Energy) collapse behind a single "Vitals" / "Vitalwerte"
parent pill that opens a popover sub-list. Each sub-page keeps its
own URL and own page component — only the strip presentation
changes, so deep-links, bookmarks, and the iOS app's surface paths
all resolve unchanged.

Implementation:
- `SUB_PAGE_GROUP` + `SUB_PAGE_GROUP_ORDER` in
  `sub-page-metric.ts` flag a sub-page as belonging to a parent
  pill. Adding a new group is one row in `SUB_PAGE_GROUP` plus a
  matching label key under `insights.tabStrip.<group>Parent.*`.
- `<InsightsTabStrip>` discriminated `TabEntry` union — flat
  `<Link>` pills coexist with `Popover`-driven group entries.
  Availability gating sits on the leaf slugs: the parent pill is
  only rendered when at least one of its children has data.
- Parent-pill active state mirrors the URL — when a user is on any
  vital-sign sub-page, the parent pill carries `aria-current="page"`
  and the `border-primary` style so spatial orientation survives
  the collapse.

Reduces the strip footprint from 14 to 10 entries when every metric
has data — Overview + Blood Pressure + Pulse + Vitals (parent) +
Weight + BMI + Workouts + Sleep + Mood + Medication.

Per `.planning/research/v1434-r-2-carryover-scope.md` §8 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 1).
2026-05-16 20:48:05 +02:00
Marc-André Bombeck c6528b9340 test(import): cover Apple Health export.zip parser + endpoints
Adds 36 unit + contract tests across 7 files:

* `import-apple-health-export.test.ts` — end-to-end parse of a tiny
  hand-authored fixture (spot weight, cumulative steps fold, sleep
  stage, workout with sport mapping, deferred + unknown identifiers,
  clinical record skip). Re-import idempotency: a second run reports
  0 inserts + N updates. Memory ceiling: synthetic 10 000-record
  export keeps heap delta under 100 MB.
* `hk-workout-activity-type-map.test.ts` — every value referenced
  in the map exists in the canonical `workoutSportTypeEnum` union;
  the iOS 18 canonical roster is covered; unknown identifiers fall
  through to `"other"` with a falsy `known` flag.
* `unzip-export-xml.test.ts` — central-directory walker round-trips a
  hand-built single-entry deflate ZIP; extractor throws on a missing
  `export.xml` member.
* `stream-to-disk.test.ts` — boundary parser; named-field capture +
  SHA-256 + text-field collection; size-cap enforcement; boundary-
  missing error path.
* Route tests — auth gate (401), 403 on non-admin to the admin
  variant, 413 on content-length cap, 429 on rate-limit; status
  endpoint admits owner + triggering admin and 404s otherwise.
2026-05-16 20:41:02 +02:00
Marc-André Bombeck c6345b71fa test(queries): pin achievements hook collapse + audit analytics consumers
v1.4.34 IW-F-Perf — three changes.

1) New `use-achievements-query.test.tsx` mounts three consumers under
   one TanStack QueryClient and asserts the shared queryKey collapses
   them into a single network call. Pre-fix the notifier issued its
   own request alongside the card; the regression guard surfaces it
   immediately if a future drift reintroduces a per-user
   discriminator.

2) Refresh `recent-achievements-card.test.tsx` and
   `achievements/__tests__/page.test.tsx` to mock the new shared hook
   directly instead of the raw `@tanstack/react-query` `useQuery`
   shim. The two test suites stay scoped to render-shape, not the
   network plumbing.

3) Capture the `/api/analytics` slim-vs-thick audit in
   `.planning/round-v1434-iwfperf-analytics-audit.md`. The dashboard
   root reads `bpInTargetPct*` + `glucoseByContext` so it must stay
   thick; the checklist is correctly slim by IW2 design but is
   mounted inside the dashboard render tree so its slim call
   duplicates the thick one already in flight. Documented two clean
   migration paths and flagged the work for v1.4.34.x behind IW-B's
   in-flight slim-slice routing.
2026-05-16 20:40:34 +02:00
Marc-André Bombeck ca7ca0e7d6 feat(queries): shared achievements query hook
v1.4.34 IW-F-Perf — three consumers
(`<RecentAchievementsCard>`, `/achievements` mother page,
`<AchievementUnlockNotifier>`) each opened their own `useQuery`
block against `/api/gamification/achievements`. Two of them collided
on the same `["gamification", "achievements"]` literal; the notifier
carried a per-user discriminator on its key so TanStack treated it
as a fresh cache cell. Dashboard cold mount fired the same endpoint
twice.

The hook mirrors `use-analytics-query.ts`: centralises the queryKey
via `queryKeys.gamificationAchievements()`, 60s staleTime, no
refetch-on-mount, optional `refetchInterval` for the notifier. One
cache cell, one network call, no behavioural change for the toast.
2026-05-16 20:39:36 +02:00
Marc-André Bombeck 73cd44e009 feat(import): Apple Health export.zip endpoints + multipart streamer
Three new routes wire the streaming parser to the wire:

* `POST /api/import/apple-health-export` — user-facing kick-off.
  Streams the multipart upload straight to `/tmp` (no in-memory
  buffer for the 1.5 GB hard cap), hashes the bytes inline for
  content-based idempotency, creates an `ImportJob` row in
  `queued`, and enqueues the `apple-health-import` pg-boss job.
  Returns `202 { jobId }` so the client can poll for progress.
* `GET /api/import/apple-health-export/[jobId]/status` — polling
  endpoint returning the canonical `ImportJobStatusResponse`
  envelope (R-1 §8). Authorises on owner-id OR triggering admin.
* `POST /api/admin/import-apple-health-export` — admin variant
  (cookie-only via `requireAdmin()`; Bearer never elevates) with
  a `userId` text field naming the target user. Same content-hash
  idempotency scoped per target user.

The kick-off path uses a hand-rolled multipart boundary walker
(`src/lib/multipart/stream-to-disk.ts`) so a multi-gigabyte
`export.zip` upload never lands in V8 heap — Next.js's
`request.formData()` buffers the full body. Captured bytes
stream to disk while a SHA-256 hash accumulates in the same pass.

Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3, §5.4,
§5.5, §9.
2026-05-16 20:35:12 +02:00
Marc-André Bombeck 9a8a9e9ab2 feat(analytics): expose per-type lastSeenByType + bfcache-friendly Cache-Control
The dashboard tile strip needs to render an "Letzter Wert vor Xd" caption on
tiles whose latest reading is older than a week so a metric the user hasn't
logged in a while keeps its tile visible (with an explicit how-old hint)
instead of disappearing from the strip.

- `/api/analytics` default path captures the freshest `measuredAt` per
  `MeasurementType` in the existing per-type chunked walk and surfaces it as
  `lastSeenByType[type] = { lastSeenAt, daysAgo } | null`. Types the user
  never logged report `null`.
- `?slice=summaries` (slim slice) carries the same field — the DISTINCT ON
  pass already orders by `measured_at DESC` to pick `latest.value`; adding
  `measured_at` to the projection costs zero round-trips.
- Both responses stamp `Cache-Control: private, max-age=0, must-revalidate`
  (the typed `NO_STORE_BUT_BFCACHE` constant) so back-forward navigation can
  restore the dashboard from bfcache instead of paying a full reload. The
  framework default for cookie-touching routes is `no-store, must-revalidate`,
  which Chromium counts as a hard bfcache breaker.
- `AnalyticsRawPayload` gains an optional `lastSeenByType` field so call
  sites read the shape with type-safety; consumers that don't care stay
  unchanged.

Three new test cases pin the contract: default-path freshness map keyed on
the chunked reader, slim-slice freshness map keyed on the DISTINCT ON pass,
and the bfcache-friendly Cache-Control header on the wire for both slices.
2026-05-16 20:33:34 +02:00
Marc-André Bombeck 9c22789489 feat(jobs): apple-health-import worker queue
Wires the streaming Apple Health export ingest into the existing
`pg-boss` worker process. New queue `apple-health-import` runs at
`localConcurrency: 1` because the parse loop is CPU-bound and a
second 1 GB import would race the first for RSS.

The handler:

* Resolves the mirror `ImportJob` row by `pgBossJobId` (with a
  250 ms retry to absorb the kick-off-vs-worker race).
* Streams `export.xml` through `streamParseExportXml()`, surfacing
  live progress onto the `ImportJob.progress` JSON column every
  ~1 000 records.
* Persists the terminal `ImportJobResult` envelope on `done` or a
  truncated error reason on `failed`.
* Cleans up the upload + extracted XML on terminal state.

On worker startup, `reconcileOrphanImportJobs()` flips any row
stuck in `unpacking | parsing | upserting` to `failed` with
`interrupted_by_restart` so an operator-side restart never leaves
the polling endpoint hung mid-phase.
2026-05-16 20:32:16 +02:00
Marc-André Bombeck 3e2d2c4687 feat(import): streaming Apple Health export.xml parser + mapper
Three additive helpers:

* `import-apple-health-export.ts` — SAX-driven streaming parser that
  folds `<Record>`, `<Workout>`, `<Correlation>`, `<ClinicalRecord>`
  into the existing `Measurement` / `Workout` row shapes. Cumulative
  HK types collapse into one `stats:<HKType>:<YYYY-MM-DD>` row per
  user-local day; spot rows survive verbatim keyed by a stable
  `sample:<sha256>` external id when no `HKMetadataKeyExternalUUID`
  metadata entry is present.
* `hk-workout-activity-type-map.ts` — `HKWorkoutActivityType*` to
  `WorkoutSportType` lookup table covering Apple's iOS 18 SDK
  roster. Unknown identifiers fall through to `"other"` and increment
  a `workouts.unknownActivityType` counter.
* `unzip-export-xml.ts` — hand-rolled ZIP central-directory walker
  + `inflateRawSync` extractor for `apple_health_export/export.xml`.
  Supports Zip64 so multi-year exports past the 4 GB barrier still
  parse. No new archive dependency.

Live progress streams through an `onProgress(ImportJobProgress)`
hook that fires every 1 000 records parsed; the worker persists the
snapshot onto `ImportJob.progress` for the polling endpoint to read.

Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
2026-05-16 20:29:16 +02:00
Marc-André Bombeck 8e05f9227d feat(http): centralise authed Cache-Control with bfcache-friendly preset
Add `src/lib/http/cache-headers.ts` exporting two presets and one
applier. `NO_STORE_BUT_BFCACHE` (`private, max-age=0, must-revalidate`)
replaces the framework's stock `no-store, must-revalidate` on
authenticated page responses; Chromium treats the latter as a hard
bfcache breaker, while the former keeps shared caches out, forces
revalidation on every navigation, and stays bfcache-eligible.
`SHORT_LIVED_PUBLIC` covers static-ish one-hour public payloads.
`applyAuthedHeaders(res)` stamps the bfcache-friendly directive onto
a Response in-place for route handlers that opt into the same posture.

Also add `scripts/print-bundle-report.mjs` (wired earlier as the
`bundle-report` npm script). Turbopack dropped the webpack "First
Load JS" summary line from `next build`; the script reads back the
`.next/analyze/client.json` payload that `@next/bundle-analyzer`
writes when `pnpm analyze` runs and prints a sorted table of the
largest client chunks (parsed, gzip, stat sizes plus totals). Output
restores an at-a-glance signal for client-bundle regressions on
release rounds without re-running Lighthouse.
2026-05-16 20:26:59 +02:00
Marc-André Bombeck 4984bce577 fix(compliance): route early intakes through the compliant bucket
The medication compliance route and the achievement perfect-day
heuristic both bucket the classifier output into `on_time` / `late` /
`very_late`. After the classifier started emitting `early`, both
paths would have silently dropped those doses into `very_late`
(via the trailing `else`). Treat `early` as compliant alongside
`on_time` so the heatmap stays green and the perfect-day streak
survives a proactive logger. The compliance route also surfaces a
dedicated `early` count on `DailyComplianceEntry` for any future
consumer that wants to break it out from `on_time`.

Updates the cadence-pairing docstring to reflect the widened grace.
2026-05-16 20:24:57 +02:00
Marc-André Bombeck 36d5398c92 fix(compliance): widen grace window and add early bucket
The classifier flushed any intake outside the 1h grace into
`very_late`, so a stored UTC offset between `takenAt` and the
schedule window (e.g. a 06:00 UTC log against an 08:00 UTC window)
flagged a fully-compliant day as orange. Widen the pre-window grace
to 3h, introduce a dedicated `early` bucket for doses logged inside
that 3h pre-window band, and extend the on-time tolerance after
windowEnd to 3h as well. The configurable `lateMinutes` option now
sizes the `late` band past the 3h tail; doses beyond that tail still
fall into `very_late`.

Tests rebuild the boundary matrix with a parameterised offset suite
covering -3.5h .. +6h around the window, exercises overnight
schedules with an `early` case, and pins the `lateMinutes` knob.
2026-05-16 20:24:44 +02:00
Marc-André Bombeck 523ee0c7d1 fix(notifications): disambiguate inbox vs channel-config naming
Both the `/notifications` inbox and the Settings -> Notifications
section used to read as plain "Notifications" in every nav surface, so
the user-card dropdown and the settings sidebar looked like two pointers
at the same destination. Rename the inbox to "Notification Center" and
the settings section to "Notification channels"; add a small breadcrumb
to either surface so the user always knows which page they landed on
and can jump to the sibling in one tap.

Locale parity across de / en / es / fr / it / pl.
2026-05-16 17:49:31 +02:00
Marc-André Bombeck 67d05c0de9 perf(analytics): slim /api/analytics?slice=summaries slice (C1)
The default `/api/analytics` path fans out 30+ `Promise.all`-wrapped
chunked findMany reads to feed `summarize()` for every
MeasurementType. For a multi-year HealthKit user the PULSE branch
alone walks hundreds of thousands of rows before the dashboard tile
strip can paint a single arrow — even though the strip only consumes
`count / latest / avg7 / avg30 / min / max / mean` plus the three
slope tuples per type.

Add `?slice=summaries` that resolves the same `DataSummary` shape
from two SQL passes (no JS-side spread anywhere along the chain):

  1. One `$queryRaw` carrying `GROUP BY type` with `COUNT`/`MIN`/
     `MAX`/`AVG` plus 7d/30d windowed averages via `FILTER` plus
     Postgres `regr_slope` + `regr_r2` at 7/30/90-day windows.

  2. One `DISTINCT ON (type)` `$queryRaw` returning the most-recent
     `(type, value)` pair per type so the slim shape can populate
     `latest` (the value at MAX(measured_at), not MAX(value)).

Slope `direction` keeps the JS helper's 0.01-units-per-day "stable"
threshold; `confidence` is `regr_r2` rounded to 2 d.p. — both match
the existing `trendSlope()` contract so the dashboard's arrow / color
logic does not have to branch on slice. Anomaly count, lastMonth, and
lastYear means stay at `null` / `0` because the dashboard tile strip
does not paint them on first paint; the comparison-baseline widget
already opts into the default slice.

The thick `correlations` / `healthScore` / `bpInTargetPct` /
`glucoseByContext` / `sleepStages` / `bmi` blocks stay on the default
path behind a hover-prefetch / explicit slice param. C1 is purely
additive — the route's default behaviour is byte-identical.

Pinned by a 4-case unit test (mocked `$queryRaw`, slope thresholds,
null-slope branch) and a 3-case real-Postgres integration test
covering the empty user, the populated descending-WEIGHT fixture,
and the default-slice-still-thick contract. 199 unit tests pass
across `src/lib/analytics` + `src/app/api/analytics`; the
integration suite picks up `tests/integration/analytics-summaries-slice.test.ts`
on next `pnpm test:integration` run.
2026-05-16 17:33:58 +02:00
Marc-André Bombeck 99af530490 chore(cleanup): retire AssistantDisabledNotice + dead settings.placeholder copy
Dead-code sweep flagged by the v1.4.33 menu audit.

* AssistantDisabledNotice (src/components/insights/assistant-disabled-notice.tsx)
  was introduced in v1.4.31 for the operator-disabled assistant surface
  but never wired into a render site. The v1.4.28 retirement of the
  weekly-report + advisor card removed the planned host components.
  Delete the component, its SSR test, and the four locale strings it
  consumed (insights.briefingDisabledByOperator,
  insights.statusDisabledByOperator, insights.correlationsDisabledByOperator,
  insights.coach.disabledByOperator) from all six locale files. The
  AssistantDisabledError thrown by requireAssistantSurface() and the
  assistant.disabled.<surface> errorCode envelope stay — iOS clients
  read the errorCode to render an inline notice.

* settings.sections.placeholder.coming_soon was an "until extraction
  lands" string for <SectionPlaceholder>. Every slug in
  SETTINGS_SECTION_SLUGS has had a matching component since v1.4.22
  so the placeholder body is unreachable through the type system. The
  defensive component itself stays as a guard, but inherits the
  section's own description for the EmptyState body, retiring the
  dedicated locale key from all six locale files.

Locale-integrity contract holds (parity check green across de, en, es,
fr, it, pl). Comments in api-handler.ts and feature-flags/index.ts
that referenced <AssistantDisabledNotice> are updated to describe
the surface generically.

Refs: .planning/round-v1433-audit-menu.md §6
2026-05-16 17:31:47 +02:00
Marc-André Bombeck af17db5dd5 perf(coach): 60s LRU on buildCoachSnapshot keyed on (userId, scope)
`buildCoachSnapshot()` reads only persisted data — ~10 measurement
findManys plus the GLP-1 / mood / intake side-fetches — yet a single
chat conversation sends 2-4 turns within a minute and rebuilds the
same snapshot every time. Wrap the existing builder (renamed to
`buildCoachSnapshotImpl`) with an in-memory cache keyed on
`(userId, scope.window, sorted scope.sources)` with a 60 s TTL and a
64-entry LRU cap. Different scopes recompute; the cache evicts on the
next eligible read.

`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn — the bulk of the
"blockt komplett" complaint between user-clicks-send and first SSE
`token` frame. The `__resetCoachSnapshotCacheForTests` export gives
the existing two snapshot-test suites a hook so each fixture is read
fresh; both add a `beforeEach` reset alongside the existing
`vi.clearAllMocks()` call. Two new tests pin the cache-hit (one
Prisma findMany call across two `buildCoachSnapshot` calls with the
same scope) and the scope-changed miss (different `window` → two
findManys). Feature-flag tests gain a parallel "per-request
memoisation" describe-block that pins the same behaviour for
`getAssistantFlags()` against a stable mocked event reference.
2026-05-16 17:23:07 +02:00
Marc-André Bombeck 4f63bd5f1e perf(coach): memoise assistant-flags per request + 60s LRU on snapshot builder
Two server-side latency cuts on the Coach drawer open / first turn path.

1. **Per-request memo on `getAssistantFlags()`.** Opening the Coach
   drawer fires five gated fetches in parallel — `/api/insights/chat`,
   the rail list, `/api/insights/comprehensive`, plus the per-metric
   status calls already on the page — and every gated route lands a
   `requireAssistantSurface(surface)` call near the top. Today each
   call issues its own `AppSettings.singleton` `SELECT`. The new
   `src/lib/request-cache.ts` carrier keys a `Map` on the active
   `WideEventBuilder` (already in `AsyncLocalStorage` via the
   api-handler) so the same request reads the row once. Outside an
   event context (unit tests, background jobs) the factory runs
   uncached so behaviour is unchanged.

2. **60-second in-memory LRU on `buildCoachSnapshot()`.** A chat
   conversation sends 2-4 turns within a minute and the snapshot reads
   only persisted data — ~10 measurement reads + GLP-1 + mood + intake
   side-fetches per turn. Cache the result keyed on
   `(userId, scope.window, sorted scope.sources)` with a 60 s TTL and
   a 64-entry LRU cap. Different scopes recompute; the cache evicts
   on the next eligible read. Tests exercise both the hit and the
   scope-changed miss; legacy tests gain an explicit reset between
   describe blocks.

`.planning/round-v1433-audit-perf.md` §3.3 estimates 200-800 ms of
server-side tail saved on the second+ turn and roughly 4×
`AppSettings.singleton` reads avoided on a cold Coach-drawer open.
2026-05-16 17:19:52 +02:00
Marc-André Bombeck adedf05f48 docs(planning): IW6 implementation report for v1.4.33 2026-05-16 17:18:36 +02:00
Marc-André Bombeck 85d74dd913 feat(insights): regroup tab-strip pills by metric category
The wave-A HealthKit pills (HRV, resting HR, SpO2, body temperature,
active energy) added in v1.4.32 stacked at the end of the strip in
insertion order so a left-to-right scan jumped across metric domains
multiple times. Reorder the pills to mirror the
MeasurementCategory overlay at src/lib/measurements/categories.ts:
vitals → body → activity → sleep → cardiovascular → mood → events.

No pill added or removed. The single source of truth is
SUB_PAGE_METRIC in src/lib/insights/sub-page-metric.ts; the strip
iterates SUB_PAGE_SLUGS (derived from the same record) and inherits
the new order automatically. Heavier regrouping (collapse five
wave-A pills under one Apple-Health pill, fold BMI into Gewicht)
is deferred to v1.4.34 — see .planning/round-v1433-audit-menu.md
§7 for the design-review rationale.

Per .planning/round-v1433-audit-runtime.md F9.
2026-05-16 17:16:50 +02:00
Marc-André Bombeck b5060f1422 refactor(insights): fold Math.min/max spreads in the six status helpers
`bmi`, `pulse`, `general`, `weight`, `blood-pressure`, and `mood`
status helpers all built their `summarizeSeries` block with
`Math.min(...series.map(...))` and `Math.max(...series.map(...))`.
The same anti-pattern hit `summarize()` in `src/lib/analytics/trends.ts`
on 2026-05-16 and crashed `/api/analytics` with
`RangeError: Maximum call stack size exceeded` once an
Apple-Health-synced PULSE series outgrew V8's ~125 000-arg ceiling;
`61107e0c` folded that one. The six insight helpers are fed bounded
windows today so they never reached the ceiling, but the spread still
allocates a transient args array on every call and the fold is both
stack-safe and cheaper.

Replace each `Math.min(...) / Math.max(...) / average(...)` triplet
with a single walk that maintains running sum / min / max. The
now-unused `average()` helper is dropped from each file. Pinned by
the existing per-helper unit tests (326 passing in
`src/lib/insights` / `src/lib/analytics` / `src/app/api/analytics`).
2026-05-16 17:13:34 +02:00
Marc-André Bombeck 61107e0c68 fix(api): stop /api/analytics 500 from Math.min/max spread overflow
Production GET /api/analytics returned HTTP 500 for users with very long
measurement histories (request-id d86db8e5… 2026-05-16 14:39:51 UTC).
The wide-event log pinned the failure at Promise.all index 3 (PULSE) with
RangeError: Maximum call stack size exceeded — a power user's Apple-Health
heart-rate stream crossed V8's ~125k function-arity ceiling on the
Math.min(...values) / Math.max(...values) spread inside summarize().

Fold sum, min, and max into a single pass over values so the transient
argument array goes away and the working set stays bounded at the array
itself. Mean is now derived from the same fold's sum/length instead of
a second reduce. No public API change; min/max semantics for an empty
series remain null via the early-exit branch.

Adds a 250 000-row regression in trends.test.ts that raises the exact
production stacktrace before the fix and passes after, plus a new
src/app/api/analytics/__tests__/route.test.ts that pins the route-entry
contract (5 000-row PULSE + zero-row brand-new user) so the upcoming
C1 architectural rewrite can't silently reintroduce a spread anywhere
along the call chain. Touched-file lint and pnpm typecheck clean;
broader src/lib + src/app/api/analytics sweep is 2671 / 1 skipped.
2026-05-16 16:51:58 +02:00
Marc-André Bombeck 1f01b941ae feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy
Adds five sub-pages for the wave-A HealthKit metrics the server has
been storing since v1.4.23 / v1.4.25 but never surfaced on the web:

  - /insights/hrv               → HEART_RATE_VARIABILITY
  - /insights/ruhepuls          → RESTING_HEART_RATE
  - /insights/sauerstoff        → OXYGEN_SATURATION
  - /insights/koerpertemperatur → BODY_TEMPERATURE
  - /insights/aktive-energie    → ACTIVE_ENERGY_BURNED

Each page composes the shared `<HealthKitMetricPage>` scaffold —
`SubPageShell` + `HealthChartDynamic` mounted on its own
`ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA
that points at the existing `/measurements?add=<TYPE>` quick-entry
where a manual entry makes sense, or an Apple-Health-onboarding cue
otherwise. The five new chart-overlay-prefs slots (`hrv`,
`restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`)
let the chart-cog popover persist per metric.

Insights tab strip picks up the five new pills via the existing
`SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation
overlay realigns HRV + resting HR to the `cardiovascular` bucket
per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a
point-in-time signal, derived heart-rate variants are the cardiac
risk surface). Six locales updated with the new sub-page titles,
descriptions, chart titles, and empty-state copy.
2026-05-16 15:53:18 +02:00
Marc-André Bombeck 3f5816112b feat(dashboard): recent workouts tile
Adds `<RecentWorkoutsTile>` as a default-visible dashboard widget
(`recentWorkouts`). The tile reads the top three canonical workouts
through `useWorkouts({ limit: 3 })`, renders each as a deep-link
row with the sport icon + the day + the duration, and falls back
to an Apple-Health-onboarding cue when the workout list is empty.

`DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry
the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up
from the same source-of-truth array. The Settings → Dashboard
layout editor surfaces it under the existing widget toggle list so
users can opt out without touching the layout JSON. Six locales
updated.
2026-05-16 15:47:44 +02:00
Marc-André Bombeck beaee5791a feat(insights): workouts list page at /insights/workouts
Adds the user-visible surface for the workout rows the iOS client
already syncs through `POST /api/workouts/batch`. The page lives at
`/insights/workouts`, consumes the v1.4.32 list endpoint through a
new `useWorkouts()` hook, and renders each row as a deep-link into
the detail page with the sport icon, the date, the duration, and
the optional distance + active-energy chips.

The insights tab strip gains a `workouts` pill driven by an
event-style `hasWorkouts` availability signal — the layout shell
probes the list endpoint with `limit=1` so the pill self-gates on
zero workouts. Empty state mounts the shared `MetricEmptyState`
primitive with a Coach prefill that steers the conversation toward
the Apple Health sync onboarding cue. Six locales updated.
2026-05-16 15:38:07 +02:00
Marc-André Bombeck 8d514ed127 feat(api): expose workouts list + detail endpoints
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).

Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.

Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
2026-05-16 15:29:33 +02:00
Marc-André Bombeck aeff47afde feat(api): gate assistant endpoints on the feature-flag matrix
Every LLM-driven endpoint now reads the operator flag set near the
top of the handler and throws `AssistantDisabledError` when the
relevant surface is off. The api-handler catches the typed error
and returns the 403 envelope:

  { data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }

Gates per the v1.4.31 brief:

  /api/insights/chat            → coach            (POST + GET rail)
  /api/insights/generate        → coach
  /api/insights/comprehensive   → coach
  /api/insights/cards           → insightStatus
  /api/insights/correlations    → correlations
  /api/insights/{weight,bmi,blood-pressure,pulse,mood,
                 medication-compliance}-status → insightStatus

Master `assistantEnabled = false` forces every sub-flag false in
the resolver, so flipping the master 403s every endpoint at once.

Older iOS clients see a generic 403 (their existing 403-handler
covers the case); v1.4.31+ clients read the `errorCode` to render
the `<AssistantDisabledNotice>` empty state. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
2026-05-16 13:46:17 +02:00
Marc-André Bombeck b68206eca4 feat(settings): add 6 assistant-surface feature flags to AppSettings
Operator-side kill matrix for every LLM-driven surface. Master flag
`assistantEnabled` plus five sub-flags carve the seven surfaces:
Coach + chat, Daily Briefing + advisor, per-metric status cards,
correlation narration, Health-Score delta explainer. Defaults are
`true` so existing installs upgrade without losing affordances.

New `src/lib/feature-flags/` module exposes `getAssistantFlags()`,
`resolveAssistantFlags()` and the `AssistantDisabledError` typed
error route handlers throw to surface the 403 +
`errorCode: "assistant.disabled.<surface>"` envelope. The resolver
forces every sub-flag false when the master is off so callers never
have to compose `master && sub`.

Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5:
the same matrix gates server-routed AND iOS on-device surfaces.

Migration 0065 is additive only; `app_settings` gains six boolean
columns, all default `true`, idempotent under `IF NOT EXISTS`.
2026-05-16 13:38:49 +02:00
Marc-André Bombeck 0522652ae2 feat(measurements): wire walking-steadiness + audio-exposure-event through every registry
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:

  - measurement-list-meta.ts label keys + icons + colors so the
    measurements list view renders both types (Gauge for steadiness,
    Volume2 for the loud-listening event)
  - 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
    typeAudioExposureEvent keys; English-fallback copy for the four
    non-EN/DE locales matches the existing v1.4.25 W8d pattern
  - chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
    reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
    in prose; dedicated chart components ship alongside the iOS sync
  - the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
    exclusion set so the canonical enum-extension lint stays green

Full unit suite passes (4035 specs).
2026-05-16 12:23:30 +02:00
Marc-André Bombeck dd487cfddc feat(measurements): add walking-steadiness and audio-exposure-event types
R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values
unlock the iOS Tier-1 surfaces Apple's own Health app shows
prominently.

Schema (migration 0064, additive):
  - measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT

WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a
0..1 fraction; HealthLog stores 0..100 percent (same convention as
oxygen saturation + body fat).

AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the
rolling 7-day average crosses the WHO 80-dBA loud-listening
threshold. Both the environmental + headphone events map to the
same MeasurementType so chart-card consumers pick them up uniformly;
the notes field carries the source token at iOS write time.

Updates ride the same release:
  - Zod measurementTypeEnum gains both values
  - apple-health-mapping.ts maps three HK identifiers
    (AppleWalkingSteadiness, EnvironmentalAudioExposureEvent,
    HeadphoneAudioExposureEvent) and removes the same identifiers
    from HK_QUANTITY_TYPE_DEFERRED
  - categories.ts assigns WALKING_STEADINESS to activity and
    AUDIO_EXPOSURE_EVENT to hearing
  - pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness
    is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold-
    cross flag, not goal-axis)

iOS contract: the two new enum values are net-new — iOS clients that
predate the codegen pass will not encounter them in read paths
because no source writes them yet. New iOS code paths can post the
new HK identifiers via the existing batch endpoint without further
server changes.
2026-05-16 12:16:39 +02:00
Marc-André Bombeck 479e0aed48 feat(measurements): shared category map for the iOS picker and the web Insights nav
R-F §4 close-out. The flat 27-value MeasurementType enum stays as the
storage shape; this overlay is presentation-only and drives both
consumers:

  - iOS HealthKit permission picker — grouped by category so the user
    sees one consent screen per logical area (vitals / activity /
    sleep / hearing / environment / cardiovascular / metabolic / body)
    rather than a flat 27-line list
  - web Insights nav (post-v1.5) — the existing 7-page strip stays
    curated; an "All metrics" overview can group cards by category
    cheaply from this map
  - Coach evidence shelf — chip-grouping by category makes the prose
    more navigable than a flat list

The map is ReadonlyMap<MeasurementType, MeasurementCategory> so
consumers cannot mutate it accidentally. Adding a new MeasurementType
without a sibling entry is caught at test time by the completeness
wall.

iOS contract: presentation-only — no API surface change. The iOS
client mirrors this map verbatim once the swift-openapi-generator
wiring (R-E H-8) lands; new types stay invisible to clients that
predate the codegen pass.
2026-05-16 12:12:52 +02:00
Marc-André Bombeck 57abfbfb90 feat(workouts): pick canonical rows across Apple Watch and Withings sources
R-F T1.1 close-out for the dashboard "Recent workouts" tile. Same
intent as pickCanonicalSourceRows() for measurements, applied to
workouts. A user wearing an Apple Watch + a Withings ScanWatch
records the same run via each integration; without the picker the
dashboard would show the run twice.

Bucket key = (5-min-rounded startedAt, sportType). The 5-minute slot
absorbs the clock skew between HK and Withings without collapsing
two back-to-back runs into one bucket (a user does not run twice in
a five-minute window in practice).

Source-priority ladder ride the existing measurement "steps" key —
unifying on one Settings UI rather than introducing a new "workouts"
metric key. Default ladder is APPLE_HEALTH > WITHINGS > MANUAL >
IMPORT per the source-priority validator's canonical default.
Metric-aware tunes (route → Apple wins, HR zones → Withings wins)
defer to v1.5.x per R-F open question #4.

iOS contract: helper is read-side only — no API surface change. The
workout batch ingest route (verified in v1.4.25 W8d) keeps the
existing rate-limit + idempotency + 5 MB body cap + 100-workout
batch cap. /api/workouts/batch is the iOS endpoint going into v1.5.

Picker exercises empty-input, single-row, cross-source overlap,
sport-discriminator-keeps-distinct, > 5-minute-distinct, user-
supplied ladder honoured, manual-only fallback, and an unrecognised
ladder fallthrough.
2026-05-16 12:11:20 +02:00
Marc-André Bombeck 11325e31a0 feat(mood): add a first-class note column replacing the tag-based workaround
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.

Schema (migration 0063, additive):
  - mood_entries.note (TEXT, nullable)

Validation + routes:
  - createMoodEntrySchema gains optional note (max 500 chars)
  - updateMoodEntrySchema accepts note + null to clear
  - POST /api/mood-entries persists note alongside tags
  - PUT /api/mood-entries/[id] honours partial note updates
  - POST /api/mood-entries/bulk carries note through the bulk path

Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.

iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
2026-05-16 12:08:53 +02:00
Marc-André Bombeck 0e92f96e72 feat(scripts): drain per-sample cumulative HealthKit rows into daily-aggregated rows
Idempotent maintenance tool that collapses pre-Option-A per-sample
APPLE_HEALTH cumulative rows (steps, active energy, flights climbed,
walking + running distance, time in daylight) into one row per day
per type, keyed by the locked dailyStatsExternalId shape. Per
user × type × calendar-day (anchored to User.timezone): SUM the values,
UPSERT a daily row at canonical midday-local (matching the Withings
activity-sync convention), DELETE the per-sample rows.

Three entry points share src/lib/measurements/drain-per-sample-cumulative.ts:
  - Library helper for unit + integration tests
  - CLI at scripts/drain-per-sample-cumulative.ts with --user / --all
    + --confirm flags (default dry-run)
  - Admin endpoint POST /api/admin/drain-per-sample-cumulative gated
    by requireAdmin() with dryRun defaulting to true

Operator runs the drain once after the iOS TestFlight build adopts
HealthKitStatisticsService.swift. Re-running on a fully-collapsed
account reports zero buckets touched.

Tests cover bucketing semantics, the canonical-timestamp math across
DST + half-hour zones, idempotent re-runs, dry-run preview, spot-metric
exclusion, and already-collapsed-row skip via real-Postgres.
2026-05-16 11:56:39 +02:00
Marc-André Bombeck 6e6bc61800 feat(api): lock the daily-stats externalId shape for cumulative HealthKit types
R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside
the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints
"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" so iOS can UPSERT one
row per day per cumulative type via the existing batch endpoint, the
unique index collapses re-syncs idempotently, and a future PATCH-on-
divergence call updates a late-watch-sync revision without inserting a
second row. Handoff docs (06 + 08) lock the shape for the iOS client.

Cumulative-type set unchanged from v1.4.29; spot metrics keep the
per-sample uuid externalId. Server tolerates both shapes during the
cutover window — the drain script (next commit) collapses pre-existing
per-sample rows post-cutover.

Tests cover the helper round-trip, the cumulative-type identifier wall,
and the iOS-trusted date-string passthrough.
2026-05-16 11:51:25 +02:00
Marc-André Bombeck f463ca812c fix(charts): use explicit tick positions on numeric x-axes
Recharts ignores the `interval` prop on `<XAxis type="number" />`.
The pulse + mood charts switched to numeric axes in v1.4.25 to
support multi-band rendering, which silently degraded the
day-aware density policy to "every tick" on those charts. On a
365-row pulse view that meant ~365 overlapping labels.

Add `computeTickPositions(chartData, viewportWidth)` — translates
the existing `chooseTickInterval` skip count into explicit indices
the numeric axis consumes via the `ticks` prop. Tick count clamps
to a 3-12 visible-label range so dense series don't overcrowd and
sparse series don't collapse to a single label.

The category-axis charts (`compliance-line-chart`,
`medication-compliance-chart`) stay on `interval` — Recharts
honours it there.
2026-05-16 10:43:03 +02:00
Marc-André Bombeck 642e78a6f7 fix(api): aggregate cumulative measurement types as a sum
`aggregate=daily|weekly|monthly` averaged step counts (and every
other cumulative HK type) instead of summing — partial-day rows
collapsed to a per-bucket mean rather than the daily total.
Five rows of 1000 steps reported 1000, not 5000.

Add `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts` listing the
five cumulative MeasurementTypes the iOS / Withings ingest path
emits (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). The route picks
`SUM(value)` for those types and keeps `AVG(value)` for every spot
metric (BP, pulse, weight, BG, body fat, mood, sleep).

Two new integration cases pin the contract: a step day collapses
to the per-row sum; a pulse day still averages.
2026-05-16 10:36:39 +02:00
Marc-André Bombeck e9b2cb1968 fix(dashboard): drop retired widget ids from the saved layout on read
Users who saved a dashboard layout before v1.4.28 still carry the
retired `glp1` widget id in `dashboardWidgetsJson`. The PUT route's
Zod enum rejects the entire blob on the next save, surfacing
"Layout konnte nicht gespeichert werden" without recovery. The
resolver now filters widget ids the current build does not know
about, so the GET response is current-build-safe and the Settings UI
can round-trip the save cleanly. Same migration shape the resolver
already applies to comparison baselines and per-chart overlay prefs.
2026-05-16 08:54:30 +02:00
Marc-André Bombeck f0e3e055f5 chore(comments): scrub forbidden vocabulary from v1.4.28 code comments
Sweep across the v1.4.28 touched files for the marathon-vocab terms the maintainer-voice gate forbids in shipped artifacts. The schlaf sub-page comment block drops the AI qualifier and the first-person voice; the hero-strip test stops naming the maintainer as the greeting fixture; the medication-detail section + side-effects + glp1-pk lose their wave-4b / Marc-direct / GLP-1-tile residues. Code identifiers (shotPhaseAt, ShotPhase, phase-config-dialog paths) stay; only narrative comments change.
2026-05-16 02:48:35 +02:00
Marc-André Bombeck 0d256230ef fix(api): aggregate measurements before applying the take limit
Push range-aware aggregation into Postgres via `date_trunc` so the
bucket cap applies AFTER bucketising, not before. A 1-year
`aggregate=daily` request on a minute-by-minute pulse account now
walks every row in the window and returns up to 365 daily buckets
instead of silently truncating to the first N raw rows.

Gate the aggregation branch on an explicit `aggregate` query param.
Callers that pass `from`/`to` without `aggregate` (the iOS path)
keep the raw `MeasurementWireDTO` wire shape — the v1.5 locked
contract stays byte-stable. The new `monthly` grain bounds
multi-year "All time" charts at 24 buckets per type.
2026-05-16 02:32:36 +02:00
Marc-André Bombeck b0ef80dcd4 perf(notifications): cache the dispatch-localised user lookup 2026-05-16 02:01:09 +02:00
Marc-André Bombeck 66e13845c0 refactor(coach): narrow launch-scope metric type to the source union
CoachLaunchScope.metric was typed as a free-form string, which would
let any future caller pass a label the sources rail then silently
ignores (the rail's source-toggle contract only honours the
CoachScopeSource union — bp, weight, pulse, mood, compliance, plus
the v1.4.23 Apple Health additions). Narrow the field to that union
so the type system surfaces drift the moment a caller invents a new
metric name. Also pin a one-consumer note on the setOpen export
(only <LayoutCoachMount> forwards it to the sheet's onOpenChange
contract; keep the raw boolean setter for that callback shape).
2026-05-16 01:47:05 +02:00
Marc-André Bombeck 8c8d6dc27f chore(medications): drop the Bestand section from GLP-1 detail
Retire the entire inventory surface from the GLP-1 medication card:
the inline "pens left · weeks of supply" summary line, the disclosure
that wrapped the per-pen list, and the InventorySection component
file (no other consumer). The orphan i18n keys under
medications.inventory.* and the glp1Inventory* labels retire alongside
the help-text revision for glp1DosesPerUnitHelp. The iOS-consumed
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays byte-stable
in the response shape, and the server-side state-machine helpers
remain for that endpoint's computed weeksOfSupply.
2026-05-16 01:33:50 +02:00
Marc-André Bombeck 52edf85f02 chore(insights): retire the InsightAdvisorCard surface
Drop the InsightAdvisorCard component, its four integration tests
under src/components/insights/__tests__, the page-level mount on
/insights, the orphan i18n keys (advisorTitle, aiOverviewTitle), the
e2e advisor-card spec, the dashboard.spec.ts advisor assertion and
every related comment. The InsightsTabStrip regenerate icon stays
untouched as the sole power-user refresh affordance. The iOS-consumed
POST /api/insights/generate endpoint and its request/response shape
stay byte-stable.
2026-05-16 01:19:14 +02:00