Commit Graph
65 Commits
Author SHA1 Message Date
Marc-André Bombeck aecf6f91b1 fix(api): publish what the GET handlers send
A response parsed against its own registered schema disagreed in thirteen
operations. Where the handler was right, the schema now says so; where the
handler had drifted from its siblings, it follows them.

Schema:
- measurements list and detail declare aggregationProvenance
- series-batch and sleep night stages are partial records: only the
  requested types and the recorded stages are present
- medications declare deliveryForm, the injection-site fields, externalId,
  externalSource, the low-stock bookkeeping, schedules[].doseWindows and
  nextDueScheduleId on the list
- notification prefs declare coach.nudgeAiComposed and measurementReminder
- mood insights correlations declare suppressed
- record settings profile.unitPreference is nullable, as stored
- insights/comprehensive describes the computed overview it returns rather
  than the model digest fields it never sends

Handler:
- GET /api/mood-entries/{id} shares the list projection and now carries
  tagKeys and ratedFactors
- GET and PATCH /api/custom-metrics/{id} carry latest and entryCount
- /api/analytics no longer leaks anomalyCount through the per-night sleep
  summary and the glucose meal-context split
2026-10-20 13:16:22 +02:00
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00
Marc-André Bombeck dee6a27ae4 Merge branch 'fix/v1437-data' into release/v1.43.7 2026-10-20 08:12:32 +02:00
Marc-André Bombeck 97d1052dd1 feat(api): say when a list was cut at its ceiling
A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
2026-10-20 06:53:17 +02:00
Marc-André Bombeck 248b5dfd6b fix(sleep-score): publish the rhythm floor and name it in the few-nights note
The note hard-coded "of 3" in every locale. SleepScoreValue now carries
rhythmMinNights, and the sheet reads the floor from it.
2026-10-20 05:14:03 +02:00
Marc-André Bombeck 862361a224 fix(recovery): publish why the recovery page is empty from the server
The page worked out its empty note on the client from the integrations
status, so every client had to repeat the rule. The analytics summaries
slice now carries `recoveryEmpty` (general, oura_permission or
oura_no_resilience, null when any recovery type has a reading), decided
once in resolveRecoveryEmpty from the gated summaries and the stored
Oura grant. The page renders that value only, and its block list is
typed to the server's recovery types so the two cannot drift.

Each reason now reads as one sentence in every locale, and every reason
offers the same way on, the integrations settings.
2026-10-20 05:01:18 +02:00
Marc-André Bombeck 44d8181cb4 docs(openapi): publish the sleep score value
The derived envelope types `value` as an open record, so the sleep
score's `rhythmNights` and per-sub-score `gap` were on the wire but in
no schema. `SleepScoreValue` joins `WellnessScoreValue` in the forced
components slot, the envelope's `value` description names it, and a
contract test holds the published field set to the one the engine
emits, for a scored rhythm and for one withheld for too few nights.

Refs #1204
2026-10-19 18:56:06 +02:00
Marc-André Bombeck adf0c7f4c2 refactor(validation): route every ISO instant through isoDateTime 2026-10-16 03:57:46 +02:00
Marc-André Bombeck 79f7eb199b feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server 2026-10-15 23:39:11 +02:00
Marc-André Bombeck 3786deb503 feat(api): publish the v1.43 contract with routes that answer 501 until filled
Every new endpoint of the contract exists, wrapped in apiHandler, with its
authentication, module gate and request validation, and answers
501 not_implemented until its behaviour lands. The OpenAPI document carries
the new operations and schemas and the additive response fields on the
changed routes. The four server feature flags are published as false.

