Commit Graph
203 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.8 fcb41b8780 feat(coach): settings UI to review and forget what the Coach remembers
Add a 'What the Coach remembers' settings section: lists the user's
durable facts grouped by category with a learned-when stamp, forgets a
single fact or clears all (behind a confirm), and notes the rolling
conversation summary. Consumes the v1.11.1 CoachFact routes via the
query-key factory; gated on the Coach feature; localised in all six
locales. Closes the API-only gap from v1.11.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:16:26 +02:00
Marc-André Bombeck cd3982a35c fix(integrations): surface the WHOOP dual-source vitals caveat on the connect card
When WHOOP and another source both supply a standard vital (resting heart
rate, blood oxygen, body temperature, respiratory rate, sleep stages), both
values can appear until a future update settles on a single preferred source.
Show this as a calm muted note on the WHOOP card so the known limitation reads
as documented behaviour rather than a bug. New whoopOverlapNote key across all
six locales.
2026-06-04 04:10:16 +02:00
Marc-André Bombeck 008160dc72 feat(whoop): settings surface — status, disconnect, credentials, sync routes + connect card
Fill the WHOOP route set beside the OAuth connect/callback: status reports
connection + token expiry + backfill progress, disconnect revokes the stored
tokens and parks the integration, credentials saves/clears the per-user BYO
client id/secret encrypted at rest, sync triggers a manual incremental or full
pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card
(BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice)
and surface whoop rows in the combined integrations-status envelope so the pill
reflects real failure state. WHOOP lives under the existing integrations
section -- no new slug.
2026-06-04 02:34:33 +02:00
Marc-André Bombeck 0be484c8a3 feat(insights): TRAJECTORY forecasting derived metric with a widening band
Add a short-horizon OLS projection over the DAY-native per-day mean
series, extending the existing trendSlope. The fit, residual standard
error and prediction interval are computed at DAY granularity only — the
rollup invariant forbids composing a slope/sd from WEEK/MONTH buckets.

The projection is gated on fit quality (R squared), in-window history and
staleness: below any floor the engine returns insufficient rather than a
weak line, so noise is never extrapolated. The prediction-interval band
widens with the horizon distance from the data centre, so the fan
visibly communicates uncertainty.

Register TRAJECTORY in the derived registry and dispatch (additive,
iOS-forward-compatible; the capabilities endpoint and drift-guard pick it
up automatically). Surface it on the weight detail page as a forecast
band beneath the chart, labelled a projection with the cited method and
a conditional, range-only headline — never a prediction or a dated
event. i18n across all six locales.
2026-06-04 01:40:27 +02:00
Marc-André BombeckandClaude Opus 4.8 be94e43480 feat(share-links): owner share-link management in Settings
Add a Sharing section under Settings (between API and Export) where the owner
creates a read-only clinician share link, sees their active and inactive
links, and revokes one. Create wires to the C4 lifecycle API: it sends the
label, a rolling history window, an expiry (capped at 90 days, validated
before the round trip), an optional scoped FHIR-API toggle, and the FHIR
resource types. The raw token is revealed exactly once on create as a
copy-able `/c/<token>` URL; the list only ever carries the server's safe
projection (no token, since only its hash is stored). Revoke confirms through
an alert dialog and invalidates the list.

RSC page mounts a client island; reads unwrap the envelope and key off the
centralised query factory. New `settings.sharing` / `settings.sections.sharing`
strings land in all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:39:58 +02:00
Marc-André Bombeck 5917bba61c feat(insights): period-narrative summary on the overview
Add a calm "your week / month in review" summary to the Insights
overview, generated from the period-narrative context (deltas, personal
bands, FDR-controlled associations, coincident days).

- New insight_narratives table (migration 0113): one durable row per
  (user, period, locale); generated prose AES-256-GCM at rest following
  the coach-message precedent; labels-only provenance stays plaintext.
- generatePeriodNarrative feeds the context into the provider chain with
  a tight, descriptive-never-causal prompt; an insufficient context
  yields no narrative, a missing provider no-ops, neither fabricates.
- Nightly warm cron on week (Mon) / month (1st) boundaries, budget-gated
  per user; the read-only GET enqueues a single-user warm on a cold read.
