Commit Graph
3408 Commits
Author SHA1 Message Date
Marc-André Bombeck 5cc35aae6e Merge branch 'fix/google-health-coverage' into release/v1.27.15 2026-07-07 11:24:46 +02:00
Marc-André Bombeck ae2e39198a feat(google-health): import blood glucose, core body temperature, and nightly wrist temperature
Full-catalogue coverage pass against the v4 data-types index. Three types
map onto existing MeasurementTypes and are now fetched in the metrics
bundle:

- blood-glucose -> BLOOD_GLUCOSE. `bloodGlucoseMilligramsPerDeciliter` is
  already the canonical mg/dL storage unit; meal/timing/specimen enums are
  not stored.
- core-body-temperature -> BODY_TEMPERATURE. `temperatureCelsius`, the core
  slot — distinct from the surface-temperature buckets.
- daily-sleep-temperature-derivations -> WRIST_TEMPERATURE. The documented
  `nightlyTemperatureCelsius` is an ABSOLUTE reading (the mean of the
  night's skin-temperature samples), which refutes the earlier deviation-
  only premise behind the launch omission. The baseline/stddev derivations
  are not stored — the user's own series carries the baseline.

Every other catalogue type is now an explicit documented skip in
mapping.md with its reason (per-sample twins of the chosen daily grains,
zone-minutes types without a HealthLog concept, total-calories folding in
BMR, hydration/nutrition without a module, ECG/IRN behind their scope
policy). The structure probe iterates the type table, so the three new
types are probed automatically.
2026-07-07 11:17:06 +02:00
Marc-André Bombeck 849607e7df fix(google-health): reconcile the daily-summary filter and two leaf fields with the v4 reference
Daily HRV and resting heart rate returned HTTP 200 with zero imports on a
live Fitbit-band account. Full reconciliation against the v4 reference
(data-types index, DataPoint schema, list/reconcile/dailyRollUp pages)
surfaces three defects:

- The daily-summary `.date` filter prefix is the docs' own contradiction:
  the data-types index's filter-parameter column says snake_case
  (`daily_heart_rate_variability`), while the list reference's only worked
  daily example is `dailyHeartRateVariability.date < "2024-08-15"` — the
  camelCase payload key. Live, the snake form is accepted (200) but matches
  nothing. Send the worked-example camelCase form first and re-walk once
  with snake_case if the very first page 400s; annotate the accepted style
  (`googleHealth.dateFilter.style`) and surface it through the structure
  probe as `requestShape` so a live account settles the grammar.
- `daily-respiratory-rate` reads `breathsPerMinute` (a plain number); the
  previously assumed `dailyRespiratoryRateBpm` leaf does not exist, so
  RESPIRATORY_RATE never parsed a single row.
- `height` carries `heightMillimeters` (int64 string), not `heightMeters`;
  the heightCm profile seed never fired. Convert mm -> cm.

Also cover the documented Exercise.ExerciseType catalogue in the sport map
(MOUNTAIN_BIKE, TRAIL_RUN, TREADMILL_WALK, STATIONARY_BIKE, SWIMMING_POOL,
FREE_WEIGHTS, CROSSFIT, TABATA_WORKOUT, ...) — the enum's multi-word values
previously fell through to "other".

Tests pin the documented field names per fixture, the camel-first/snake-
fallback walk (including no-fallback for sample shapes and unfiltered
backfills), and the extended sport map. mapping.md records the filter
contradiction and the corrected leaves.
2026-07-07 11:16:27 +02:00
Marc-André Bombeck 4b466be1d2 Merge pull request #424 from MBombeck/release/v1.27.14
Release v1.27.14 — cross-device dose sync and phone-width polish
v1.27.14
2026-07-07 10:52:02 +02:00
Marc-André Bombeck 456a547ada chore(release): v1.27.14 — cross-device dose sync and phone-width polish 2026-07-07 10:41:52 +02:00
Marc-André Bombeck d7b98ad9f8 Merge branch 'fix/mobile-density' into release/v1.27.14 2026-07-07 10:38:17 +02:00
Marc-André Bombeck 714aa0f2c0 test(e2e): guard phone widths against overflow and tile escapes
New density guard at 390x844 and 360x780 on the dashboard and
/insights: page scrollWidth never exceeds the viewport, and no
descendant's visible box crosses its card / hero / list-row edge
(clip-aware below the tile; content cut off at the tile's own edge
counts as an escape). The snapshot mock gains hero fixtures — layout
heroVisible, three score rings, and a briefing whose signal rows pair
long wrapping headlines with wide delta strings — so the guard
exercises the exact shape that regressed, and asserts the spotlight
rows actually render rather than passing vacuously.
2026-07-07 10:36:57 +02:00
Marc-André Bombeck 29e2b44310 fix(insights): stack the health-score delta caption below the delta line
The v1.22 inline explainer caption rendered INSIDE the delta line's
inline-flex row, so the short delta text ("-2 vs last week") wrapped
into a narrow four-line column beside the multi-sentence read on every
viewport. The wrapper now stacks: delta line with its trend icon on
top, the caption below at full width. The aria-describedby thread from
the delta digit to the caption id is unchanged.
2026-07-07 10:36:45 +02:00
Marc-André Bombeck 39034afcdc fix(dashboard): stack briefing-row headline over delta on phone widths
A long signal headline beside a wide delta string squeezed the headline
into a one-word-per-line column on the hero spotlight rows; at 360 px
the delta pushed past the tile edge and got cut off by the band's
overflow clip. Below sm the headline/delta pair now stacks — headline
wraps at full row width, the delta sits beneath as a left-aligned
tone-coloured line — and the side-by-side row returns at sm+. Same
treatment on the /insights briefing card's shared finding/signal rows,
which speak the identical row language.
2026-07-07 10:36:33 +02:00
Marc-André Bombeck 28749ec6e7 docs(dashboard): describe the MED_COMPLIANCE ring as today's dose tally
The scoreRings doc comment still called the ring the pooled 7-day
adherence — that was the v1.27.7 first cut. Since v1.27.8 the ring is
today's taken/scheduled dose progress from the snapshot's own medsToday
block (see src/lib/dashboard/score-rings.ts). Comment-only change.
2026-07-07 10:22:17 +02:00
Marc-André Bombeck 043698a4d3 docs(env): document IP_GEO_ALLOW_INSECURE in .env.example
The opt-in shipped in v1.25.6 and is documented in
.env.production.example, but the dev-facing .env.example never mentioned
it. Add the commented entry with the plaintext-egress caveat. The
check-env gate reads scripts/env-manifest.json, not the example files,
so this is documentation only.
2026-07-07 10:22:17 +02:00
Marc-André Bombeck dca4ca544e feat(medications): dispatch the intake sync push from every intake mutation route
The cross-device sync push fired only from POST /api/medications/intake
and /intake/bulk. The five other routes that mutate intake state — the
per-medication dose log, the intake-event edit and delete, the bulk
delete, the history import and the purge — persisted the change but
never woke the user's other devices, so a dose undone or edited on the
web left a stale Live Activity / widget until the next foreground or
background wake.

Queue the same coalesced fan-out from all five after the canonical
write, excluding the originating device via X-Device-Id as on the
existing paths. Best-effort as everywhere else: a push miss never
affects the mutation response. The import skips the queue when zero
rows imported, and the bulk delete returns before the hook on a no-op
match.
2026-07-07 10:22:06 +02:00
Marc-André Bombeck b9b38d182c feat(notifications): coalesce the intake sync push per user and strip its payload
The MEDICATION_INTAKE_SYNC fan-out used to fire one silent APNs per
distinct dose slot, so a bulk intake marking five doses woke every other
device five times. Replace the per-slot dispatch with a per-user
coalescer: the first mutation in a burst dispatches immediately, every
further mutation inside the 2 s window folds into one trailing fan-out.
A bulk request now costs exactly one queue call, and a rapid burst of N
single-dose mutations costs at most two fan-outs. The window state is
in-process; multiple app instances throttle independently, which is
accepted because the iOS reconcile is idempotent and the deployment
model is a single container.

The silent payload shrinks to a pure sync trigger — eventType +
syncedAt. The per-slot medicationId / scheduledFor fields are gone: the
iOS handler runs a full reconcile regardless, a coalesced burst has no
single slot to name, and a push payload travels through Apple's
infrastructure, so it now carries no health data by construction. The
Live Activity end push likewise sends only the ActivityKit envelope.

The trailing flush keeps the origin skip only when every coalesced
mutation came from the same device; a mixed-origin burst wakes everyone.
Each flush still records one push_attempts ledger row, reaps
hard-rejected device tokens, and annotates the wide event with the
recipient count and coalesced-mutation count.
2026-07-07 10:21:53 +02:00
Marc-André Bombeck 0da11811e2 docs: bring the README in line with what actually ships
The records bullet claimed inbound document handling — the document
vault is disabled and belongs on the roadmap, not in the feature list.
The device bullet gains Google Health and Nightscout, the check-in
bullet gains WHO-5 and the SCI, the AI bullet reflects guideline-band
interpretation and acceptable plans, and the language claim grows from
two locales to the six that ship.
2026-07-07 09:26:09 +02:00
Marc-André Bombeck d40c5898f7 Merge pull request #422 from MBombeck/release/v1.27.13
Release v1.27.13 — assessments that interpret
v1.27.13
2026-07-07 00:55:37 +02:00
Marc-André Bombeck 9ea22b8a98 chore: drop an unused registry-keys export 2026-07-07 00:45:23 +02:00
Marc-André Bombeck c7b4ccf24a chore(release): v1.27.13 — assessments that interpret 2026-07-07 00:41:30 +02:00
Marc-André Bombeck 52d09d17c7 feat(insights): inject guideline band placement into generic metric assessments
Thread the interpretation block into the generic per-metric assessment
path (the ~40 HealthKit metric cards). For a metric the knowledge base
covers, the prompt now carries the band table plus the server-computed
position of the current value, so the assessment names where the value
sits on the guideline scale and judges the trend by that position — the
visceral-fat 2.7 case now reads as "comfortably in the healthy band"
rather than reciting how many times it was logged.

The block is absent for uncovered metrics and for a sex-split band with
no known sex, so those cards fall back to exactly today's personal-relative
behaviour. Band edges carry no strip risk here — the generic assessment
path runs no post-generation numeric grounding gate.
2026-07-07 00:36:43 +02:00
Marc-André Bombeck 3ce396081f feat(insights): anti-recitation and interpretation-depth prompt contracts
Add two shared cross-surface contracts and compose them into the
assessment cards (base-system) and the comprehensive overview + daily
briefing (insight-generator):

- anti-recitation: a measurement count, a logging cadence, or page
  mechanics is not an insight and may only appear when it carries a
  consequence — stated as the consequence, not the count.
- interpretation-depth: say what a value MEANS, place it on its guideline
  scale where one is attached, judge the trend by band position, and frame
  the consequence without diagnosis; where no band exists, interpret
  against the person's own baseline and say so honestly.

The cross-surface coverage guard now asserts both contracts reach the
surfaces that enforce them. The briefing's grounding and format machinery
is untouched.
2026-07-07 00:36:34 +02:00
Marc-André Bombeck 8fc71620e8 feat(insights): guideline interpretation registry for per-metric assessments
Add a typed, hand-curated registry mapping metrics to guideline
interpretation bands derived strictly from the health knowledge base —
band edges, plain labels, valence, direction-of-good, and a source tag,
each entry citing the KB file + section it came from. A metric the KB
gives no numeric band for gets no entry, so its assessment stays
personal-relative rather than inventing a threshold.

The band position is computed server-side (`classifyBandPosition`, strict
upper-edge semantics + edge-hugging proximity), and `buildInterpretationBlock`
renders the table + placement into the assessment prompt so the model states
where a value sits and what the band means instead of reciting counts.
`interpretationBandEdges` exposes the edges for any numeric grounding gate.

Covers visceral fat, resting heart rate, blood oxygen, respiratory rate,
body temperature, sleep duration, waist (sex-split), waist-to-height,
pulse-wave velocity, and BMI. Tests pin every band edge against the
guideline value so a silent edit breaks a test.
2026-07-07 00:36:25 +02:00
Marc-André Bombeck ca1e8c2415 Merge pull request #421 from MBombeck/release/v1.27.12
Release v1.27.12 — Google Health daily totals
v1.27.12
2026-07-06 20:38:42 +02:00
Marc-André Bombeck 8e2fff4e7f chore(release): v1.27.12 — Google Health daily totals 2026-07-06 20:29:17 +02:00
Marc-André Bombeck 3653402d4a feat(google-health): report the accepted rollup shape from the structure probe
The structure probe (POST /api/integrations/google-health/test with
probe:"structure") already exercises one 7-day dailyRollUp per cumulative
type; it now also reports which documented request shape Google accepted
(days90 standard chunking vs days14 conservative fallback) as
requestShape, next to the field-name skeleton. A self-hoster can settle
the range-constraint question from the UI without server logs; no value,
timestamp, or identifier is added to the output.
2026-07-06 20:24:50 +02:00
Marc-André Bombeck de86ee26f2 fix(google-health): one broken collection no longer kills its siblings
The live 400 on fetchSteps suppressed fetchDistance / fetchFloors /
fetchActiveEnergy entirely: handleCollectionFetchError rethrew anything
that was not a per-class 403, and the rethrow escaped the rollup loop in
sync-activity, emptying the whole cumulative group after the first bad
collection.

- handleCollectionFetchError records the classified failure, warns with
  the per-collection detail, and returns instead of rethrowing; every
  call site already sits in a per-collection catch, so the sibling
  collections keep fetching.
- A per-cycle hard-fail ledger (AsyncLocalStorage, mirroring the 403
  soft-skip tracker) still fails the cycle: the run counts as a partial
  error, the watermark is not stamped, and the next tick refetches the
  window - a broken collection cannot silently advance past its gap.
- The orchestrator's per-resource warning names the resource explicitly;
  fn.name minifies to a single letter in the production bundle (the live
  log read "google-health c failed").
- Tests pin the no-rethrow contract per classification and the activity
  loop reaching all four rollup types plus VO2 max after a steps failure.
2026-07-06 20:24:40 +02:00
Marc-André Bombeck 1c3c733fab fix(google-health): build dailyRollUp ranges the way the docs demonstrate
The first live sync on v1.27.4 imported every list-based type but died on
the first cumulative chunk: fetchSteps(chunk=0,page=0) returned
HTTP 400 INVALID_ARGUMENT "Invalid argument in request.".

Re-verified the request contract against the v4 reference:

- Request body (users.dataTypes.dataPoints/dailyRollUp): range
  { start/end: CivilDateTime }, windowSizeDays, pageSize, pageToken,
  dataSourceFamily - every field the client sends is legal under that
  exact name.
- CivilDateTime (inlined on the DataPoint page): nested
  {date:{year,month,day}, time:{hours,minutes,seconds,nanos}} objects.
- range: "Required. Closed-open range of data points that will be rolled
  up. The start time must be aligned with the aggregation window. The
  maximum range for calories-in-heart-rate-zone, heart-rate,
  active-minutes and total-calories is 14 days. The maximum range for
  all other data types is 90 days." - steps / distance / active-energy /
  floors sit in the 90-day class, so the 90-day chunk constant stands
  (the in-file "14-day-max" note applies to heart-rate only).
- The worked dailyRollUp example (developers.google.com/health/endpoints)
  rolls up ONE civil day as start = {date: day, time: 00:00:00} and
  end = {date: SAME day, time: 23:59:59} - the end bound names the last
  covered day, not the next day's midnight.

The previous body sent end = {date: <next day>} with no time object, so
a maximal 90-day chunk touched 91 civil dates - the one divergence from
the documented example, and the prime suspect for the 400 (the docs do
not spell out how the cap counts days).

- buildDailyRollUpBody() pins the documented shape: explicit date+time
  on both bounds, end = last covered day at 23:59:59, windowSizeDays: 1,
  no pageSize (the documented default of 1440 covers the <=90 windows a
  chunk can produce), pageToken threaded on follow-up pages.
- fetchDailyRollUp() keeps 90-day chunking; if the very FIRST request of
  a walk is rejected with a 400 it re-walks the range once at the most
  conservative documented span (14 days, the tightest cap the docs state
  for any type) and annotates googleHealth.rollup.shape with the shape
  that succeeded, so the next live sync reports which constraint binds.
- extractGoogleApiErrorDetail() surfaces AIP-193 error.details[]
  BadRequest fieldViolations as "field: description" fragments (field
  paths and descriptions only, redacted and capped at three), so the
  next 400 names the offending request field in the logs instead of the
  bare "Invalid argument in request.".
- mapping.md documents the verified range semantics; unit tests pin the
  exact body JSON, the 90/91-day boundary, the fallback walk and the
  violation extraction.
2026-07-06 20:24:24 +02:00
Marc-André Bombeck 4ea50c5d64 Merge pull request #420 from MBombeck/release/v1.27.11
Release v1.27.11 — consistency, colour, and locale fixes
v1.27.11
2026-07-05 22:50:54 +02:00
Marc-André Bombeck c5eddef31d test(e2e): pin the shared auth state to an explicit English locale cookie
The server honours User.locale when the locale cookie is absent now, and
the locale-switch spec mirrors its German pick into the shared e2e
user's profile — every spec running after it rendered German and the
English-string assertions failed. The explicit cookie sits at the top
of the resolution ladder, keeping each spec context deterministic;
locale-switch still overrides it inside its own context.
2026-07-05 22:41:43 +02:00
Marc-André Bombeck c49734842b chore(release): v1.27.11 — consistency, colour, and locale fixes 2026-07-05 22:28:39 +02:00
Marc-André Bombeck ec586ba75c fix(dashboard): paint the dose ring in the medication hue, not green
The 'Doses today' ring pinned the ring system's green arc — a colour
that vocabulary reserves for band semantics. Every medication surface
in Insights paints --primary (the compliance progress bars, the
compliance trend line, the drug-level and dose-strength curves), so the
ring now rides a new constant 'meds' hue on that token: flat by design
(both gradient stops on --primary), never a band gradient over pending
morning doses. Contrast, computed: dark #bd93f9 — 6.78:1 card / 5.90:1
bg; light #644ac9 — 6.00:1 card / 5.60:1 bg; both clear the 3:1
graphics floor with room. The guard now windows the assertion to the
dose ring's own SVG and pins primary present, ring-green absent.
2026-07-05 22:11:26 +02:00
Marc-André Bombeck 72ad228f3e fix(ui): lift the remaining explainer prose out of muted
Residual grey-on-content sweep after the page-description lift:
multi-sentence explainer paragraphs are content and read in the
regular foreground, matching the metric sub-page explainer that set
the pattern — the labs biomarker and custom-metric detail leads, the
recovery metric and resilience explainers, and the onboarding
carousel/done-screen body copy. Muted stays reserved for meta:
timestamps, units, captions under a stat, one-sentence description
slots.
2026-07-05 22:01:15 +02:00
Marc-André Bombeck eb730bf7f3 fix(insights): one card language on the sub-page spine
A measured pass over every insights sub-page, the dashboard, and the
settings sections (computed text colour, left text edge relative to the
card edge, header→body distance, per card) surfaced the drift the eye
kept catching:

- The blood-pressure pulse-pressure/MAP explainer was a bare muted
  paragraph at the container edge, 16–24 px left of every neighbouring
  card's text. It now rides a compact Card in the regular foreground.
- The 'Usual range' read strip was a hand-rolled bg-card/60 shell with
  its own padding; rebuilt on Card + TileHeader + CardContent at the
  compact density so background, radius, icon spacing and text edge
  match the neighbouring tiles.
- The spine tiles carried three different header→body distances (6, 12
  and 24 px) via off-scale overrides (gap-1.5, md:gap-3, md:py-5,
  gap-3). Assessment, target summary, medication tiles, the mood
  stand-out card and the sleep hypnogram now share the sanctioned
  compact tier (gap-2 py-3 md:py-4).
- The hero score card and its skeleton kept their translucent identity
  but move from a static px-4 py-4 to the p-4 md:p-6 card norm, putting
  their text on the same edge as every other card.
- Two dashboard shells (recent workouts, recent unlocks) froze at p-4
  on every breakpoint; now p-4 md:p-6.
- Settings: the modules hint moves into the header's description slot
  and the injection-site grid joins the section-wide mt-4 rhythm — the
  two gaps that deviated from the settings 16 px header→content norm.
2026-07-05 22:00:53 +02:00
Marc-André Bombeck a4b204ffe4 refactor(ui): retire the raw dracula utilities for semantic tokens
Migrate every remaining *-dracula-* utility site (127 lint warnings,
193 class occurrences) onto the semantic vocabulary. The swaps are
pixel-identical in both themes — each target token aliases the same
primitive the utility read, including the light-theme AA overrides:

- green/orange/cyan/red → success/warning/info/destructive
- purple → primary (same value in both themes)
- pink → the new --brand-pink token (Coach gradient, Apple-Health
  badge, recording mic): dark #ff79c6 — 6.86:1 card / 5.97:1 bg;
  light #a3134d — 7.32:1 card / 6.83:1 bg (computed)
- yellow sites resolve to their status meaning: warn-level log icon,
  update-available pill and offline-geo hint → warning; the admin
  helpful-rate ramp → success/warning/destructive; the confidence
  meter mirrors the coverage-meter precedent (medium + low share the
  caution tint, the lit count carries the distinction)
- the confidence-badge low tier dropped dead *-dracula-comment/*
  classes (no generated utility) for border-border + bg-muted/50

Close two rule blind spots the sweep surfaced — directional borders
(border-l-dracula-red) and the parenthesised CSS-var shorthand
(bg-(--dracula-green)) — and flip healthlog/no-dracula-utility from
warn to error so the class cannot regrow. var(--dracula-*) references
in chart code stay: they are token references the light theme
overrides, and the chart palette is pinned visually.
2026-07-05 21:28:09 +02:00
Marc-André Bombeck 68b4dd08a7 feat(lint): guard the gap-based Card slots against padding overrides
Add healthlog/spacing-scale (error): pt-*/pb-* on CardHeader/CardContent
fights the gap-based Card contract — the header→body distance comes from
the parent flex gap, so slot padding stacks on top of it or is a no-op.
This is the drift class the earlier slot-padding sweep cleaned; the rule
keeps it from reaccumulating. Density belongs on the Card itself
(gap-2 py-3 md:py-4). No allowlist — an exception, should one ever be
load-bearing, carries an inline disable with its reason.

Remove the two stragglers the rule caught: pb-1 on the device-score tile
and pb-4 md:pb-6 on the intake history list, both stacking dead padding
on top of the card gap. RuleTester coverage for the modifier-prefixed,
arbitrary-value, cn()/ternary/template and member-expression forms.
2026-07-05 21:10:01 +02:00
Marc-André Bombeck e9692f0b81 fix(i18n): survive Safari's 7-day cookie cap on the chosen locale
The first-paint ladder consulted only the healthlog-locale cookie and
Accept-Language — never User.locale. The cookie is written client-side
via document.cookie, which Safari ITP caps at 7 days, so a chosen
language silently reverted to the browser language once a week.

Three seams close it:
- resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts)
  now reads User.locale on a cookie miss via a lightweight session read
  (no sliding-expiry write, no cookie mutation, fail-soft) before the
  Accept-Language fallback.