contract-stub-inventory.test.ts registers the stubbed routes; each one
leaves the register with the change that fills it. The route, sharing,
session, security, module-gate and outcome inventories name the new routes
deliberately, and the error-code catalogue lists the codes the routes emit
today.
2026-10-15 21:49:02 +02:00
Marc-André Bombeck 7c905249fb fix(medications): page the side-effect list and name every GLP-1 medication in the timeline 2026-10-15 15:05:51 +02:00
Marc-André Bombeck 9aa60fb2ff fix(illness): keep chronic conditions out of rest mode
A chronic ongoing condition has no end date, so counting it as rest mode
kept the account in it for good: Day N counted from an onset years back,
cadence nudges never resumed and every streak gap since the onset was
bridged. Only active episodes that are not CHRONIC_ONGOING (acute,
recurring, flare) now trigger rest mode, and Day N counts from the
earliest of those. The achievements streak freeze applies the same
filter to its own episode query.

Chronic conditions stay context: a coincident vital deviation is still
framed as explained by any active episode, and the Coach block and the
briefing still name them, with restMode false.

Refs #1216
2026-10-14 18:57:01 +02:00
Marc-André Bombeck 62140a4aee feat(insights): follow every score over time with one range selector
The health score had no history anywhere, readiness and the sleep score
showed a fixed sparkline, and only the stored nightly scores offered the
range tabs. Every score page now charts its course with the same
7 / 30 / 90 / All tabs, remembered per page, and each point opens its day.

- GET /api/insights/score-history?score=&days= serves the daily course of
  the health score (its stored day rows), readiness (the nightly blend,
  filed on its wake day) and the sleep score (each night against the
  trailing window ending on it), so a point equals what the day view shows
  for that day. MANAGE record read, module-gated for readiness and sleep,
  days 1 to 3650, additive.
- The health score's line breaks where its recipe changed (configuration
  or algorithm version); the usual range is formed from the newest side
  of the last seam.
- The range tabs move into one shared component used by every chart;
  recovery, stress and strain keep their measurement chart, now with the
  range remembered and no overlay dropdown.
- New page /insights/health-score, linked from the score panel and from
  the day view's score tile.
2026-10-09 16:45:39 +02:00
Marc-André Bombeck 08f532b428 feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.

The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.

The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
2026-10-09 13:44:58 +02:00
Marc-André Bombeck 898729d1a8 feat(insights): explain the shorter correlation list once
Since correlations are tested on seasonally adjusted residuals with an
effective sample size, an account with history sees far fewer
relationships, which reads as a defect without a word about it.

The correlations response now carries findingsBeforeSeasonalAdjustment:
true when the record kept a discovery pattern first stored before the
change, one indexed lookup on the pattern store. An account created since
can never qualify. The mood "What stands out" card shows a short note
for those records, closable, remembered per viewer like the AI setup
hint. Copy in all seven locales; OpenAPI regenerated.
2026-10-09 09:29:38 +02:00
Marc-André Bombeck ac482c508d Serve the device's day strain on the strain page when no proxy exists
The recovery page charts the band's own DAY_STRAIN while the strain
score page read only the computed proxy, so one account showed weeks of
strain on one page and 'not enough data' on the other. Without a proxy
row in the window the derived strain read now falls back to DAY_STRAIN:
the score is the reading as a share of the device scale, an additive
'device' block carries the native value, and the course below the card
charts that same series.
2026-10-08 18:17:20 +02:00
Marc-André Bombeck aca497d9ac fix(insights): correlate residuals, not the calendar
Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.

Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.

The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.

scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
2026-10-08 08:32:15 +02:00
Marc-André Bombeck 072c1e9394 Compare glucose signals by the hour and date stale strip readings
The signals of the day held today's newest single reading against the
7- and 30-day mean of every reading. For glucose, whose level climbs
with each meal, a fasting morning read as low against an all-day mean
and fed that into the briefing and status prompts. A type in
SAME_HOURS_TYPES now compares today's mean so far with the earlier
days cut at the local time of today's latest reading, the basis the
coincident-deviation flag and the metric strip already use. Below a
week of like-for-like days there is no signal. The signal carries
basis "sameHours" and both briefing prompts say what it means. Other
metrics keep the reading-against-window comparison.

