The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.
Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:
- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).
The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.
Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.
Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.
Refs #1108
The range badge renders nothing for a reading without a reference range, so
that row had four cells instead of five and the range bar and the trend
moved one column to the left. The badge now sits in a cell of its own. The
chevron is hidden from lg up, where the row's hover already says it opens;
a phone keeps it.
Seven locales for the background-run copy: the calm line while a document
read or a lab scan runs in the worker, the hint when no worker has picked it
up, and the two failures only a background run can produce.
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.
Both now run as a background run (`DocumentAiRun`, migration 0366):
- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
answers 202 with a run id once every quick refusal has passed. Without
the header it answers exactly as before, which the iPhone app relies on.
A second request while a read of the same document is queued or running
attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
route answers with, or the failure code and status it would have given.
Scoped to the caller; another account's run is a 404.
The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.
Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.
The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
The round-trip fixture deleted its measurement on a fixed date. The restore
drops a measurement tombstone older than TOMBSTONE_RETENTION_DAYS (75) on
purpose, so on 2026-10-03 the fixture aged out and the suite failed on main
and on every open branch. The deletion is now ten days before the run.
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.
- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
the reading's unit in meta, and writes nothing. The unit is compared only
when one was sent: on the biomarkerId path it may be omitted, and the
marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
reads partial or failed instead of a clean save, and the toast names the
analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
shows a calm badge on a row in another unit, starts it unticked like a
duplicate, and treats it as a blocking error on the unit field if it is
ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
prefix, the case of the litre, and a unit printed in capitals for a short
list where capitals cannot mean another unit. Case is otherwise significant,
so mIU/L and MIU/L, or g/L and G/L, are never merged.
The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.
Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.
Fixes#1094
The form's start defaults to the minute it opened, so a workout logged
right after it finished was stored as ending up to a day ahead. The
duration now fails validation when start + duration lies past now, with
the same one-minute slack the start check uses.
The placeholder said "Default (~120)" while most surfaces fall back to
60 s and the briefing to 180 s. The placeholder and the help line now
interpolate both numbers from the constants the server applies
(PROVIDER_DEFAULT_TIMEOUT_MS, AI_BUDGETS.comprehensive.timeoutMs), in
all seven locales, and a test fails if a locale writes a number of its
own.
Refs #1090
Pins the four ways the setting could be skipped again: a wire client
reading params.timeoutMs itself instead of callTimeoutMs, an exported
resolver in provider.ts returning a provider it did not bind, a client
constructed outside provider.ts (the offline evaluation harness is the
named exception), and a surface ceiling not listed with its reason.
Each matcher asserts a non-zero match count.
Refs #1090
The browser gave up on its own: 120 s for the document routes, 90 s for
a lab scan, and the 15 s default on the lab text path, while a slow
self-hosted model with a raised response timeout was still allowed to
answer. A five-page report read by a local model failed on the client
side with the server still working.
Each request now sizes its abort from ai.provider.responseTimeoutMs on
the account payload, times the model calls the route can make, plus
30 s: one call for suggest and summary, three for Read with AI (the
transcription, then the lab staging read and its corrective retry), two
for a lab scan (the read and its retry). With the setting unset that is
90 s, 210 s and 150 s.
Refs #1090
The response-timeout setting reached the briefing, the status notes and
the Coach, which threaded it by hand. Document reads, document
extraction, lab OCR and lab staging called the provider without a
timeout, so every client fell back to its own 60 s literal and a slow
self-hosted model was cut off however high the setting stood.
The setting now rides on the provider instance. resolveProvider,
resolveProviderChain and resolveProviderForTest stamp the record's
aiResponseTimeoutSeconds on everything they return, and each wire
client reads its ceiling through callTimeoutMs: the setting, else the
surface's own timeoutMs, else 60 s. A call site no longer has to pass
it, so a new one cannot forget it. The connection test runs under the
same ceiling a read does.
The Coach nudge tick and the reaction line keep their short ceilings
(timeoutPolicy: "surface-ceiling"): the tick runs accounts one after
another under a shared budget, the reaction line's claim lease is
shorter than the largest setting, and both have deterministic copy
that stands in.
/api/auth/me publishes the effective value as
ai.provider.responseTimeoutMs, so a client can size its own request
timeout from it instead of guessing.
Refs #1090
The spec compared field tops within 8 px. A field label that wraps onto a
second line, which depends on the runner's font metrics, moves that
field's input down by one line while it stays in the same row, and the
560 px case failed on it. A field is now beside the value when it sits to
its right and starts above the value's bottom edge.
On a phone and a small tablet the review sheet slides up from the bottom;
its width settles before the slide ends, so waiting on width alone still
compared fields from different frames. The spec now waits for every
running animation to finish and for the surface to hold its position and
width across two frames.
Main measured 3718 KB gz after v1.39.7 and v1.39.8, almost all of it new
copy in the seven lazy locale catalogs; the workout form, its sheet, the
delete action and their copy add about 7 KB (3725). The cap moves from
3724 to 3749, the same 24 KB headroom as before; the eager routes stay
inside their drift allowances.
Refs #1085
The dashboard add menu asked only whether the record's section was
writable, so a switched-off mood or medications module still offered its
entry here while the bottom bar's picker hid it. The menu now renders from
the picker's own visibleCaptureKinds, so the two cannot disagree.
Refs #1085
The review stage widens the sheet and the row grid re-flows from the
container width; on a slow runner the 560 px case measured a half-laid-out
row and failed on the first attempt. The spec now waits until the surface
keeps its width across two frames before it measures.