- Read route is stale-while-revalidate and never blocks on the provider;
  the card renders plain text (no markdown), a single provenance
  disclosure, and a fixed footprint (no CLS). i18n in all six locales.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck 6aa3c75d27 feat(share-links): public clinician view at /c/[token]
A standalone read-only RSC that resolves the path token through the share-token
resolver and renders an owner-scoped clinical summary: provenance header,
clinical vitals and labs, medications with adherence, and a fenced muted card
for the descriptive wellness scores carrying the not-a-clinical-assessment,
not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI,
no session, no markdown — every value renders as escaped text. force-dynamic and
noindex; the page returns a flat 404 for any null resolve.

The route joins the proxy public-path allowlist and the AuthShell standalone
list so it reaches the page bare without a sign-in redirect. New clinicianView
strings land in all six locales.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck fbd45f3044 feat(ai): durable provider-health ledger and credential-expired surfacing
Promote the volatile per-worker last-working cache to a Postgres
provider-health ledger so the fallback chain shares one health signal
across every instance, modelled on the atomic-upsert rate limiter.

The ledger holds a durable negative cache: an auth-class failure
(401/403) benches that provider for a cooldown instead of re-burning the
dead round-trip on every generation; a sustained hard failure backs off
briefly. The local model is never benched — it stays the guaranteed
floor of the chain. Every ledger interaction fails open, so a DB error
falls back to today's chain walk exactly.

When the user's primary provider fails auth-class, the Coach emits a
distinct credential_expired frame that deep-links to reconnect rather
than a generic "try again later".

Adds the provider_health table (migration 0112) and the
errorCredentialExpired copy across all locales.
2026-06-04 00:50:13 +02:00
Marc-André Bombeck 6db3bef413 feat(measurements): add WHOOP source, native score types, and source-priority ladders
Add the WHOOP integration data layer (migration 0111): seven new
MeasurementType values (HRV_RMSSD, DAY_STRAIN, WORKOUT_STRAIN,
SLEEP_PERFORMANCE, SLEEP_EFFICIENCY, SLEEP_CONSISTENCY, SLEEP_NEED,
ENERGY_EXPENDITURE_KJ), a WHOOP MeasurementSource, the WhoopConnection
and WhoopOAuthState models, and the per-user encrypted BYO-key columns
on User. Day-strain takes DAY_STRAIN (not the COMPUTED STRAIN_SCORE) and
RMSSD takes HRV_RMSSD (not the SDNN HEART_RATE_VARIABILITY) so a native
value never shares a bucket with a derived proxy.

