Commit Graph
918 Commits
Author SHA1 Message Date
Marc-André Bombeck b4bef03fd7 feat(charts): mini ChartSkeleton variant for trends-row slot
The trends-row chart slot is now pinned to 140 px, but the default
`<ChartSkeleton>` paints a `rounded-xl` card with `p-4 md:p-6` and a
240 px chart band — overflowing the slot and reintroducing the
layout shift the height pin was meant to eliminate.

Add a `mini` prop to `<ChartSkeleton>` that renders the lighter
`rounded-md p-2` chrome plus a 140 px band. Expose
`HealthChartDynamicMini` from the chart-dynamic boundary so the
trends-row consumers pick up the matching loading shell without
having to wire the skeleton choice at every call site.

Trends-row swaps both `<HealthChartDynamic>` calls (BP + weight) for
the mini variant and the local `<MoodChart>` `next/dynamic` loader
now passes `mini` to its skeleton. The dashboard hero + sub-page
charts keep the default card chrome (`mini={false}`).
2026-05-17 12:11:55 +02:00
Marc-André Bombeck 4d111e39dd fix(insights): pin trends-row chart slot to h-[140px]
Each `trends-row-chart-slot` wrapper had `shrink-0` but no explicit
height. Recharts' ResponsiveContainer mounted with `width=-1
height=-1` and emitted a warning on every Insights cold mount; the
mood card visibly drifted taller than BP / weight as the chart
hydrated, contributing the bulk of the Lighthouse 0.46 CLS hit.

Pin the wrapper to 140 px (matches the chart-internal mini default).
Recharts now mounts at a known size, the three cards line up on the
same baseline from first paint, and the warning is gone. Test pins
the height on all three slots so a future refactor has to revisit
the row rhythm intentionally.
2026-05-17 12:07:27 +02:00
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 03e8627765 mobile: textarea text-base on mobile to suppress iOS Safari zoom-on-focus
Five textareas in the app (Coach composer, bug-report description,
admin feedback note, medication-side-effects notes, medication-intake
JSON import) shipped `text-sm` (14 px) on every viewport. iOS Safari
triggers a viewport zoom-on-focus whenever an input renders text
below 16 px — so every tap on these surfaces yanked the page scale
and rebounded on blur. Switched each className to `text-base sm:text-sm`
so the mobile baseline clears 16 px while desktop keeps the compact
look the rest of the form chrome converged on.

No shared <Textarea> primitive exists yet — F-5's deferred work — so
the fix is per-call-site. Five files, one className change each, no
component re-shape.
2026-05-17 00:51:47 +02:00
Marc-André Bombeck c81ddbdbc8 mobile: TopBar reserves safe-area-inset-top for iOS PWA notched iPhones
The sticky header was h-16 with no safe-area handling. On notched
iPhones (~44 px status-bar inset) the iOS PWA painted the status bar
over the header — the HealthLog logo + auth controls clipped under
the system clock. Adding the inset as inline padding-top reserves the
overlay without touching the 64 px content baseline.

Inline `style` over a Tailwind class because Tailwind doesn't expose
`pt-[env(safe-area-inset-top)]` without arbitrary-value escaping and
the inline form is clearer in the diff.
2026-05-17 00:36:10 +02:00
Marc-André Bombeck 26f9880264 test: update toJson mock factories for doctor-report-prefs + healthkit
The v1.4.34.3 code-quality batch added a `toJson<T>()` helper in
`src/lib/db.ts` to consolidate the seven Prisma JSON-cast sites.
Two existing route tests mock the entire `@/lib/db` module via
`vi.mock` — those mock factories now need to expose the new export
so the route's import resolves to a real function under test.

The mock implementation is the identity cast (`<T>(v: T) => v`),
which matches what production does and keeps the test's JSON-payload
assertions stable.
2026-05-17 00:34:58 +02:00
Marc-André Bombeck be7f4aa92b mobile + docs: themeColor light-mode, Coach FAB safe-area, Sonner system theme, self-hosting docs
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.

Mobile-deep:
  - `viewport.themeColor` was pinned to `#282a36` (dark). The
    Android URL bar + iOS PWA status bar stayed dark even when the
    user was on the light theme. Replaced with the dual-media-query
    form: `#ffffff` under `(prefers-color-scheme: light)`,
    `#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
    now matches the active palette.
  - Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
    the home-indicator safe area is ~34 px so the FAB collided with
    the bottom-nav. Moved to
    `bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
    floats above the inset-aware nav.
  - Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
    page the toast contrasted badly against the surface. Switched to
    `theme="system"` so Sonner reads the resolved theme from the
    root element (set pre-paint by the inline theme script in
    layout.tsx) instead of pinning dark.