The usual-range strip called the newest reading today's on every
metric page, even when it was days old. CoachReadBaseline now carries
latestDate (the reading's day in the account's zone) and latestIsToday
(isFromToday), on the REST response and the get_metric_baseline MCP
output. The strip names the date when the reading is not from today,
in all seven locales.
2026-10-06 20:53:39 +02:00
Marc-André Bombeck 3e968f03a9 fix(insights): place a glucose day in progress against the same hours
The usual-range strip on a metric page placed the latest single reading
against a band built from whole-day means. For glucose the day mean climbs
with every meal, so at nine in the morning a fasting reading sat under a
band of whole days and the strip said "below your range" on an ordinary
morning; the MCP baseline read reported the same.

Glucose is now placed by its day mean. A day still in progress, in the
reader's own zone, is held against the earlier days cut at the local time
of today's latest reading, the comparison the health status already makes;
with fewer than seven earlier days that have readings in those hours there
is no verdict. The baseline carries `basis: "sameHours"` on the coach-read
route and the MCP read, and the strip names the range as the one for this
time of day, in all seven locales. Other metrics keep their placement.
2026-10-06 20:29:57 +02:00
Marc-André Bombeck f6b0131731 fix(daily): serve the briefing as it stands today on every surface
Only the digest applied briefingForToday. The dashboard snapshot (the
web dashboard, its server prefetch and the iOS cold-launch read) and
the /insights advisor payload still served an earlier day's text and
the raw model deltas ("+33.72 bpm").

The snapshot read now resolves the briefing after the cache, beside
the capability decision, from the payload's generation moment and the
snapshot's own newest reading per type; a withheld briefing reads
"preparing", never "ready" with nothing in it. The digest takes the
resolved briefing from that read on its own clock instead of resolving
again. The advisor route resolves dailyBriefing on every branch it
returns, reading the newest reading per signal type in one grouped
query only when a reading-backed signal is present; text generated on
an earlier day no longer counts as fresh on the read GET (so the warm
is enqueued) and no longer short-circuits a provider-backed POST.

The OpenAPI descriptions of both advisor responses say so.
2026-10-05 22:03:58 +02:00
Marc-André Bombeck befac59576 feat(ai): send a per-entry reasoning effort to Local and gateway providers
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.

- Every exported resolver binds the entry's value onto the client, the
  connection test included, keyed on the instance type so the operator
  provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
  compatReasoningEffort and GET reports them. A provider absent from
  the chain holds its value on a disabled entry; restating Default on a
  chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
  openai-compatible entries, refuses it on any other, and keeps the
  stored value when the body leaves it out. Its schema is now shared
  with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.

Refs #1126
2026-10-04 10:42:59 +02:00
Marc-André Bombeck 0dfce73570 fix(ecg): accept the fractional heart rate and frequency HealthKit reports
The ingest route required integers for the average heart rate and the
sampling frequency. HealthKit reports both as doubles and a watch's average
heart rate is often fractional, so most new recordings were refused with
422 while older ones, whose value happened to be whole, went through. Both
are now accepted as numbers and rounded once before they are stored; the
columns stay integers. A refused body now logs which fields were refused
and why, never their values.

Refs #1060
2026-10-01 12:50:08 +02:00
Marc-André Bombeck 4e7beb797c fix(analytics): all-time extremes and range deltas count one source per day
The all-time weight, blood-pressure and pulse figures the insight features
carry averaged every raw row, so a device that samples densely outweighed the
readings the source ladder prefers on the same days. They now keep one source
per day, like the dashboard's all-time figures.