- PUT /api/auth/me/locale mirrors the accepted locale into the cookie
  via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via
  shouldEmitSecureCookie) — server-set cookies are exempt from the ITP
  cap, and the mount-time backfill hitting the route doubles as the
  weekly refresh.
- getSession re-emits the cookie when the user has a persisted locale
  but the cookie is absent (absent-only, so a mid-switch cookie is
  never reverted before the PUT lands).

Users who never chose a language stay on Accept-Language.
2026-07-05 21:01:50 +02:00
Marc-André Bombeck ef82545870 Merge pull request #419 from MBombeck/release/v1.27.10
Release v1.27.10 — two new check-ins: wellbeing and sleep
v1.27.10
2026-07-05 20:55:19 +02:00
Marc-André Bombeck 0103557566 chore(release): v1.27.10 — two new check-ins: wellbeing and sleep 2026-07-05 20:45:25 +02:00
Marc-André Bombeck ce9b5bec29 fix(mental-health): drop the landing rhythm hint and lift page descriptions to foreground
The cadence sentence between the page header and the instrument cards
reads as clutter once the recall windows live in each instrument's own
description. The canonical PageHeader description also moves from the
muted tier to foreground: it is content, not meta.
2026-07-05 20:45:24 +02:00
Marc-André Bombeck b1688fc8f7 build(deps): adopt the open dependency updates
next 16.2.10, @tanstack/react-query 5.101.2, nodemailer 9.0.3,
react-hook-form 7.81.0, @types/node 26.1.0; docker buildx/metadata/
build-push action pins to their current releases.
2026-07-05 20:37:24 +02:00
Marc-André Bombeck 6e59fe5ce0 Merge branch 'feat/w-g-screenings' into release/v1.27.10 2026-07-05 20:36:50 +02:00
Marc-André Bombeck 8f1485fdcb feat(mental-health): rebuild the check-in page around four instrument cards
The landing slims to intro + instrument cards: the combined history card
leaves the main page entirely. Each card body now opens a per-instrument
detail sheet (the med-/Vorsorge card interaction) carrying the last
score + band, the score-over-time trend chart (restored from the
pre-v1.27.6 chart, lazy Recharts, bands surfacing discreetly in the
tooltip), the dated result history with the crisis re-surface, the Start
action, and the instrument's required attribution line — the Verlauf is
deliberately opt-in behind a click, never pushed onto the page someone
arrives at to take stock.