Carry the new types through the completeness walls (units, value ranges,
chart tokens, PR direction, categories, list-meta colours/icons/labels,
HealthKit-mapping exclusions) and feed the cross-source picker: recovery
ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the
overlapping vital ladders and the workout ladder, plus skinTemperature
and respiratoryRate metric keys.
2026-06-04 00:49:31 +02:00
Marc-André Bombeck 73041b10cd docs(insights): note the Enright 40-80 cohort in the six-minute-walk caveat
The Enright & Sherrill 1998 predicted-distance equation was established on a
40-80 reference cohort; the metric accepts adult ages 18-120, so the
prediction extrapolates outside that range. State it in the user-facing
caveat (all locales) and the norms doc so a younger or older user reads
their percent-of-predicted with that honesty.
2026-06-03 21:33:06 +02:00
Marc-André Bombeck 097af3b84a feat(insights): surface the active STRAIN anchor per score
Carry the per-day STRAIN anchor (personal vs population) from the
strain_trimp_cache row through to the STRAIN WellnessScoreValue read, and
pick the framing line by it on the score-anatomy surface: relative to your
own typical effort once anchored personally, a general reference during
cold start. The method copy was describing both regimes generically and
could not tell the user which one produced this score. Additive read field
— iOS non-breaking.
2026-06-03 21:33:06 +02:00
Marc-André Bombeck 17ad93ca5d feat(insights): surface the four new derived metrics as dashboard tiles
The estimated 6-minute-walk band + the three any-user HealthKit baseline
bands (wrist temperature, stair ascent/descent speed) were registered,
provenance-mapped and engine-tested but rendered on no surface. Add a
"Mobility & body" section to the vitals dashboard: the 6MWT passthrough
re-frame (distance + trend always, percent-of-predicted framing only with
the Enright demographics) and a shared baseline-band tile for the three bands.
Every tile is data-availability-gated like the existing ones — absent with no
readings, CoverageMeter while provisional, value + framing + provenance when
ready — and the section heading hides when none resolve. All four tokens ride
the single batched derived request. Derive the sparkline gradient id via
useId() to avoid duplicate-label collisions.
2026-06-03 20:07:46 +02:00
Marc-André Bombeck a31add266f fix(i18n): port STRAIN personal-anchor honesty copy to all locales
The personal-relative-anchor explanation (own recent training-day load vs a
general reference while building history) was EN-only; de/es/fr/it/pl carried
the stale v1.10.0 method+description. Port both strings to all five so the
two-mode honesty distinction is visible regardless of locale. Also correct the
stale strain-score header comment: STRAIN is not excluded from the doctor PDF —
it rides a segregated, disclaimed Wellness-summary section.
2026-06-03 20:07:46 +02:00
Marc-André Bombeck f1ca0e28eb feat(insights): anchor STRAIN to the user's own training load
Map day-total Banister TRIMP to 0-100 against a personal reference -- the
EWMA-smoothed 75th percentile of the user's own training-day TRIMP over a
42-day chronic window -- instead of the fixed population anchor that pinned a
deconditioned or chronic-condition user near 0 regardless of their effort.
Below a 7-training-day cold-start floor the engine falls back to the
population anchor, recorded as anchor: population with lower confidence; at
or above it the personal anchor takes over. Always writes a score row -- no
new null gate. The anchor is derived from the TRIMP input, never the 0-100
output, so there is no circular feedback; rest-day zeros stay out of the
training-day distribution.

Add a server-internal per-(user, day) strain_trimp_cache (day-total TRIMP +
running EWMA reference + anchor) so the chronic window reads cheap cached
values instead of re-integrating 42 days of HR series each night. The cache
is not a Measurement -- it stays out of every user-facing surface, the
doctor PDF, and the FHIR bundle by construction. No backfill: the nightly
idempotent recompute populates the cache forward and self-heals, falling
back to the population anchor until enough personal history accrues.

Keep the Banister/Morton/Tanaka citations and the doctor-PDF exclusion; the
honesty copy now states the scale is relative to the user's typical effort,
with a general reference during cold start.
2026-06-03 19:31:16 +02:00
Marc-André Bombeck 059ba571e3 feat(insights): derived framing for additive HealthKit metrics
Register four new derived metrics for the additive HealthKit signals:

- WRIST_TEMPERATURE_BASELINE, STAIR_ASCENT_SPEED_BASELINE,
  STAIR_DESCENT_SPEED_BASELINE — any-user-baseline personal bands reusing
  the type-generic median +/- k*MAD engine via a new BASELINE_CAPABLE_TYPES
  allowlist. Wrist temperature is a personal-deviation band only (no illness
  or cycle inference); stair speeds are personal trend bands (no population
  cutoff, geometry-confounded).
- SIX_MINUTE_WALK_BAND — passthrough re-frame placing the device-estimated
  distance against the Enright & Sherrill 1998 predicted 6MWD (ATS 2002
  test standard), as percent-of-predicted with a green/yellow/red band;
  band null when age/height/weight/sex are incomplete.

Fall count and the breathing-disturbance index stay trend-only by design,
documented in the registry: fall count is a zero-inflated discrete safety
event, the breathing index is a regulated screening signal where a derived
band would imply a diagnosis.

Note wrist temperature as a deferred correlation outcome channel held
pending a privacy review (cycle-phase inference risk), mirroring the
medication-compliance omission note.

Add provenance + standards entries, method/caveat copy across all six
locales, and tests for the engine routing, the Enright equation, and the
percent-of-predicted band.
2026-06-03 19:29:36 +02:00
Marc-André Bombeck 789ec60d97 feat(insights): elevate coincident-deviation to a dedicated "Today's signal" card
Promote the COINCIDENT_DEVIATION flag from a fire-only tile buried in the
below-the-fold vitals grid to an always-mounted headline card at the top of
the Insights overview. The card renders four calm states off the single
existing derived payload — building-baselines, all-clear, watch, fired — with
no new engine math, route, or schema change.

