Commit Graph
1248 Commits
Author SHA1 Message Date
Marc-André Bombeck d4686ea2a6 feat(capabilities): advertise the FHIR REST face and share-link surface
Add a single canonical FHIR_REST_RESOURCE_TYPES catalogue (plus the
$everything operation and honoured search params) to fhir/rest.ts; the
metadata CapabilityStatement and the share-link resource-type enum now
derive from it so the advertised set can never drift from what is routed.

Extend GET /api/meta/capabilities with the FHIR REST descriptor
(restBaseUrl, readScope, resourceTypes, operations, searchParams) and a
clinician-share descriptor (supported, maxDays, resourceTypes, scopeable
sections) — every list sourced from its canonical registry, guarded by
the existing drift assertions. Grow the OpenAPI CapabilitiesResponse to
match.

Fill the clinician test-matrix gap: assert the clinician view never
requests a KVNR / identifier opt-in from the aggregator (default-OFF by
construction) and resolves a rolling window without reaching data older
than the frozen start.
2026-06-04 02:34:33 +02:00
Marc-André Bombeck 93694cacd0 fix(idempotency): scrub hls_ share-link tokens from cached bodies
The body-content secret guard matched hlk_ / hlr_ / sk- but not the
new hls_ clinician share-link prefix, so a response echoing a freshly
minted share token could be persisted to the idempotency cache. Add
hls_ to SECRET_PATTERN alongside the existing token prefixes.
2026-06-04 02:34:33 +02:00
Marc-André Bombeck 01f3374ab0 fix(insights): make TRAJECTORY_TYPES a distinct array, not an alias 2026-06-04 01:56:45 +02:00
Marc-André Bombeck 8e00e1615d fix(settings): add the sharing section icon to the placeholder map 2026-06-04 01:43:55 +02:00
Marc-André Bombeck 4afe1dc6d8 chore(api): regenerate OpenAPI for the trajectory derived metric 2026-06-04 01:40:48 +02:00
Marc-André Bombeck 0be484c8a3 feat(insights): TRAJECTORY forecasting derived metric with a widening band
Add a short-horizon OLS projection over the DAY-native per-day mean
series, extending the existing trendSlope. The fit, residual standard
error and prediction interval are computed at DAY granularity only — the
rollup invariant forbids composing a slope/sd from WEEK/MONTH buckets.

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

Register TRAJECTORY in the derived registry and dispatch (additive,
iOS-forward-compatible; the capabilities endpoint and drift-guard pick it
up automatically). Surface it on the weight detail page as a forecast
band beneath the chart, labelled a projection with the cited method and
a conditional, range-only headline — never a prediction or a dated
event. i18n across all six locales.
2026-06-04 01:40:27 +02:00
Marc-André Bombeck dd1071158f feat(whoop): OAuth connect/callback + HMAC-signed webhook + CSP gating
Connect mints the state nonce into the WhoopOAuthState ledger and redirects
to the WHOOP authorize page; callback verifies the nonce (atomic
delete-first consume, four distinguished reason tags), exchanges the code,
fetches the WHOOP profile for the user id, persists the encrypted
WhoopConnection, and enqueues the self-converging history backfill.

The webhook handler rate-limits per source first, verifies the
WHOOP_WEBHOOK_SECRET path segment, then verifies the HMAC-SHA256 body
signature over the raw bytes (timestamp + body, timingSafeEqual, stale
timestamp reject) before any work. Valid *.updated enqueues the matching
per-resource sync; *.deleted soft-deletes (workouts hard-delete, no
soft-delete column). Unknown users return 200 to stop retries.

