Commit Graph
755 Commits
Author SHA1 Message Date
Marc-André Bombeck 6935feda15 feat(insights): compute and store a nightly strain score from workout load
Derive a daily cardio-load proxy from the per-workout heart-rate series
plus active energy. Each workout's HR series scores via Banister's
gender-weighted exponential TRIMP over the fractional heart-rate reserve
(Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals
map to a 0-100 saturating scale. Days with no usable HR series but a
non-trivial active-energy total fall back to an energy-only proxy, with the
path recorded in the result.

Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated
when neither a usable HR series nor active energy is present.

Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP);
Tanaka, Monahan & Seals 2001 (HRmax).
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 57d9cc1674 feat(insights): compute and store a nightly stress score from intra-day HRV
Apple Watch has no electrodermal sensor, so there is no direct stress
measurement to read — what HealthKit does give is discrete SDNN samples
through the day. Derive an honest HRV-through-day PROXY: a suppressed,
below-baseline intra-day SDNN is a recognised marker of higher sympathetic
load. The provenance and label say "HRV-derived proxy" and never imply a
galvanic/EDA reading.

The engine reuses the shared vitals-baseline math (the same SDNN baseline
the wellness index reads), maps the day's below-baseline suppression to a
0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a
client can never POST it. It gates and writes nothing below the minimum
intra-day sample floor or without a usable baseline.

Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012
(neurovisceral integration). Idempotent per user per day via the nightly job.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck ce2b745cc3 feat(measurements): add a bounded dense intra-day retention tier for HRV/HR
Daytime heart-rate-variability (SDNN) and heart-rate samples need to keep
their intra-day shape long enough for the stress engine to read them, but
the raw per-sample volume must stay bounded. Add a retention tier that
keeps the last 14 days of raw HRV/HR samples and folds everything older to
one daily-mean row via the proven mean-consolidation drain (same idempotent
stats:<HK>:<day> fold, soft-delete, and rollup recompute).

The two dense-tier types are deliberately EXEMPT from the destructive
daily-mean drain allowlist (HIGH_FREQUENCY_MEAN_TYPES) — that drain would
collapse the intra-day shape and soft-delete the raw rows. A regression test
pins both exclusions so a future edit that adds either type to the drain
allowlist fails loudly.

Migration 0108 adds only the partial index that keeps the retention drain's
discovery and per-user scan cheap; the retention bound lives in code so
tuning it never needs a migration.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 56fc9cefbe docs(meta): sharpen the base SEO description
Replace the generic page description with the self-hosted positioning —
Withings + Apple Health sync, transparent derived wellness metrics, and
AI Insights you own — so the search-result snippet matches the README
and landing copy. OpenGraph and Twitter card descriptions are unchanged.
2026-06-02 22:39:40 +02:00
Marc-André Bombeck 3dcec54726 feat(insights): compute and store a nightly recovery score
Add a recovery-score engine that turns a user's already-stored nightly
signals into a stored 0-100 score. It reuses the readiness blend — the
resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation
from the personal baseline, with the null-redistribution the health
score already uses — so the stored series and the live readiness surface
can never drift. The lineage is the recovery-marker literature (Plews
2013, Buchheit 2014): a higher score reads as more recovered. It is a
descriptive wellness proxy, not a clinical or training-recovery
assessment, and it is only stored when enough components are present —
below that floor no row is written rather than a headline from one
signal.