The wizard drives from the registry: option values in source order (the
WHO-5 leads with 5 = all of the time, the SCI with 4), shared vs
per-item anchor groups, and recall-stem captions above the WHO-5 / SCI
item fragments. WHO-5 items and anchors ship VERBATIM from the official
WHO publication in all six locales; the SCI is validated in English, so
every non-English locale presents the English items plus a localized
'validated in English' note (a self-made translation would invalidate
the instrument — the leak-guard allowlist documents this as intended).
Result view: direction-aware follow-up hints (WHO-5 <= 50 points gently
to the PHQ-9 check-in; SCI <= 16 carries the paper's neutral band
wording; PHQ-9 / GAD-7 keep their >= 10 hint). The intro states each
recall window honestly (two weeks vs past month). The Vorsorge screening
group offers all four instruments. Chrome localized in de/en/es/fr/it/pl.
2026-07-05 20:34:01 +02:00
Marc-André Bombeck 03d4496d3a feat(measurements): surface the WHO5_SCORE and SCI_SCORE trends
Wire the two new screening totals through the read surfaces on the
PHQ-9 / GAD-7 precedent: signal-registry entries (higher-better
direction, off the Coach snapshot and MCP by construction, no FHIR
coding attached — neither total has a verified LOINC, and the export
skips uncoded signals rather than shipping an invented code), the
scores category cluster, list labels/icons/colours, the generic
insight-chart tokens, a null PR direction (screeners are never
gamified), and the module-gated mental-health section of the doctor
report. The vitals-table exclusion rationale is documented alongside
the existing screener entries.
2026-07-05 20:33:44 +02:00
Marc-André Bombeck 1262333acc feat(mental-health): add the WHO-5 and SCI screening instruments
Two new self-report instruments join the mental-wellbeing engine on the
existing PHQ-9 / GAD-7 infrastructure:

- WHO-5 Well-Being Index: 5 items on the 6-point 5..0 scale over the last
  two weeks; the reported total is raw-sum x 4 (0-100) per the official
  WHO scoring (WHO/UCN/MSD/MHE/2024.1, CC BY-NC-SA 3.0 IGO). A total of
  50 or below follows the WHO comment (below 50 = indication for further
  assessment).
- Sleep Condition Indicator: 8 DSM-5-aligned items, 0-4 each with
  item-specific anchors, total 0-32, higher = better sleep; a total of
  16 or below maps to the probable-insomnia range of the source
  publication (Espie et al., BMJ Open 2014;4:e004183, CC BY-NC,
  (c) Sleepio Limited).

The instrument registry generalises rather than forking: per-item answer
ceilings, option order/label schemes, recall-stem captions, a reported-
score multiplier, score direction (higher-is-better inverts the follow-up
threshold), per-instrument attribution lines, and the validated-item
locale set are now instrument properties. Scoring stays server-
authoritative; the create route validates the per-instrument answer
ceiling and projects the total onto a COMPUTED WHO5_SCORE / SCI_SCORE
measurement row (never client-attributable), which also auto-satisfies a
planned screening reminder. Migration 0226 adds the enum values
(additive, idempotent). OpenAPI regenerated.

