Commit Graph
61 Commits
Author SHA1 Message Date
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 93d639c746 perf(rollups): boot-backfill discovery widens to per-type missing coverage
Marc's account hit the slow-path live SQL aggregator on every read
after every deploy: `/api/analytics` cold-mounted in ~75 s,
`/api/mood/analytics` in ~13 s, `slim_summaries` annotated
`path:"live", row_count:347114`. Root cause traced to
`enqueueBootTimeRollupBackfill`'s discovery query:

  WHERE NOT EXISTS (
    SELECT 1 FROM measurement_rollups r WHERE r."user_id" = u."id"
  )

The clause only matched users with ZERO rollup rows. Any account
that had ever been folded (every long-running user) silently
stranded once a brand-new measurement type landed: the existing
WEIGHT bucket kept the user invisible to discovery, but the new
type's empty DAY partition kept `isFullyCovered(coverage)` at
`false` forever. Every analytics / dashboard read then fanned out
to the live aggregator across the full measurement table.

Discovery now joins `(user, distinct type)` against
`measurement_rollups` on the `(user, type, granularity=DAY)`
partition and surfaces users where any partition is unmatched. The
v1.4.35.1 zero-rollup case is still covered (the LEFT JOIN of an
empty rollup table emits a row for every type the user has logged).

Integration test pins the partial-coverage regression: a user with
WEIGHT rolled up + a brand-new PULSE row now lands on the queue.
2026-05-18 06:06:35 +02:00
Marc-André Bombeck 61208da6ec test(rollups): force synchronous recompute before reading the aggregate
The v1.4.37.1 read-path hotfix changed `ensureUserRollupsFresh` from
an awaited populator to a fire-and-forget. On a cold testcontainer
the integration test inserts measurements and immediately reads
through `buildComprehensiveAggregate`, which now no longer waits for
the rollup-write path to land — the coverage probe sees no buckets
and the `dailyByType` branch returns an empty object.

Calls `recomputeUserRollups(user.id)` explicitly in the test setup
so the rollup-driven branch is exercised deterministically. Matches
the production warm-cache contract (the test was always asserting
the rollup-fed shape, just no longer racing against it).
2026-05-17 23:55:44 +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 5a3ca330ac v1.4.35.1 — auto-converge rollups on worker boot
Removes the operator step that was implicit in the v1.4.35 foundation
release. Self-hosters no longer need to run scripts/backfill-rollups
after upgrade; the worker boot discovers any account with measurements
but no rollup coverage and enqueues a one-shot full fold via pg-boss.

New rollup-full-backfill queue + handler folds one user per job at
serial concurrency. enqueueBootTimeRollupBackfill runs once at
startReminderWorker boot, idempotent across reboots via the
discovery query (matches only zero-rollup accounts) and a
singleton-key per user so fast restarts coalesce.

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: 4285 unit + 230 integration. tsc + lint clean.
2026-05-17 11:29:00 +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 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
Marc-André Bombeck 3ece95f192 chore(release): v1.4.30
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
2026-05-16 12:37:55 +02:00
Marc-André Bombeck d76c0b56e7 chore(release): v1.4.29
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
2026-05-16 10:59:42 +02:00
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00
Marc-André BombeckandClaude Opus 4.7 f1e6630958 chore(format): prettier sweep across develop
Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:27:51 +02:00
Marc-André BombeckandClaude Opus 4.7 650f150353 fix(coach): feedback references coach_messages, drops plaintext column
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.

Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.

Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:17 +02:00
Marc-André BombeckandClaude Opus 4.7 deadc73863 perf(analytics): groupBy + cursor-paged BP aggregate
Reviewer note: src/app/api/analytics/route.ts fanned out 17+
unbounded prisma.measurement.findMany per request — growth linear
in HealthKit-days. Replace with prisma.groupBy where possible;
chunked-paged with take=5000 where running-window slope or paired
correlations require row-level access. Wide-event annotation
analytics.bp_aggregate.row_count for slow-query attribution.