The period-over-period range delta averaged readings. For step-like totals
the current window still holds the last day and a half as many small
samples, so the average collapsed; it now compares average daily totals. For
sleep it averaged stage segments and in-bed envelopes; it now compares
average nights of time asleep. The API descriptions say which.
2026-09-26 16:07:45 +02:00
Marc-André Bombeck 9c7ddc13b3 Merge branch 'feat/ai-routes-insights-coach' into release/v1.39.0
# Conflicts:
#	src/app/api/insights/correlations/__tests__/route.test.ts
#	src/app/api/insights/correlations/route.ts
2026-09-24 10:26:56 +02:00
Marc-André Bombeck 1f6a13e2f7 feat(ai): answer the status notes with a null note when AI is unavailable
The status family (the five metric cards, medication compliance, the
generic metric status and the biomarker assessment) asked the retired
switch and the `insights` module and refused with 403. The note is model
text and the card around it is data, so they are mixed reads now: while
the `statusText` capability is available the note is served and warmed
as before, with `ai` beside it; otherwise the answer is 200 with the
note null, `preparing: false`, `hasProvider` as provider presence only
and `ai` naming the reason, and neither the cache nor the warm queue is
touched. The generic metric status still reports `insufficient` from the
data. The mood card keeps its mood module gate.

Kept apart so it can be held back while the shipped iPhone app's
rendering of a 200 without a note is confirmed.

Refs #975
2026-09-24 09:30:23 +02:00
Marc-André Bombeck 1273a76a63 feat(weight): judge the weight trend against the stored target
The weight tile's arrow, 7-day delta and comparison caption were wired
as "up is bad", so a person below their own target who was gaining saw
every step toward it coloured as a setback. Movement is now judged
against the target they entered: below the band a gain is progress,
above it a loss is, and inside it holding steady is, with a move either
way shown neutral rather than orange. Without a stored target the tile
reads exactly as before; the height-derived band is not treated as a
target because nobody chose it.

The direction is resolved once on the server and published on the
dashboard snapshot as tiles.weightTrend, so the web tile and the native
client colour the same reading the same way. The personal-record
direction reads the same placement helper. The feature payload behind
the briefing, the overview analysis and the Coach now carries the
target band and which way is progress, so a narrative no longer calls a
gain a regression when gaining is the goal. The insights vitals grid
drops weight from its fixed "up is bad" set.

Refs #1006
2026-09-24 09:22:50 +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 5ab7a2ca81 fix(dashboard): paint the blood-glucose tile for untagged readings
Tile eligibility filtered the four named contexts against the per-context
summaries and asked whether any of them held readings. An account whose
source records no meal time matched none of them, so the tile was omitted
with the module on, the layout toggle on, and a reading from today. Nothing
warned; the strip was simply short one card.

Move the gate out of the page into `resolveGlucoseTiles`, which walks the
shared bucket list — untagged included — and hand the caller the label key
with the summary. Both summary producers group through the shared resolver,
so `UNSPECIFIED` reaches the wire with its own count, latest value and
trend, and a tagged account still gets its per-context breakdown.

Refs #943
2026-09-09 23:03:21 +02:00
Marc-André Bombeck 4aaed5c312 fix(dashboard): give the time-of-day greeting one clock
Three pieces of code answered "what hour is it" for the salutation. The
snapshot builder computed a `greetingHour` in the user's zone and put it on
the payload so clients would not have to; no client ever read it. The
insights hero derived one through the shared `hourInTz`. The dashboard header
derived one through a local helper that fell back to the DEVICE clock
whenever the runtime could not resolve the configured zone, which is what
happens to an IANA entry the browser's data does not yet carry. At one
instant, for one account, those three returned 20, 21 and 05.

Keep the client end and drop the server one. The hour is a clock reading, not
a fact about the record, and the snapshot body serves stale for up to an hour
past its fresh TTL, so a stored hour can name a salutation the clock has left
behind. What the server IS authoritative for is the zone, and that already
travels on the same payload and is already what both surfaces read.

Both greetings now share `hourInTz`, so an unresolvable zone falls back to the
same default the rest of the app uses instead of to the device. The header's
private helper is gone; it had no other caller.