Keep it calm and clinically safe: at most an amber band, never red, no score,
no chart, no push. Soften a fired flag to the watch tone when the backing
history is thin. The fired/watch states carry the load-bearing "possible
factors, never a cause / not a diagnosis" framing and reuse ProvenanceExplainer
+ CoverageMeter + the band tokens verbatim.

Remove the now-redundant tile, its batch token, and the hasRenderableVital
branch from the vitals grid so the signal is not shown twice.
2026-06-03 19:29:36 +02:00
Marc-André Bombeck 9bdf76ea11 fix(settings): localise AI connection-test failure reasons
Return a stable, secret-free reasonCode from /api/ai/test alongside the
English reason, map it to a localised string in the AI settings panel,
and fall back to the plain reason for any unmapped code. A German
operator no longer sees an English failure sentence in an otherwise
localised panel.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck 732e85e4df fix(insights): add scores drill-down back-link and unify back-link styling
The composite-score detail page (/insights/scores/[metric]) gains a leading
back-link to the Insights overview, matching the other drill-down surfaces.
Align the medication detail + history back-links onto the canonical ghost
recipe (-ml-2 w-fit, mr-1 size-4 icon) used by the values and workout-detail
pages, replacing the divergent -ml-2 gap-1 variant.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 5b5ab5795c fix(settings): guard AI test and regenerate against non-JSON responses
The test and regenerate handlers called res.json() unconditionally. A
proxy-rewritten HTML error page made that throw "Unexpected token '<'",
which leaked into the toast. Both handlers now check the content-type and
wrap the parse, showing a clean message on a non-JSON body. The test
handler also renders the new { ok:false, reason } payload verbatim.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 5ae7aa7570 feat(insights): add load, error, and empty states to the vitals dashboard
Render a skeleton row matching the resolved tile height while the derived
batch loads so the grid reserves its final footprint with no layout shift.
Hide the section heading until there is content (or a skeleton placeholder)
under it, removing the stranded-heading layout shift on empty data. Surface
an inline error message plus a Retry that refetches the shared batch query
when the batch fails, instead of the whole surface silently vanishing. Mark
the tiles container aria-busy while loading and aria-live polite, matching
the insights a11y pattern.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 551d352581 feat(insights): surface inputs, method and a cited standard for all 11 derived metrics
Only Sleep and Readiness reached a provenance surface; the other nine
shipped a number with the cited standard buried in a code comment. Give
every derived metric a path to its inputs + method + cited standard:

- Recovery, Stress and Strain get a ScoreAnatomyView detail page and a
  tappable ring (they were read-only dead-ends). Stress surfaces its
  'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain
  cites the TRIMP load model; Recovery the HRV-guided recovery literature.
- The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age)
  gain an info explainer affordance carrying the method + standard, since
  they render as tiles with no detail page.
- The coincident-deviation flag gains a dashboard tile (shown only when it
  fired) with the same explainer (median ± k·MAD basis).
- A single standards map (src/components/insights/derived/standards.ts)
  pins each metric's method copy key, standard name and a real linkable URL
  (DOI / WHO / Wikipedia) so the citation a metric exposes stays one source.