Fixes: Sr-H1 (Wave-6 v1.4.23 senior-dev review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:37:49 +02:00
Marc-André BombeckandClaude Opus 4.7 13977bcc70 fix(devices): auto-upsert APNS NotificationChannel on registration
The dispatcher's APNS branch only fires when a NotificationChannel of
type APNS exists for the user, but no production code path created one.
Every iOS install needed a manual settings toggle that doesn't exist,
making the v1.4.23 W3 APNs cascade unreachable in real traffic.

When `POST /api/devices` accepts an apnsToken, eagerly upsert the
channel row with an encrypted empty config — the per-device token and
environment live on the Device row by design, so the channel only
exists to flip the dispatcher's gate. Mirrors the legacy Telegram
on-first-dispatch auto-migration but eager so the very first reminder
after registration routes through APNs.

Adds an integration test that registers a device with apnsToken and
asserts the channel row exists, registers without one and asserts
absence, and re-registers the same apnsToken and asserts idempotency
(channel count stays at one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:12:02 +02:00
Marc-André BombeckandClaude Opus 4.7 05c7f1474d feat(coach): per-message thumbs feedback + admin aggregate view
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.

Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.

Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.

Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.

Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.

UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.

OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.

Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:51:27 +02:00
Marc-André BombeckandClaude Opus 4.7 3f60c81b9e feat(coach): per-user prompt-tuning surface (settings cog returns)
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.

Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.

Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.

Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.

Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.

UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.

Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.

Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:35:45 +02:00
Marc-André BombeckandClaude Opus 4.7 fa0774835e perf(api): chunked BP aggregate replaces unbounded findMany
The W2-of-v1.4.20 BD-Zielbereich fix needed the complete sys + dia
series to compute the all-time + prior-month + prior-year windows,
so it pulled both tables via single-shot `findMany`. For a 5-year
power user (~9 000 rows per side) that's a 50-100 ms Prisma round-
trip and a ~2 MB working-set allocation per /api/analytics request.

Switch to a cursor-paged 5 000-row chunked read via the new
`fetchBpSeriesChunked()` helper. The accumulated `BpReading[]` is the
same shape `computeBpInTargetWindows()` already consumed; the
working-set ceiling is now `BP_CHUNK_SIZE` instead of unbounded. Safety
bound at 1 000 pages = 5 M rows so a cursor-staleness bug can't loop
forever.

Wide-event annotation `analytics.bp_in_target.row_count` (+ per-side
`sys_rows` / `dia_rows`) lets ops attribute slow requests to outlier
users without rerunning the DB queries.

Integration test seeds a 6 000-row dataset (3 000 sys + 3 000 dia)
spanning a chunk boundary and asserts the response shape stays the
same — last30Days, last7Days, allTime windows all carry the
hand-derivable percentages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:21:20 +02:00
Marc-André BombeckandClaude Opus 4.7 9088cbd789 test(notifications): APNs sender unit + dispatcher integration coverage
23 unit tests under `senders/__tests__/apns.test.ts` exercise:
  * env-var loader — all-or-none guard, inline-PEM `\n` unescape, file
    fallback path, cache, APNS_PRODUCTION override
  * Provider lazy-init + per-gateway cache (sandbox vs production stay
    isolated; APNS_PRODUCTION funnels every send through production)
  * permanent-failure detection (`Unregistered`, `BadDeviceToken`,
    `DeviceTokenNotForTopic` flip `shouldDisable: true`; transient
    reasons stay false)
  * `sendViaApns()` dispatcher entry — fan-out, dead-row cleanup,
    hard-vs-soft channel-reject classification, HTML stripping

3 integration tests under `tests/integration/apns-dispatch.test.ts`
drive the real dispatcher against a Postgres testcontainer with a
stubbed `@parse/node-apn` Provider:
  * Round-trip: notification fans out via APNs and the channel-state
    machine records `lastSuccessAt` + clears any cooldown
  * Failure path: `Unregistered` deletes the dead Device row,
    auto-disables the channel with the captured reason, and writes the
    `notification.channel.auto_disabled` audit-log entry
  * Cascade: a disabled APNS channel falls through to Telegram on the
    next dispatch — the apns send-mock stays untouched

Adds `senders/__mocks__/apns.ts` — a deterministic mock with queued
per-token responses + recorded calls so future test files can swap the
real sender for a fixture-driven double without touching `@parse/node-apn`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:44:26 +02:00
Marc-André BombeckandClaude Opus 4.7 49c1a41c08 test: cast analytics GET handler in sleep-stages integration test
The W2 commit shipped with `as never` casts on the request + params
arguments, which let the runtime test pass but failed strict tsc
because `apiHandler<() => Promise<Response>>` narrows the wrapper's
signature down to its inner handler's. Cast through `unknown` so the
wrapper-side `request.url` read works and tsc is satisfied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:28:24 +02:00
Marc-André BombeckandClaude Opus 4.7 5470649d01 feat(analytics): per-stage sleep aggregation in /api/analytics
Apple Health stores sleep as one Measurement row per stage per night,
so the existing single-value SLEEP_DURATION summary would treat each
stage as its own datapoint and grossly understate the per-night total.
The route now sums per Berlin day before summarising — one summary
datapoint per night, value equal to total minutes asleep.

A new optional `sleepStages` block surfaces the per-stage breakdown
over the trailing 30 days when stage-tagged rows exist (and `null`
otherwise so the existing UI keeps painting the totals-only path).
The block reports total minutes, the night count covered, and the
per-stage minute sums so the upcoming insights surface can render
"averaged across N nights" captions truthfully.

Two integration tests cover both shapes — a four-stage night
aggregating to 880 minutes with a populated stages map, and a
manual-source no-stage row keeping `sleepStages` at null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:25:01 +02:00
Marc-André BombeckandClaude Opus 4.7 5cc0a00eaf feat(api): batch measurement ingest for the iOS Apple Health sync
`POST /api/measurements/batch` accepts up to 500 HealthKit-shaped
entries per call, runs each through the new mapping helper, and writes
them under `source = APPLE_HEALTH` with the inbound sample UUID as the
external id. The composite unique index on
`(user_id, type, source, external_id)` does the per-entry dedup;
`Idempotency-Key` covers the batch-level retry case so the iOS observer
queue can replay in flight without double-writing.

Per-entry status (`inserted` / `duplicate` / `skipped`) lets the
client advance its sync cursor accurately even when individual rows
land on different paths. Skip reasons cover unknown HealthKit
identifiers and out-of-plausible-range values so a single bogus sensor
reading never rejects an otherwise clean batch.

Insertion uses chunked `createMany` with `skipDuplicates: true` so a
race between two concurrent retries reconciles cleanly via the unique
index instead of failing the transaction. Wide Event annotation logs
`processed / inserted / duplicates / skipped` for ops visibility.

Six integration tests against a real Postgres testcontainer cover the
happy path, the per-entry duplicate path, the over-cap 422, mixed
unmappable + out-of-range entries, per-stage sleep rows, and
`Idempotency-Key` replay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:20:37 +02:00
Marc-André BombeckandClaude Opus 4.7 bbdc3fbd00 style: prettier on W5 reconcile touchpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:30:53 +02:00
Marc-André BombeckandClaude Opus 4.7 b1ce6ebe81 refactor(auth): fold onboarding-cookie write into createSession + destroySession
The `setOnboardingPendingCookie` helper was called from six different
auth routes (login, passkey-verify, register, password, /me,
/onboarding/complete) by convention. A future auth surface (SSO,
magic link, social login) added without remembering the helper would
silently re-introduce the dashboard flash that v1.4.22 C4 was
designed to fix.

Make `onboardingPending` a required parameter on `createSession`
(not optional) so issuing a session without anchoring the cookie is
type-impossible. The five primary auth surfaces now thread the
user's `onboardingCompletedAt == null` value through; the
`/api/auth/me` mirror remains as a fall-back for legacy sessions
that predate the cookie. The `/api/onboarding/complete` clear stays
where it is — it is a value flip after a DB write, not a session
issuance.

Three new integration tests pin: createSession sets the cookie when
pending, clears it when completed, and destroySession clears it
alongside the session cookie.

Closes Sr-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:11:07 +02:00
Marc-André BombeckandClaude Opus 4.7 75ccc01984 fix(coach): graceful fallback when sentinel-only output strips empty
When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.

Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.

Closes Code-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:05:14 +02:00
Marc-André BombeckandClaude Opus 4.7 7b8efaae14 feat(coach): parse evidence-block sentinel out of streamed prose
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:07:32 +02:00
Marc-André BombeckandClaude Opus 4.7 8606767058 fix(analytics): BP target headline reads last-30d, all-time becomes sub-row
Up to v1.4.19 the BD-Zielbereich tile pinned 50/50/50 because the
route routed the headline through `windows.last30Days?.pct` — making
the headline a literal copy of the `30T` sub-value. v1.4.19 A1
re-anchored the headline to all-time so the three windows could
diverge. That fixed the algorithmic pin but introduced an emotional
problem: the headline became the slowest-moving aggregate possible,
punishing users who had recently dragged their compliance up. A user
seeing 7d 50 % / 30d 50 % alongside a stubborn 11 % all-time read it
as "your work doesn't count yet."

Re-anchor the headline to `windows.last30Days?.pct` and surface
all-time as a third sub-value (`bpInTargetPctAllTime`) so power users
still see the long-arc number without it dominating. The integration
test pins the new contract: with three legitimately divergent windows
the route's `bpInTargetPct` equals `last30Days.pct`, and the new
all-time field carries the historical figure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:42:09 +02:00
Marc-André BombeckandClaude Opus 4.7 236b4986a4 feat(api): emit Health Score from /api/analytics
The route now builds the four-component Health Score input from the
already-fetched BP figure plus a fresh weight + mood + medication
intake fetch (last 30 days, plus a 7-day-shifted snapshot for the
delta). The response gains a `healthScore: { score, band,
components, delta } | null` field; null when no component is
computable so the UI hero panel can hide cleanly.

The wide-event annotation carries `healthScore.score` + `band` +
`delta` so admin-app-logs can attribute coverage without DB queries.

The previous-week BP rate reuses the all-time figure (it's a slow-
moving aggregate; rewinding it cheaply isn't possible without a
full historical re-pair). Delta therefore primarily reflects week-
over-week movement in weight / mood / compliance — the components
that actually move on a weekly cadence.

Integration coverage seeds three synthetic-data shapes against the
postgres testcontainer:
- strong-positive → green band (>= 75)
- empty user → null healthScore
- compliance-only → bp/weight/mood null, compliance weight 1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:37:20 +02:00
Marc-André BombeckandClaude Opus 4.7 3168ba8e0a feat(coach): GET list, GET single, DELETE conversation endpoints
GET /api/insights/chat returns the caller's conversations newest-first
with cursor pagination (default limit 20, hard cap 50). Each row
carries id, title, timestamps, and a message count so the rail can
render without paying the per-message decrypt cost.

GET /api/insights/chat/[id] fetches one conversation with every
message decrypted in memory before serialisation. Provenance metadata
round-trips alongside the assistant turns.

DELETE /api/insights/chat/[id] hard-deletes the thread; the FK chain
cascades the message rows.

Cross-user lookups map to 404, never 403, so the existence channel
never leaks across accounts.

6 integration tests cover the list ordering, owner-only filter,
decryption round-trip, and the 404 boundary on both verbs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:11:12 +02:00
Marc-André BombeckandClaude Opus 4.7 16f3ece90f feat(coach): add POST /api/insights/chat SSE streaming endpoint
The route gates on requireAuth (cookie or bearer), enforces the daily
token budget, runs the prompt-injection / off-topic refusal scanner,
and only then walks the user's provider chain.

The conversation is created via withIdempotency() when no
conversationId is supplied, so a retry under the same Idempotency-Key
replays the original assistant reply rather than spinning up a second
thread. Existing-thread turns skip the wrap.

Replies stream as SSE: one "token" frame per word-sized chunk, then a
"provenance" frame describing the windows + metrics the assistant
could see, then a "done" frame carrying the persisted message id.
Provider failure cascades to a single "error" frame and the assistant
message is never persisted. Conversation history is capped at 20 turns
in the prompt window via a synthetic summary placeholder so cost stays
bounded.

`buildCoachSnapshot()` reuses the analytics features pipeline so the
Coach narrates the same numbers the dashboard tiles render.
`getCoachSystemPrompt()` extends the strict-insight ground rules with
conversational framing while keeping the zero-hallucination rule.

5 integration tests pin the round-trip, refusal short-circuit, budget
429, foreign-conversation 404, and validation 422 paths against the
postgres testcontainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:47 +02:00
Marc-André BombeckandClaude Opus 4.7 5527001a01 feat(coach): add CoachConversation, CoachMessage, CoachUsage models
Persist Coach chat threads with AES-256-GCM-encrypted message bodies
and a per-user, per-day token-spend ledger. Encryption reuses the
active ENCRYPTION_KEYS entry so rotation continues to work without a
backfill. metric_source_json carries provenance labels only (window +
metric names, never raw values), keeping it queryable for analytics.

Cascade-on-user-delete is wired through the FK chain so GDPR delete
removes Coach data with the rest of the account. The integration-suite
truncation list picks up the new tables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:54:28 +02:00
Marc-André BombeckandClaude Opus 4.7 a8562727a7 fix(dashboard): BD-Zielbereich headline shows all-time, not 30-day
Marc reported the BD-Zielbereich tile pinned at exactly 50/50/50 for
7T, 30T, and the headline ("total") on his real production data — the
fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 → v1.4.19).

Root cause: the analytics route routed `bpInTargetPct` (the headline)
through `windows.last30Days?.pct` — a literal copy of the `30T`
sub-value. Since `computeBpInTargetWindows` only returned 7d + 30d
windows, the headline could never legitimately differ from `30T`. For
Marc's real data (572 paired readings since 2022, recent 30d = 50 %,
all-time ≈ 11 %) the tile read 50 % / 50 % / 50 % and looked
algorithmically pinned — exactly the symptom he reported.