Two tests pinned the old shape by asserting the payload carried an hour.
Inverted rather than deleted: the payload must now be shown NOT to carry one,
which is the property that was missing when the field went unread for months.
`greetingHour` leaves the response contract, so a native client decoding it as
required needs the field made optional or dropped.
2026-09-03 05:49:18 +02:00
Marc-André Bombeck f29995a18c docs(api): correct the score descriptions the adaptive rule outdated
Three descriptions in the OpenAPI registry still describe the floor that
v1.38 removed, and one of them contradicts its own error-response
description two dozen lines below it.

- The score-config PATCH still said the write is refused below three
  distinct areas and one physical measurement. The route refuses the
  empty selection and nothing else; its 422 description already said so.
- The dashboard snapshot's healthScore still said the block is null until
  three eligible domains include a physiological one. It is null only
  when no pillar is readable at all.
- The pillar domain field still pointed at a three-distinct-domains rule
  that no longer exists, where what a client needs to hear is that domain
  count comes resolved on scoreBasis and must not be counted out of
  composition.

Regenerated docs/api/openapi.yaml.

The user-facing description of the score lives in the docs site's own
repository, not here; nothing under docs/ describes the composite.
2026-08-30 17:08:39 +02:00
Marc-André Bombeck e4880dcdc1 feat: the score says when a pillar stopped counting
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.

The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.

Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.

The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.

This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.

Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.

Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
2026-08-30 16:16:57 +02:00
Marc-André Bombeck aadc3a8e89 feat(api): carry the score's basis on every surface that carries the score
`scoreBasis` rides the composite, the dashboard snapshot's flattened hero
block and the daily digest's score. It says how many distinct areas of
health the number counted, how many the method recommends, which tier that
lands in, and whether any counted pillar was a physiological measurement.

Optional everywhere, because two of the three surfaces are served out of a
cache and a payload written before this release cannot grow a field. The
snapshot and digest blocks have taken six additive fields this way already;
this is the seventh.

Resolved on the server for the same reason `configured` is. Counting
`composition` client-side would get it wrong more often than right — three
of the seven pillars share the cardiometabolic area, so a three-pillar
score can rest on one.

One shared component, `HealthScoreBasis`, declared once in the insights
schema module and referenced from the other two registry modules, rather
than three inline copies that drift the first time the block gains a field.
Declared through the assigning `.meta()` form: a bare `.meta()` statement
registers an instance nothing references, and the id then never becomes a
component.

The composite's `insufficient` reason narrows to the single value
`no_usable_data` and says so in the published description. That field is an
open string in the document, so the change is additive on the wire even
though the value space shrank.

The score-config 422 description drops the two retired reasons and states
plainly that the empty selection is now the only refusal, so a client does
not keep a three-area rule of its own in front of a write that no longer
has one.
2026-08-30 14:16:06 +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 97d0c26ceb fix(glp1): match side-effect tags by key, not by an English/German word list
The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.

The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.

All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.

A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.

The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.

The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
2026-08-23 13:56:32 +02:00
Marc-André Bombeck 3ae5bfe1c0 Capture the schemas that annotated themselves into nothing
`.meta()` clones the schema in Zod 4 and registers the clone, so a bare
`schema.meta({ id, description })` statement registers nothing at all.
Forty-one of them sat across nine route modules. The ids they named —
`MeasurementType`, `MeasurementSource`, `LoginPasswordRequest`,
`CreateMeasurementRequest`, `CreateBatchWorkoutRequest` and thirty more —
reached `components.schemas` zero times, the descriptions written for them
were published nowhere, and every use site inlined an anonymous copy. A client
generated from the contract got duplicated nameless types where one named
model belongs, which is the part that matters, because the iOS client is why
the contract is published.

Forty of them now bind their clone, and every use site points at it. Where the
schema comes from `src/lib/validations/*` the import is aliased and the clone
takes the name the use sites already spell, so nothing mutates the runtime
validation schema. The seven that cross module boundaries are exported from
`./shared` and the eight consumers import them from there; importing the raw
schema from the validation module instead is exactly what inlined them.

Thirty-four named components appear where inline copies stood. Fully
dereferenced, the document differs by eighty-six added descriptions and
nothing else: no operation's request or response shape moves. `CoachPrefs`
was already force-registered in the index and finally carries its description.