Self-hosting docs:
  - `docs/self-hosting/getting-started.md` — step-by-step from
    `git clone` to first measurement, covering the 3-secret env
    setup (post-SESSION_SECRET drop), `docker compose up -d`, the
    first-user-becomes-admin contract, and the next-step pointers.
  - `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
    Proxy Manager, Coolify, and bare-Nginx config examples. Closes
    the README link at line 107 that previously punted to the docs
    site.
2026-05-17 00:34:41 +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 1768b1d971 ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.

i18n leaks (3 primitives):
  - Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
    so a German user heard 'Close, button' on every modal. Both lift
    `useTranslations()` and read `common.close`.
  - ChartSkeleton's sr-only loading announcement was English-only.
    The component is already a client island; reads `charts.loadingLabel`
    from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
  - Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
    was hardcoded. Routes through `t("nav.breadcrumb")` with a new
    key in each locale.

Focus rings (mobile + tablet nav):
  - Bottom-nav primary links + the More overflow trigger relied on a
    `text-muted-foreground` → `text-primary` colour transition only;
    keyboard / switch-control users saw no focus indicator at all.
    Added `focus-visible:ring-ring/50 focus-visible:ring-2
    focus-visible:outline-none focus-visible:ring-offset-2` matching
    the v1.4.33 sidebar treatment.
  - Mobile top-bar trigger had `focus:outline-none` without a
    `focus-visible` replacement. Same treatment applied.

Tap-target floor (WCAG 2.5.5):
  - Input / NativeSelect / Select / DateTimeInput were locked to
    h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
    mobile clears the 44 px floor while desktop keeps the 40 px norm
    that the Button primitive already converged on in v1.4.33.

Admin chip-strip auto-scroll-into-view:
  - The admin shell shipped 13 chips but lacked the
    `scrollIntoView({inline: 'center'})` effect the settings shell
    got in v1.4.33. Admins tapping a chip past the right edge landed
    on the new page with the strip scrolled to the left and the
    active chip off-screen. Copied the settings-shell pattern verbatim.

List-page subtitles on mobile:
  - Measurements / Mood / Medications pages hid their descriptive
    subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
    the subtitle on mobile because it doubles as contextual help.
    Dropped the hide rule; the H1 still anchors the visual.

Insights empty-state CTA vocabulary:
  - `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
    `TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
    `MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
    empty-state CTAs emitted those mismatched tokens and the dialog
    silently failed to open. Replaced with a single source of truth
    derived from `MEASUREMENT_TYPES` plus a normalisation map for
    legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
    New unit test walks every `?add=` token emitted under
    `src/app/insights/**` and asserts it resolves to a real form type.