The previous three passes treated this as either a predicate bug
(v1.4.16 ceiling-vs-narrow-band, real and merged) or a missing
`avg7`/`avg30` wiring (v1.4.18, also real and merged). Both fixes
landed correctly, but neither addressed the headline duplication.

Fix:
  - `computeBpInTargetWindows` now returns a third `allTime` window
    that aggregates EVERY paired reading in the input series.
  - The analytics route fetches all paired BP rows (no 30-day cutoff)
    and routes the headline through `windows.allTime?.pct`. The route
    only re-pairs once internally so the cost is paid once per request.
  - The 7T / 30T sub-values are unchanged (still windowed slices of
    the same input).

Failing test reproduces the bug with Marc's data shape (`allTime` MUST
not equal `last30Days` once older history is present); fix turns it
green. Integration test seeds 40 paired readings against real Postgres
and asserts all three windows compute independently.

Hand-counted against Marc's prod data:
  - 7T:        50 % (1/2) — matches what he sees, real coincidence
  - 30T:       50 % (5/10) — matches what he sees, real coincidence
  - Headline:  ~11 % (62/572) — was 50 %, now correct

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:01:08 +02:00
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00
Marc-André BombeckandClaude Opus 4.7 75c74f1c49 feat(achievements): worker re-evaluates new + hidden achievements on data change
Two trigger fixes so the new achievements actually fire end-to-end.