A nightly job computes each eligible user's score and upserts it as a
COMPUTED RECOVERY_SCORE row, idempotent per user per day on the
externalId day key, so a re-fired tick overwrites in place instead of
duplicating. The score is never computed on a page visit. The queue is
registered in the worker's queue inventory, scheduled, and wired to a
handler; a grep guard pins all four points so it cannot silently never
run.
2026-06-02 22:31:21 +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 51fc48f93c feat(fhir): raise the MedicationAdministration cap to 5000 and make it configurable
Raise the per-export administration ceiling from 1000 to 5000 (~3.5 years
at four doses a day) and resolve it from FHIR_MAX_MEDICATION_ADMINISTRATIONS,
bounded 1..50000 with a fallback to 5000 on an unset or out-of-range value.
The windowed-then-capped newest-first behaviour and the honest-truncation
narrative are unchanged; this only raises the ceiling so it bites far less
often. Add the var to the compose environment whitelist and the production
env example so it reaches the container and check-env stays green.
2026-06-02 22:22:50 +02:00
Marc-André Bombeck e893a43005 feat(fhir): wire the German BfArM ATC locale toggle into the export route
The builder already accepts a germanAtc option that appends the BfArM ATC
URI after the WHO entry. Thread it from the health-record export route: an
explicit germanAtc flag on the selection payload wins, otherwise it derives
from a German-region locale. The WHO coding stays coding[0], byte-identical
and locale-independent, so a WHO-only consumer is unaffected.

Add the optional germanAtc field to the strict export selection schema and
regenerate the OpenAPI contract.
2026-06-02 22:22:50 +02:00
Marc-André Bombeck 9990815efe feat(fhir): add additive SNOMED route and site codings to MedicationAdministration
Emit a SNOMED CT coding alongside the existing .text anchor on the
administration dosage. Route maps ORAL to 26643006 (Oral route) and
INJECTION to 34206005 (Subcutaneous route, the form HealthLog injections
take). Site keys on the eight InjectionSite enum values, collapsing to the
three gross body-region concepts (abdomen 818983003, thigh 68367000, upper
arm 40983000); laterality and the abdominal quadrant stay verbatim in the
.text anchor rather than inventing lateralised concepts.

Referencing concept ids in a FHIR instance is not redistributing the
licensed SNOMED distribution, so this is safe to ship. The .text anchor is
unchanged, so a consumer without a SNOMED binding is unaffected.

Reconcile the dose fixture to the real uppercase enum value the live
builder emits (the previous lowercased value never matched production).
2026-06-02 22:22:50 +02:00
Marc-André Bombeck 95064b405a test(healthkit): cover device-event ingest + the awareness surface
Add mapping tests for the five event classes (verdict resolution,
severity grading, the value-pinned-to-1 contract, unknown-codepoint
fallback, and that a continuous measurement never gets a classification),
the awareness-card gating + device-verdict framing + disclaimer presence,
and the events route narrowing + empty-state contract. Extend the enum,
chart-token and Coach-gate drift guards for the new event types.
2026-06-02 22:10:16 +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 a45325af52 feat(healthkit): thread the device-event verdict through batch ingest
Accept the optional HKCategoryValue codepoint on each batch entry and
persist the resolved rhythm_classification on the stored row. The wire
field is additive and ignored for non-event identifiers, so every
existing caller is byte-for-byte unchanged. Regenerate the OpenAPI
contract.
2026-06-02 22:07:45 +02:00
Marc-André Bombeck b06b1cada4 feat(healthkit): add a categorical device-event row class
Add a discrete EVENT row class to the Measurement model for the
device-flagged health notifications a wearable produces on its own —
irregular-rhythm, high/low heart rate, walking-steadiness and
breathing-disturbance alerts.

Following the sleep-stage / glucose-context nullable-context precedent,
this is an additive nullable column on the existing table, not a new
storage paradigm. Five new MeasurementType values carry the event class
and a new rhythm_classification enum column carries the device's own
verdict / severity (migration 0105, with a CHECK that pins the column to
the event types). An event row stores value 1 and the device verdict;
it never participates in trend, rollup, chart or personal-record
analytics.

For the irregular-rhythm class, HealthLog ingests only the
classification RESULT the device's own certified on-device algorithm
already produced. It never ingests a raw ECG waveform, never
re-classifies, and never derives a verdict of its own.

