75 Commits
Author SHA1 Message Date
Marc-André Bombeck 9f2a3e25fe perf(i18n): generate the catalog boot scripts as static assets
The dynamic /i18n/[locale] route handler answered with Next's default
Cache-Control (private, max-age=0, must-revalidate) and no validator —
its immutable-cache header was overridden, so a non-PWA browser would
re-download the full catalog on every hard load. Static public/ files
keep an ETag (repeat loads revalidate to a 304) and the service
worker's cache-first rule covers the offline relaunch; the ?v= build
key the layout appends busts both per deploy.

scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all
six locales as a prebuild/predev step (the sw-version.js pattern);
output is gitignored. The route handler is removed.

Measured on a production build, authenticated DE session: dashboard
document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE
catalog moves into a one-time 119 KB gz cached asset.

H2
2026-07-07 13:15:37 +02:00
Marc-André Bombeck 85058716f6 fix(pwa): stop treating network failure on the auth probe as session end
A failed /api/auth/me now only counts as a session END on an explicit
401/403. Network-level failures (offline relaunch fetch TypeError, the
service worker's synthetic 503 offline envelope, abort/timeout) and 5xx
carry no session verdict: the hook holds the last-known auth state via a
non-sensitive localStorage marker instead of flipping unauthenticated.

Pre-fix, an airplane-mode relaunch of the installed PWA redirected to
/auth/login and ran clearCachesForSessionEnd(), wiping the SW data/page
caches and the IndexedDB query snapshot — the entire offline read path
was unreachable in exactly the scenario it exists for.

- use-auth: isAuthVerdictUnknown() classification, was-authenticated
  marker (set on every successful /me, cleared on session end),
  isAuthUnknown exposed for the shell
- auth-shell: the wipe runs only on a real verdict; a non-verdict
  failure without a prior-session marker redirects without wiping
- explicit logout and true 401/403 expiry wipe exactly as before

H1
2026-07-07 12:02:43 +02:00
Marc-André Bombeck f5b0ec431e feat(coach): surface plan proposals for confirmation and add the plans page
The Coach has extracted goal / if-then plan proposals since v1.21.3 —
written as status "proposed", never activated without the user — but no
surface ever showed them, so nothing could be confirmed and the active-plan
memory block stayed permanently empty. This lands the missing user half:

- Chat thread: proposal confirm cards at the tail of the open conversation
  (proposals land asynchronously via the post-turn memory-refresh worker,
  so the block slow-polls the proposed list while the thread is mounted and
  only shows proposals born in that conversation). Accept activates the
  plan — it enters the Coach's snapshot memory from the next turn on;
  decline soft-deletes the proposal so it never activates and stops
  counting against the per-user cap. A settled card collapses to a
  one-line confirmation.
- /coach/plans: the management ledger, a sibling of /coach/conversations
  reachable from the composer's + menu. Groups proposals (confirm /
  decline), standing plans (goal met / end), and the settled history
  (remove). No prose editor by design — the extractor is the only writer
  of plan text and the PATCH contract only moves the lifecycle.
- Shared use-coach-plans hook + centralised coach-plans query keys, so the
  thread cards and the page invalidate each other's reads.
- Both coach-gate invariants (operator master flag, per-user opt-out)
  register the new page; i18n keys land in all six locales.

SSR tests pin the card contract: only same-conversation proposals render,
undecryptable rows are skipped, both actions are real buttons.
2026-07-05 20:00:38 +02:00
Marc-André Bombeck 109866b5d0 fix(layout): apply the sidebar viewport default after hydration
Reading matchMedia (or localStorage) inside the initial render branches
the hydration render away from the SSR markup — the sidebar is only
CSS-hidden below md and still hydrates its DOM there, so the phone
viewport tripped React #418 in the dashboard console guard. The
collapsed state now gates on the mounted probe like the module filter
above it: SSR and hydration paint the expanded shell, the stored pref
or tablet default applies right after. useIsMobile learns the lg cut
for the tablet probe.
2026-07-04 16:13:15 +02:00
Marc-André Bombeck ea380d6f68 fix(auth): clear the in-memory query cache at every session boundary
The root QueryClient is created once and survives every client-side
navigation, so logout then login on the same browser left the previous
account's non-user-scoped health-data queries in memory and the next
account read them before any refetch landed. Wipe the entire in-memory
cache on logout, on session expiry, and at the start of a fresh login,
alongside the existing IndexedDB and service-worker cache wipe, so no
session ever inherits another account's data.
2026-06-28 21:37:55 +02:00
Marc-André Bombeck c862b4880f feat(coach): add an opt-out for proactive ambient suggestions
Add a per-user toggle that turns off the proactive ambient example suggestions
(the daily seeded opener on the Coach hero). The flag rides the existing coach
notification-prefs blob (coach.ambientSuggestions, default on) — no schema
change — and a shared hook makes it the single source every ambient suggestion
surface consults, so the seeded opener and its fetch both stand down when off.
2026-06-28 16:04:22 +02:00
Marc-André Bombeck 0790a96ca4 feat(labs): add an AI assessment and stat strip to the biomarker detail page
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.