2026-05-17 00:34:00 +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 e0fe7c0a3d docs: README discoverability rework + OG metadata + planning audit reports
README:
  - Add a "How it compares" matrix against Withings web, Apple Health,
    Oura, Garmin, and generic CSV. The audit found this section absent;
    self-hosters arrive comparing alternatives and bounce when there's
    no head-to-head.
  - Promote Apple Health import + AI Coach as standalone Key-Features
    bullets. Apple Health was the v1.4.34 banner feature with zero
    grep matches in the README until now; the audit ranked it the
    single highest-intent search keyword for ex-iOS users. AI Coach
    + Insights moved out from under "Multi-Provider AI Insights" into
    a dedicated bullet that names the Coach, Daily Briefing, Weekly
    Report, and Health Score surfaces.
  - Status block under "What it is" so visitors see release cadence
    + current focus (native iOS client) without trawling the changelog.
  - Badge row: drop "PRs welcome" + duplicate Self-Hosted, add a live
    "Latest release" badge + a GHCR multi-arch badge so the landing
    page surfaces version + container distribution at a glance.
  - Tagline tightened to lead with "self-hosted health tracker" + the
    two integration anchors (Withings + Apple Health) — both phrases
    are search-keyword fronts the GitHub search index picks up.
  - Drop the dead SESSION_SECRET env entry from Quick Start (3
    secrets, not 4) and the Environment Variables table — the env
    var was documented as required but never read by code (per the
    code-quality audit). Companion env-removal in docker-compose /
    .env.example / entrypoint ships in a parallel batch.
  - Add a one-line OpenAPI pointer above the API-Reference table so
    integrators discover the codegen source.

Layout / OG metadata:
  - openGraph + twitter cards now carry images, a higher-intent
    description, and a summary_large_image card. Uses the existing
    logo-readme.png as the OG asset; replace with a 1200x630 dashboard
    capture when one ships.

GitHub repo metadata (via gh CLI, no git diff):
  - description tightened from 290 chars to 156 chars, keyword-front.
  - homepage flipped from a personal tenant to demo.healthlog.dev so
    sidebar clicks land on a working demo instead of a login wall.
  - Topics: dropped 6 generic/PII-adjacent entries (glp-1, mounjaro,
    tracking, health, dashboard, bloodpressure), added 6 high-intent
    ones (apple-health-import, withings-alternative, glucose-tracker,
    mood-tracker, ai-insights, personal-dashboard).

Planning artifacts:
  - 7 audit reports drop into .planning/round-audit-*.md covering
    mobile-security, UX, code-quality, README/discoverability,
    perf, mobile-deep (pending), test-coverage, docs-depth. These
    are the working notes for the v1.4.34.4 audit-driven release.
2026-05-17 00:28:05 +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 8886c0ffdf feat(dashboard): remove the hero Coach CTA
Per maintainer directive — the v1.4.34 IW-B "Frag den Coach" sparkles
button sat next to the "Hinzufügen" dropdown on the dashboard hero and
read as visual noise rather than a useful surface. Remove it; the
Coach drawer remains reachable from the /insights tree (its original
home), and the auth-shell `<CoachLaunchProvider>` + drawer mount stay
intact so any future page that wants its own opt-in CTA can call
`askCoach()` without churn.

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

All drop out cleanly.
2026-05-17 00:05:54 +02:00
Marc-André Bombeck 7bbcc872f8 perf(api): wire 6 remaining hot reads through the server cache
The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.

Reads wrapped (60 s TTL unless noted):
  - GET /api/mood/analytics                 → caches.moodAnalytics, key userId
  - GET /api/workouts                       → caches.workouts, key includes
                                              all params (limit/offset/since/
                                              until/sportType)
  - GET /api/bugreport/status               → caches.bugreportStatus, key
                                              "singleton" (10 min TTL);
                                              isAdmin layered per request
  - GET /api/medications                    → caches.medications, key userId
  - GET /api/dashboard/widgets              → caches.dashboardWidgets, key
                                              userId (5 min TTL)

Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.

The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.

The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
2026-05-16 22:24:56 +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 322d0844ee fix(charts): reserve chart-card dimensions to avoid Recharts -1 size warning
The scatter-correlation card wrapper relied solely on Tailwind's
aspect-ratio utilities to derive its height from width, and explicitly
unset any inline height. When the dynamic-loaded chart mounted in
place of the h-[180px] skeleton, ResponsiveContainer briefly measured
a zero-sized parent before the browser resolved aspect-ratio against
the parent's width, emitting a width(-1)/height(-1) console warning.