The Apple Health batch ingest maps the five category identifiers, with
the device's HKCategoryValue codepoint resolved to the stored
classification; an unknown codepoint degrades to a fallback verdict
rather than dropping the event.
2026-06-02 22:07:28 +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 30be0c4e0d feat(workouts): accept an optional HR series on the batch contract
Extend POST /api/workouts/batch with an optional per-workout `samples`
array — a route-independent heart-rate series so indoor workouts still
yield a strain-relevant signal. Each entry is `{ t, hr?, speedMs?,
power?, cadence? }`; the array is capped at 30 000 points
(MAX_WORKOUT_HR_SAMPLES), covering a long endurance session sampled at
1 Hz while bounding the largest new workout-side write stream the same
way the 20 000-point route cap bounds the geometry.

Persistence rides the existing route-attachment path: the freshly
written workout id is resolved once (by composite key, or the manual
tuple), then both the route geometry and the HR series attach by FK
with skipDuplicates so a re-submitted batch stays idempotent. A workout
may carry both a GPS route and a canonical HR series, or just one.

GET /api/workouts/{id} returns the series alongside the route. OpenAPI
regenerated.

`samples` keeps first-write-wins/overwrite parity with the workout row
it hangs off: the 1:1 FK plus skipDuplicates means a re-ingest of an
existing workout never duplicates or mutates its series.
2026-06-02 22:02:46 +02:00
Marc-André Bombeck 14d79ab453 fix(insights): reconcile the derived vitals + composite waves
Resolve the overlap where the vitals tier and the composite tier both extend
the derived-metric registry, dispatch, query-key factory, i18n bundles and the
OpenAPI contract: every derived metric (vitals baseline, fitness/vascular age,
HRV balance, BMI, sleep score, readiness, coincident-deviation) is now wired and
implemented, the dispatch test asserts the full set, and the locale bundles carry
both the vitals and the score-anatomy/composite strings.
2026-06-02 21:25:58 +02:00
Marc-André Bombeck 440f24bff2 fix(medications): drop the bottom-pinned action gap on the cards
The v1.9.0 equal-height change set the card content to a flex column and
pinned the action buttons to the bottom with mt-auto. On a shorter card next
to a taller one in the grid, that opened an empty gap between the content and
the buttons. The cards already stretch to a shared height via the grid, so the
buttons can sit directly after the content: drop the flex column and the
mt-auto wrapper on both card variants. The reserved dose-row slot stays, so the
dose lines still align across the grid; only the gap is gone.
2026-06-02 21:20:02 +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 aa281d6649 feat(insights): sharpen the metric-status normalRange with age/sex norms
Thread the reference-range enabler (derived/norms.ts) into the existing
generic assessment path. When lookupNormalRange resolves an age/sex
adjusted band for the metric + profile, the snapshot uses it in place of
the coarse flat registry anchor and tags it normalRangeSource:
age-sex-adjusted; otherwise the flat anchor is kept unchanged. Strictly
additive — a metric with no sharper norm, or a profile missing
demographics, behaves exactly as before.
2026-06-02 20:01:48 +02:00
Marc-André Bombeck 4ad65a5b7d feat(insights): add GET /api/insights/derived + OpenAPI contract
Generic route mirroring the v1.8.7.1 metric-status precedent:
GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<vital>].
apiHandler + Zod safeParse on the query, closed registry enum (unknown
id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed
from the session (never a query field). The profile is read once and
passed into the pure compute function. Returns the flat Derived<T> union
in the standard { data, error, meta } envelope so the native client
decodes one stable shape and combines values across metrics.

Register the query + response schemas (DerivedMetricQuery,
DerivedMetricResponse + DerivedCoverage/Confidence/Provenance) and the
path operation in the OpenAPI registry; regenerate docs/api/openapi.yaml.
Add the route to the insights gate inventory (insightStatus-gated, no
Coach prose).

Route tests cover the 200 flat-envelope shape, session-narrowed userId,
422 on an unknown / missing metric, 401 unauthenticated, and the
insufficient flattening (null value + confidence, populated reason).
2026-06-02 20:01:42 +02:00
Marc-André Bombeck 6dac56b659 feat(insights): add the derived-metrics compute foundation
Introduce src/lib/insights/derived/ — the shared, compute-once layer for
derived wellness metrics. Core pieces:

- types.ts: the Derived<T> discriminated union (ok carries value +
  coverage + confidence + provenance; insufficient carries coverage +
  provenance + reason). Client-safe — pure types, no server imports.
- coverage.ts: deriveCoverage() + the buildOk/buildInsufficient terminal
  constructors. Confidence blends the history fraction and the
  input-presence fraction so it drops monotonically as inputs go missing.
- registry.ts: the closed DERIVED_METRIC_ID enum + per-metric metadata
  (inputs, archetype, min-history / min-inputs floors, implemented flag).
- norms.ts: age/sex reference-range tables (VO2max FRIEND registry,
  resting-HR + respiratory-rate by age) behind lookupNormalRange().
  Age + sex only — no ancestry/region ranges.
- baseline.ts: the flagship engine — computeVitalsBaseline() returns the
  personal typical-range band (median +/- k*MAD) for one vital. Center
  reads the composable DAY rollup mean; the spread is DAY-native only
  (the sd/slope/r2-don't-compose invariant), with a per-type bounded
  live-SQL fallback on a coverage miss.
- dispatch.ts: maps a DERIVED_METRIC_ID to its compute function; stubbed
  metrics return an insufficient with reason not_implemented.
- index.ts: the append-only public barrel.

Unit tests cover the coverage model, the Derived<T> ok/insufficient
branching, the norms lookup, the baseline ok / insufficient /
coverage-miss-fallback paths + the composed-vs-raw-DAY band parity, and
the dispatch stub guard.
2026-06-02 20:01:34 +02:00
Marc-André Bombeck 9106489196 docs(api): document the medication compliance endpoint
GET /api/medications/{id}/compliance was absent from the OpenAPI document, so
a client generating types from the contract could conflate it with the separate
cadence endpoint (a different shape). Register the route with its exact response
— compliance7/compliance30 summaries, the per-day dailyCompliance grid, and the
two-row complianceDisplay block — documenting that compliance30.totalExpected is
the 30-day denominator and that a per-day glyph is drawn only where due is true.
Handler unchanged; this closes the contract gap.
2026-06-02 19:13:24 +02:00
Marc-André Bombeck 879833b372 fix(insights): stop warming every assessment on an overview visit
Opening the Insights overview fired a once-per-session background warm that
forced the provider to regenerate the comprehensive briefing and every category
and metric assessment at once — a multi-minute burst that contended with
foreground requests and made the app feel unresponsive until it finished. The
nightly pass keeps the caches warm and the per-metric status GETs revalidate
gently on their own, so the warm-on-mount is redundant. Remove it; the manual
'prepare assessments' button remains the explicit on-demand re-warm.
2026-06-02 18:39:53 +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 1c91abcf2a docs(medications): correct the GLP-1 card equal-height comment
The reserved-slot comment claimed the compliance bars and action row
align "row-for-row across the grid" with a generic sibling. That
overstates it: the GLP-1-only rotation-hint block sits between the
reserved slot and the compliance bars, so the bars are pushed down by
its height and do not share a Y with a generic card's bars. What the
layout actually guarantees is equal card height and a shared bottom
action baseline (`h-full` + `mt-auto`); only the header, the reserved
slot, and the pinned action row align across a mixed-variant row.
Reword the comment to claim exactly that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7320c8b1b9f704d22eaba10be42a560594a5ca11)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck ada9006d9c fix(export): cap the FHIR MedicationAdministration count per export
The administration set was windowed only to the report period (up to 730
days). A chronic medication dosed several times a day across that window,
multiplied across several medications, can still materialise thousands of
MedicationAdministration resources into a single Bundle — bounded by the
window but unbounded by dose frequency.

Cap the set at the most-recent 1000 acted intakes overall. The omitted
rows are the oldest in the window, so recent adherence is preserved
intact. When the cap trims rows the aggregator records the original and
included counts and the FHIR document narrative discloses the
truncation, so the export is honest about the omitted history rather
than silently dropping it. Cover the cap in the report aggregator and
the narrative note in the bundle builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c6ce7df2bf6efb5e2c9d948d8f189d95d1289758)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck f0f2caab79 fix(medications): bound the RxNorm code length
The RxCUI field validates against `^\d+$`, which is unbounded — a caller
could submit an arbitrarily long digit string. Add a `.max(20)` ceiling
as defence-in-depth so the column write stays tight; real RxCUIs are at
most a handful of digits. `atcCode` needs no max — its regex already
fixes the length. Regenerate the OpenAPI contract for the new
`maxLength` on both the create and update bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8b97d35bc375ef3b2e70f938c0d6729a5fa6bccb)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck bfdf0a12a0 fix(medications): route injection-site taps to the nearest quadrant centre
The two stacked abdomen quadrants sit 14 viewBox units apart, yet each
per-dot hit-target was an r=14 transparent circle (Ø=28u), so the
targets overlapped by ~14 units. SVG hit-testing resolves to the
topmost-painted element rather than the nearest centre, so a tap in the
overlap band logged the wrong quadrant — defeating the rotation feature.