The doctor-PDF hidden Easter-egg used to watch a non-existent
`doctor-report.export` audit action. The real route emits two:
`doctor-report.generate` (the JSON helper) and
`doctor-report.pdf.generate` (the PDF route). Either now counts.

The locale-flip hidden Easter-egg used to watch
`settings.locale.update`, which no endpoint emits — the locale
field shares the catch-all `profile.update` audit row with every
other profile field. Profile-update now emits a separate
`settings.locale.update` row when (and only when) the locale value
actually changes (compares the new value to the prior persisted
locale, suppresses the row when they match), with the from/to
values in the details payload for traceability.

Already-existing TanStack Query invalidation map covers the new
metrics: `measurementDependentKeys`, `moodDependentKeys`, and
`medicationDependentKeys` already include
`queryKeys.gamificationAchievements()`, so any measurement / mood /
intake mutation triggers a refetch and the route persists the new
unlock to `UserAchievement` (existing v1.4.15 mechanism).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 6912d4e2b6 test(achievements): coverage for evaluators + UI states
Three new layers of coverage for the v1.4.18 expansion set:

- Unit (expansion-metrics.test.ts) — drives synthetic data through
  every new evaluator (count by type, mood mean improvement, weekend
  warrior, hidden Easter-egg triggers including the Berlin TZ
  classifier for night-owl / early-bird) and the earnability flag
  builder. 13 new assertions.