Add a min-h-[180px] floor (matching the loading skeleton) so the
wrapper always has a non-zero height at first paint. The aspect-ratio
rules continue to drive the final card shape once layout settles.
2026-05-16 21:53:07 +02:00
Marc-André Bombeck bcdab48e96 test(cache): integration coverage + CHANGELOG entry for server cache
Three integration tests against the real Postgres container pin the
v1.4.34 IW-G contract:

  1. Ten concurrent /api/analytics reads against a cold cache fan into
     a single builder call (misses === 1; remaining 9 split between
     stampede joiners and warm-cache hits, with no second miss).
  2. Priming analytics then writing a measurement evicts the cache —
     the next read misses again and the response reflects the new row.
  3. Compliance buckets re-read after an intake POST flush the cache
     and surface the new taken-count.

Drops the unused _request parameter from buildAnalyticsResponse() so
the analytics route lints clean. CHANGELOG carries one Added line per
the marathon's user-facing voice rules.
2026-05-16 21:11:37 +02:00
Marc-André Bombeck 757387ca83 feat(cache): wire 13 write endpoints to per-user invalidation helpers
Every endpoint that mutates a measurement / mood / medication /
intake / workout / dashboard-widget row now calls the matching
invalidateUser* helper after its database commit so the next cached
read paints fresh data instead of waiting out the TTL. The admin
app-settings PUT also flushes the bug-report-status singleton cache.

Endpoints wired (per blueprint §6 matrix):

  POST/DELETE /api/measurements
  PUT/DELETE /api/measurements/[id]
  POST /api/measurements/batch
  DELETE /api/measurements/by-external-ids
  POST/PUT/DELETE /api/mood-entries[/id]
  POST /api/mood-entries/bulk
  POST/PUT/DELETE /api/medications[/id]
  POST /api/medications/[id]/intake
  PUT/DELETE /api/medications/[id]/intake/[eventId]
  POST /api/medications/intake/bulk
  POST /api/workouts/batch
  PUT/DELETE /api/dashboard/widgets
  PUT /api/admin/settings (bug-report toggle path)

Batch endpoints gate invalidation on insertedCount > 0 so a
duplicates-only ingest stays a no-op. Route-level tests reset every
cache in beforeEach so re-using a fixed userId doesn't carry response
state across cases.
2026-05-16 21:05:41 +02:00
Marc-André Bombeck 68c4bb73b2 perf(api): wire 3 hot read routes through the server cache
/api/analytics — wraps both the slim (?slice=summaries) and default
slice paths through caches.analytics, keyed on ${userId}|${slice}.
TTL 60s. The default builder lifts into buildAnalyticsResponse() so
the cache miss / hit boundary stays at the route entry.

/api/gamification/achievements — wraps the locale-agnostic web result
through caches.achievements keyed on userId. TTL 60s. The iOS-format
locale transform runs after the cache read so the cache stays format-
neutral and the dashboard's duplicate mount (HAR-captured) coalesces
through single-flight.

/api/medications/intake?scope=compliance — wraps the per-day
compliance bucket build through caches.medicationsIntake keyed on
${userId}|compliance|${days}|${userTz}. TTL 15min because daily
buckets are slow-moving. The POST handler invalidates the per-user
medications/compliance/achievement caches after committing the dose
status change.

Each cached() call hands annotate() to the wrapper so cache.<name>.
outcome (hit|miss|stampede) + cache.<name>.key_hash land on every
request's wide event.
2026-05-16 20:58:25 +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 0729c2028b feat(dashboard): hoist Coach launch provider and add hero CTA
The Coach drawer was previously only reachable from `/insights/**`. The
`<CoachLaunchProvider>` + `<LayoutCoachMount>` now live on the global
`<AuthShell>` so every authenticated route shares the same launch context,
and the dashboard hero strip carries a "Frag den Coach" / "Ask the coach"
CTA next to the existing "Hinzufügen" pill.