Replace the overlapping per-dot hit-circles with a single transparent
overlay that projects the pointer into viewBox space and resolves it to
the nearest site centre (new `nearestSiteAt` helper, constrained to the
effective allowed set). Every pixel now maps to its closest site, so
full-area routing holds without sub-target dead zones, and the abdomen
quadrants can no longer mis-route. Keyboard and assistive-tech users
keep a per-site focusable r=6 circle (non-overlapping at this spacing)
with the Space/Enter handler and aria-label. Add tests asserting a tap
in the former overlap band maps to the nearer quadrant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
2026-06-02 17:34:52 +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 b13721cc97 fix(insights): stop a timed-out warm from evicting its own freshly-warmed caches
The on-demand force-warm bounds the comprehensive briefing with a 45 s
timeout and then warms the per-status + generic-metric caches itself. The
timeout could not cancel the underlying generation, so a comprehensive that
resolved after the timeout reached its own per-status eviction and deleted
the rows the warm passes had just written — leaving the user back on a cold
cache.

Thread an AbortSignal into the comprehensive generation and abort it when the
timeout fires; the generation now bails out before its write + evict once the
signal is aborted, so a late resolve can never undo the warm.

Also align the force-warm gating with the route: the route admits the job on
the per-user assessment surface, so warm the per-status + generic-metric
cards whenever that surface is enabled, even when the global briefing switch
is off. Only the comprehensive briefing stays gated on the briefing switch.