Five ids legitimately stay out of `components.schemas` —
`ListMeasurementsQuery`, `ListAllergiesQuery`, `ListEncountersQuery`,
`SuggestEncounterQuery` and `ListPractitionersQuery`. The emitter expands a
`requestParams.query` object into inline `parameters`, so a query id has
nowhere to land; they stay in the assigning form for consistency.

Three findings the wiring turned up:

`CreateShareLinkRequest` and `HealthRecordExportRequest` were annotated in the
Coach module, which imported the two schemas for no other reason and never
referenced them. Both operations that send these bodies live in the
health-record module, so the annotations move there. Both descriptions
described a `sections` field: the export body has no such field and rejects it
with a 422, and the share-link body freezes scope through `selection` plus
`documentIds` / `documentOnly`. Publishing either sentence verbatim would have
published a lie, so both are corrected to the shape the routes actually take.

`CoachPlansListQuery` — the forty-first — named a component that nothing
referenced and that a query object could never reach. The operation publishes
its two parameters by hand, because the schema carries a cross-field
refinement the expansion cannot express, and every clause of the annotation is
already published on the operation and on both parameters. It is deleted
rather than left registering nothing.
2026-08-22 15:34:32 +02:00
Marc-André Bombeck edccc17a4d Register the rest of the export, insights and medication surface
Twenty-five path entries across nine domains, all of them served and none
of them described. Same cause as the first batch: `openapi:check`
compares the registry against the YAML and never the route tree against
the registry, so a route registered nowhere drifts with every gate green.

Four groups where the contract had to say more than the shape does.

The full backup is the download that carries a whole record in plain
text, so what it contains is a safety question. It has two modes and this
route reaches only one: called with no options, it always emits the
PORTABLE form — decrypted columns, no tombstones, no row ids — while the
disaster-recovery form belongs to the worker and the admin path. Three
things it leaves out say so in the payload's own manifest: document
bytes, workout GPS and per-sample series, and the sensitive pair, where
the screener answers include the PHQ-9 self-harm item and a consent
receipt restored elsewhere would assert an agreement that operator never
obtained. The undeclared gap is named too, since roughly thirty models
the plan marks as backed up still have no reader or restore branch.

The four intake mutations differ in ways a caller has to know before
firing one. The by-id delete, the bulk delete and the purge all
tombstone, so the sync feed carries each removal; only the purge's
compliance rollups are dropped outright, and those recompute. The first
two refund the inventory stamp because not taking a dose puts the units
back; the purge deliberately does not, because refunding a whole history
would inflate the current container count instead. The bulk delete drops
unmatched ids silently, so a partial match succeeds with a lower count
than the ids sent.

The two provider-configuration surfaces are documented by what they do
NOT return. The chain answers provider types and boolean state, never a
credential. The per-medication ingest endpoint returns a raw token
exactly once, on the PUT that mints it, and a re-enable answers a null
token with `created: false` rather than re-issuing — the stored value is
an HMAC and there is no path back.

Insight generation costs money, so both gates are published with the
refusals they produce: the hourly per-user limit, and the day's token
ceiling, which is refused at reservation time before any provider is
contacted and carries its own code so a client can tell "too often" from
"the day's spend is gone". A 200 there does not mean a provider ran.

Two things the code said that were no longer true. The seeded-question
route's docblock declared it deliberately unregistered; it is registered
now, and a comment contradicting the registry is worse than none. And
the idempotency guard went red on `/api/insights/feedback`, whose
exemption said the path could not be published — which is exactly what
that leg exists to catch, so the entry is gone rather than the assertion.