- Integration (achievements-expansion.test.ts) — round-trips real
  prisma rows (MoodEntry's YYYY-MM-DD `date` string, Measurement
  type discrimination, audit log row) through
  `buildExpansionMetricValues` so a schema drift in any of the row
  shapes surfaces here rather than at runtime. We don't drive the
  full HTTP route in this spec because of pre-existing
  medication_schedules.days_of_week migration drift.

- E2E (achievements.spec.ts) — the existing sidebar smoke gets a
  sibling describe-block exercising all four card states (unlocked /
  locked-earnable / hidden-locked / hidden-unlocked) and asserting
  hidden-locked cards never leak their real strings to the rendered
  HTML, which is the entire point of the "hidden" state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 d70a9b1ee9 feat(charts): persist per-chart overlay prefs (default off; clean line)
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.

Wires up:
  - GET /api/dashboard/widgets surfaces the resolved prefs in its
    response so a fresh dashboard load knows which overlays to paint.
  - PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
    prefs without round-tripping the whole layout.
  - Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
    the client doesn't send, so saving widget visibility doesn't wipe
    chart prefs.
  - useChartOverlayPrefs hook reads + optimistically updates the cache.
  - HealthChart, MoodChart, MedicationComplianceChart all consume the
    hook, render the controls dropdown next to the range tabs, and gate
    their three overlays behind the persisted toggles.

Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 23363ca05e fix(dashboard): wire 7T and 30T sub-values on the BD-Zielbereich tile
The tile rendered the 30-day headline correctly but the `7T:` and
`30T:` sub-values fell back to "—" even when the user had paired BP
readings in both windows. Root cause was that `/api/analytics` only
returned a single `bpInTargetPct`; the tile received `avg7={null}`
and `avg30={null}` and the TrendCard rendered the dash placeholder.

