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.
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
A small sheet with sport, start, duration and optionally distance and
active energy, nothing else. The start is read in the profile timezone,
distance in the reader's unit and stored in metres. It saves through
POST /api/workouts/batch as source MANUAL with an external id minted once
per opened form, so a second tap lands as a duplicate, not a second row.
The workouts page carries the action in its header and in the empty
state; the detail page offers delete for a hand-entered workout.
Refs #1085
The body fat tile was the one strip tile that led nowhere. Its href map
said there was no dedicated sub-page, which was true: body fat percentage
was tiled and charted on the dashboard but never got the Insights page
every body-composition sibling has.
Add /insights/body-fat on the generic metric-page scaffold (body group,
tab-strip pill, seven locales, manual-entry empty state) and link the
tile to it.
Move the tile href map out of the page into tile-destinations.ts, keyed by
the tile-capable widget id and typed against the sub-page slug union, so
a missing or non-existent destination fails to compile. The link passes
the destination's insights-page surface gate, so it never points at a
page a switched-off module hides.
A guard derives the tile set from TILE_CAPABLE_WIDGET_IDS and from the
ids the dashboard actually pushes into its strip, and fails when a tile's
metric has a sub-page the tile does not open, when a destination has no
route, or when a tile links to a page owned by another module. Tiles that
deliberately lead nowhere need a written reason; there are none today.
The reverse-lookup test pinned BODY_FAT as having no page; it now
resolves to body-fat.
Refs #1084
The time-in-range labels in the doctor report, the target caption on
the glucose panel, the glucose page description and the fasting
explainer printed fixed mg/dL figures, so a reader on mmol/L saw
"Low (< 70 mg/dL)" beside values in mmol/L.
The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`,
`GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints,
the analytics engine counts its bands from the same constants, and
every sentence takes the band edges and unit as parameters through
`glucoseBandParams`, formatted with the reader's number format.
The unit-preference guard now also reads the locale bundles: a string
that names mg/dL or mmol/L fails unless it is allowlisted with a
reason.
Refs #1067
The basal body temperature field, its chart and its tooltip were fixed
to degrees Celsius whatever the reader had chosen: the placeholder said
°C, the field accepted only 30-45, the chart plotted Celsius and the
tooltip printed "{temp} °C". Two chart titles carried a fixed unit as
well ("Wrist temperature (°C)", "Walking speed (km/h)") above an axis
already in the reader's unit.
The field now reads and writes in the reader's unit and stores Celsius;
a reading the person did not touch is saved back unchanged rather than
through a round trip of rounding. The chart plots, pads and labels in
the reader's unit, the two titles drop the unit the axis already
names, and the cycle contrast's fixed kg, °C and mg/dL labels, which
the transform had already replaced, leave the bundles.
Refs #1067
Both backup purposes now carry an accountSettings section. The restore writes
it to the account being restored inside the same transaction, names every
refused value under the new accountSetting skip catalogue, and re-warms the
stored texts after the transaction when a unit preference changed, the way the
unit settings routes do. A file without the section leaves the account row as
it is, which is how every older backup restores. The instance settings
restore stays its own opt-in.
A PDF longer than the pages a scan renders was read from its first ten
pages, and the cut was recorded only in the wide event. The person checking
the extracted rows had no way to know that values on later pages were never
read.
The rasterizer now returns the PDF's page count, the extract response
carries pageCoverage { read, total } when the cap applied, and the review
screen says how many of the pages were read.
An unknown or archived tag key, or a factor the catalog no longer carries,
was dropped on the way in while the entry still reported "inserted" (bulk)
or 201 (create, edit). A client had no way to tell that part of what it sent
never landed.
Each write now names those keys: the bulk result per entry, the create and
edit responses on the entry, as droppedTagKeys and droppedFactorKeys. Both
are present only when non-empty, so a response where everything landed is
unchanged. An archived tag an edit preserved is on the entry and is not
listed. The web shows a short notice after saving when a picked tag was left
off.
Two more surfaces carried a fixed unit. The insights trends row labelled
weight in kg and body temperature in °C for every reader, and plotted
walking distance as raw metres under a km label; its trend caption
said the same. A single transformed series there now takes unit, scale
and offset from the preference, and the caption describes the converted
series against a converted noise floor.
The illness day sheet and timeline showed and took a fever in °C. The
fever is now seeded, typed and shown in the reader's temperature unit
and stored in °C as before.
Refs #1067
The clinician view printed bare numbers for every measurement group and
the per-context glucose rows in raw mg/dL, so a recipient read glucose
without a unit and in the wrong one for an account on mmol/L. The PDF's
measurement table had the same gap for the raw glucose series.
Both artefacts now take the unit and the displayed number from one helper
(doctor-report/stat-display.ts): SI for mass, length and temperature as
before, hours for sleep, and the record owner's unit for glucose.
Refs #1067
The dashboard and capture-picker intake form filtered the medication list
on `active && trackIntake`, so a course that had ended (still active,
nobody paused it) was offered for a dose while the cards, the table and
"take all" already followed the server's `intakeActionable`. The picker
now renders from that one answer as well.
When medications exist but none offers a dose today, the empty state no
longer claims there are no medications; it says nothing is due and
points to the medication history for a past dose.
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.
The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.
The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.
A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.
Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.
The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.
Refs #1031
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.
Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.
- POST /api/tokens/workouts mints it, mirroring the measurement and
document mints: cookie session plus a fresh proof, a literal permission
array, 10 mints a minute, at most 10 live tokens (409
tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
behave as before. A scoped caller's workouts are stored as EXTERNAL,
resolved from the credential, and a batch in which any entry names a
source is refused 422 (workout.batch.source_not_permitted), the same
rule the measurement batch applies. EXTERNAL is on no source ladder, so
when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
error codes; docs/api/README.md lists the workout and document mints.
Refs #1054
The review dialog silently dropped any selected reading that could not be
written (no date, no unit, no value) and saved the rest. When nothing was
left it showed "Select at least one reading to save", which described a
different situation and said nothing about what was missing.
A save is now all-or-nothing over the selected readings. When one is
incomplete, nothing is written; the dialog states how many readings need
attention, marks each missing field (aria-invalid, inline message, red
border, required marker on the label) and moves focus to the first one.
Marks appear after a save attempt and follow the edits. The "nothing
selected" message is kept for that case only.
Validation is a pure function next to the payload mapping, so the rule that
blocks a save and the rule that builds the payload cannot drift apart.
Adds EN and DE strings; the other locales are left to the maintainers.
Refs #1055
A week or month point of a cumulative metric is the average daily total,
which keeps the axis comparable with the 7, 30 and 90 day tabs. The
caption said "Weekly avg", which reads as an average week's total. Steps,
energy, distance, flights and daylight now say "Daily avg per week" or
"per month" on the caption and in the tooltip, in all seven languages;
level metrics keep the plain average.
The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.
An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.
The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
A medication whose end date had passed still showed taken and skip in the
table and on its card, because the action row only looked at whether the
medication was active. The schedule engine already stops minting doses
after the end date, so reminders, the doses card and the today list were
quiet, but the medication itself kept inviting a dose that is no longer
part of the course.
The server now resolves one rule and publishes it on the list and detail
reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the
user's clock, both ends inclusive) and `intakeActionable` (active, intake
tracked, course includes today). The table and both card variants render
their actions from it, the take-all flow skips such a medication, the card
and table name an ended course with an "Ended" badge, and no overdue
escalation is raised for it. A course that ends today keeps its actions
for the whole local day.
The detail header's "Ended" status now follows the same field. It compared
the end date's UTC midnight with the clock and so called the whole last
course day ended. Logging a past dose from the history is unchanged.
A withheld table now leaves a line saying why (module off, or the stored
tables could not be read) and a failed read shows an error row with a
retry, so the Data used count matches what is listed. A table copied from
an earlier answer names that answer's date under its title. The preview
shows the latest twelve periods of a time series, and the truncation note
says the latest rows are kept, which is what the server keeps. The title,
copy and view controls sit above the scrolling table with the title kept
as a hidden caption, and a reply with tables takes the full column width.
The step headers and the related-metric chip read as label: value in all
seven locales, so a metric name no longer sits capitalised mid-sentence.
The method line merged a repeated read by keeping the larger count. With
show_result that could let an earlier answer's count stand over the read
this turn just made. A fresh read of the same source, window and period
now always wins, whichever order the steps ran in, and the two are never
added. An entry that rests only on an earlier table says so ("from an
earlier answer"), including a reused table no step accounts for.
The new catalog string is in all seven locales.
The small chevron above the first nav entry becomes a full footer row that
heads the utility group: directly above Admin for an administrator, directly
above Settings for everyone else. PanelLeftClose / PanelLeftOpen mark the two
states; the label reads Collapse while expanded and stays for screen readers
only in the icon rail, where the tooltip names the action. aria-label switches
between collapse and expand; the stored choice keeps its storage key.
The width transition now honours reduced motion. Adds the expand and
sidebar-action labels in all seven locales.
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).
Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.
The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.