Add the per-metric method/caveat/description copy across all six locales.
Map storage units to display symbols so a baseline tile reads '36.6 °C',
not '36.6 celsius'. Show the band word under the strip rings so a value
reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline
placeholder when no trailing series exists rather than reserving dead
space, and give the tile a series + provenance slot for when one does.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 3f8c8e750c fix(i18n): translate the device-notification namespace for es/fr/it/pl
The entire insights.rhythmEvents namespace — section title, intro, the
load-error, and the medical-adjacent device-detection disclaimer — shipped
the English string verbatim in es/fr/it/pl. The locale-integrity guard only
checks key shape + placeholders, so the copy-paste passed CI while rendering
English on a Spanish/French/Italian/Polish device-notifications card,
disclaimer included. Translate the four keys into real localised copy.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 8c24f3ec2a feat(insights): add a wellness-score summary to the doctor report and FHIR export
Surface the server-derived nightly scores (recovery / stress / strain) in the
health-record export without polluting the clinical layer. The aggregator now
emits a wellnessScores summary (latest / avg / min / max / count per score over
the window) built from the already-fetched COMPUTED rows. The PDF renders them
in a clearly-labelled "Wellness summary" section, separate from the clinical
vitals table, with a "descriptive, not a clinical assessment" disclaimer. The
FHIR bundle emits one Observation per score under the survey category with a
text-only concept and the same disclaimer as an Annotation note, so a viewer
never mistakes a band for a diagnosis.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck d9405e17b9 feat(dashboard): surface the wellness scores as a data-gated ring strip
Add a wellness-scores section to the vitals dashboard: a row of score rings for
Readiness, Recovery, Sleep, Stress, and Strain. Every ring is
data-availability-gated — a score with no underlying data simply does not
render, so the strip never shows an apologetic empty ring. Readiness and Sleep
are tappable and route to their existing score-anatomy detail pages; the
persisted nightly scores render read-only. Every ring reads the one
/api/insights/derived route through the shared useDerivedMetric hook.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 33b75361f3 feat(measurements): add a COMPUTED source and first-class score types
Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).

COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.

All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.

Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
2026-06-02 22:31:21 +02:00
Marc-André Bombeck 135141a750 feat(insights): surface device-flagged events as an awareness timeline
Add a timeline of the health notifications the user's device produced on
its own, reachable from the Insights overview. The surface reflects the
device's own decision: each row reads "Your device flagged …" and a
permanent disclaimer states this is not a medical assessment by
HealthLog and that HealthLog does not diagnose any condition.

The surface is data-availability-gated — it un-mounts entirely when the
user has no such events, so an account that never triggered one never
sees an empty or alarming card. Copy renders as plain React text
children; no markdown library is involved.
2026-06-02 22:08:12 +02:00
Marc-André Bombeck 635e64ace7 feat(healthkit): ingest seven additive HealthKit signals end-to-end
Wire seven previously-deferred HealthKit quantity identifiers through
the full pipeline — enum, mapping, plausibility range, category, list-UI
metadata, FHIR coding, and the generic metric-status assessment:

  CARDIO_RECOVERY          heartRateRecoveryOneMinute   bpm
  WRIST_TEMPERATURE        appleSleepingWristTemperature celsius
  FALL_COUNT               numberOfTimesFallen           count
  SIX_MINUTE_WALK_DISTANCE sixMinuteWalkTestDistance     m
  STAIR_ASCENT_SPEED       stairAscentSpeed              m/s
  STAIR_DESCENT_SPEED      stairDescentSpeed             m/s
  BREATHING_DISTURBANCES   appleSleepingBreathingDisturbances count

Each value rides raw on the wire (identity conversion). Fall count is
cumulative (daily SUM); the stair speeds drain to a daily mean; the rest
store as latest per-workout / per-night spot readings. Wrist temperature
and breathing disturbances are flagged privacy-sensitive. The category
sleep-apnea and walking-steadiness EVENT identifiers stay deferred — they
are events, not quantities.

Each new metric gains an Insights sub-page on the shared scaffold with the
generic assessment card, localised across all six locales. Cardio fitness
(VO2 max) — ingested since v1.4.23 but assessment-less — gains its own
/insights/cardio-fitness page so it picks up the assessment instead of
riding as a bare chart-row on the pulse page.

Normal-range anchors are cited where defensible (six-minute walk 400-700 m,
ATS guideline + population reference); the rest defer to the user's own
baseline rather than fabricate a band.

Migration 0104 appends the enum values additively (IF NOT EXISTS, forward-only).
2026-06-02 22:02:50 +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 663a3ee09d feat(insights): add the vitals dashboard + its derived metric tier
Build the Apple-Health-Highlights grid of personal-vital tiles on the
Insights overview, backed by four new pure derived-metric compute
functions over the established seam:

- cardio-fitness band ("Fitness Age" frame) — VO2max placed against the
  FRIEND-registry age/sex norm (Kaminsky 2015), surfaced + trended, never
  re-derived.
- vascular-age delta — Withings vascular-age vs chronological age, a
  relative wellness indicator (Vlachopoulos 2010 PWV framing), no medical
  claim.
- HRV (SDNN) balance — reuses the typical-range engine for the band and
  places the recent average as balanced / above / below; SDNN never
  relabelled RMSSD; cadence-gated.
- BMI from weight + height — exact WHO classification, the fallback when
  no device BMI exists.

Each metric carries coverage + confidence + provenance and cites its
standard through the provenance explainer. The dashboard renders one
SparklineDeltaTile per available vital: an absent vital does not render,
a still-building one shows the honest coverage meter rather than a
headline from too little history. Tiles read the generic
/api/insights/derived route through a TanStack Query hook keyed off the
centralised factory.

Regenerates the OpenAPI contract for the two new metric ids and adds the
vitals copy across all six locales.
2026-06-02 21:17:05 +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 107a111545 fix(insights): polish range pills, zero-delta caption, scrollbar gutter, mood tiles
- Range pills: make the radiogroup a single tab stop with roving tabindex and
  Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern.
  Previously every pill was its own tab stop and the arrow keys were dead.
- Range delta caption: render a neutral dash + "no change" for a perfectly
  stable metric instead of a down-arrow with "−0%". Adds the `noChange`
  string across all six locales.
- Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the
  body-scrolled shells (login / register / onboarding / legal) via a
  `data-scroll` attribute the shell sets, so the height-locked authenticated
  branch no longer reserves a second, never-painted gutter on classic
  scrollbar platforms.
- Mood tiles: a lone in-target / stability tile now spans full width instead
  of leaving a half-width orphan beside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 7857aa3dd2 fix(settings): shorten the notifications section label to one line
The notification-channels section heading and the left-nav label both
render `settings.sections.notifications.title`, whose German value
("Benachrichtigungs-Kanäle") is a long compound that wraps to two lines
in the nav rail. Shorten the displayed value to the plain plural across
all six locales (de "Benachrichtigungen"; en "Notifications"; es
"Notificaciones"; fr "Notifications"; it "Notifiche"; pl
"Powiadomienia"). Key id is unchanged; the breadcrumb on the
notification-center page keeps its own wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit af437f48b09b9f99b6a80709ddacd565c10132ab)
2026-06-02 17:01:14 +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 0a8c6e307c feat(medications): add optional ATC + RxNorm codes to the medication row
Add two nullable, additive columns to `Medication` (`atc_code`,
`rxnorm_code`) so a user or clinician can assert a drug-classification
code that the health-record FHIR export emits on the medication concept.
Codes are taken verbatim, never machine-guessed: a wrong code is a
clinical-safety problem, an absent one is merely a (conformant)
text-only concept.

- Migration 0103 — additive, nullable, no backfill. Pre-existing rows
  read NULL, which the exporter already treats as "no code".