The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.

The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
2026-06-28 13:43:14 +02:00
Marc-André Bombeck 9c748382b9 feat(tables): share column-sort state across the value lists
Lift the byte-identical sortBy/sortDir/toggleSort triple the measurement
and mood lists each carried inline into a shared useTableSort hook. Both
lists now compose their page-reset and selection-clear side effects around
the hook's toggle rather than re-implementing the direction math, and the
date columns keep their descending-first default via descColumns.
2026-06-28 13:11:03 +02:00
Marc-André Bombeck 5331528076 feat(coach): surface reminders in-app and add the action card
Add a "Your reminders" panel under Settings → Coach that lists the stored
reminders, highlights the due ones, and gives the user view, confirm,
done/dismiss, and delete control over their own memory. Light the Coach
launcher dot when a reminder is due, alongside the existing nudge signal.

Add the generic confirm-card component for the suggested-action frame: a
plain-text preview with a localized per-action heading and a single confirm
tap that posts the validated params to the build endpoint.

Translate the new copy in English and German.
2026-06-27 13:38:25 +02:00
Marc-André Bombeck 86a9ca2e1e feat(profile): persist and expose the date-format preference
Accept dateFormat on the profile PATCH field-by-field, echo it from
the profile + /me reads, mirror it into the client auth user, and add
the field to the OpenAPI request/response schemas.
2026-06-26 12:15:09 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 0a8ccaa30c feat(onboarding): one-time medical-disclaimer acknowledgment
Replace the per-page and per-chart disclaimer banners with a single
acknowledgment at the start of onboarding: HealthLog is a private tracking
tool, not a clinical assessment or diagnosis; talk to a doctor if worried.

- record the acknowledgment per user (disclaimer_acknowledged_at/_version,
  migration 0175) mirroring the research-mode acknowledgment precedent
- gate the welcome "Get started" on the checkbox; expose the timestamp on
  /api/auth/me so a returning user is never re-asked
- keep the full legal text reachable on the public privacy page
2026-06-18 21:37:13 +02:00
Marc-André Bombeck 59e7b33285 feat(onboarding): add resumable module-tour progress field + API
Add nullable onboarding_tour_progress_json to users (migration 0173),
a Zod-validated progress shape, and extend POST /api/onboarding/tour to
accept a mid-tour resume checkpoint and clear it on replay. Surface the
resume point on GET /api/auth/me and register the contract in OpenAPI so
the native client mirrors it.
2026-06-18 19:27:28 +02:00
Marc-André Bombeck b474ea1489 fix(pwa): stop the offline cache from persisting sensitive health data
The offline layer wrote far too much to disk and cleared it too rarely.
The query persister used a denylist (only auth/session/admin/tokens), so
every other family — coach, insights, illness, labs, mood, cycle — was
dehydrated to IndexedDB in plaintext; the service worker likewise
prefix-cached GET /api/insights, which serves decrypted Coach
conversations. Neither store carried an account marker and both were
wiped only on explicit logout, so a session that ended by expiry or a
401 left health data on disk for the next account on a shared profile.