`docs/api/openapi.yaml` is regenerated separately.
2026-08-22 13:23:25 +02:00
Marc-André Bombeck d66c4f6cdc Register eleven served routes in the OpenAPI contract
Eleven routes the native client calls have never been in the registry, so
`docs/api/openapi.yaml` does not describe them. Nothing caught it:
`openapi:check` compares the registry against the YAML and never compares
the route tree against the registry, so a route registered nowhere drifts
with every gate green.

  GET       /api/analytics
  GET,POST  /api/cycle/symptoms/custom
  GET       /api/dashboard/summary
  GET       /api/environment
  GET       /api/export
  GET,PUT   /api/insights/settings
  GET       /api/insights/targets
  GET,POST  /api/medications/intake
  GET,POST  /api/medications/{id}/glp1
  GET       /api/mood/insights
  GET       /api/personal-records

Each contract is derived from the handler rather than from its comments:
the methods it exports, the parameters it reads with their real defaults
and bounds, the statuses it can actually produce, whether it resolves the
record fence, and which module gate it sits behind. Where the answer is
awkward it is written down rather than smoothed over — `/api/export`
answers a bare `data` key and not the standard envelope; `/api/analytics`
serves two unrelated shapes behind one query parameter and records the
health score it computes; `GET /api/medications/intake?scope=today`
projects rows before it reads them; the GLP-1 GET is not delegable while
the POST on the same path is; and `highlightInsight` on the dashboard
summary is pinned null by the handler and has never carried anything.

The intake aggregator's query and body schemas move from inside the
handler to `@/lib/validations/medication`, so the contract generates from
the same object the route parses with instead of a second copy of it. The
environmental overview gets its own route module because nothing owns that
surface. `docs/api/openapi.yaml` is regenerated separately.
2026-08-22 12:30:05 +02:00
Marc-André Bombeck 38766cd4ca docs: define sharing refusal contracts
Every route that resolves a record through the sharing fence can answer 403
`sharing.access.denied`, and until now that fact was published on the selector
header and on none of the 120 operations that produce it. All 120 carry it now,
byte for byte, from one constant: the refusal is deliberately indistinguishable
across a missing account, a missing grant, a scope that does not reach the
surface and a read grant on a write, so 120 paraphrases would invite a client to
tell apart what the server refuses to.

Where a delegable route already answered 403 for its own reason — a module
gate, the cycle gate, AI consent — the two share one description rather than one
of them going unpublished, since OpenAPI allows a single response per status.

The guard recomputes the delegable set from the route sources on every run and
classifies per verb, which matters: seventeen modules have a delegable read
beside a write that refuses a switch outright.
2026-08-07 04:56:56 +02:00
Marc-André Bombeck 3b37d73f31 Merge branch 'work/ecg-ingest' into release/v1.35.3 2026-08-01 19:50:52 +02:00
Marc-André Bombeck 0e301b580c Let an Apple Watch ECG arrive without a 1.5 GB export archive
POST /api/insights/ecg takes one recording per request: the stable
recording id the client already has, the on-device timestamp, the
sampling rate, the waveform as integer micro-volts, the lead, the
average heart rate, and the device's own verdict. Same gating as the
reads (auth, the insights module, the insightStatus surface), userId
from the session, APPLE_HEALTH the only source a client may claim.
sampleCount and durationSeconds are derived here, and nothing about the
waveform is ever interpreted.

The write body now lives in one place, src/lib/ecg/persist-recording.ts,
shared by the Withings sync, the export-archive importer and the new
route. Three writers with three persistence bodies is how the two ends
of one rule drift apart.

Migration 0291 adds the second unique index on
(user_id, source, recorded_at, sampling_frequency). The archive importer
identifies a strip by a hash of its content and a live client by the
device's own sample uuid, so the same physical recording arriving
through both doors carried two different ids and would have landed
twice. One device does not record two different strips at the same
instant at the same sampling rate, so the constraint is sound and the
convergence stops being a convention.

The table already holds rows on deployed instances and two writers have
been filling it since v1.19.0, so the index cannot just be created: a
pre-existing collision would abort the migration. The migration resolves
duplicates first, keeping the row with the most samples (a stored strip
beats a verdict-only row), then the one linked to its event
measurement, then the most recently written, then the lowest id so the
choice is total. The losers' event link is salvaged onto the keeper
first, so resolving a duplicate never costs the recording its place in
the event timeline.