Neither instrument carries a self-harm item; the PHQ-9 item-9 crisis
routing is unchanged.
2026-07-05 20:33:29 +02:00
Marc-André Bombeck bb0b82c604 Merge pull request #418 from MBombeck/release/v1.27.9
Release v1.27.9 — coach plans you can accept
v1.27.9
2026-07-05 20:20:38 +02:00
Marc-André Bombeck 4b3336d1cc chore(release): v1.27.9 — coach plans you can accept 2026-07-05 20:10:57 +02:00
Marc-André Bombeck f5b0ec431e feat(coach): surface plan proposals for confirmation and add the plans page
The Coach has extracted goal / if-then plan proposals since v1.21.3 —
written as status "proposed", never activated without the user — but no
surface ever showed them, so nothing could be confirmed and the active-plan
memory block stayed permanently empty. This lands the missing user half:

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

SSR tests pin the card contract: only same-conversation proposals render,
undecryptable rows are skipped, both actions are real buttons.
2026-07-05 20:00:38 +02:00
Marc-André Bombeck acd2f29da2 feat(coach): scope filter + conversation provenance on the plans list
Groundwork for the plan confirm/manage UI. The CoachPlan pipeline has been
complete on the server since v1.21.3/v1.22 — extractor writes proposals
(status "proposed", per-user cap, de-dup, encrypted prose), the PATCH
confirms proposed -> active, buildCoachPlansBlock injects the top-6 active
plans into the snapshot, and the review sweep drives the reviewDate
lifecycle — but no surface ever listed or confirmed a proposal, so the
extraction spend had no user-visible outcome.

