Slice 2 of phase 3 of #1095. A reading that stated a unit other than its
marker's was refused. When the marker's analyte is known and the registry has the
pair, the three write paths now convert it instead: POST /api/labs, the OCR
commit and the document commit.
The converted value is stored at full precision in the marker's unit, with what
the report printed in the existing source_value and source_unit columns. The
report's own reference window is in the printed unit, so it is converted with the
value; otherwise the verdict would compare a converted value with an unconverted
window. A pair the registry does not have is still refused as before (the 422
now says why), as is a marker whose analyte is unknown.
The marker's key is filled the first time a reading needs it. The same scan
committed again is still a duplicate: a converted row holds the number in the
marker's unit, so the duplicate check also matches on what the report printed.
Editing the value of a converted reading clears the conversion record, because
the number is then the person's own.
The lab result DTO and the OpenAPI schemas (labs and the OCR commit) carry
converted, sourceValue and sourceUnit.
Refs #1095
Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing
said which analyte it was. This adds the explicit alias list in the catalog the
issue settled on, including the registry-only keys (urea, blood urea nitrogen,
uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key
(from migration 0385) from it.
matchAnalyteKey equals a marker's folded name against the localised names of the
catalog seeds the registry knows and against the alias list. It matches the
whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes
claim matches neither; and the marker's own unit must be one the analyte reaches,
so a marker called "Glucose" in a unit the registry cannot convert stays
unlinked.
The key is set when a marker is created (the biomarker route, the mint used by
lab writes, the legacy backfill) and, for an existing marker, by
ensureAnalyteKey the first time a reading needs it. It never overwrites a key
that is set. There is no boot job for the existing markers: the conversion that
uses the key fills it on first use, and a sweep can be added later if wanted.
The key is returned on the biomarker API and in the OpenAPI schema.
Refs #1095
Without a zoom in the URL the window now follows the record: one that
starts inside the last three months opens on three months clamped to
its first entry (at least four weeks up to today), an older one with
entries there opens on three months, an older one without opens on
everything, and an empty one keeps the empty state. The choice is made
from the first answer before anything is drawn, so the page does not
jump between windows.
Opened without a zoom, three months that hold nothing while the record
has older entries now show everything instead of a dead end, without
writing the zoom into the URL. A window named in the URL that is empty
offers "Show everything" when the record has entries before it. An
unreadable ?zoom= or range opens on the same default as no zoom.
The pin's SET on connect runs detached; its failure line now registers
with the background-task tracker like every other detached writer. The
coach thread test reads the new budget reset copy.
The token ledger keyed every day on UTC while the daily briefing and the
other per-day generations run on the account's local day, so east of UTC
the first generations after local midnight were refused against the
previous day's spent ceiling. The budget day is now the calendar day in
the account's time zone; each request still resolves it once and books
its reservation and reconcile on the same row. The medication extraction
reads relative phrases against the same local day, the operator's spend
card shows the operator's own day, and the limit copy says the budget
resets at midnight in the person's time zone.
On a phone the date's calendar is a sheet over the day's own sheet. In
the moment after it opens, before Radix has re-rendered both layers, the
day's sheet still holds the Escape listener, so an Escape aimed at the
calendar closed the day as well; the day-view spec caught it as a flake
on the phone viewport (4 of 10 runs locally). The day's sheet now ignores
an Escape that comes from inside another dialog, and the spec waits for
the layer handover instead of racing it.
The coach open-at-end spec read the thread's height as soon as an element
with the thread's slot was visible, but the empty state carries the same
slot. It now waits for the loaded thread's frame and all of its answers.
The document sheet gains an Open in new tab action for the types the
browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report
gets the browser's own zoom and search instead of the small preview. It
opens the object URL the sheet already fetched through the app transport:
a plain navigation to the serve route carries no record-session
assertion and would be refused inside a shared record. Every other type
keeps the download only, and the serve route's matrix now pins SVG and
HTML to attachment as well.
The docked panels' strips now run from the top edge of the window to the
bottom, and the top bar ends left of them. The panel header no longer
carries a fold button in docked mode: the strip opens and closes the day
and, on /coach, the conversations; opening moves focus to the panel's
heading and closing returns it to the strip. The sheets keep a close
button, the day's now an X at the end of its header.
The command palette's door is a magnifier at every width, named Search,
with the shortcut in its tooltip. g p opens or closes the day like a
click on its strip, and below the docking width opens the sheet on the
last day or closes it; the shortcuts list shows it under Day.
The dot before a mean sat on the x-height middle, a little below the
text, and a wrapping line could leave it at the end of one line while its
value moved to the next. Each dot and value is now one inline-flex unit
that centres the dot on the text line and never wraps inside; the row
breaks only between units, a long name is cut short inside its own unit,
and the leading mean sign rides in the first unit. The legend and chip
dots no longer shrink beside long text.
Under "What happened" an intake now reads "Ramipril 5mg" on one line, as
the same medication does under Running that day, instead of the name over a
muted dose line. A lab day's analyte count moves from the run-on second part
to the entry's tag: a chip of its own in the selection bar, brackets in the
screen-reader line and the hover title, the muted line in the chronicle.
From 1280 px the day and, on /coach, the conversations each keep a
narrow strip at the right edge, open or not. A click on the strip opens
its panel left of the strips and a second click closes it; the strip
never moves. Strips start below the top bar's band, side by side,
conversations first. Below 1600 px one panel is open at a time.
The day slides like the conversations (width, 200 ms, none under reduced
motion), stays open through a page change instead of folding and
reopening, and does not slide in on arrival. Its content loads when a day
first opens, so the scores, sections and calendar leave the shell's first
load: every watched route falls back under its unchanged ceiling. The
aggregate cap is re-measured for the chunk double count this causes.
react-day-picker and the seven date-fns locales rode the first load of
every page with a date field, the dashboard and the settings sections
among them. The calendar now lives in its own module that the popover
loads on first open; the locale map moves beside it.
Keeping the day open across pages read every loss of ?day= as a page
change, so Back past the step that opened the day on the same page
reopened it at once. A loss of ?day= with the page unchanged is the
person leaving the day, and it closes it as before; only a change of
page carries the open day along.
The layout settings gain a switch for the dashboard's top card (the Today
card above the tiles). Stored server-side on the layout blob as
`todayCardVisible`: missing means shown, only an explicit false hides it,
the default is omitted from storage, a save that omits the field keeps
the stored choice, and a layout reset shows the card again. Hidden, the
card leaves no skeleton or error card behind. The flag rides the
account-settings backup with the rest of the layout.
The field is deliberately not `heroVisible`, the retired opt-in hero's
flag that older stored layouts may still carry.
An end-to-end journey on its own account switches the card off and on
and walks the score histories' range tabs.
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.
A closed period's name could not stand right of its own bar: the room
check counted the bar it names as the next mark ahead, so "Away" went
above its bar and sat on top of the life event named on the row above.
The room ahead now counts only what starts to the right. A row's names
and the names above the next row's bars share one band a few pixels
tall, so whatever is placed on one is written into the other as well.
The phone chronicle listed "First value of a measure" twice on a day
when two metrics began on it, under one React key; it lists one line per
kind of notable a day.
The journey now writes two medications through the real routes (one with
a start date and its course, one known only by an intake) and asserts
each is drawn on exactly one row; carries an open day from /timeline to
/labs and back after folding it; hovers and focuses a value point and
checks the card names that line alone and stays inside the chart; and
measures the page at 1280, 1440 and 1920 px, with and without the docked
day: no sideways scroll, and no height past the content beyond the
shell's own padding. Expanding all of a day's values scrolls the panel
and leaves the page height as it was. The "Open <day>" link the bar no
longer has is asserted absent; a chip opens the day instead.
Label texts carry their lane under their own attribute, so a lane
selector matches the lane and not its names.
A point of a value line now shows a card for that line and that point
only: its name, the value with its unit, the stretch it is the mean of and
the readings behind it, in the tooltip the other charts use. Each point
has a 24 px HTML target over it: a pointer resting on it shows the card,
the keyboard reaches every line once and walks its points with the
arrows, a tap shows the card and a second tap (or its "View the whole
day") opens the day. The card is clamped to the chart's width. The SVG's
native titles are gone.
The two screen-reader tables beside the chart were hidden with sr-only on
the table element itself, where overflow: hidden does not apply: they laid
out at full size below the page and made <main> scroll sideways and past
its last card. They are hidden by a wrapper now, and the page ends where
its content does at every width, with and without the docked day.
The docked day stays open across pages. Leaving a page drops ?day= from
the URL, and the column folded to its edge on every navigation. The
remembered day now also remembers that it was left open (per account and
record, as before); a page reached without ?day= while it was left open
brings the day back, without taking focus from the page. Folding,
closing, Escape, a swipe down or opening the Coach's conversations
beside it is what closes it.
Cmd/Ctrl+K or the search control at the end of the top bar opens a
combobox over a client-side index built from the lists the navigation
already renders: pages, Insights pages, every Settings section and card
(deep-linked by a new anchor prop on SettingsCardHeader), the Appearance
sub-pages, module switches and a few actions. Entries follow the module
map, the Coach capability, the admin role and a shared record's grant;
inside somebody else's record no cards, switches or backup are offered.
Ranking is prefix, then word start, then substring, then a small edit
distance, then subsequence, with a short multilingual synonym list. The
empty state shows recent places (per account, in localStorage) and the
main actions. The palette is its own lazily loaded chunk; the top bar
shows a search field where it has room and a magnifier otherwise. The
module switch mutation moves into a shared hook used by the Modules hub
and the palette.
Labels are placed without collisions: a name keeps a full gap before the
next mark on its line, is cut with an ellipsis when only part of it fits,
and is left out when not even a few characters do. A name above an open
bar ends before the bar's arrow head. All names are drawn after the today
and selection lines, on a halo of the card colour, so a line passes
behind a name instead of through it.
A value line's name wraps onto a second line instead of reading "Blood
pressure sy…", in every bundle, and it leads to the metric's page, as do
the value tiles in the day view. Both read one map from a kind of value
to its page (metricPageHref); a kind without a page stays plain text. The
links carry a 44 px target, a hover and a focus ring.
A life event's category is no longer run on after its title ("Parental
leave Family"): the chart names the event alone, the selection bar shows
the category as a small chip inside the entry, and the phone chronicle
puts it on the muted line below.
The selection bar shows each mean as name and value; the readings behind
it ("mean of 19 readings") are a hover title and a screen-reader detail.
The lone "Open <date>" link at its end is gone; a day opens from its
chips, Enter on the chart or a double click.
In the day view an event's time keeps to one line in every locale.
Running that day listed a medication, its course and its pause as rows of
their own, so Mounjaro read as "Mounjaro 7.5 mg" above "Mounjaro". The day
read now folds them into one entry per medication: its name with the dose
in effect that day (the last dose change on or before it), the course's
days on the second line, or the paused entry while a pause runs. The
Coach's get_day reads the same loader and gets the same single entry.
The dashboard's pulse widget showed the resting heart rate whenever one
was recorded but called it "Pulse", while the day view's "Pulse" tile
was the day's pulse; one day read 62 on the dashboard and 77 in the day.
The widget is now named by what it shows ("Resting heart rate" with
resting rows, "Pulse" without), and the pulse's latest value is the
latest day's pulse, the mean of its local hours' means through the shared
day-mean helper and the day view's own canonical-source pick, so both
"Pulse" figures are one number. The day view's pulse goes through the
same helper in the record's zone.
The count line above the day ("33 values, 3 entries") and the usual range
repeated beside the focus value ("Your 30 days before") are gone; the
sections and the value tiles already say both. An assessment's score and
band join with a comma, not a middle dot.
An active medication whose courses had all ended had no stretch left in
the current window: the courses decided when it was taken, none reached
into the window, and the medication's own open span was set aside because
it had courses. The row disappeared while the day view listed the
medication as running. The time since its last course now stands in as
its stretch, open to today.
The server cut a lane's items at the window edge, so a medication arrived
with only some of its courses and without the dose change that set the
dose of the piece running into the window. A medication touching the
window is now sent whole.
On a phone, every entry wears its lane's colour: the icon in the lane
colour, and a medication listed inside an illness keeps a dot on its own
rail instead of reading as the illness's orange. A month without an event
is no longer called empty when it had readings (its header stays and
carries the means) or while a medication was taken in it; only a month
with none of the three gets the "No entries" line.
A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.
Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.
The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
g then d/m/l/t/i/c/s opens a page through the same visible-destination
lists the navigation renders, so a switched-off module, a Coach without a
provider or a door a shared record does not cover stays shut. n opens the
capture picker, [ and ] step the open day, ? lists everything. Keys typed
into a field, held with Cmd/Ctrl/Alt, or pressed while a dialog or menu is
open are left alone; [ and ] still act over the day sheet and when the
layout needs Alt or AltGr to type them. The list is also reachable from
the sidebar account menu.
A medication arrived as up to four kinds of item (its own span, its
courses, its dose changes, its pauses) and was drawn item by item, so a
medication with a course read as two bars on two rows. Every item of one
medication now carries the medication's id as `group`, and the lane draws
one row per group: the courses are the stretches it was taken, each dose
change cuts them into pieces labelled with their dose, and a pause is a
gap in the same bar. The selection bar and the phone chronicle list one
entry per medication with the dose in the text.
The value series cap of six is gone. The response carries
`availableSeries`, every series the record can show, and the picker is
one compact "Values (n)" button whose menu lists them. Past the fifth
line the five data colours repeat; every line is named at its left.
Choosing a range opens a popover anchored to the "Range" segment (a sheet
on a phone) with "Apply", so the chart no longer moves; the URL keeps the
range as before. The selection bar sits on the card under a hairline
instead of a grey box, the legend explains the dashed stroke and the
hollow point, and no timeline text joins its parts with a middle dot or
calls an open span "ongoing".