Factor the per-status cache-action shape into a single helper so the key is
built in one place rather than hand-assembled across the generators, the
eviction sweep, and the ingest debounce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 94415e468e fix(layout): reserve the scrollbar gutter so the nav no longer shifts
The authenticated shell scrolls inside its own `<main>` (the body is height-
locked with `flex h-dvh`), and that `<main>` centres its content with
`mx-auto`. A tall section paints the vertical scrollbar and narrows the
content box; a short one hides it and widens the box, so the whole column —
including the admin/settings sidebar — recentres a few px sideways on every
view switch. The admin "Insights Quality" ↔ "Integrations" toggle was the
visible case.

Reserve the gutter with `scrollbar-gutter: stable` on the `<main>` scroll
viewport, and on `html` for the body-scrolled login / onboarding / standalone
pages. The content box width is now constant whether or not the bar is
painted, so the left nav no longer shifts when a scrollbar appears. The fix is
global: every signed-in route renders inside the same `<main>`, so the same
shift on settings, insights, and dashboard navigation is fixed at once. The
height-locked authenticated body never scrolls the document, so no double
gutter results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c1459e5ee42b7f7acbfa933a9eb7d1f5a1c6890a)
2026-06-02 17:10:36 +02:00
Marc-André BombeckandClaude Opus 4.8 6d1587531f test(settings): expect the shortened notifications section label
The settings section reverts to the single-line "Notifications" /
"Benachrichtigungen"; update the shell title assertions to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:40 +02:00
Marc-André Bombeck 170862fdf8 fix(targets): convert glucose targets back to mg/dL on save
The per-metric target editor receives a glucose range already projected
into the user's display unit (mmol/L) along with `unit="mmol/L"`, but it
seeded, validated and saved that value verbatim. HealthLog stores glucose
canonically in mg/dL, so a user on the mmol/L preference either had a
valid target (e.g. 3.9–5.5 mmol/L) rejected by the 40–400 mg/dL bounds,
or — when an override already existed — saw a mg/dL number rendered as if
it were mmol/L and wrote it straight back.