CSP gates api.prod.whoop.com to the WHOOP settings surface + /api/whoop/*;
the webhook path joins the public-path allowlist and the path-secret redact
registry. Env wiring adds WHOOP_WEBHOOK_SECRET + WHOOP_REDIRECT_URI to the
compose whitelist and .env.production.example.
2026-06-04 01:40:27 +02:00
Marc-André BombeckandClaude Opus 4.8 be94e43480 feat(share-links): owner share-link management in Settings
Add a Sharing section under Settings (between API and Export) where the owner
creates a read-only clinician share link, sees their active and inactive
links, and revokes one. Create wires to the C4 lifecycle API: it sends the
label, a rolling history window, an expiry (capped at 90 days, validated
before the round trip), an optional scoped FHIR-API toggle, and the FHIR
resource types. The raw token is revealed exactly once on create as a
copy-able `/c/<token>` URL; the list only ever carries the server's safe
projection (no token, since only its hash is stored). Revoke confirms through
an alert dialog and invalidates the list.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:39:58 +02:00
Marc-André BombeckandClaude Opus 4.8 f4629a17d6 feat(share-links): scrub the share token from logs and harden the /c edge
Register the `/c/` prefix in `PATH_SECRET_PATHS` so the raw `hls_` clinician
share token, which rides as the trailing path segment, is rewritten to
[REDACTED] in `http.path` / `http.route` before any Wide Event or GlitchTip
report leaves the process — the same posture as the Withings webhook path
secret. The resolve path never logs, throws, or annotates the raw token.

Defend the share surface at the edge irrespective of what the RSC emits: the
proxy now sets `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`,
and `Referrer-Policy: no-referrer` on `/c/` responses, so no downstream proxy
caches a scoped record, no crawler indexes the token, and the token-bearing
URL is not leaked in an outbound `Referer`.

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

- New insight_narratives table (migration 0113): one durable row per
  (user, period, locale); generated prose AES-256-GCM at rest following
  the coach-message precedent; labels-only provenance stays plaintext.
- generatePeriodNarrative feeds the context into the provider chain with
  a tight, descriptive-never-causal prompt; an insufficient context
  yields no narrative, a missing provider no-ops, neither fabricates.
- Nightly warm cron on week (Mon) / month (1st) boundaries, budget-gated
  per user; the read-only GET enqueues a single-user warm on a cold read.
- Read route is stale-while-revalidate and never blocks on the provider;
  the card renders plain text (no markdown), a single provenance
  disclosure, and a fixed footprint (no CLS). i18n in all six locales.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck 337f024a87 feat(whoop): pg-boss sync workers + self-converging backfill
Build the WHOOP sync layer on the W2 client, mirroring the Withings
queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since
the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's
after-the-fact re-scoring, maps via the W2 mappers, and upserts
Measurements with source=WHOOP keyed on (userId,type,source,externalId)
so a re-post overwrites in place. Workouts upsert into Workout with
per-workout strain + HR zones in metadata and kJ->kcal energy.

Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior
access and refresh token on every refresh). A self-converging boot
backfill enqueues one full-history sync per un-backfilled connection and
stamps backfillCompletedAt; the discovery query drops completed accounts,
idempotent across reboots.

Every new queue is registered in the worker's allQueues + cron schedule
(recovery/sleep/workout poll catch-all, cycle poll-only, backfill,
oauth-state sweep). IntegrationStatus gains the whoop key + label.

Tests: rotating-refresh persistence, idempotent measurement upsert,
incremental-window overlap, backfill self-convergence, and a
queue-registration inventory guard.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck 6aa3c75d27 feat(share-links): public clinician view at /c/[token]
A standalone read-only RSC that resolves the path token through the share-token
resolver and renders an owner-scoped clinical summary: provenance header,
clinical vitals and labs, medications with adherence, and a fenced muted card
for the descriptive wellness scores carrying the not-a-clinical-assessment,
not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI,
no session, no markdown — every value renders as escaped text. force-dynamic and
noindex; the page returns a flat 404 for any null resolve.

The route joins the proxy public-path allowlist and the AuthShell standalone
list so it reaches the page bare without a sign-in redirect. New clinicianView
strings land in all six locales.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck 9830ae8027 feat(share-links): public share-token resolver
resolveShareToken hashes a raw hls_ token, looks up the ClinicianShareLink,
enforces not-revoked and not-expired, and returns a ShareContext carrying only
the owner userId plus the frozen scope. It is not an auth primitive: it never
reads a session or sets a cookie, so a share token can authenticate only the
share surface. Unknown / revoked / expired / malformed tokens resolve to null
for a blunt 404. Access counters bump fire-and-forget on a hit.

A token presented on a normal authed route is rejected by construction —
requireAuth reads only the cookie session and Authorization: Bearer (against
ApiToken), never the share header, and an hls_ token has no ApiToken row. The
invariant test locks that boundary.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck ceabf8a54d chore(api): regenerate OpenAPI for the FHIR and share-link routes 2026-06-04 01:05:15 +02:00
Marc-André BombeckandClaude Opus 4.8 57b8fd8ad9 feat(fhir): read-only FHIR R4 REST search routes
GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,
MedicationAdministration} and $everything, returning type:"searchset"
Bundles (total + self/next links, offset paging via _count clamped to 200)
as application/fhir+json, with an OperationOutcome on error. The per-resource
emitters are reused from src/lib/fhir/resources.ts so the document export and
the REST face share one coding source and can never drift. Auth is a narrow
fhir:read scope via requireAuth (cookie sessions also pass); userId is
narrowed from auth. Read-only — no write handlers. Rate-limited per user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:05:06 +02:00
Marc-André BombeckandClaude Opus 4.8 6c3b8be099 feat(share-links): owner clinician share-link lifecycle API
Authenticated owner routes to create, list, and revoke a ClinicianShareLink.
Create mints an hls_ token (192-bit), stores only its HMAC hash, and returns
the raw token exactly once; the scope columns (window, sections, FHIR resource
types, API toggle) are frozen write-once. expiresAt is required and capped at
SHARE_LINK_MAX_DAYS (90). Revoke sets revokedAt and seals a cross-user id as
404. userId is narrowed from auth; rate-limited; strict Zod rejects unknown
keys. This is the owner lifecycle only — the public resolver/view is separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:05:06 +02:00
Marc-André Bombeck 2258cbb489 feat(whoop): WHOOP v2 API client, response classifier, credential resolution
Add the WHOOP integration data layer on copy-of-Withings rails:

- client.ts: hand-rolled fetch over safeFetch (no SDK) — OAuth authorize
  URL / code exchange / rotating-refresh, the four v2 collection fetchers
  (recovery, sleep, cycle, workout) with next_token pagination, single
  body-measurement + profile reads, and the field->Measurement mappers
  (RECOVERY_SCORE/HRV_RMSSD/RESTING_HEART_RATE/OXYGEN_SATURATION/
  SKIN_TEMPERATURE, per-stage SLEEP_DURATION ms->min, SLEEP_NEED/SLEEP_*,
  DAY_STRAIN/ENERGY_EXPENDITURE_KJ, source=WHOOP). Day strain maps to
  DAY_STRAIN and RMSSD to HRV_RMSSD so the device-native values never
  share a bucket with the COMPUTED STRAIN_SCORE / SDNN proxies.
- response-classifier.ts: HTTP-status-driven success/transient/
  reauth_required/persistent verdicts + WhoopApiError, mirroring the
  Withings reason-tag classifier.
- credentials.ts: per-user BYO-key resolution, decrypting the client
  id/secret from the User columns.
- oauth-state.ts: opaque-nonce mint helper + cookie/TTL constants.
- mapping.md: field->Measurement reference, kept in sync with the mappers.

Unit tests cover the classifier, pagination, every fetcher, the mappers
(ms->min, kJ->kcal factor, stage mapping, unscored-record handling), and
credential resolution against mocked WHOOP JSON. No routes / OAuth flow /
sync workers / migration yet.
2026-06-04 01:05:06 +02:00
Marc-André Bombeck 999f2b6b08 feat(insights): period-narrative context assembler
Assemble the structured, provenance-carrying data the period narrative
narrates, without any LLM call. buildPeriodNarrativeContext reads one
bounded window covering the current and prior period of equal length and
delegates to a pure core that produces metric deltas, derived-band
transitions, FDR-surviving correlation drivers, and coincident-deviation
flags as label+number+source beats.

Reuse the rollup-backed daily series, the median ± k·MAD personal band,
and the existing Benjamini-Hochberg correlation engine, so the
descriptive-never-causal contract carries through verbatim — a driver
never enters without clearing the FDR target, and each keeps its
conservative interpretation string unchanged. Gate on data availability:
too little history returns an insufficient shape, never a fabricated
story. Pure core is unit-tested over seeded fixtures.
2026-06-04 01:05:06 +02:00
Marc-André Bombeck 86dea3e176 chore(api): regenerate OpenAPI for the WHOOP measurement types 2026-06-04 00:50:26 +02:00
Marc-André Bombeck fbd45f3044 feat(ai): durable provider-health ledger and credential-expired surfacing
Promote the volatile per-worker last-working cache to a Postgres
provider-health ledger so the fallback chain shares one health signal
across every instance, modelled on the atomic-upsert rate limiter.

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

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

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

Carry the new types through the completeness walls (units, value ranges,
chart tokens, PR direction, categories, list-meta colours/icons/labels,
HealthKit-mapping exclusions) and feed the cross-source picker: recovery
ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the
overlapping vital ladders and the workout ladder, plus skinTemperature
and respiratoryRate metric keys.
2026-06-04 00:49:31 +02:00
Marc-André Bombeck 747a62516a refactor(fhir): extract per-resource emitters into resources module
Lift the Observation / MedicationStatement / MedicationAdministration /
Patient / Coverage builders — with their LOINC/ATC/SNOMED/UCUM codings and
the survey-category wellness split — out of build-bundle.ts into a shared
src/lib/fhir/resources.ts. buildFhirDocumentBundle now composes the emitters
so the upcoming FHIR REST search routes reuse the exact same coding logic
instead of a second copy of the mapping.

Pure refactor: the document-bundle output stays byte-identical (the existing
FHIR + export tests pass unchanged). The shared coding constants and option
types re-export from build-bundle so existing import paths keep working.
2026-06-04 00:49:31 +02:00
Marc-André Bombeck f20ad79043 feat(db): add ClinicianShareLink model (migration 0110)
Owner-minted, scoped, time-limited, revocable read view of the health
record for a clinician holding the raw hls_ token. The token is stored
only as an HMAC-SHA256 hash; the scope (range start, sections, resource
types) is frozen at creation and never widened. expiresAt is mandatory
(capped at SHARE_LINK_MAX_DAYS, default 90); only revokedAt, lastAccessAt
and accessCount mutate after mint. Cascades on user delete.

Schema + migration only; no behaviour yet.
2026-06-04 00:49:31 +02:00
Marc-André Bombeck ea3dab33f7 Merge pull request #250 from MBombeck/release/v1.10.4
v1.10.4 — strain honesty label + six-minute-walk reference caveat
v1.10.4
2026-06-03 21:42:42 +02:00
Marc-André Bombeck b56f712d4e chore(release): v1.10.4 — strain honesty label + six-minute-walk reference caveat 2026-06-03 21:33:30 +02:00
Marc-André Bombeck 73041b10cd docs(insights): note the Enright 40-80 cohort in the six-minute-walk caveat
The Enright & Sherrill 1998 predicted-distance equation was established on a
40-80 reference cohort; the metric accepts adult ages 18-120, so the
prediction extrapolates outside that range. State it in the user-facing
caveat (all locales) and the norms doc so a younger or older user reads
their percent-of-predicted with that honesty.
2026-06-03 21:33:06 +02:00
Marc-André Bombeck 097af3b84a feat(insights): surface the active STRAIN anchor per score
Carry the per-day STRAIN anchor (personal vs population) from the
strain_trimp_cache row through to the STRAIN WellnessScoreValue read, and
pick the framing line by it on the score-anatomy surface: relative to your
own typical effort once anchored personally, a general reference during
cold start. The method copy was describing both regimes generically and
could not tell the user which one produced this score. Additive read field
— iOS non-breaking.
2026-06-03 21:33:06 +02:00
Marc-André Bombeck a116848276 Merge pull request #249 from MBombeck/release/v1.10.3
v1.10.3 — personalised strain, a daily signal card, deeper derived metrics
v1.10.3
2026-06-03 20:18:13 +02:00
Marc-André Bombeck 730c40dfef chore(release): v1.10.3 — personalised strain, a daily signal card, deeper derived metrics 2026-06-03 20:10:17 +02:00
Marc-André Bombeck 1e469d8185 fix(insights): eliminate layout shift on the Today's-signal card
The dynamic loader skeleton reserved 120px while the resolved card's
insufficient (~168px) and fired (~192px) states ran taller, shifting the hero
and everything below it down 48–72px on resolve. Pin a min-h-48 on the card
shell sized to the tallest realistic state, and match both the chunk-loader
skeleton and the in-component CardSkeleton to that footprint (including the
44px provenance header row) so there is zero shift across loading → any state.
Announce the fired state to screen readers via a polite role=status (not the
assertive role=alert), and mark the decorative loader skeleton aria-hidden.
2026-06-03 20:07:46 +02:00
Marc-André Bombeck 17ad93ca5d feat(insights): surface the four new derived metrics as dashboard tiles
The estimated 6-minute-walk band + the three any-user HealthKit baseline
bands (wrist temperature, stair ascent/descent speed) were registered,
provenance-mapped and engine-tested but rendered on no surface. Add a
"Mobility & body" section to the vitals dashboard: the 6MWT passthrough
re-frame (distance + trend always, percent-of-predicted framing only with
the Enright demographics) and a shared baseline-band tile for the three bands.
Every tile is data-availability-gated like the existing ones — absent with no
readings, CoverageMeter while provisional, value + framing + provenance when
ready — and the section heading hides when none resolve. All four tokens ride
the single batched derived request. Derive the sparkline gradient id via
useId() to avoid duplicate-label collisions.
2026-06-03 20:07:46 +02:00
Marc-André Bombeck a31add266f fix(i18n): port STRAIN personal-anchor honesty copy to all locales
The personal-relative-anchor explanation (own recent training-day load vs a
general reference while building history) was EN-only; de/es/fr/it/pl carried
the stale v1.10.0 method+description. Port both strings to all five so the
two-mode honesty distinction is visible regardless of locale. Also correct the
stale strain-score header comment: STRAIN is not excluded from the doctor PDF —
it rides a segregated, disclaimed Wellness-summary section.
2026-06-03 20:07:46 +02:00
Marc-André Bombeck 7ff91fcf55 refactor(insights): drop the unused baseline-capable-type allowlist
The additive HealthKit baselines dispatch via an explicit per-metric type
map, so the allowlist helper and its constant had no consumer.
2026-06-03 20:03:14 +02:00
Marc-André Bombeck 46be440ee8 chore(api): regenerate OpenAPI for the derived series field 2026-06-03 19:48:57 +02:00
Marc-André Bombeck b7cb92d951 fix(insights): compute the sleep midpoint in the user's timezone
The sleep midpoint was minutes-of-day in UTC, so a non-UTC sleeper's
consistency and timing sub-scores drifted with the offset (a 03:00-local
midpoint read as a different clock time than a UTC user's). Resolve the user's
stored zone in computeSleepScore and express the midpoint against it via the
existing tz helpers; reconstructNights takes an explicit tz (UTC default) so
the pure path stays test-pinnable.
2026-06-03 19:48:48 +02:00
Marc-André Bombeck 8806ce4e08 refactor(insights): name the latest-day-mean row cap on the derived readers
The readiness + coincident-deviation latest-day-mean reads cap the row scan
on a dense intra-day day. Lift the bare 50 into a named MAX_LATEST_DAY_ROWS
constant and document the dense-intraday-retention reasoning at both call
sites so the bound is intentional rather than a loose literal.
2026-06-03 19:48:48 +02:00
Marc-André Bombeck 3ca444d23f fix(insights): interpolate age/sex norm bands across bracket boundaries
The age/sex reference tables are coarse decade / paediatric brackets, so a
fractional age read a hard step at a bracket edge (39.9 -> 30s band, 40.0 ->
40s band). Anchor each band at its bracket centre and linearly interpolate a
fractional age between the two adjacent centres; below the youngest centre /
above the oldest centre hold the nearest cited band flat (clamp, never
extrapolate) so the band stays within the span of the two cited brackets and
the standard's provenance stays accurate.
2026-06-03 19:48:48 +02:00
Marc-André Bombeck 99e089ff4c feat(insights): populate the trailing sparkline series on derived metrics
The Derived<T> tile (SparklineDeltaTile) renders a trailing series, but the
engines never filled it, so the inline sparkline was always empty. Each engine
now carries a capped trailing series sourced from the rows it already reads:

- baseline-band metrics (incl. wrist-temperature / stair ascent+descent) ship
  the per-day mean series the band is built from;
- HRV balance reuses the day-mean read it already does for the recent average;
- the wellness scores (recovery / stress / strain) ship their window scores;
- the six-minute-walk band ships its reading series;
- BMI reads a bounded recent-weight set and derives a BMI series at the fixed
  profile height.

All series cap at the last 30 points. The vitals dashboard wires the series
into the baseline, HRV and BMI tiles; the tile already renders >= 2 points and
collapses the sparkline row otherwise.
2026-06-03 19:48:48 +02:00
Marc-André Bombeck e2c7d08bad chore(api): regenerate OpenAPI for the additive derived-metric ids 2026-06-03 19:31:29 +02:00
Marc-André Bombeck efa132b1aa fix(insights): make the score candidate finders deterministic
The recovery / stress / strain nightly finders used distinct + take with no
order, so when more than the cap of users qualified the selected set was
arbitrary across runs. Switch to a grouped query ordered by each user's
newest input first with userId as the stable tiebreak, so the cap takes the
most-recently-active users reproducibly. The strain finder interleaves its
workout and active-energy lists so the merged cap favours recency across both
sources.
2026-06-03 19:31:16 +02:00
Marc-André Bombeck f1ca0e28eb feat(insights): anchor STRAIN to the user's own training load
Map day-total Banister TRIMP to 0-100 against a personal reference -- the
EWMA-smoothed 75th percentile of the user's own training-day TRIMP over a
42-day chronic window -- instead of the fixed population anchor that pinned a
deconditioned or chronic-condition user near 0 regardless of their effort.
Below a 7-training-day cold-start floor the engine falls back to the
population anchor, recorded as anchor: population with lower confidence; at
or above it the personal anchor takes over. Always writes a score row -- no
new null gate. The anchor is derived from the TRIMP input, never the 0-100
output, so there is no circular feedback; rest-day zeros stay out of the
training-day distribution.

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

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

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

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

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

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

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

Remove the now-redundant tile, its batch token, and the hasRenderableVital
branch from the vitals grid so the signal is not shown twice.
2026-06-03 19:29:36 +02:00
Marc-André Bombeck fe099acd67 Merge pull request #248 from MBombeck/release/v1.10.2
v1.10.2 — honest AI connection test, consistent insights, retention re-enabled
v1.10.2
2026-06-03 09:27:33 +02:00
Marc-André Bombeck 44028b2a6e chore(release): v1.10.2 — honest AI connection test, consistent insights, retention re-enabled 2026-06-03 09:18:55 +02:00
Marc-André Bombeck 9bdf76ea11 fix(settings): localise AI connection-test failure reasons
Return a stable, secret-free reasonCode from /api/ai/test alongside the
English reason, map it to a localised string in the AI settings panel,
and fall back to the plain reason for any unmapped code. A German
operator no longer sees an English failure sentence in an otherwise
localised panel.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck d8f623aea3 fix(measurements): close residual P2002 in the dense intra-day fold
Resolve the canonical daily row deterministically: prefer the row already
carrying the target stats: externalId before falling back to the row on the
canonical local-noon instant, with a stable orderBy tiebreaker. Adopting a
sibling and stamping the target externalId onto it could otherwise collide on
the (userId, type, source, externalId) index with the real canonical row.

Re-anchor the adopted row to local-noon only when that slot is free, so an
externalId row at a tz/DST-shifted instant cannot collide on the measured_at
composite during adopt.

Guard the create against a concurrent insert on the same canonical composite:
catch P2002 outside the transaction (a caught error inside leaves the tx in
the aborted state) and retry the fold once, which then resolves and adopts the
winning row instead of creating a duplicate.

Add a real-Postgres case covering a row already on the canonical instant under
the target stats: externalId plus a fresh out-of-window batch: the fold adopts
in place, no P2002, one canonical row with the correctly folded mean.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck 8456d3bbc3 chore: correct body-cap comments, dockerignore licence posture, stale log copy
- workouts/batch + safeJson: the body cap is a DoS ceiling, not a bound
  the worst-case payload always fits under; an extreme GPS-heavy backlog
  can exceed it and falls back to smaller batches client-side. Note that
  request.text() still buffers the raw body, so the cap bounds the parse
  cost, not the initial read.
- .dockerignore: keep LICENSE / LICENSE.md in the image — AGPL-3.0
  conveys with the software.
- reminder-worker: the dense walk flag is an operator kill-switch now
  that the fold is reworked, not a pending-rework gate.
2026-06-03 09:10:46 +02:00
Marc-André Bombeck 33358c7960 test(medications): pin the GLP-1 card clock off the schedule window
Two assertions depended on the live wall-clock: the card suppresses the
upcoming-injection line while now sits inside a schedule window, so a CI
run during the 08:00-20:00 window rendered the pill instead of the
Next/Recommended-next line. Pin to 07:00 Berlin on a non-scheduled day.
2026-06-03 08:59:31 +02:00
Marc-André Bombeck 0ec5f7552f chore(api): regenerate OpenAPI for the capabilities endpoint 2026-06-03 08:59:31 +02:00