Provider hoist
- `<CoachLaunchProvider>` wraps the authed branch in
  `src/components/layout/auth-shell.tsx`; `<LayoutCoachMount>` sits beneath
  the main scroller so the drawer renders once at the shell level.
- `src/app/insights/layout.tsx` drops the provider + drawer mount. The FAB
  stays scoped to `/insights/**` because the floating action would distract
  from the dashboard hero on surfaces with a contextual inline CTA.

Dashboard hero CTA
- New `<Button>` next to the "Add" dropdown calls `coachLaunch.askCoach(null)`
  on the hoisted context. 44 px tap target via `min-h-11` (WCAG 2.5.5),
  shrinks back to 36 px on `sm:` upwards alongside the sibling pill.
- Self-gates on the provider mount + the operator's `coach` feature flag so
  the button silently disappears when the Coach surface is disabled.

The `dashboard.coachCta` i18n key (6 locales) ships in the locale commit
that pairs with this change. New test file pins the hoist contract at the
source level; the existing v1.4.27 R3d MB4 polish test was updated to
reflect the new mount location instead of the old insights-layout one.
2026-05-16 20:48:50 +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 fd81d3b806 refactor(settings): merge Sources into Thresholds as "Targets & Sources"
The two former settings sections (`/settings/thresholds` and
`/settings/sources`) covered the same conceptual shelf — per-metric
configuration. Reading them together matches how users actually
think about each metric ("for this metric, which sources do I trust
AND what range counts as healthy?") and collapses two adjacent
sidebar slots into one.

`<ThresholdsSection>` now hosts both editors stacked under one page
header: the per-metric threshold ranges on top, the per-metric source
priority + two-axis device-type ladder below. The two mutation flows
(`/api/user/thresholds` and `/api/auth/me/source-priority`) stay
distinct so a save on one half never disturbs the other.

`<SourcesSection>` gained a `mode` prop. The default `"standalone"`
behaviour is preserved for any caller that still mounts it directly;
the new `"embedded"` mode drops the inner header so the assistive-tech
reading order stays one combined surface, not two.

The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` (down to
ten sections), and the Sources entry was removed from the in-shell
sidebar + mobile chip-strip. `/settings/sources` stays alive as a
`permanentRedirect` to `/settings/thresholds`, so external bookmarks
(iOS Settings deep-links, docs, anything that linked the URL) follow
through unchanged.

Tests updated: `SETTINGS_SECTION_SLUGS` size assertion drops from
eleven to ten; `<ThresholdsSection>` mount test now expects both
editor mount-points; the section title moves from "Personal
thresholds" / "Persönliche Zielwerte" to "Targets & Sources" /
"Zielwerte & Quellen".

Per `.planning/research/v1434-r-2-carryover-scope.md` §7 and
`.planning/round-v1433-audit-menu.md` §7.1 (item 3).
2026-05-16 20:47:46 +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 3b77b6a073 refactor(gamification): collapse achievement consumers onto shared hook
v1.4.34 IW-F-Perf — migrate `<RecentAchievementsCard>`, the
`/achievements` mother page, and `<AchievementUnlockNotifier>` from
ad-hoc `useQuery` blocks onto `useAchievementsQuery()`.

Pre-change the dashboard cold mount fired
`/api/gamification/achievements` twice — once for the card, once for
the notifier — because the notifier carried a per-user discriminator
on its queryKey and TanStack treated it as a fresh cache cell. The
shared hook routes all three consumers through the same
`queryKeys.gamificationAchievements()` slot, so a single network
call now backs every surface. The notifier keeps its 2-minute
refetch cadence; long-open tabs still surface new-unlock toasts and
the card picks up the same payload from the shared cache without
issuing a parallel request.
2026-05-16 20:40:16 +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 30c8555cee refactor(charts): drop the v1.4.33 heatmap classifier-bug fallback
v1.4.33 shipped a defensive fallthrough in `getColor` that detected
the unambiguous "every taken dose landed in veryLate" pattern and
recoloured those cells via the rate-based palette. With the
classifier hardening landed in `compliance.ts` the pattern can no
longer arise from a UTC offset between `takenAt` and the schedule
window, so the workaround is dead code. Replace the long
`looksClassifierBug` block with a single comment pointing at the
root fix and let the timing-based palette run unconditionally when
timing data is available.
2026-05-16 20:25:09 +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 03b0be18ea feat(ios): serve apple-app-site-association on the app domains
Adds a Next.js Route Handler at `/.well-known/apple-app-site-association`
that answers 200 with `application/json` on every host that fronts the
main app — both `healthlog.bombeck.io` (maintainer prod) and
`demo.healthlog.dev` (demo) share this handler since they ride the same
standalone runtime. The body advertises 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 same ceremony that today exists at `/api/auth/passkey/*` on both
domains.

The `applinks.details` array stays empty for now; Universal Links are
out of scope for the first cut and pick up later once the iOS side opts
in to deep-linking specific routes.

Pairs with the proxy `/.well-known/` allowlist entry — without that
bypass the auth gate would 307 Apple's CDN fetch to `/auth/login`
before the handler ran.
2026-05-16 18:57:53 +02:00
Marc-André Bombeck 2acb11f80e feat(auth): admit /.well-known/* without a session
Adds the `/.well-known/` prefix to the proxy's `PUBLIC_PATHS` allowlist so
RFC 8615 discovery endpoints reach a 200 response before the auth gate
runs. Apple in particular reads
`/.well-known/apple-app-site-association` without credentials to wire Web
Credentials (passkey sharing) and Universal Links to the iOS bundle, and
the previous 307 redirect to `/auth/login` broke that fetch on every host
the app serves (`healthlog.bombeck.io` and `demo.healthlog.dev` both).

The trailing-slash entry future-proofs the namespace for `/security.txt`,
`/openid-configuration`, and the other well-known endpoints the IETF
registry covers — adding a new file under `src/app/.well-known/` won't
need a second proxy edit.

A regression test pins the allowlist in place: unauthenticated requests
for the AASA file (and a synthetic `/.well-known/security.txt` peer) must
answer 200 with no location header, while a protected page like
`/insights` still 307s to the auth gate.
2026-05-16 18:57:42 +02:00
Marc-André Bombeck ab1700a843 fix(onboarding): really stop spotlight tour from blocking the quick-add button
The first attempt at the F2 fix (f9b8f3bd) split the spotlight tour
backdrop into four rectangular dim panels around the spotlight rect
and opted each one back into pointer-events: auto with its own
onClick={handleSkip}. The intent was to keep the spotlighted target
clickable while preserving the "tap backdrop to dismiss" affordance.

The flaw: tour-step 1 on the dashboard spotlights the tile-strip,
which sits in the middle of the page. The header "Add" button sits
ABOVE the spotlight. The TOP dim strip (rendered from y=0 down to
where the spotlight begins) therefore covered the entire header
area, so the very target the F2 audit named — the header Add
dropdown — was still being eaten, just by a smaller rectangle.
The four-panel split fixed exactly ONE coordinate (inside the
spotlight rect) and broke every coordinate outside it that a real
user might want to click during the tour: header buttons, the
user-menu avatar, sidebar links, the locale switcher.

This commit ships the contract that Joyride / Shepherd / Intro.js
have all standardised on: the dim is purely visual. The whole
tour layer is pointer-events: none; only the tooltip card opts
back into hit-testing. The four-panel split survives because it
still serves the visual purpose of NOT dimming the spotlighted
target, but each panel is now a <div aria-hidden="true"> with
pointer-events: none — no onClick, no aria-label, no interactive
semantics. The center-placement fallback follows the same shape.
The tooltip's onClick={(e) => e.stopPropagation()} guard came off
because nothing bubbles to the dim any more. Tour state (Skip,
Back, Next) lives entirely on the tooltip's footer buttons,
matching the affordance users expect and keyboard-accessible via
the existing Esc shortcut.

The e2e regression spec at e2e/onboarding-tour-passthrough.spec.ts
gained a third case and tightened the first one. Case 1 now runs
document.elementsFromPoint at the quick-add button's centre, walks
the stack, and asserts that NO element belonging to the tour
overlay has computed pointer-events !== "none". This pinpoints the
regression instead of observing the symptom. Case 2 swapped from
"click a dim strip skips" (an artefact of the failed intermediate
fix) to "the explicit Skip button in the tooltip dismisses the
tour". Case 3 reads computed pointer-events from every
[data-testid="onboarding-tour-dim"] and asserts each is "none" —
the load-bearing invariant the F2 regression violated.

Quality gates: typecheck clean, eslint clean on the touched files,
all 43 onboarding unit tests pass, all 6 F2 e2e cases pass locally
(3 cases x chromium-desktop + chromium-mobile).
2026-05-16 18:42:02 +02:00
Marc-André Bombeck e3590e77bb test(insights): align b3-wiring import guard with next/dynamic wrapping
The page now resolves CorrelationRow and TrendsRow through dynamic()
imports rather than top-level `from "..."` statements; the static-source
scan accepts either spelling so a future revert to eager imports still
flags the load-bearing module path.
2026-05-16 18:07:20 +02:00
Marc-André Bombeck bafac84f8d chore(i18n): retire the now-orphan settings.kiInsights key
The dedicated card H2 was retired in the previous "drop AI prefix"
pass; the `settings.kiInsights` translation key it consumed has no
remaining call-site in source. Drop it from de / en / es / fr / it /
pl. Also update the message-thread test to the new "Insights
provider" copy.
2026-05-16 18:02:59 +02:00
Marc-André Bombeck 81225a764a fix(settings): scroll-snap on the mobile section strip
`no-scrollbar` already suppresses the painted scrollbar so the strip
doesn't look like every settings card has an overflow problem. The
`snap-x snap-mandatory` layer on the strip plus `snap-start` on each
chip means a swipe-flick lands on the next chip's leading edge instead
of the in-between dead zone — the auto-scroll-into-view effect on
mount stays the canonical positioner for the active chip; snap is the
polish layer for manual flicks.
2026-05-16 18:00:38 +02:00
Marc-André Bombeck 0de1e2ebe7 fix(settings): fold the About section into the user-card dropdown
Eleven sections on the settings sidebar was one over the comfort
line. About is three small cards (identity, links, update check) that
collectively get read once or twice a year and didn't earn a top-level
slot. Hide it from the in-shell nav and surface it as "About HealthLog"
in the sidebar user-card dropdown (mobile dropdown mirrors). The
`/settings/about` route stays alive so direct links, the public
`/about` permalink, and the dropdown entry continue to resolve.
2026-05-16 17:59:52 +02:00
Marc-André Bombeck 2438dce63d docs(layout): pin the legal-page narrow-column convention in code comments
The privacy + about pages constrain their main column to `max-w-3xl`
(768 px) while the rest of the app uses `max-w-screen-xl` (1280 px).
That's a deliberate readability choice -- long-form legal text is
easier to read at 70-80 chars per line -- but the convention wasn't
documented inline, so a future contributor could see the deviation
from the shell width as a stale token and "fix" it.

Inline rationale comment on both `<main>` blocks per the audit
finding §3.4. No layout change.
2026-05-16 17:58:20 +02:00
Marc-André Bombeck 2620064ef1 fix(a11y): repair non-sequential heading order on three surfaces
Lighthouse v1.4.33 a11y pass flagged "heading elements not in
sequentially descending order" (score 91/94). Three offenders:

- `<TherapyTimeline>` (insights/medikamente sub-page): sr-only
  drug-name heading was an h4, but the parent SubPageShell ships
  the only h1 and CardTitle is a <div>. Promoted to h2 so the
  outline stays h1 -> h2 without skipping levels.
- `<FeedbackInboxDetailSheet>`: metadata + screenshot section
  headings rendered as h4 inside a ResponsiveSheet whose dialog
  title is h2 (Radix default). Demoted to h3 to stay sequential
  beneath the sheet title.
- `<RemindersSection>`: results heading was h4 inside the admin
  SectionFrame's h1. Promoted to h2.

Coach panel headings (IW5 surface) audited but not changed.
2026-05-16 17:57:28 +02:00
Marc-André Bombeck 972c8a56dd fix(nav): drop the redundant "Home" group label from the desktop sidebar
The sidebar has exactly one nav group, and the first entry is already
"Dashboard" pointing to `/`, so the "HOME / Dashboard" pairing used to
read as if there were two separate destinations for the homepage. Drop
the group label and retire the `nav.home` locale key in every bundle.
The collapse toggle keeps its position at the top-right of the strip.
2026-05-16 17:57:21 +02:00
Marc-André Bombeck d254e9cd98 perf(insights): defer below-fold mother-page blocks behind next/dynamic + bundle config hygiene
Lighthouse v1.4.33 flagged ~463 KiB of unused JS on first /insights
paint plus ~211 KiB of bundled JS as unminified. Two coordinated
moves close the gap without touching the Coach panel internals or
the dashboard tree.

next.config.ts
- `compiler.removeConsole` with `exclude: ["error", "warn"]` strips
  the chart-wiring + Coach SSE breadcrumb literals from the prod
  bundle. Error + warn paths still flow through to GlitchTip.
- `headers()` advertises `Permissions-Policy: unload=()` so Chromium
  admits the page to the back/forward cache when navigating away. We
  carry no `beforeunload` / `unload` listeners in-tree so the hint is
  honest.

src/app/insights/page.tsx
- `<DailyBriefing>`, `<CorrelationRow>`, `<TrendsRow>` move behind
  `next/dynamic({ ssr: false, loading: <Skeleton /> })`. All three
  paint below the hero on every viewport and were eagerly imported
  before. Each loader skeleton matches the dimensions of the resolved
  block so layout doesn't shift.
- `<HeroStrip>` stays an eager import — the greeting + health-score
  badge are the visible-first-paint surface.
2026-05-16 17:57:03 +02:00
Marc-André Bombeck 432ebccdae fix(i18n): drop the "KI"/"AI" prefix from user-facing copy
Marc-Voice rule — the product never names itself "KI" or "AI" in
copy. Settings -> "KI-Auswertungen" becomes "Auswertungen" / "Insights",
the inner "KI-Insights" card heading collapses into the parent section
heading, "Aktiver KI-Provider" becomes "Aktiver Provider", and the
admin "AI Quality" view becomes "Insights Quality".

Sweep across de / en / es / fr / it / pl. Provider product names
(Anthropic, Claude, ChatGPT, OpenAI) are unchanged — those are
proper nouns, not the "AI" label.
2026-05-16 17:56:34 +02:00
Marc-André Bombeck 0cac3d3545 fix(a11y): give icon-only buttons accessible names on the medications page
Lighthouse v1.4.33 a11y run flagged "links without discernible name"
(score 91/94). The medications page renders five icon-only buttons
(reset-form dropdown, three copy actions, status dropdown) without
an aria-label, so the SR announcement collapses to the role name.

This patch reuses existing common.* translation keys (no new strings
added; messages/*.json off-limits for IW9):

- the reset / status DropdownMenu triggers get `common.moreOptions`,
- the three copy icon-buttons get `common.copy`.

Icon-only buttons in `src/components/settings/**` (IW4),
`src/components/insights/coach-panel/**` (IW5), and the bottom-nav
already carry aria-labels per earlier polish rounds.
2026-05-16 17:53:29 +02:00