- Validate on write — ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`;
  422 on a malformed value. Wired field-by-field through the create +
  update routes (no mass assignment); `null` clears a code on update.
- Surface both codes in the medication advanced-settings sheet's Data
  group with a self-saving input, and echo them on the GET/POST/PUT
  response shape. OpenAPI regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2e0051d42826fa3f043c32e78a34dab7c8050165)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 555138110c feat(medications): give the external API its own settings group
The advanced-settings sheet crammed the external ingest endpoint into the
Data group next to CSV import/export — two unrelated audiences sharing one
frame. Pull `ApiTokensRow` into a dedicated "Externe API" group so the
developer-facing surface reads as a distinct, lowest-frequency block. New
group order: Reminders -> Lifecycle -> Data -> Externe API -> Danger.

The new group reuses the existing `SettingsGroup` bordered-card chrome, so
it stays visually consistent with its siblings. Adds the `externalApi`
group label across all six locales and updates the composition test to pin
the five-block order plus the API row's new home.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f9f3a1277b5aad1710815511ddf7f6b210a652b2)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck f65181bbf2 feat(insights): surface mood time-of-day, stability, and weight/BP correlations
Render the new mood analyses on the insights surface:

- a four-bar time-of-day chart (morning / afternoon / evening / night),
  shown only when the server marks the pattern reliable
- a stability tile beside the in-target tile, with the descriptive band
- the time-of-day takeaway in the narrative feed (new Clock icon, daypart
  label resolved from the bundle)

Also wire the mood x weight and mood x systolic-blood-pressure scatter
cards. Their Pearson coefficients and points were already computed and
returned by the endpoint but never rendered; they now sit alongside the
sleep / activity / pulse cards, each gated on the same minimum-pairs
threshold so a thin pairing renders an empty state rather than a noisy r.

New i18n keys land EN-first and propagate across de / es / fr / it / pl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f)
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 2472579059 feat(insights): warm assessments from the overview page
Wire the on-demand full assessment warm into the Insights overview. A new
useInsightsWarm hook POSTs /api/insights/pregenerate; the cards then fill
through their existing stale-while-revalidate reads with nothing to
invalidate locally.

Two entry points share the one mutation. A background warm fires once per
browser session (sessionStorage-gated) as soon as the overview confirms
the user has data, so a returning user lands on warm caches without
acting; it is silent and tolerates the 429 the server returns while a
warm is already in flight. A manual button ('Prepare assessments') runs
the same warm and shows a toast.

Add the insightsPregenerate mutationKey to the factory and the label +
toast strings across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9)
2026-06-02 14:29:49 +02:00
Marc-André Bombeck 4364f3b509 i18n(settings): translate the source-priority editor body for es/fr/it/pl
Splitting Sources back out into a standalone settings page re-exposed the
source-priority editor body — card title, help text, reorder buttons, the
move-up / move-down ARIA labels, per-metric and device-type controls — which
were still English placeholders for es/fr/it/pl. Provide natural
translations so a non-de/en screen-reader user no longer hears English.

Drop the orphaned `settings.sections.sources.redirectNotice` key from all six
bundles; the redirect it captioned was deleted when Sources became a real
route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 945d3ed8a6981c23dc51f45c22a1cd3f2625c859)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 78b76d2ac5 feat(settings): split targets and sources, rename AI Insights section
Three related settings changes:

- Split the combined Targets & Sources page back into two pages: Targets
  (/settings/thresholds) owns the per-metric target-range editor and
  Sources (/settings/sources) owns the source-priority and device-type
  ladders. Each is its own left-nav entry and route, served by the
  dynamic [section] route. The former /settings/sources permanent
  redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
  is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
  bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
  pages.

Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck 28af138691 feat(medications): show the GLP-1 drug-level estimate by default
Render the estimated drug-level-in-blood curve for any recognised GLP-1
medication without requiring the Research Mode opt-in. The chart still
self-gates on a recognised brand and on having intake data, and the
pharmacokinetic-estimate disclaimer is now always attached so the curve
reads as a modelled value, not a measured blood concentration.

Drop the now-dead Research-Mode gated-placeholder branch and its copy.
The researchModeEnabled flag is unchanged; it still gates the advanced
settings surface and its acknowledgment dialog.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck cd1eaa1a4e chore(targets): deprecate the targets page and drop it from the menu
Target editing now lives inline on the insights pages, so the standalone
targets page is no longer a navigation destination. Remove its sidebar
and bottom-nav (overflow) entries and add a top deprecation banner that
points the user at the insights pages and notes the page will be removed
in a future release.

The page stays routable: the /zielwerte to /targets redirect remains and
the route still renders. Removal later is UI-only because the insights
panel consumes the same targets read and threshold-override write. New
deprecation strings land across all six locales.
2026-06-01 23:07:32 +02:00
Marc-André Bombeck 68517240e1 fix(insights): always caption a briefing-driven trend card
The Trends row charts the metrics the daily briefing flags, but only
the legacy BP / weight / mood triple carried an advisor annotation.
Every additive metric (pulse, sleep, steps, HRV, resting heart rate,
active energy, flights, distance, VO2 max, body temperature) rendered
the chart with empty space underneath because the caption only painted
when a slot carried an annotation key.

Give every chart config a standard one-line description (captionKey)
and render it for slots that carry no advisor annotation, so no card
is ever caption-less. The legacy triple keeps its annotation slot. Add
the caption strings across all six locales and regression tests that
pin a caption on every tile.
2026-06-01 22:59:14 +02:00
Marc-André Bombeck 3f9fd9095f feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.

The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.

Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
2026-06-01 22:59:14 +02:00
Marc-André Bombeck 308d9c9d6d i18n(medications): add short step labels and jump-navigation copy for the wizard stepper 2026-06-01 22:59:14 +02:00
Marc-André Bombeck 795898bb0a fix(medications): rebuild the advanced-settings sheet as a single column
Drop the lg:grid-cols-2 data|reminders grid that read as overloaded.
The sheet now narrows from 4xl to 2xl and stacks four bordered section
cards in most-used-first order: Reminders, Lifecycle, Data, Danger. One
spacing rule throughout — space-y-4 between cards, divide-y plus py-3
rows inside each card, with the SettingsGroup card owning the dividers
so no row interleaves its own separator.
2026-06-01 22:54:20 +02:00
Marc-André Bombeck ac51cfdc18 feat(mood): add a ranked narrative takeaway feed
Surface plain-language takeaways above the mood charts: the weekday dip
and peak, the overall trend direction, the weekend-vs-weekday effect,
the strongest tag-to-mood lift and drop, the in-target share, and the
current logging streak. Each takeaway is threshold-gated — every minimum
sample count and minimum effect size is a named constant, and a takeaway
only renders once its bar is cleared, so the feed never falls back to
filler. The list is ranked by effect size and capped.

The takeaways are pure helpers computed from the existing mood
aggregates, so the same feed reaches both the insights endpoint and the
mood snapshot the model reasons over — what the page shows matches what
the model is sent. Also promote the previously snapshot-only in-target
percentage to a visible headline tile.
2026-06-01 22:51:10 +02:00
Marc-André Bombeck 4935a9b000 feat(insights): surface the diversity hint as a heading lightbulb
The measurement-diversity nudge used to render as an inline note block on
each category page; read in context it looked like a data finding rather
than a tip and confused. Move it onto the heading as a small Lightbulb
glyph that only mounts when the spread is genuinely lopsided; the hint
text it used to show inline now lives in a hover / focus Tooltip with an
accessible name. The sub-page shell carries the glyph in the heading row,
drops the removed explainer trigger, and normalises the foot-of-page
"show all readings" control to the standard secondary-button height.
2026-06-01 22:50:59 +02:00
Marc-André Bombeck e5b873833a fix(medications): restore the two-row compliance display, scaled to cadence
v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose
adherence timeline on sparse-cadence medications. The timeline breaks the
card layout and reads as restless, so every medication is back to two
percentage rows.

The two windows are no longer hardcoded — they scale with the dosing
cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and
returns the densest rung whose both windows clear MIN_STABLE_DOSES realised
expected doses, so a daily med stays on 7/30, a weekly med steps up to
30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When
even the top rung can't clear the floor (a brand-new or very rare med) the
widest rung is returned so the card still shows two honest rows. The row
labels follow the chosen windows via a parametrised complianceWindow string.

The compliance percentage math is unchanged: each row is the existing
cadence-aware calculateCompliance over the selected window. compliance7 /
compliance30 stay on the wire verbatim for iOS and the Health Score.

- compliance.ts: replace the timeline display with selectComplianceWindows +
  the window ladder; buildComplianceDisplay now returns the two scaled rows
- remove the dose-adherence-timeline component and its now-dead strings
- medication-compliance-bars: parametrise the row labels on the day-counts
- switch the standard and GLP-1 cards back to the always-two-row bars
2026-06-01 22:44:13 +02:00
Marc-André Bombeck a5c894b58e i18n(medications): translate the injection-site strings for es/fr/it/pl
The injection-site / log-dialog / settings cluster shipped translated in
de and en but verbatim English in es, fr, it and pl, so those users saw
English copy in the medication wizard, the log-injection-site dialog and
the settings injection card. Localise the eleven keys and drop the two
orphaned injection-rotation keys in the same bundles.
2026-06-01 21:16:56 +02:00