Add `computeBpInTargetWindows()` next to the existing `computeBpInTargetPct()`
helper. It filters the input series by `measuredAt` against the supplied
clock and runs the same pair-and-count logic over the trailing 7-day and
30-day windows. The route fetches the last 30 days of data once, derives
both shares from the result, and surfaces them as `bpInTargetPct7d` and
`bpInTargetPct30d` on the response. The dashboard tile maps them to
`avg7` and `avg30` so the sub-values now show the actual percentages.

Coverage:
- 6 unit tests for the windowed helper (empty input, mixed window data,
  null-7d-with-real-30d, default-clock, Marc's production fixture).
- 2 integration tests against testcontainers seeding 30 days of paired
  BP measurements; both windows produce the expected hand-counted shares.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:14:11 +02:00
Marc-André BombeckandClaude Opus 4.7 8255e3e49a feat(api): /api/insights/feedback persists thumbs with provider attribution
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.

Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.

The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.

Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.

Tests:
  - 7 unit tests for the Zod request schema (valid path, severity
    poisoning, time-range poisoning, empty fields, stringified bool,
    extra-field stripping)
  - 5 unit tests for `pickProviderType()` priority + fallback
  - 4 integration tests: 201 happy path with provider attribution,
    409 dedup, 422 validation, fallback to unknown when no audit row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:30:06 +02:00
Marc-André BombeckandClaude Opus 4.7 badc893853 feat(db): RecommendationFeedback model with GDPR cascade + provider attribution
v1.4.16 phase B5e: ship the table that backs per-recommendation
thumbs-up / thumbs-down feedback. Each row captures (userId, recId,
recText) with snapshotted severity + metric-source attributes plus the
provider + prompt-version that produced the rec — server-fills the
attribution from the user's most recent cached payload so the client
can't tamper.

Dedup key (userId, recommendationId, recommendationText) lets a
regeneration that rewrites the same id with new text re-rate cleanly
while blocking accidental double-submission of the same (id + text).

Indexes: (userId, createdAt) for the per-user timeline and
(severity, helpful, createdAt) for the daily aggregator's slice. Both
are needed by the v1.4.16 admin AI quality preview.

GDPR: ON DELETE CASCADE through users; cascade-delete integration
test now seeds and asserts a feedback row.

Same migration adds AppSettings.adminAiInsightsFeedbackSummary — the
JSON blob the daily aggregator (next commit) writes a per-(severity x
provider x prompt) helpful-rate to. Null until the aggregator runs
or until there is feedback in the 30-day window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:25:57 +02:00
Marc-André BombeckandClaude Opus 4.7 70206b9bf5 test(insights): integration coverage for PUT /api/insights/provider-chain
Two scenarios pinned end-to-end against the postgres testcontainer:
saving a valid reordered chain persists the JSON column with priority
normalised to 1-indexed insertion order, and saving an empty chain
rejects with 422 + leaves the column untouched. No fetch mocks; the
route runs against the real Prisma client so a future schema drift on
User.aiProviderChain trips the suite immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:10:19 +02:00
Marc-André BombeckandClaude Opus 4.7 d2bda42a3b test(ai): provider fallback covers happy path, hard-fail cascade, cache
End-to-end integration test for the multi-provider redundancy runner.
Boots the postgres testcontainer, seeds a user with both an OpenAI key
and an Anthropic key plus an explicit chain (openai -> anthropic),
intercepts upstream HTTP calls via fetch-spy, and exercises three
scenarios:

1. Hard-fail-then-success cascade. OpenAI primary returns 401, the
   runner walks to Anthropic, the route surfaces a 200 with
   `chainProviderType: "anthropic"` and `fallbackHopCount: 1` recorded
   in the audit log.

2. All-fail cascade. Every chain entry returns 503, the route surfaces
   a 503 with the "temporarily unavailable" envelope.

3. Last-working cache. After scenario 1 the runner remembers Anthropic
   was the working provider; the next call (cache bypass forced via
   nulled `insightsCachedAt`) reorders the chain so Anthropic is tried
   first and only ONE upstream fetch fires (not two — the doomed
   OpenAI 401 round-trip is skipped).

Test routing on URL substring (`/chat/completions` for OpenAI,
`/messages` for Anthropic) instead of headers so the assertion is
robust against any future header-shape drift in the clients.
Constrained to user-level providers because the testcontainer
migration history pre-dates several `app_settings` columns added
through schema-only edits — touching admin-openai resolution would
blow up before the chain runner even sees the request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:39:39 +02:00
Marc-André BombeckandClaude Opus 4.7 830b2b0c6b test(export): integration test points at the new plain-segment endpoints
The integration suite that landed in commit 226cac4 still imported
the dotted-segment routes (`@/app/api/export/measurements.csv/route`)
that vitest can't resolve. Realign the import paths and request URLs
to the actually-shipped `/api/export/{kind}` segments. 9 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:11:36 +02:00
Marc-André BombeckandClaude Opus 4.7 226cac4c02 docs(planning): mark phase B3 (host-load chart) complete
Host-load chart above /admin/system-status renders the last 2h of
load1, memory %, and disk-IO % from a per-minute pg-boss sampler.
Schema migration 0032_host_metric, /api/admin/host-metrics endpoint,
HostMetricsChart component (Recharts wrapper, next/dynamic from the
consumer), system-status section wired with the chart on top, EN+DE
i18n keys, 13 unit + 6 integration tests.

Three commits landed cleanly under their own titles (db, jobs, api);
the chart + wiring + i18n got absorbed into another agent's commit
due to a parallel-agent staging race during the verification gate's
stash/restore loop. Functionally everything is on origin/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:08:32 +02:00
Marc-André Bombeck 2877710b37 feat(api): /api/admin/host-metrics returns last 2h of host load + memory
Backs the new chart above /admin/system-status. Reads HostMetric rows
captured by the per-minute sampler and returns the window the chart
asks for via `?since=` (30m / 1h / 2h / 6h / 12h / 24h, default 2h).

Per-row response shape is what Recharts can plot directly:
  - capturedAt          ISO-8601
  - loadAvg1            number
  - memUsedPercent      0..100, rounded to 2 decimals
  - diskReadBps / diskWriteBps  number | null

Disk fields are derived server-side from the cumulative byte counters
the sampler captured. Computing the per-second delta in the route
keeps the chart component stateless and means a hypothetical CLI / iOS
admin view gets the same units. The first row in any window has no
predecessor, so disk fields are null. A negative delta — counter reset
on host reboot — also surfaces as null instead of a misleading negative
BPS spike.

`meta.memTotalBytes` carries the most recent total so the tooltip can
render an absolute "4.2 GiB / 8 GiB" without a second round-trip.

requireAdmin()-gated: host telemetry is admin-only, never exposed via
Bearer tokens. apiHandler wrapper handles the wide-event annotation
("admin.host-metrics.list") and request-id propagation for free.

6 integration tests against the testcontainer Postgres pin auth
gating, the 2h default window, the diskBps derivation across a
counter-reset, the `?since=30m` filter, and the empty-DB happy path.
2026-05-09 23:52:43 +02:00
Marc-André BombeckandClaude Opus 4.7 5d1ece1fd5 feat(db): HostMetric model for host-load sampling
Adds the per-minute snapshot table that backs the new admin host-load
chart above /admin/system-status. One row per minute, captured by the
pg-boss sampler in the next commit, with 7-day retention enforced
inside the worker so the table stays under a few thousand rows.

Schema mirrors what the chart needs:
  - load_avg_1/5/15 (DOUBLE PRECISION) — os.loadavg() output
  - mem_used_bytes / mem_total_bytes — derived from os.totalmem() and
    os.freemem(), kept as raw bytes so the API can compute either a
    percent or a MiB delta downstream
  - disk_read_bytes / disk_write_bytes — nullable cumulative byte
    counters from /proc/diskstats. NULL on macOS/Windows dev hosts;
    Linux production picks them up automatically. The API endpoint
    converts deltas to bytes-per-second between samples

Indexed by captured_at so the "last 2 hours" query stays index-only.

Migration 0032_host_metric. integration setup.ts adds host_metrics to
the truncate list so testcontainers stays clean across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:46:56 +02:00
Marc-André BombeckandClaude Opus 4.7 577d8dd29f fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.

Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".

Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.

Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
  exact production fixture (10 paired readings -> 50% under v1.4.16,
  was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
  testcontainer with Marc's reading shape + null-tolerance + hypotension
  edge cases.

Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:28:54 +02:00