Commit Graph
20 Commits
Author SHA1 Message Date
Marc-André Bombeck 2143445da2 feat(ai): show stored model text inside a shared record as the owner would
Serving stored text and starting model work are separate questions. A
delegate may not start model work on somebody else's record, but reads
the owner's stored briefing, period narrative, status notes, workout
notes and Coach unread signal exactly when the owner would: those
readers now resolve the record's own capability state, not the
viewer's. Generation, warms and enqueues still ask as the viewer, so a
delegate's read of a stale narrative queues nothing. A hidden
model-written narrative is no longer overwritten by a deterministic
warm while AI is unavailable.

The sharing front door reads the digest's briefing lead again, and an
integration case pins both halves.

Refs #975
2026-09-24 10:17:00 +02:00
Marc-André Bombeck 3309ac177c feat(ai): serve Insights and Coach data whatever AI says
Data never depends on AI. The Insights data reads no longer ask an AI
switch or the `insights` module (now only the AI analysis opt-out): ECG
list, detail and upload, rhythm events, derived scores, rule cards,
correlations and patterns, the baseline strip, health status, intraday
pulse, and the Today digest with its dismiss. Breathing screening moves
to the sleep module and lab changes to the labs module. Stored Coach
facts, conversations, message ratings and the seen stamp stay readable
and erasable while the Coach is unavailable. Cards now say
`provider: "rules"`.

Reads that mix data with model text answer 200, null the model text
and publish an `ai` state from the capability resolver, without warming
anything: comprehensive (`ai.briefing`, `ai.statusText`, `hasProvider`
from the presence probe), the derived assessment override, the period
narrative, nudge status, the seeded opener, about-me questions, the
workout paragraph, the pregenerate warm (`queued: false`), the Today
digest (lead, reaction line, check-in) and the dashboard briefing,
which is now applied per read after the snapshot cache. Hiding the
Coach no longer hides the briefing.

`insights/generate` GET and POST ask the `briefing` capability and
`insights/chat` POST the `coach` capability, with the typed refusal
envelope; a missing provider keeps the chat's `coach.provider.none`
frame and adds `reason`. The /coach page, the MCP ECG and intraday
tools and `aiAvailable` on the provider route follow the same answer.

An inventory of AI and data routes replaces the Coach gate inventory:
every route that asks a capability names it, and no data route asks.

Refs #975
2026-09-24 09:19:36 +02:00
Marc-André Bombeck c7f7a2925f fix(insights): keep the overview reads open with the assistant switched off
With the assistant switched off on the server, the Insights overview did
not load. Its main read, /api/insights/comprehensive, was gated on the
Coach surface, and the derived tiles, the ECG list, strip and live ingest,
and the rhythm events were gated on the status-card surface. The master
flag forces every surface off, so all of them answered 403 and the page
replaced the whole overview with an error.

None of these handlers calls a provider or returns assistant prose. They
now gate on the insights module only, like health-status, labs-changes and
patterns already do. comprehensive had no module gate of its own, only the
Coach gate, so it gains the insights module gate the other five already
call. Routes that serve generated text keep their gate.

The route-gate inventory moves the six files onto the not-Coach-owned
list, four OpenAPI descriptions drop the gate they no longer have, and a
comprehensive test pins a 200 with the master flag off.