Add `mmolToMgdl` / `toCanonicalMgdl` (the inverse of `convertGlucose`) and
teach the edit sheet to treat the display unit at its boundary: seed the
persisted mg/dL override into the display unit, validate against bounds
expressed in the display unit, and convert the typed value back to
canonical mg/dL before the PUT. A no-op for every non-glucose metric and
for users on the mg/dL preference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 09af60303d feat(medications): redraw the injection-site picker as an anatomical silhouette
Replace the stick-figure outline with a traced front-of-body silhouette,
still an inline SVG so it inherits the theme tokens (currentColor + a
soft currentColor wash) and tints correctly across light/dark and the
zinc/Dracula palettes with no raster asset.

Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using
the iOS client's hands-on fractional coordinates, so both apps land their
dots on the same anatomy. The coordinates double as the Euclidean basis
for the rotation recommender, so picker and recommendation stay in sync.

Dot states: selected (filled), recommended-next (dashed primary ring),
and a new last-used solid ring; hit targets are r=14 on the viewBox
(≈45 CSS px at the rendered scale), clearing the 44px floor. Props,
callbacks, the screen-left=="links" convention and the v1.8.5 site→enum
bridge are unchanged — graphic and coordinates only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
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 4feac6b7e4 fix(insights): gate the assessment warm on insightStatus, not coach
POST /api/insights/pregenerate warms the assessment cards — the comprehensive
insight plus the specialised status cards — all of which the read-only status
GETs gate on the insightStatus surface. It was gating on coach, so a user with
assessments enabled but the Coach disabled got a 403 and could not warm. Move
the gate to insightStatus to match the surface it actually serves, add it to
the route-gate inventory allowlist, and cover the coach-disabled-still-warms
path with a route test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 18cde65af7 docs(openapi): register the six specialised insight status routes
The generic metric-status route set the OpenAPI pattern for the assessment
cards; retrofit the six specialised routes onto it. Add registry entries
for /api/insights/{blood-pressure,pulse,weight,bmi,mood,medication-compliance}-status
mirroring the metric-status envelope (hasProvider/text/cached/updatedAt plus
the preparing + revalidating flags). Medication-compliance carries its richer
summary + per-medication note array as its own schema. Regenerate the YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 91887a13b30e8978e91f615604163331bdc54950)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 7fa2866154 feat(fhir): emit Coverage on a bare KVNR + add Composition.text
Align the server's `Coverage` emission with the iOS exporter: a
Coverage is now emitted whenever ANY payor signal is present, including
a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the
`subscriberId` with no contained payor Organization; the insurer-known
case is unchanged. A bundle with no payor signal at all still omits
Coverage entirely.

