Commit Graph
267 Commits
Author SHA1 Message Date
Marc-André Bombeck 6e204d054a feat(insights): age-stratify grip strength and waist reference bands
Grip strength and waist circumference resolved against a single sex-aware
bracket for every adult age. Replace each with age x sex norm bands:

- Grip strength: age x sex bands from Dodds et al. 2014 (PLOS ONE
  9(12):e113637) British normative data — band low is the 10th centile
  floored at the EWGSOP2 probable-sarcopenia cut-off (men 27 kg, women
  16 kg), band high is the 50th-centile median. Strength peaks in the 30s
  and declines from midlife, so the bands step down with age.
- Waist circumference: WHO increased-risk threshold (men 94 cm, women
  80 cm) held flat through midlife and raised for older adults toward the
  WHO substantially-increased-risk line, since the WHO cut-points derive
  from cohorts aged 20-74. The standard threshold is never lowered.

The norms resolver gains an age-independent clinical cut-off row per sex,
used only as the no-age fallback so a profile with a known sex but no date
of birth keeps the sex cut-off rather than the coarse registry anchor. The
signal registry structure is unchanged — only the resolver tables move.
2026-06-28 20:22:24 +02:00
Marc-André Bombeck 0f2b548138 style: apply prettier 3.9.1 formatting
Mechanical reflow from the prettier 3.9.1 bump; no behaviour change.
2026-06-28 19:26:16 +02:00
Marc-André Bombeck a69697b50f fix(insights): wire grip / pain / waist / waist-to-height across web and MCP
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.

Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).

Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.

Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).

MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
2026-06-28 18:33:53 +02:00
Marc-André Bombeck cdee3e3efc feat(environment): register env signals and wire weather into correlation discovery
Register the seven environmental-exposure signals through the signal registry
as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1),
and fold their daily series into the correlation engine as lagged behaviour
channels. Weather/daylight now pair against mood, sleep, and vitals through
the existing FDR / effect-size / shrinkage gates, so a thin series degrades to
absent rather than a spurious link. All env channels share one ENVIRONMENT
family so two same-day fields never lag-correlate against each other.
2026-06-28 16:58:30 +02:00
Marc-André Bombeck 2563690e4b fix(insights): add chart tokens for v1.25 clinical measurement types
The PHQ-9 / GAD-7 screener totals and the physical clinical measures
(grip strength, pain NRS, waist circumference, waist-to-height) are
continuous numeric series. Register their metric:<TYPE> tokens so the
chart-token drift guard passes and the AI insight prose can reference
them through the generic chart renderer.
2026-06-28 16:15:48 +02:00
Marc-André Bombeck d570d558fa feat(insights): make sleep debt a configurable source ladder with a computed explainer
Generalise sleep debt onto the existing per-metric source-priority picker
(a new sleepDebt ladder, native-above-computed, default WHOOP > Oura > Polar >
computed) instead of assuming one provider. The rhythm read resolves the active
source off the ladder and carries it on the DTO; when the active source is our
own COMPUTED engine — every user today, since no provider ships a native
sleep-debt balance — the Sleep page explains what the figure means and that it
differs from a wearable's native number. The computed model itself is unchanged
and correct (a short rolling balance of need minus sleep, surplus-credited);
only stale comments that still referenced the old 14-night window are corrected.
2026-06-28 16:04:22 +02:00
Marc-André Bombeck dfce3e2710 refactor(fhir,insights): derive FHIR and correlation tables from the registry
Replace the hand-maintained MEASUREMENT_LOINC literal and the BUCKETED_TYPES
correlation list with signal-registry projections. Both reads are byte-identical
to the previous tables (guarded by the registry-invariant test), so there is no
behaviour change — only one source of truth for each.
2026-06-28 15:12:09 +02:00
Marc-André Bombeck c0a91dbbcd Merge branch 'fix/375-generated-narratives' into release/v1.25.0 2026-06-28 14:42:46 +02:00
Marc-André Bombeck 95ee088646 Merge branch 'feat/biomarker-detail-page' into release/v1.25.0 2026-06-28 14:42:04 +02:00
Marc-André Bombeck ccbf4e07ae Merge branch 'wave/v125-delight' into release/v1.25.0 2026-06-28 14:41:41 +02:00
Marc-André Bombeck fb03ddcc6e Merge branch 'wave/v125-perf' into release/v1.25.0 2026-06-28 14:40:19 +02:00
Marc-André Bombeck f17131dbdf test(labs): cover the biomarker assessment, detail page, and marker delete/range
Add coverage for the new biomarker-assessment seam and the marker detail
surface:

- the generator serves today's cache, re-stamps unchanged readings without an
  LLM call, and regenerates only when the latest-reading fingerprint changes
- the read-only endpoint returns the envelope, 422s a missing id, and honours
  the auth + insightStatus gates
- the detail page paints the stat strip, the chart placeholder, and the
  assessment card
- the biomarker resource route drops the marker with its readings in one
  userId-narrowed transaction and rejects an inverted target range

Hold the input gate independent of the worker's forced run so an idle marker
re-stamps cached text instead of regenerating, and list the new route on the
non-Coach gate allowlist.
2026-06-28 13:58:43 +02:00
Marc-André Bombeck 0790a96ca4 feat(labs): add an AI assessment and stat strip to the biomarker detail page
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.

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

The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
2026-06-28 13:43:14 +02:00
Marc-André Bombeck d8c82910a7 fix(insights): localize and auto-send the coach assessment prompt
Thread an autoSend flag through the coach-launch flow so a card's
"ask the coach about this" hand-off dispatches its seeded prompt as
the conversation's first turn instead of only seeding the composer.
The flag rides the launch context, the layout mount, the drawer, and
the shared conversation surface, where a ref-guarded mount effect fires
the send exactly once for a fresh thread.

Localize the previously hardcoded English assessment opener in the
metric status card behind insights.coach.assessmentPrompt with a
{metric} placeholder, and localize the resting-pulse coach prefill.
Both keys land in all six locales.
2026-06-28 13:20:24 +02:00
Marc-André Bombeck 556f442724 fix(insights): honor the per-user AI timeout and keep the last good narrative
The daily briefing and the per-metric status assessments ignored the
per-user response-timeout setting: they hardcoded their per-surface
AI_BUDGETS default, so raising the timeout for a slow self-hosted or
local backend had no effect and the surfaces still aborted early. Only
the coach threaded the setting through.

Resolve the effective timeout from User.aiResponseTimeoutSeconds in one
shared helper and thread it onto every non-coach generation call:

- runStatusCompletion is the single chokepoint every status / reference
  surface funnels through (per-metric cards, the batched assessment, the
  derived assessments, the period narratives), so resolving it there
  applies the setting to all of them at once — on both the upstream
  call's own timeout and the outer wall-clock cap that previously clipped
  a raised value at 60 s.
- The comprehensive briefing generator and the user-initiated regenerate
  route thread it onto the main generation, the JSON retry, the grounding
  retry, and the paragraph re-roll.

On a failed generation the briefing keeps its last good text — no cache
row is written, so the previous payload stays intact — and records a
dated, append-only failure marker (no migration; reuses the audit-log
JSON pattern). The read path compares the marker against the last
successful generation so it can tell a held briefing from a current one.
2026-06-28 12:42:35 +02:00
Marc-André Bombeck 4bf45efcab revert(insights): drop hydration logging
Remove the in-app water-intake logging feature: the WATER_INTAKE
measurement type, the per-user daily-goal field, the goal-ring card and
its endpoint, and the related query keys, OpenAPI route, and locale
strings. The feature is dropped from this release; the baseline-drift,
breathing-screening, and labs-changes insight cards are unaffected, and
the pre-existing TOTAL_BODY_WATER body-composition type stays intact.
2026-06-28 11:39:26 +02:00
Marc-André Bombeck a827fe2ca7 fix(insights): correct medication compliance and per-user insight bucketing
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:

- Paused medications. A doctor-ordered pause flipped `active` off and stamped
  `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
  and every paused day became a missed dose after resume — collapsing
  adherence across the Health Score, the BP gate, the Coach snapshot and the
  report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
  migration 0211), close the open era on resume, thread the eras through the
  compliance context and drop slots inside a paused interval from the
  denominator. Every compliance call site now fetches and passes the eras.

- Health Score eras. The Health-Score fast path was the only compliance
  caller that skipped the shared schedule select and schedule revisions, so
  after a schedule edit it scored past days against the current schedule
  while every other surface used the historical era. Switch it to the shared
  select and thread revisions (and pause eras) like the canonical path.

- Insight bucketing timezone. The per-metric status series bucketed every
  user in Europe/Berlin, so a near-midnight reading landed in the wrong day
  and the relative-day phrasing was off for non-Berlin self-hosters. Thread
  the resolved user timezone and key off `userDayKey`.

- Correlation significance. User-facing correlations surfaced from five
  paired points with strength read straight off the coefficient. Route them
  through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
  so small-N coincidences no longer render as confident findings.

- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
  raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
  label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
  daily low-percentile proxy) so the label matches the input.
2026-06-28 03:28:37 +02:00
Marc-André Bombeck ec3fcea84f feat: baseline-drift, breathing-screening and lab-change insight cards
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.

- Health status: surfaces what is drifting from the user's own normal by
  folding the personal-band deviations (coincident-deviation engine) with
  the dated, sustained level shifts (changepoint detector) at
  GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
  sleep-breathing-disturbance index plus device-flagged events at
  GET /api/insights/breathing-screening. Mirrors the device's own
  classification and states plainly that it is a screening signal, not a
  diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
  each shared analyte's signed delta and reference-band standing at
  GET /api/insights/labs-changes. Neutral framing only.

Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
2026-06-28 03:20:49 +02:00
Marc-André Bombeck 31c35baec2 feat: hydration logging and daily goal ring
Add a WATER_INTAKE measurement type and a per-user daily goal so the
Insights overview can track water intake against a goal ring. Logging
rides the existing measurements create path (type WATER_INTAKE, unit ml,
summed cumulatively); GET/PATCH /api/hydration read the day total and own
the goal. The goal-ring math (empty / partial / met / exceeded) lives in
a pure helper, free of Prisma and React, and is unit-tested in isolation.

Wire the new type through the measurement enum, value ranges, cumulative
HealthKit handling, categories, display transform, list metadata, record
direction, and the chartable-token allowlist. The HealthKit DietaryWater
import stays on the nutrition hold, so the type carries no import mapping
by design.

Document GET/PATCH /api/hydration in the OpenAPI contract.
2026-06-28 03:20:28 +02:00
Marc-André Bombeck 84683f4ce9 perf: bound heavy-tenant memory on feature, consolidation, backup and auth paths
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.

- extractFeatures: read newest-first with a 6000-row cap (mirrors the
  Coach snapshot) and reverse to ascending, so a 365-400 day window no
  longer materialises hundreds of thousands of PULSE / glucose rows per
  Coach turn and nightly briefing. Full-history extremes still come from
  readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
  the type-less range scans that extractFeatures and the consolidation
  passes drive; the existing (userId, type, measuredAt) cannot serve them
  as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
  (measuredAt, id) instead of one unbounded findMany, removing the giant
  single result-set allocation that drove the boot storm. Output is
  identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
  offsets so the heavy full-history loads no longer all drain in parallel
  onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
  for the weekly and off-host writers, bounding transient heap. Off-host
  still includes soft-deleted rows. Covered by a serialize/restore
  round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
  already records auth_method and user_id, so the polling client no longer
  drives a write + pool checkout on every request. Failure-path audit
  stays.
- Coach conversation detail: cap the message include to the newest 200 so
  a long thread no longer decrypts every message on each open; response
  shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
  and its count stop sweeping open-ended over the substring filters.
2026-06-28 03:07:15 +02:00
Marc-André Bombeck b217535b62 chore(v1.22): clear the QA punch-list across Coach, insights, settings
Code-review and design follow-ups before the v1.22 cut. No behaviour change
beyond the items below.

Coach / insights:
- Coach SSE route: a non-provider error raised inside the open producer (e.g. a
  DB failure persisting the reply) now records on the wide event and forwards to
  GlitchTip instead of masquerading as a provider outage; it still surfaces an
  error frame since the stream is open.
- Lab ↔ outcome correlations: hedge faint-tier pairs ("a faint hint, if
  anything … too small to lean on") so the narrated confidence never outruns the
  effect, matching the daily engine.
- Reminder sweep: mint the plan-review reminder and clear the plan's reviewDate
  in one transaction so a half-applied write can't duplicate the reminder.
- Fold the two raw provider-fallback runners onto one shared chain walker.

Design / accessibility:
- Lift sub-44px tap targets to the 44px mobile floor (suggested-action card,
  Coach bubble affordances), brighten the resting timestamp clock for non-text
  contrast, and route the lab chart height through the shared constant.
- Replace ad-hoc arbitrary type sizes with the scale token and static size-11
  icon buttons with the named icon-lg variant.
- Settings: add the missing About card-header descriptions, normalise the
  low-stock numeric widths, and relax the notification-status rows from nested
  cards to a divided list.

Housekeeping:
- Drop three unused exported types (and the now-orphaned source const), re-stamp
  v1.21.5 comments to v1.22, and register the live-judge eval harness as a knip
  entry so the script-only module resolves.
2026-06-27 16:29:58 +02:00
Marc-André Bombeck 7ad32f7ab1 feat(coach): adherence storyline, changepoint engine, signal-trust, n-of-1 substrate
Add the medication-class to target-vital map and the adherence-to-vital
storyline so the Coach can surface, association-only, when an adherence dip
lines up with a drift in the vital that medication targets. Never causal,
never a dose-change suggestion; dose questions defer to the prescriber.

Add an in-repo CUSUM / binary-segmentation changepoint detector over DAY
rollups with a high firing bar (persistence plus within-segment-spread
magnitude) so the Coach can name a dated, sustained level shift instead of a
vague "lately", and stays silent when no break clears the bar.

Surface which source recovery is read from when two sources materially
diverge, with the honest reason, instead of silently averaging.

Lay the n-of-1 experiment substrate: a nullable encrypted outcome column on
coach_plans (migration 0197), the review_due / reviewed lifecycle statuses
wired into the encrypted-column registry and rotation script, and a daily
worker that reads the experiment result back in the user's own before/after
numbers — association-only, honest-null, no cheerlead on an adverse trend,
and routing a worsened vital to the clinician. The user-visible read-back
stays behind COACH_EXPERIMENT_VERDICT until its review cases clear.

Extend the outbound dose screen to catch experiment-shaped dose changes
(halve / skip / stop a medication to test it). Add evaluation cases and unit
tests for each new surface.
2026-06-27 15:21:04 +02:00
Marc-André Bombeck b393527cac feat(insights): verdict-first briefing, richer score cards, grounded opener rotation
Replace the fixed three-beat assessment skeleton with a menu of moves the
model orders freely, and reframe the daily briefing from per-signal
enumeration to a verdict-first read that weaves the few salient signals into
one associative story. Score cards now name both the helped and the hurt
contributors, translate the band into a forward read for the day, and close
on one grounded nudge; the deterministic fallback gains the same band-meaning
sentence so provider-less and demo accounts read as well.

Add a hash-seeded opener-archetype rotation (deterministic, testable) so
consecutive cards and days no longer open the same way, a day-rotated sampling
seed for the period narrative in place of the fixed reference seed, and a
shared paragraph formatting contract. Add a score-number grounding gate and
extend the briefing grounding allow-set to the glucose, labs, preventive-care
and workout aggregate blocks so real figures can be cited without opening the
gate. Gate the daily score warm on an input fingerprint so a quiet day skips
the model call.
2026-06-27 14:39:54 +02:00
Marc-André Bombeck 2f22d66ef7 feat(insights): wire labs, preventive care, workouts and glucose into the briefing
The daily briefing's always-on feature payload carried only the core vitals,
mood, sleep, steps and medication compliance, leaving four captured domains
invisible to it. Add four blocks to the aggregated features:

- Labs: recent flagged biomarkers (out-of-range or trending vs the prior
  reading). Retired markers are excluded; qualitative results stay neutral with
  no fabricated verdict.
- Preventive care: due and overdue items off the server-authoritative next-due
  date, so an overdue screening can prompt an early nudge.
- Workouts: a provider-agnostic count, duration and distance aggregate over the
  trailing windows (the briefing previously saw activity only as step counts).
- Glucose: the present-and-trend block the other vitals carry, where glucose
  previously reached the briefing only as a single signal-of-the-day row.

The three extra reads are scoped to the wide briefing window; the tight
snapshot window that already carries its own context skips them. Every block is
omitted when its domain is empty rather than emitting zeros, and user-entered
labels are sanitised before they reach the prompt.
2026-06-27 13:34:43 +02:00
Marc-André Bombeck 138211b46a feat(insights): early-detection window and new correlation linkages
Add a rolling 14-21 day early-detection pass to the FDR correlation engine
so an emerging shift (illness pulling HRV and sleep down together) surfaces
before the 180-day retrospective scan accrues the n>=20 it needs. The recent
pass re-runs the same engine over the trailing window with a lower paired-day
floor, a tighter Benjamini-Hochberg target, and a no-faint effect bar, then
drops any pair the retrospective scan already established so a pattern is never
reported as both established and emerging.

Extend the curated behaviour x outcome matrix with three clinically meaningful
links: medication adherence -> next-day blood pressure (systolic and diastolic,
the latter previously never a candidate channel), sleep as a behaviour
(poor sleep -> next-day mood / HRV), and a separate point-vs-window labs ->
outcome pass (each draw against the contemporaneous outcome mean) since sparse
lab draws cannot meet a daily-lag floor. Every pass keeps FDR control, the
effect-size floor, and shrinkage; thin data degrades to absent, never a
fabricated link. All associations stay descriptive, never causal.
2026-06-27 13:34:30 +02:00
Marc-André Bombeck 7d3c528157 merge: W1 insights surface (i-icon, subtitle, steps, type-click) into v1.22 2026-06-27 13:05:27 +02:00
Marc-André Bombeck 8a7a2bc2e8 Link a measurement type to its insights sub-page
Clicking a type on the measurements list now drills into that metric's
insights sub-page when one exists, falling back to the existing
?type= filtered list when it does not. A reverse type-to-slug map backs
the navigation, preferring the dedicated single-metric page so an
ambiguous type resolves correctly (PULSE to pulse, not blood-pressure;
WEIGHT to weight, not bmi). No API change.
2026-06-27 13:03:09 +02:00
Marc-André Bombeck 7e48e2c26d fix(insights): stop the daily briefing going blank on slow generations
The comprehensive insight that feeds both the daily briefing and the
insights trend narrative is a single reasoning-heavy generation over the
full feature snapshot. On a large account it runs past the provider
client's fixed 60 s timeout and is aborted mid-stream, so the chain
reports an all-failed outcome, no cached block is written, and the
surface falls back to its static placeholder on every visit. The warm
paths bounded the same generation tighter still (45 s) and counted the
failure with no observable signal, so the blank state was permanent and
silent.

Make the per-request upstream timeout configurable
(CompletionParams.timeoutMs, default 60 s for every existing caller) and
raise it to 120 s for the comprehensive surface only, across all three
generation paths: the on-demand regenerate, the on-demand warm, and the
nightly pre-generation. Raise the warm-pass budget to 130 s so the
provider's own timeout governs a genuine completion rather than clipping
it early. Annotate the comprehensive failure in both the generator and
the nightly loop so a generation that keeps failing is queryable instead
of leaving the surface empty without a trace.
2026-06-27 12:37:25 +02:00
Marc-André Bombeck 2c33faa648 chore(insights): align the ambient-Coach file comments to the shipped version
The A1–A3 source files carried v1.22.0 docstrings from before the work was
re-cut as the v1.21.2 point release. Comment-only sweep so blame and the
in-file version tags read consistently with the shipped version.
2026-06-27 00:48:40 +02:00
Marc-André Bombeck f42ac47268 fix(insights): drop the undeclared server-only import from the coach-read builder
The builder is server-only by construction — only the metric-page route
imports it and it reads Prisma — and no other module in the tree depends on
the `server-only` package, so the import was both redundant and an undeclared
dependency. Removing it keeps the dead-code gate clean without weakening the
boundary.
2026-06-27 00:37:14 +02:00
Marc-André Bombeck 6c1a4b7f45 merge: wave/v1220-narrative into release/v1.21.2 2026-06-26 23:58:51 +02:00
Marc-André Bombeck fe6c2e281b feat(insights): ambient narrative presence — callbacks, tension verdict, streaks, MI moves
Add a deterministic narrative layer the Coach narrates rather than invents:

- Briefing callbacks + forward-looks: the daily briefing recalls the prior
  period and points ahead, anchored to a figure, one callback per surface.
- Tension Verdict: when the readiness composite's contributors disagree
  (good sleep but a rising resting pulse), surface a brief honest internal
  read on the health-score card. A real clinical red-flag is never reconciled
  away into a calm verdict.
- Streaks + return-to-baseline: a pure detector over a DAY-bucket series
  closes a worry once a metric returns inside the personal MAD band after a
  genuine out-of-band run; a gap day breaks a streak.
- Coach prompt: add develop-discrepancy, roll-with-resistance, and an
  anti-persuasion clause; ratchet PROMPT_VERSION.

The tension flag and streak event are computed server-side; the card lines
render resolved fields and the model narrates them.
2026-06-26 23:57:37 +02:00
Marc-André Bombeck cbf903841c feat(insights): add Coach-read strip above metric charts
Render a compact two-line "Coach read" above each metric sub-page chart:
an own-baseline line ("Your usual range is X–Y; today's Z sits within /
above / below") and one top lagged-association line from discovered
correlations, framed descriptively. It makes a metric read seen-in-context
rather than isolated.

The strip is server-authoritative: a new GET /api/insights/coach-read
resolves the band via the median ± k·MAD baseline engine (with its 7-day
history floor — below it the strip says "still learning your range" rather
than inventing a range) and the strongest on-metric driver from the
existing FDR-controlled, effect-size-floored correlation discovery (no
lowered floor; the line is omitted when nothing clears it). The client
component only renders the resolved DTO, so web and iOS read identical
numbers. DTO shapes and the pure selection logic live in a client-safe
sibling shared by the component and the unit test.

Wired through the HealthKit metric scaffold (every generic metric page) and
the bespoke weight, blood-pressure, and pulse pages.
2026-06-26 23:56:28 +02:00
Marc-André Bombeck 57682f8df1 fix(coach): build compliance + symptom channels in get_correlations
The correlation discovery matrix gained two non-MeasurementType channels
(medication compliance, symptom severity), but the Coach get_correlations
reader still filtered its measurement query by hand and never built the new
channels. The channel keys leaked into the Postgres enum cast and threw for any
user with medication or illness data.

Extract the route's two channel-series fetchers into a shared module
(correlation-channel-series.ts) so both consumers build the channels identically,
then wire the reader to derive its type filter from discoveryMeasurementTypes and
fold the fetched compliance + symptom series into the discovery input. The reader
now humanises the channel keys to "medication adherence" / "symptom severity".
2026-06-26 13:50:17 +02:00
Marc-André Bombeck aad72ea0e1 feat(insights): add compliance and symptom severity to the correlation matrix
Render daily medication-compliance rate and illness symptom-severity as
first-class series in the FDR discovery engine, so the adherence-dip →
symptom-flare link (and compliance → vital drift) can finally surface.

Compliance pools every active medication's dose-history ledger into a per-day
taken/(taken+missed) rate, re-keyed to the user's display timezone. Symptom
severity reads the illness day-log functional impact, zero-filling healthy days
only across real episode spans so a user with no episodes yields an empty
series rather than a constant. Both flow through the existing n >= 20, BH-FDR,
effect-size, and shrinkage gates unchanged; a sparse series degrades to absent.

Each channel forms its own metric family (no self-lag tautology) and keeps the
association-not-causation framing. Add discoveryMeasurementTypes() so every
caller drops the non-measurement channels before a Prisma type filter.
2026-06-26 13:42:06 +02:00
Marc-André Bombeck 7093e46e41 merge(WS3PERF): A2-M2 boundary align + Withings/med-intake N+1 fixes 2026-06-26 13:22:35 +02:00
Marc-André Bombeck 86d917c5e2 fix(insights): align cold-fallback slope window to the rollup day boundary
The rollup read path windows the 7/30/90-day regression on
startOfUtcDay(now - N days), while the live-SQL fallback filtered the
REGR_* aggregates on the wall-clock NOW() - INTERVAL bound. An account
crossing a coverage boundary (warm to cold) therefore saw the boundary
day's readings flip in or out of the slope window depending only on
whether the rollup tier was populated, returning a cache-dependent
slope for the same request.

Anchor both cold-fallback paths on date_trunc('day', NOW() AT TIME ZONE
'UTC') - INTERVAL 'N days', the SQL twin of startOfUtcDay, so warm and
cold return identical boundary-day membership. The avg7/avg30/median
windows stay wall-clock since the warm narrow query computes them the
same way.
2026-06-26 13:16:00 +02:00
Marc-André Bombeck 99a6163343 feat(insights): gate discovered correlations on effect size and confidence
Significance alone surfaced trivial drivers: a large-n pair with r=0.16
narrated as a confident "tends to go with" link while explaining ~2.5%
of variance. Add three gates before a pair is ranked as a driver:

- exclude same-metric-family lagged pairs (mood->mood, BP component
  self-lag) as serial-autocorrelation tautologies, widening the existing
  exact self-pair skip to the whole family.
- shrink each Pearson estimate toward null by n/(n+10) so a thin-data
  correlation cannot out-rank a deep one on an inflated point estimate.
- floor the shrunk effect at 0.2 (drop below) and bind 0.2-0.3 to a
  hedged "faint hint" phrasing tier, 0.3+ to confident phrasing scaled
  by sample depth. The reported r and p stay the honest raw statistics.

Rank by shrunk effect magnitude, q as tie-break.
2026-06-26 13:10:29 +02:00
Marc-André Bombeck b75cef9190 fix(insights): key coincident-deviation 'today' in the user's timezone
The coincident-deviation read grouped the latest day with a UTC date
slice while the sibling readiness read already used the tz-aware day key.
For a user east or west of UTC, a late-evening or early-morning reading
landed on the wrong calendar day, so a fired flag could compare a vital
from the wrong day against its band and narrate '2 or more vitals out of
band today' on the wrong day.

Thread the user's timezone into the latest-day read and mint the day key
with userDayKey, matching readiness. The Coach derived-snapshot and the
correlations reader pass the real account timezone through. Adds a
regression covering a UTC+2 user whose late-night reading rolls to the
next local day.
2026-06-26 13:01:39 +02:00
Marc-André Bombeck f2a9716671 fix(safety): unify clinical floors into one source of truth
The hypertensive-crisis floors were defined in three modules with a
divergent diastolic value: the notification engine and the Coach acute
clause used 180/120, but the dashboard hero used 180/110. A reading like
170/112 lit the red critical-BP banner on the hero yet never tripped the
notification alarm or matched the Coach's stated acute number — one
surface said crisis, the other two stayed calm on the same row.

Promote the absolute floors into a dependency-free clinical-floors leaf
and have the hero, the safety-floor engine, the status registry's fever
band, and the illness escalation all import from it. The crisis diastolic
floor is the guideline-correct 120 (ACC/AHA hypertensive urgency); the
wider 110 hero net is dropped so the surfaces tell one story. A coverage
test pins that every consumer resolves the same constants.
2026-06-26 12:58:50 +02:00
Marc-André Bombeck 490c9d240a merge(FALLBACK): v1.21.0
# Conflicts:
#	src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts
#	src/lib/insights/narrative/period-narrative-generate.ts
2026-06-26 12:40:03 +02:00
Marc-André Bombeck 37dfce2104 merge(COACHUI): v1.21.0 2026-06-26 12:39:17 +02:00
Marc-André Bombeck 44796d1c0e feat(coach): carry metric scope into the Coach launch
Make the launch context's metric scope live instead of discarding it.
A conversation opened from a metric surface now narrows its snapshot to
that metric with a data-aware seed question, and the global FAB inherits
the page's ambient scope so drilling into a metric and tapping it no
longer opens a blank chat. The scope threads through the drawer into the
first turn of a fresh conversation.
2026-06-26 12:37:54 +02:00
Marc-André Bombeck d22372e800 fix(insights): warm the period retrospective to the shared house voice
The retrospective narrative was the one model surface that omitted the
shared tone contract, so it read colder than the daily briefing beside it.
Compose the tone fragment into both prompts and warm the hand-written tone
line to name a genuine win when the period earns it, while keeping the
descriptive-never-causal and no-alarm guards intact. The cross-surface
coverage test now asserts the contract reaches this surface too.
2026-06-26 12:29:43 +02:00
Marc-André Bombeck 5b406d58cd fix(insights): close the deterministic score assessment with one grounded step
The per-score deterministic text named the score, its standing, and the
weakest contributor, then stopped at the diagnosis. When the band is not
green and the weakest driver is behaviourally addressable (sleep, mood,
consistency, timing), append a single grounded pointer drawn from that same
contributor. Physiology-only drivers add nothing, so the text affirms and
watches rather than manufacturing a step.
2026-06-26 12:29:35 +02:00
Marc-André Bombeck b470a14187 fix(insights): ground deterministic status fallbacks in the user's own numbers
The no-key and timeout fallback text carried zero reference to the user's
own readings and, on a provider timeout, rendered as hasProvider:true —
indistinguishable from a fresh assessment. Compose each fallback from the
per-metric signal the card already builds: name the current value, place it
against the user's own baseline, and close with one plain-language pointer,
in the same warm voice as the model surfaces. Degrade to the prior generic
tip only when a metric has no usable history. The timeout envelope now
reports hasProvider:false so the UI surfaces it as the computed summary it
is rather than mislabelling it as provider prose.
2026-06-26 12:29:29 +02:00
Marc-André Bombeck 34ed1a0c93 feat(coach): add acute red-flag escalation to the shared safety contract
The shared contracts cover chronic deferral (dose, diagnosis, drug-level
to a clinician) but had no acute branch. Add a closed-list crisis clause —
chest pain, syncope, sudden severe symptoms, hypertensive-crisis readings,
suicidal ideation — that points to prompt or emergency care without
diagnosing, and keep every other turn calm and non-alarmist. It rides the
single shared-contract source so it lands on the Coach, the per-metric
status cards, the comprehensive briefing, and the period narrative; the
coverage test asserts it on all four surfaces.
2026-06-26 12:22:32 +02:00
Marc-André Bombeck 5e701cebc0 test(insights): bracket the snapshot window floor with the call's own clock
The floor reads Date.now() inside buildComparisonSnapshotForUser, so it
lands in [before - 400d, after - 400d] relative to the test's brackets.
The prior upper bound used before - 400d, which fails when a millisecond
ticks between the bracket read and the snapshot's clock read.
2026-06-26 09:44:35 +02:00
Marc-André Bombeck 23cc48475d perf(insights): bound the comparison snapshot to a 400-day window
buildComparisonSnapshotForUser read a user's entire measurement history
per type with no date floor, then fed each series to summarize(). The
snapshot only consumes avg30 / avg30LastMonth / avg30LastYear, and the
widest of those reaches into the [365, 395)-day window (ageMs < 395 days
in meanOfWindow), so nothing older than 395 days can change a result.

Add a 400-day measuredAt.gte lower bound to every per-type read in both
copies of the builder — the page-blocking POST /api/insights/generate
handler and the shared comprehensive-generate path the nightly
pregenerate cron drives. The fan-out runs all seven snapshot types via
Promise.all, so on multi-year accounts the unbounded scan was tens of
thousands of rows per type. 400 days keeps a 5-day floor over the
strict-less-than 395-day reach.
2026-06-26 09:21:29 +02:00
Marc-André Bombeck d3b341f8a7 Merge AI client + coach cache fixes into release/v1.20.0
Send the OpenAI JSON response format only when JSON is requested and no
tools are in play, so the Coach prose path (and the tool loop's final
round) is no longer corrupted into a JSON blob. Resolve the Anthropic
finish reason to tool_calls whenever tool_use blocks are present. Memoize
the per-request feature extraction and prefs read and project the feature
query, so the coach tool fan-out shares one read instead of six.
2026-06-22 08:19:12 +02:00