Flip the persister to a strict allowlist (dashboard snapshot, per-chart
series, widget layout — the offline-dashboard set only) and stamp each
payload with the live account id, discarding on mismatch. Drop
/api/insights and /api/analytics from the worker allowlist. Wipe the
IndexedDB blob and the data/page caches on every session end — the
401/expiry redirect and the logout path both route through one helper.
2026-06-18 18:18:24 +02:00
Marc-André Bombeck bb08aa42dc feat(pwa): real offline read value via SW data cache + query persistence
An installed PWA opened offline rendered empty skeletons forever: the
service worker hard-excluded every /api/* path and there was no client
query persistence. Close both gaps for read value.

Service worker: add a stale-while-revalidate branch for an allowlist of
safe GET /api/* reads (dashboard snapshot + widgets, measurements,
medications, insights, analytics, version) in a release-versioned
healthlog-data cache. Two-layer refusal — a path deny guard for any
auth/token/session/login segment plus a body-level secret refusal for
hlk_/hlr_/sk- shapes and no-store responses — so a secret-shaped body or
an auth surface can never be persisted. Mutations stay network-only.

Client: add a dependency-free IndexedDB query persister that hydrates the
last successful reads before the first authenticated paint, build-version
and age gated, debounced on cache change, with an auth/session/admin/token
denylist. Cleared on logout alongside the SW page and data caches so a
shared device never leaks one account's data.

Offline banner now states it is showing last-synced data when a cached
snapshot is present. Bump release to v1.18.6.
2026-06-18 15:18:48 +02:00
Marc-André Bombeck 7451e886f1 fix(vorsorge,coach): align reminders + coach chrome with the gold standard
Twice-daily BP protocol (RRULE BYHOUR=7,19) now fires at every clock
hour: byHourTimesOfDay expands BYHOUR into the engine's timesOfDay so
the label, RRULE, and engine output agree instead of collapsing to one
nudge at notifyHour.

Vorsorge surface:
- move create/edit into a ResponsiveSheet with a pinned Cancel/Save
  footer, matching Labs/Illness/Medications
- wire the unused PATCH support: Edit affordance opens the form
  pre-filled; an enable/disable Switch drives the enabled flag
- translate COACH-origin labels (the row stores an i18n key, not prose)
  and render a neutral Coach provenance badge + an until-<date> line
- surface origin + endsOn on the hook type
- pull-to-refresh + always-on rationale documented on the nav entry
- NativeSelect-vs-Radix choice documented as deliberate

Coach:
- expired COACH course-window reminders self-clean: the cron tick
  soft-deletes finished finite cadences instead of leaving dead rows
- accept-dedup gains a partial unique index (user, metric) where
  origin=COACH and not deleted, with a P2002 catch in the route
- per-user rate-limit bucket on the suggestion-action endpoint
- SUGGEST-REMINDER parser keeps prose written after the block
- document POST /api/coach/reminder-suggestions in OpenAPI

Coach chrome:
- HistoryRail hideHeading prop drops the duplicate Conversations
  heading on the page surface
- align the rail-tray strip inset to px-3 sm:px-4 so the dividers line
  up; reword the FAB inset comment

satisfyReminder closes the forward-only TOCTOU via a conditional
updateMany (count===0 no-op). Migration headers corrected to v1.18.1.
2026-06-16 23:11:08 +02:00
Marc-André Bombeck a524cad4c4 fix(insights): guard the module sub-pages against direct-URL hits
The sleep, blood-glucose, workouts, and recovery insights pages
fetched their series and half-rendered when the backing module was
disabled — a disabled account that navigated straight to the URL saw
the surface flash before any guard. They now resolve a shared
useModulePageGuard hook (mirroring the cycle page): an unauthenticated
visitor bounces to login, a disabled-module account bounces to
/insights, and the page renders a calm loader until the gate clears.
The backing /api routes enforce the gate server-side, so this is the
UX layer, not the security boundary.
2026-06-16 02:01:47 +02:00
Marc-André Bombeck 2425832f47 merge(reconcile): r4-logic audit findings 2026-06-16 00:59:31 +02:00
Marc-André Bombeck 91d01d154a fix(settings): make delegated module rows read-only managed deep-links
The Module hub rendered live switches for the delegated keys cycle and coach,
but the gate resolves those from their own sources (cycle from CycleProfile,
coach from the operator flag + disableCoach), so a toggle wrote an inert entry
to modulePreferencesJson, snapped back, and still fired a success toast. Render
delegated modules as read-only rows that deep-link to their real control, strip
delegated keys from the modules PATCH schema so no inert value is stored, drop
the duplicate cycle control, and surface an operator-disabled state with the
previously orphaned hint string.
2026-06-16 00:51:16 +02:00
Marc-André BombeckandClaude Opus 4.8 3de53f549c fix(health-score): drop the mood pillar when the mood module is disabled
The Personal Health Score weighted the 0.20 mood-stability pillar
unconditionally — the fast-path read mood entries regardless of the
account's module state, so a user who turned the mood module off was
still scored (and silently penalised or rewarded) by a pillar hidden
everywhere else in the product. The dashboard already blanks the mood
tile and the coach snapshot unions mood out; the score ring and the
insights card pillar list did not.

Thread a default-on `moodEnabled` flag through the fast-path. When the
caller resolves the mood module as disabled the helper skips the mood
read entirely and leaves the series empty, so `moodStability` returns
null and the existing proportional null-redistribution re-scales the
surviving pillars to sum to 100. The analytics route resolves the
module server-side and threads the flag; the insights card hides the
Mood row via a new SSR-safe `useModuleEnabled` accessor so it never
advertises a pillar the account turned off.

Golden fixtures pin mood-on vs mood-off to different, correctly
re-weighted scores and assert the disabled read is never issued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:39:55 +02:00
Marc-André Bombeck 6bde45ce0a merge(modules): gate achievements behind the achievements module flag
# Conflicts:
#	src/hooks/use-auth.ts
2026-06-15 21:57:43 +02:00
Marc-André Bombeck 70402ea95a feat(modules): gate achievements behind the achievements module flag
When an account turns the achievements module off the whole gamification
surface disappears. The route enforces the gate after auth and returns the
403 module.disabled envelope, so no badge evaluation, unlock persistence,
or payload is produced. The achievements page, the dashboard recent-unlocks
tile, and the unlock notifier read the resolved module map from
GET /api/auth/me and render nothing / disable their query when the module
is off. Default-on: an absent key reads as enabled, a stale /me payload
coerces to an empty map so no surface vanishes on a partial-deploy
rollback.
2026-06-15 21:25:40 +02:00
Marc-André Bombeck 5682055fe2 feat(modules): hide nav entries and Insights pills for disabled modules
Generalise the per-destination cycle gate into a requiresModule marker
on each nav destination and read it from the account's resolved module
map (the modules field on GET /api/auth/me). Fold the bespoke cycle
boolean into requiresModule: "cycle" so there is one gating mechanism;
cycle and coach are delegated keys already reflected in the map, so
reading them here is not a re-derivation.

Tag mood, cycle, labs, coach and achievements with their module keys;
core destinations (measurements, medications, vorsorge, insights, the
dashboard) carry none and always render. The sidebar, the mobile More
hub, and the Insights tab strip all filter on the same map.

Insights pills: a metric pill belonging to a toggleable module (mood,
sleep, glucose, workouts) hides when its module is disabled, on top of
the existing data + saved-layout gates; the Recovery pill gates on the
recovery module. Overview and core-metric pills always show. Every gate
fails open — a missing key or an absent map keeps the entry, matching
the gate's default-on contract, so a stale /me payload never blanks the
nav.
2026-06-15 21:18:47 +02:00
Marc-André Bombeck c6045482c3 feat(reminders): preventive-care surface — wann, was, wo
Add the Vorsorge page and section: an upcoming-reminders list sorted by
server-computed next-due, with a create form (measurement-linked or
free-text, cadence, notify hour, location) and a per-card Erledigt and
delete action. Cards stay neutral-coloured regardless of due state — the
status reads through a discreet badge only, never an alarming tint. Copy
ships in all six locales.
2026-06-14 23:33:37 +02:00
Marc-André Bombeck 0c4b6ab8ba fix(pwa): wipe the page cache on logout
Now that the service worker is registered app-wide, cached navigation
HTML survives a logout. The everyday routes carry no server-rendered PII,
but on a shared device a clean session boundary should leave nothing in
CacheStorage. On a successful logout, best-effort delete the
`healthlog-pages-*` caches. The static cache (hashed chunks, icons) is
left intact so the next login does not re-download the shell. The wipe
never blocks the redirect to the login page.
2026-06-14 10:46:01 +02:00
Marc-André Bombeck b5e799c2d6 fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target
version instead of disarming for the whole session, so a second deploy
re-arms the self-heal and a stale shell can no longer strand with
404ing chunks. Lazy chart imports retry once and mount inside a
per-card error boundary with a reload affordance — a failed chunk
degrades to one card instead of replacing the page. The JSON API
client attaches a fifteen-second timeout unless the caller brings its
own signal (the raw streaming path is exempt), chart queries surface
errors with a retry instead of rendering as an empty period, per-type
fetch failures reject instead of caching an empty series as fresh, and
the session and snapshot queries retry once on transient network or
server errors.
2026-06-11 21:17:27 +02:00
Marc-André Bombeck a8149e0bdf fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
2026-06-11 03:30:18 +02:00
Marc-André Bombeck 9445d89b76 refactor(api,tz): one typed client for every call and one home for wall-clock math
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
2026-06-11 02:19:27 +02:00
Marc-André Bombeck 7e7e17c68c feat(medications,coach,insights): record the dose you actually took
An intake can carry the dose that was actually taken — editable in the
quick-add and log dialogs, shown as a quiet deviation line in the
history. The measurements and mood lists refresh by pull on touch, the
insight sub-pages gain the same customize entry as the overview, and an
answered coach follow-up offers to adopt the answer into the matching
self-context field.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck aabc7915cb feat(settings): a time-format preference and one home for insight pills
The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
2026-06-10 13:34:46 +02:00
Marc-André Bombeck c1976249c3 chore(insights): drop the now-unused layout hook wrapper 2026-06-07 17:12:28 +02:00
Marc-André Bombeck 6ebac22c65 fix(insights): reconcile customizable-overview review findings
- Layout PUT accepts version 1 as well as 2 (live iOS client still PUTs
  version 1); the serializer normalises to the canonical v2 blob, so a v1
  body never 422s. Mirrored in the OpenAPI schema + description; yaml regen.
- Add the Weight tile to the inline tile editor so it can be hidden /
  reordered like every other Vitals grid tile.
- Replace the non-transitive Vitals tile-drag comparator with a pure,
  total-order rebuild helper (reorder the Vitals subset, substitute it back
  into the full list in original-order slots, densify).
- Gated-off section rows keep a disabled eye toggle in the same position
  with the gate hint as a caption below the title, so the row never jumps.
- Cap the disclosed Vitals tile list height (max-h-[60vh] scroll,
  overscroll-contain) so a tile drag does not fight the page scroll.
- Gate edit mode until the layout GET settles so a save can never flush
  defaults over a real saved layout.
- Drop the duplicate top-right "Done" affordance: hide the customize toggle
  while editing; the in-card Done / Reset own save and reset.
- Restore focus on enter (edit-card heading) and on close (customize toggle).

Tests: route regression for a version-1 PUT persisting a resolved v2 layout;
unit coverage for the new Vitals rebuild helper (order, non-Vitals stability,
per-tile visibility, no mutation).
2026-06-07 17:08:25 +02:00
Marc-André Bombeck e3539d7c94 feat(insights): drive overview section + tile render from saved layout
Read the resolved insights layout on /insights and render the customizable
region from it. Sections render in saved order, hidden ones drop out, and
the Vitals grid honours the per-tile order + visibility. HeroStrip stays
anchored above the customizable region; every existing feature/data gate
(briefing flag, cycle-enabled, per-tile data) still applies on top, so the
default layout reproduces the prior page.

- useInsightsLayout() reads GET /api/insights/layout through the query-key
  factory, defaulting to DEFAULT_INSIGHTS_LAYOUT while in flight.
- orderedVisibleSectionIds + resolveTileLayout: pure, unit-tested helpers.
- Widen DEFAULT_VISIBLE_TILE_IDS with the Vitals/Mobility grid tiles so a
  fresh account keeps the tiles it shows today.
2026-06-07 16:45:26 +02:00
Marc-André BombeckandClaude Opus 4.8 7333d8e40f feat(cycle): add the cycle-tracking web vertical
Build the web UI for the cycle-tracking category against the existing
`/api/cycle/*` routes: a month calendar + circular phase wheel, a fast
log-day sheet, a predictions panel, phase-insight cards, and a single-page
settings form, all under a gated `/cycle` tab.

- Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the
  wellness-ring aesthetic with calm per-phase hues) showing the current
  phase + day-of-cycle, alongside a month grid where logged period days show
  a filled pip, the predicted next period renders as a ranged band (never a
  single date), the fertile window shows only when the goal allows it
  (the API suppresses it server-side otherwise), and ovulation + per-day
  symptoms carry markers. Reads `GET /api/cycle/calendar`.
- Log-day sheet: one-tap period start, flow / symptom / ovulation-test /
  mucus chips, basal-temperature, intercourse + protection, and a free-text
  note. Optimistic write to the day-log + period routes; invalidates the
  cycle query keys.
- Predictions panel: next-period range with a confidence pill, goal-gated
  fertile window, a still-learning state for fewer than three cycles or
  raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer
  (reusing the drug-level-estimate disclaimer pattern).
- Insights cards render whatever phase-correlation cards the API surfaces,
  with a graceful empty-state until the feed lands.
- Settings: a single dynamic form where the inclusive goal selector drives
  the copy (conception framing only for trying-to-conceive), plus typical-
  length priors, raw-chart mode, sensitive-category encryption, discreet
  notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`.
- Nav: a Cycle entry in the sidebar and the mobile More hub, gated on
  `cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise.

Add the cycle query keys + dependent-key bundle to the factory, full copy
across all six locales, and dedicated per-theme phase hues. Plain
second-person voice, WCAG 2.1 AA, no new dependencies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:06:20 +02:00
Marc-André BombeckandClaude Opus 4.8 20d7187b0d feat(insights): premium animated wellness-score rings
Rebuild the wellness-score strip on /insights as a signature, high-end moment
instead of a flat dial.

- Five distinct per-metric hues — readiness green, recovery teal, sleep
  indigo-blue, stress amber, strain violet — hand-tuned per theme so each holds
  contrast on the white light-mode card. Fixes the dark-theme collapse to a
  cyan/green wash (the semantic info token resolved to the same cyan as three
  rings) and the light-theme wash-out of the raw cyan on white.
- A spring-eased arc sweep that overshoots ~6% then settles, a left-to-right
  staggered reveal with each number trailing its own arc, a conic specular
  sheen that sweeps once, a token-tinted bloom (a gentle breathing pulse only
  on a top-band ring), a segmented instrument-style track, and a faint
  film-grain tile surface for material depth.
- A "vs your normal" ghost arc under the live arc for recovery, stress and
  strain, derived from the trailing score series already on the payload, so the
  user sees today against their baseline at a glance.

The reveal plays once per browser session (sessionStorage-gated) so a
background sync refetch never re-triggers it. All motion is CSS-first — no
animation dependency, @property + a sampled linear() spring — and is fully
disabled under prefers-reduced-motion. The arc stays a hand-rolled SVG gauge;
the band semantic still rides data-band + the band word + the aria-label, never
colour alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:40:00 +02:00
Marc-André BombeckandClaude Opus 4.8 465c8b8e33 feat(insights): drive metric stat strip from the chart range tab; add chart tile header
Remove the draggable chart brush. The Min/Max/Median/Mittelwert strip now
always reflects the data visible under the chart's active range tab (7/30/90/
all): the chart reports per-type stats for its range-sliced series and the
sub-page threads them into the strip. No drag, no "selected range" pill — the
range tab is the single selector for both chart and strip. Blood pressure
recomputes both systolic and diastolic.

Give each metric chart card the canonical TileHeader (icon + title, same size
and spacing as the assessment/target/stat tiles) via an optional titleIcon;
the dashboard and mini mounts keep the compact heading.

Drop the now-unused charts.selectRange and insights.subPage.stats.selectedRange
keys from all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:53:32 +02:00
Marc-André BombeckandClaude Opus 4.8 d0d0f49385 feat(insights): add loading skeleton and error+retry to HealthKit subpages
The HealthKit metric pages consumed only `{data, isEmpty}` and ignored the
hook's `isLoading`/`error`, so they painted nothing in flight and fell
through silently on failure. Branch on `isLoading` to a stat-strip + chart
skeleton, and on `error` to a message plus a Retry that re-issues the
analytics query (mirrors the vitals-dashboard pattern). Expose `refetch`
from the analytics hook and add a `subPage.loadError` string in all six
locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:46 +02:00
Marc-André BombeckandClaude Opus 4.8 0995664893 feat(charts): add opt-in brush that reports per-type windowed stats
Add an optional Recharts <Brush> to the shared HealthChart, gated behind a
new `selectableDomain` prop. When set, the chart mounts the brush beneath
the plot and reports the Min/Max/Median/Mean over the brushed window per
type through `onDomainStats`. A range-tab change or a series refetch clears
or clamps the selection so the brush never indexes past the data.

The dashboard, trends-row, VO2 row, sleep, and rationale mounts do not pass
the prop, so they render unchanged — the feature is fully isolated behind
the flag.

`computeWindowStats` is a pure helper (no React/Recharts) so the chart can
summarise the array it already holds without a refetch and the maths is
unit-tested directly. The median uses the same linear-interpolated midpoint
as `summarize()` so a window and the full-range summary read on one
algorithm. `useChartDomainStats` lifts the per-type window into shared state
with a stable callback and a shallow guard against repeat windows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:35:44 +02:00
Marc-André Bombeck 4ace1b7706 refactor(insights): unify the metric subpage template — target tile, order, stat strip, drop the range row
Make every metric subpage identical by construction in the shared
scaffold rather than per-page bespoke.

- Target tile matches the other cards: dark `bg-card` background and the
  `rounded-xl border p-4` spacing the assessment and stat-strip cards
  use, instead of the old translucent `bg-card/20` panel.
- Drop the 7T/30T/90T/1J range row and its "+X% vs previous" delta from
  every subpage. The chart owns its own window via its in-card range
  tabs, so the chart query is unaffected. Retire the now-dead
  `MetricRangeControls`, `MetricRangeDelta`, `TimeRangePills`,
  `useAnalyticsRange`, the `useInsightsRangePref` persisted-range hook,
  and `sentimentFromMetricDirection` along with their tests.
- Fix the spine order to intro -> chart -> target card -> assessment ->
  Min/Max/Median/Mittelwert stat strip. The stat strip moves to the foot
  of the spine, below the assessment. Remove the "Letzte Messung" card
  and the headline primary tile from subpages; retire
  `MetricLastMeasurementCard` and `MetricPrimaryTile`.
- Blood pressure gains the stat strip it was missing: two stacked
  systolic / diastolic strips, since a single-series strip would drop the
  diastolic half. `MetricStatStrip` takes an optional series caption.

Update the spine guard to assert the target card precedes the
assessment and that the retired range row and last-measurement card
appear on no subpage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 80b2a810314b4c0951c0d68c04bffc89003005d6)
2026-06-05 10:42:47 +02:00
Marc-André Bombeck 496147687d refactor(insights): unify the assessment term, restore the canonical spine, consolidate status cards
The per-metric AI assessment was named four ways across the bundle
(Einschätzung / Auswertung / Analyse / Bewertung in German, with parallel
drift in the other locales). Sweep every assessment-state value — the
no-provider, no-result, start / refresh / re-run, regenerate and warm
strings — onto the one noun the card title already carries, in all six
locales. Keys are unchanged; only the human strings unify.

Restore the canonical metric-detail spine on the two bespoke pages that had
drifted: the mood page rendered the assessment directly under the first
chart with the target summary and breakdown sections below it, and the
medications page rendered the therapy timeline after the assessment. Both
now end on the assessment block, matching weight / bmi / pulse /
blood-pressure and the generic scaffold. A source-level guard test asserts
the assessment card is the last child of every bespoke page's shell so the
order cannot silently regress again.

Consolidate the repeated eight-prop assessment wiring: the five
useInsightStatus-backed pages now mount a shared SlugInsightStatusCard seam
(the sibling of MetricStatusCard for the generic route) instead of
hand-wiring the card and the status?.x defaults inline. The medications page
keeps its inline card because its route carries a richer summary-shaped
envelope than the standard text-only generators. The assessment footer's
"updated" caption now uses the shared relative-time formatter the briefing,
hero, last-measurement and coach-history captions use, so adjacent cards no
longer read one relative and one absolute timestamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2cd73ce5d6cdca63fea0829990fac2a7e24afebf)
2026-06-05 08:10:12 +02:00
Marc-André Bombeck 02c50acd46 feat(insights): unify the derived-metric query hook onto one factory key
Two parallel loaders for the generic derived-metric route had grown — a
components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a
3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix
with different tuple shapes, which silently poisons the TanStack cache between
surfaces. Collapse to the single components-local hook (auth-gated, 8 s client
ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is
always present so the metric prefix can no longer carry two shapes.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 95a2a7692f feat(insights): add composite scores, score-anatomy view, and correlation discovery
Build the composite tier of the derived-metrics layer on the shared seam:

- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
  consistency, timing and composition sub-scores over the per-stage sleep
  rows, reweighting around any sub-score the data cannot support (a legacy
  asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
  blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
  deviation-from-baseline components; returns insufficient below two present
  components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
  the latest day and fires when two or more coincide, listing the
  contributing vitals as possible factors, never a cause or a diagnosis.

Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.

Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.

Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.

Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
2026-06-02 21:19:56 +02:00
Marc-André Bombeck 32bc11f968 feat(insights): add the derived-metrics design-system primitives
Build the four reusable presentational components the v1.10 vitals and
score-anatomy surfaces share: a radial score ring, a coverage meter, a
sparkline delta tile, and a provenance explainer. Each extends the
existing dracula band + trend-sentiment vocabulary rather than a new
colour system, and ships its populated, provisional/empty, and stale
states up front so a sparse-data surface never goes blank.

- score-ring: centred tabular-nums number on a Recharts RadialBarChart,
  band-coloured, size prop (sm tile / md / lg anatomy), count-up + sweep
  on mount via a 0 KB requestAnimationFrame hook; role=img aria-label
  restates the number so the ring is never colour-only.
- coverage-meter: CSS-only filled/hollow dots driven by the Derived<T>
  coverage + confidence facets, with an accessible breakdown tooltip
  (present vs required inputs, history days, missing list).
- sparkline-delta-tile: extends the trend-card grammar with an inline
  Recharts area sparkline, a signed period delta, and an age/normal-range
  framing slot.
- provenance-explainer: collapsible Popover (desktop) / bottom-sheet
  (mobile) rendering inputs, plain-language method, and an optional cited
  standard as React text children — no markdown library.

All four read their data off the Derived<T> union as a type-only import
so the client bundle stays free of the server graph. Recharts only, no
new runtime dependency. New i18n keys propagated across all six locales.
2026-06-02 20:14:49 +02:00
Marc-André Bombeck a87d6b94e4 fix(insights): keep the server-only rollup readers out of the insights client bundle
The v1.9.0 time-range work imported ANALYTICS_RANGES as a value into the
time-range pills and the insights layout-prefs hook from range-delta.ts.
That module also pulls the rollup readers and the Postgres client, so the
value import dragged pg (dns/fs/net/tls) into the browser bundle and broke
the production build with eight "Module not found" errors.

Move the client-safe pieces — the range list, its type, the per-range
window length, and the period-over-period result shapes — into a new
range-shared module that imports nothing server-side. The insights client
components import from there; range-delta.ts re-exports the same names so
the server route and the OpenAPI registry keep their import paths. The
window-composition logic and the rollup/db imports stay server-side.

No runtime behaviour change — a pure module-boundary split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75124e76c1e987d8a022fb39d891049340199b38)
2026-06-02 17:41:33 +02:00
Marc-André Bombeck d880385635 refactor(targets): retire the standalone Targets page
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.

Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.

The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 3694efe6e8 fix(insights): keep the assessment card polling while a stale text revalidates
The stale-while-revalidate path serves the last-good assessment on a cache
miss and returns a terminal payload, so the status hook stopped polling. The
worker warmed a fresh assessment out of band seconds later, but the open card
never picked it up — the stale text stuck until the page remounted.

Add a `revalidating` flag to the read-only miss outcome, set only when
last-good text is served AND a refresh was enqueued (false on the
suppressed-stub branch and when no prior text exists). Thread it through the
generic metric-status generator and the seven specialised generators onto the
wire, type it on the client, and make `nextStatusPollInterval` keep polling on
`preparing || revalidating`. The existing 12-attempt ceiling bounds both
signals, so a persistently failing generation still cannot poll forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 03925ddece feat(insights): time-range pills with period-over-period deltas on metric pages
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.

New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.

The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 58b793ee4a fix(insights): correct the active-energy assessment id and share the status card
The active-energy page passed `ACTIVE_ENERGY_BURNED` as `statusMetric`, but
that is the DB MeasurementType, not the registry id the metric-status route
accepts. The route's closed enum only knows `ACTIVE_ENERGY`, so every fetch
422'd and the assessment card silently fell back to its empty body.

Point the page at the `ACTIVE_ENERGY` registry id and type `statusMetric`
(plus the `useInsightMetricStatus` metric param) to the closed
`MetricStatusMetricId` union instead of a bare string, so a future
MeasurementType-vs-registry-id mismatch is a compile error rather than a
silent 422. A typecheck pass over all 29 pages confirms active-energy was the
only diverging one.

Extract a shared `MetricStatusCard` that owns the `useInsightMetricStatus`
hook and the assessment-card wiring, collapsing the duplicated mount block in
the HealthKit metric scaffold and the bespoke sleep page to a single line
each (only the icon differs). Behaviour is unchanged: the card mounts only on
the data-bearing branch, so a source-less account never fires an assessment
round-trip. Add a test asserting the active-energy page wires an accepted
registry id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6e371aaeb9698f7b4251c22e1d8f381ddfa70a00)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 16b0c349f2 feat(insights): add generic per-metric assessment loader
Add a parameterised query key (insightsMetricStatus) and a
useInsightMetricStatus hook that reads the generic per-metric
assessment route GET /api/insights/metric-status?metric=<id>. The
seven bespoke status routes keep their own keys and hook union; this
one covers the HealthKit metric sub-pages that share a single route
keyed by the metric id. The hook mirrors the existing status loader:
8 s client ceiling, no inline retry, and the bounded preparing poll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a3407a2f6c1e9f54c92a6f182db894ae0593e69f)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck b99b07ad38 feat(profile): capture the insurer IK number
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.

The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.

Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
2026-06-01 23:24:10 +02:00