Also add a top-level `Composition.text` narrative (reusing the same
escaped summary the section narrative carries) so a strict
US-Core-style validator sees the document-level narrative it expects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck ca8781867b feat(fhir): add a MedicationAdministration per acted intake
Emit one `MedicationAdministration` per intake the user actually
actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`),
`not-done` for an explicit skip (`effectiveDateTime = scheduledFor`).
Pending / missed slots and soft-deleted tombstones are never exported,
so the record only asserts administrations that happened.

Each administration reuses the statement's ATC/RxNorm + text concept,
so it is self-describing without a cross-resource reference. A `dosage`
is emitted only when a structured dose Quantity is available (resolved
from the dose-change history in effect at the administration instant) —
a dosage carrying only `.text` would violate the R4 dose-or-rate
invariant. Route + injection site ride along as text-only concepts. The
administrations are referenced from the existing Medications section, so
the document's two-section graph is preserved.

`FhirMedicationAdministration` is new; `FhirDosage` gains `dose`,
`route`, and `site`. The source rows come from the same aggregator the
PDF consumes, keeping FHIR and PDF aligned by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 2fcd2e75f5 feat(fhir): emit ATC + RxNorm codings on the medication concept
The health-record export's `MedicationStatement.medicationCodeableConcept`
gains additive `coding[]` entries when the medication carries a stored
code: ATC primary (`http://www.whocc.no/atc`), RxNorm secondary
(`http://www.nlm.nih.gov/research/umls/rxnorm`). The free-text name
stays the `.text` anchor and the concept collapses to exactly the
prior text-only shape when no code is present — the export never
machine-guesses a code, only emits what the user stored.

The aggregator now carries `atcCode` / `rxNormCode` on `medications[]`
and assembles the acted-intake source rows
(`medicationAdministrations`) the administration builder consumes, so
the FHIR export keeps describing the same numbers as the PDF by
construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 740c86b3a830efa7e76d69b29ae376addeee6811)
2026-06-02 16:45:23 +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 eff7b42348 feat(medications): collapse the API request examples and quiet the URL
The external-API row was the tallest thing in the advanced-settings sheet:
a full-width endpoint URL block plus an always-expanded 4-9 line request
snippet, on screen for every user whether or not they touch the API.

Collapse the multi-language request examples behind a chevron disclosure,
closed by default — the language picker and the live-interpolated bearer
token now only render once the user opts in, keeping the token-shaped
payload out of the default-visible DOM. De-emphasise the raw endpoint URL
to a muted, single-line truncated `<code>`: the copy button already carries
the full value, so the long absolute URL was redundant noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6310ed49f4523e3210b26c05f2c934748967af88)
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 141c083ff6 feat(insights): add time-of-day and stability mood analyses
Extend the mood-aggregate engine with two pure helpers over the rows it
already fetches:

- computeTimeOfDayAverages buckets every entry into morning / afternoon /
  evening / night in the entry's own timezone (legacy null-tz rows fall
  back to UTC). A spread guard keeps the once-a-day logger — whose entries
  cluster in a single bucket — from producing a misleading daypart claim:
  the pattern is only marked reliable once at least two buckets each clear
  the per-bucket sample floor.
- computeMoodStability maps the population standard deviation of the daily
  means onto a 0-100 score (higher = steadier) with a descriptive,
  non-judgemental band. Sparse loggers below the minimum-days floor get
  null rather than a meaningless number.

Both feed the MoodAggregates object that drives the visible mood page and
the LLM snapshot, so the prose and the charts agree. A new time-of-day
narrative takeaway joins the ranked feed, gated on the same reliability
flag plus the existing effect-magnitude bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 685b53c0fd994324c270df2b9a5ac964bb442c82)
2026-06-02 16:45: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 f1145c0e91 fix(insights): debounce ingest-driven status invalidation to coalesce the regeneration storm
A constantly-syncing client drips measurement batches every few minutes.
Every batch deleted the dirtied per-metric assessment cache rows and
re-enqueued a regeneration. The per-(user,scope,locale) enqueue singleton
is only 120 s, so it could not coalesce across batches that arrive minutes
apart, and each delete dropped the row a fresh enqueue would otherwise have
found warm — so a metric card regenerated several times an hour and its
assessment felt regenerated on every visit.

Before evicting and re-enqueuing, check whether the scope's cached
assessment was already (re)generated within a 30 min debounce window. A
scope that is fresh is left intact and not re-enqueued; a genuinely stale
or missing scope still refreshes immediately, so correctness holds. A
timeout stub never counts as fresh, so a scope that recently stalled still
retries. One indexed read per user answers it for every candidate scope at
once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 024baea664490ef0494add78c87492f01e61c6b9)
2026-06-02 16:23:16 +02:00