Refs #975
2026-09-16 12:55:43 +02:00
Marc-André Bombeck d7d8e437ee feat(insights): let a caller choose the derived-metric window
`GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and
threads it to the dispatcher. Omitting it changes nothing: every engine
keeps its own default, so a caller that asks for nothing reads exactly
what it read before.

Ninety is where the tier this route promises runs out. The rollup router
opens its WEEK floor at 91 days, and the baseline engines cannot compose
a spread from WEEK buckets, so a 91-day request falls through to a raw
`measurement.findMany` with no row cap — for a densely sampled type that
is a full history scan, and the analytics-read budget allows 120 of them
a minute per account. The trailing `series` is capped at 30 points
regardless, so past the ceiling a wider window would only broaden the
mean behind the value. Out of range is a 422 rather than a clamp.

An unhonoured window is visible rather than silent. `provenance.windowDays`
is the window the engine actually used and `coverage.historyDays` the days
that actually backed it, so a three-week record answering a thirty-day
request reports both numbers instead of implying coverage it does not
have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the
widest; a new suite freezes that it is the only metric that does, so an
arm added later cannot quietly drop the parameter.

Recovery keeps its canonical resolution under the wider window. A worn
band stamps the wake morning and the computed proxy the night before, and
only `resolveCanonicalRecovery` pairs them; a test seeds forty such
nights, asks for sixty days, and holds the answer to forty nights with a
flat trend — a bypass leaves eighty rows and a false climb.

Contract: model `WellnessScoreValue` field for field and publish it. The
envelope's `value` has to stay an open record because one route serves
eighteen differently-shaped metrics, so nothing can `$ref` the shape and
it went out undocumented — `series`, `daysInWindow` and `asOf` were on
the wire with no way to find them short of inspecting a live response. It
is registered in the forced-components slot instead, held against the
runtime interface by a test, and the fields it does not cover are named
in the schema comment so the remaining gap is known rather than invisible.
2026-08-24 02:00:55 +02:00
Marc-André Bombeck d86358f3e4 fix: recover managed sharing boundaries 2026-08-06 00:19:35 +02:00
Marc-André Bombeck c91558f18f feat(analytics): replace health score with reference composite 2026-07-28 21:10:15 +02:00
Marc-André Bombeck ae3d291feb refactor(insights): name the instruction-body rule on the derived grid
The derived batch grid carries only the deterministic assessment, whose
templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was
correct but indistinguishable from the accidental binaries that were just
removed; route it through instructionLocale so the intent is explicit.
Behaviour unchanged.
2026-07-18 23:33:37 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 4cf923647d feat(insights): gate every AI-narrative insights route on the insights module
Require the `insights` module (in addition to the existing assistant-surface
gate and any per-metric data-module gate) on all 14 narrative insights
routes: blood-pressure / bmi / pulse / weight / medication-compliance /
mood / metric / derived / derived-batch status, cards, correlations,
narrative, pregenerate, and rhythm-events. A 403 module.disabled envelope
now precedes any provider or cache work when the account has insights off.

The per-metric sleep/glucose/recovery/mood gates and the
requireAssistantSurface("insightStatus"|"correlations") checks are kept;
this adds the module layer on top of them.

Route tests that now resolve the gate mock @/lib/modules/gate
default-enabled so their existing data-shape and assistant-flag assertions
ride through unchanged.
2026-06-16 02:40:38 +02:00
Marc-André Bombeck ca4952b2c8 fix(insights): gate the batch derived route on the module map
The batch derived endpoint (the one the dashboard wellness strip reads)
computed Recovery/Strain/Stress unconditionally while the single derived route
already gated them, so disabling the recovery module did not hide the rings.
Reuse the single route's DERIVED_MODULE map (now exported, with READINESS added
since it equals the computed recovery score) and drop disabled-module metrics
from the batch payload per request, without mutating the cached map.
2026-06-16 01:39:18 +02:00
Marc-André Bombeck fb13759f45 feat(modules): gate insights status routes behind module flags
The derived-score, metric-status and mood-status insight routes serve a mix
of core-vital and toggleable-module signals. Map the module-owned metric ids
(sleep/recovery/glucose) to their module key and refuse with the shared 403
module.disabled envelope when the account has that module disabled; mood-status
gates wholesale on the mood module, mirroring the cycle gate. Metrics absent
from the map are core vitals and stay open.
2026-06-15 21:13:16 +02:00
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00
Marc-André Bombeck 242d2514ee perf(dashboard,insights): reserve the cold-paint footprint and cache the slow overview reads
The dashboard reserves chart-row skeleton cells from the saved layout
before the snapshot resolves, so a cold load paints close to its final
footprint instead of growing by a screen when data lands; the tile
silhouettes count only tile-capable widgets with blood pressure as two,
the greeting line keeps its box through name personalisation, and the
getting-started checklist moves below the charts where its arrival
shifts nothing. On the insights overview the targets build runs its
eight reads in parallel with a one-year floor on the glucose scan
instead of strictly in sequence over an unbounded history, the derived
batch gains a server cache where it previously recomputed sixteen
metrics on every read, both serve stale-while-revalidate, and the
health-score column reserves its exact footprint while the analytics
payload is pending.
2026-06-11 21:52:36 +02:00
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +02:00
Marc-André Bombeck 298e4c34b4 perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
2026-06-10 12:55:56 +02:00
Marc-André Bombeck 2b9d2370e9 fix(cycle): drop card log button, fill predicted-period days, assessment on batch
- Phase education card no longer carries its own log-today button (the
  top-level log entry covers it).
- Predicted-period calendar days render as a soft dashed fill instead of an
  underline, so they read as period rather than an unfinished dash.
- The derived batch route now attaches the deterministic per-score assessment
  for the five scores so the dashboard grid (and iOS) paints the explanation
  without a second per-id round-trip; guarded so it never fails the grid.
2026-06-06 20:36:29 +02:00
Marc-André BombeckandClaude Opus 4.8 30a7437f4d feat(insights): per-score assessment text + shared metric signal block
Add an additive, non-breaking assessment field to GET /api/insights/derived:
{ text, source, updatedAt } | null, keyed to the requested metric id for the
five per-score ids (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE,
STRESS_SCORE). The field is null when the metric is not one of those or when
its status is not "ok". A deterministic text always fills it — composed from
the score's own contributors, so provider-less accounts and the demo are never
empty — and warmer provider prose overrides it once cached, warmed out of band
so the GET never blocks on a model round-trip. de/en, following the v1.12.12
deterministic-narrative model. The batch grid read returns assessment: null;
the detail sheets use the single-metric route.

Introduce a shared buildMetricSignal() helper: a token-lean, signal-rich
descriptor (current, own baseline, signed delta, spread, normal-swing verdict,
normal-range placement, contributors) computed server-side so the model states
the comparison instead of re-deriving it from raw buckets. Thread it into the
generic per-metric assessment snapshot and lead the base prompt from it. Add a
PROMPT_VERSION constant to the base system prompt so prompt changes are
traceable.

Regenerate docs/api/openapi.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:57:39 +02:00
Marc-André Bombeck b92d5cec82 feat(insights): batch the derived-metric reads behind one route
The Insights cold mount fired 14+ independent GET /api/insights/derived
requests — Readiness, Sleep and the vitals baselines are compute-on-visit,
and all of them share one Prisma pool. That is the pool-starvation class
that surfaces as the app hanging then recovering.

Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the
requested metrics server-side under a bounded p-limit(4) with the profile
loaded once via the shared loadBaselineProfile, returning a map of the same
flat Derived<T> envelope keyed by the request token. Add the useDerivedBatch
hook + the insightsDerivedBatch query-key factory entry; the single-metric
route + hook stay for the per-score detail pages. The single route now also
adopts loadBaselineProfile instead of re-inlining the profile read. Honors
the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing
on visit. Register the new route in the OpenAPI contract.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 663a3ee09d feat(insights): add the vitals dashboard + its derived metric tier
Build the Apple-Health-Highlights grid of personal-vital tiles on the
Insights overview, backed by four new pure derived-metric compute
functions over the established seam:

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

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

Regenerates the OpenAPI contract for the two new metric ids and adds the
vitals copy across all six locales.
2026-06-02 21:17:05 +02:00
Marc-André Bombeck 4ad65a5b7d feat(insights): add GET /api/insights/derived + OpenAPI contract
Generic route mirroring the v1.8.7.1 metric-status precedent:
GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<vital>].
apiHandler + Zod safeParse on the query, closed registry enum (unknown
id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed
from the session (never a query field). The profile is read once and
passed into the pure compute function. Returns the flat Derived<T> union
in the standard { data, error, meta } envelope so the native client
decodes one stable shape and combines values across metrics.

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

Route tests cover the 200 flat-envelope shape, session-narrowed userId,
422 on an unknown / missing metric, 401 unauthenticated, and the
insufficient flattening (null value + confidence, populated reason).
2026-06-02 20:01:42 +02:00