If that resolution is wrong on some instance, the failure is visible at
deploy time and not later: db:migrate:deploy runs on boot, so the
container crash-loops with the migration named in the logs and the
release never serves traffic. A deploy that finishes in under thirty
seconds did not run it.

The GET list and detail routes and the export-archive path are
unchanged; this is a second door, not a replacement.
2026-08-01 19:19:01 +02:00
Marc-André Bombeck f6edff9b82 Merge work/contract-touchups 2026-08-01 11:50:33 +02:00
Marc-André Bombeck 43c591fe19 Correct three contract claims the published spec got wrong
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.

The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.

The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
2026-08-01 11:42:07 +02:00
Marc-André Bombeck c60689affd Take the fitness pillar out of the health score
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.

So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.

That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
2026-08-01 10:51:01 +02:00
Marc-André Bombeck c29055be1c Carry the configured flag onto the snapshot and digest wires
The dashboard snapshot copies it off the composite, the daily digest
copies it off the snapshot, and the derived-metric payload already
carries it inside the composite value. Optional on both DTOs like the
score identity fields beside it, so older cached bodies stay valid.

The digest's score object is additionalProperties:false and had never
declared deltaReason, scoreVersion or composition, all of which it has
been sending. A generated strict decoder would have rejected the real
payload, so they are declared here too rather than left standing next
to a field that documents itself correctly.
2026-07-31 18:03:53 +02:00
Marc-André Bombeck 087f3f496b Stop a settings change from reading as a score drop
Changing which pillars count is an act the person performed, not
something that happened to their health. Until now the week-over-week
comparison could not tell the difference: both windows are computed in
one request under whatever recipe is in force right now, so the pillar
sets agree, the subtraction looks legitimate, and the dashboard could
announce "your score dropped 14 points" to someone who had just come
back from the settings page.

The recipe now carries its own dated identity, and the delta guard
compares against it the same way it already compares against the global
method boundary: a recipe that changed between the two windows
suppresses the delta with a new reason, config_changed, and the surfaces
that gate on an unexplained delta stay quiet. The score record stores
the recipe version and its change date on every write, resolved from the
same stored blob the composition came from, so a row can say which
recipe produced it. A new series module reads those versions back, marks
the first day after a change as a break, refuses to join the composite
line across it, and keeps the per-pillar rows running through it: a
pillar is graded against its own reference band and stays comparable
when the average of the pillars does not.

The notice key folds the person's recipe version in beside the method
version, so a change in either raises the existing dismissible notice
once and dismissing one never dismisses the other. An account that never
chose keeps its old key untouched.
2026-07-31 16:17:20 +02:00
Marc-André Bombeck 9b69be8ab5 fix(openapi): declare shrunkR and tier on DiscoveredCorrelation
The correlation-discovery engine always sets shrunkR (the
sample-size-shrunk effect) and tier (the phrasing-confidence class)
on every surfaced pair, but the closed DiscoveredCorrelation schema
never declared either — same defect class as the dashboard snapshot:
additionalProperties: false rejecting a field the runtime actually
sends. Both are required, matching src/lib/insights/correlation-discovery.ts.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck d04eab71a3 fix(openapi): declare the dashboard snapshot fields the route actually emits
GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.

Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck 9a5206ee5f docs(openapi): give rhythm-events its own six-value classification enum
GET /api/insights/rhythm-events was never in the spec, so a client had
nothing to generate a type from but the three-value ECG classification
next door — reusing it would drop VERY_LOW / FIRED. Register the route
with its own RhythmEventClassification carrying all six
RhythmClassification values; the ECG enum stays untouched at three.
2026-07-30 16:06:53 +02:00
Marc-André Bombeck 7b8fb98e48 feat(analytics): replace health score with reference composite 2026-07-28 21:12:05 +02:00