Two additive contract changes the UI needs:
- GET /api/coach/plans accepts ?scope=open|past|all (open = proposed +
  active + review_due, past = met + abandoned + reviewed, all = no status
  clause) so the management page reads its ledger in one round-trip.
  Mutually exclusive with ?status=; both omitted keeps today's
  non-terminal default unchanged.
- Plan items now carry sourceConversationId so the chat thread can show
  only the proposals born in the open conversation.

OpenAPI regenerated; route tests cover the scope mappings, the
status+scope 422, and the provenance passthrough.
2026-07-05 19:45:20 +02:00
Marc-André Bombeck 4d0f16d5a9 Merge pull request #417 from MBombeck/release/v1.27.8
Release v1.27.8 — dashboard follow-ups and design-system sweeps
v1.27.8
2026-07-05 19:01:11 +02:00
Marc-André Bombeck d7cd29ad56 chore(release): v1.27.8 — dashboard follow-ups and design-system sweeps 2026-07-05 18:50:23 +02:00
Marc-André Bombeck 5a7a13c934 test(records): cover the new report-section toggles in the prefs route fixtures
The doctor-report prefs route resolves every toggle to the full
canonical shape; the fixtures predate the allergies/familyHistory
sections and asserted the seven-key shape.
2026-07-05 18:40:59 +02:00
Marc-André Bombeck 133b84fa72 feat(mental-health): ask the PHQ-9 functional follow-up as the last question
The PHQ-9's validated functional-impairment item left the web wizard
with the review step; the API kept the field but no web client captured
it. It returns as a regular question 10 of 10 in the same style as the
nine scored items: optional per the instrument (submit stays armed
without an answer, a second tap clears the selection, a short hint says
skipping is fine) and never scored into the total. The answer rides the
existing functionalDifficulty field; GAD-7 keeps its seven steps. The
validated wording returns to all six locales.
2026-07-05 18:40:59 +02:00