Commit Graph
3215 Commits
Author SHA1 Message Date
Marc-André Bombeck ba426706be Merge remote-tracking branch 'origin/main' into fix/v1398-polish 2026-10-01 22:23:09 +02:00
Marc-André Bombeck debd44a75b fix(charts): keep axis unit labels on one line; 44 px date and time triggers
Recharts word-wraps a tick label that it measures wider than its axis,
and it measures in the page's body font rather than the tick's 11 px.
"62 bpm" on the pulse chart under a workout and "106 mg/dL" on the
glucose page therefore broke onto two lines at every width, and the
pulse chart's second line sat on the first x tick. Axis units are now
joined with a no-break space through `axisUnitSuffix`, so the label is
one word and stays on one line. A guard fails when a chart axis joins a
unit with a plain space.

The calendar and clock buttons in the date and time fields paint at
32 px; a `::before` halo now gives them a 44 px touch target without
changing the field.
2026-10-01 22:18:28 +02:00
Marc-André Bombeck 801295f55a fix(dates): read date-only lab and document dates as calendar dates
A date-only value is stored at 12:00 UTC. The lab list and history, the
document cards and pickers, the document date suggestion, extracted
fact dates, source-picker rows and the compliance and mood heatmaps
formatted that instant in the reader's zone, which is the next day from
UTC+12 to UTC+14.

`useCalendarDate` formats such a value through the UTC-pinned calendar
formatter the vaccination list already uses, with the same field order
and locale as instant dates. Lab readings hold both kinds, so
`useLabDate` reads a noon-UTC anchor as its calendar date and a reading
typed with its time of day in the reader's zone. A document without a
filing date shows its upload day, an instant, in the reader's zone.
2026-10-01 22:14:07 +02:00
Marc-André Bombeck fe13fa966b fix(labs): print range bars and lab values in the reader's number format
Range bars and the lab list, detail rows, chart tooltip and reference
ranges printed numbers with a plain `String()`, so a German reader saw
"1.8 mIU/L" and "61.3 kg" under a German interface. The same was true
of the mean, GMI, eA1C and the advanced indices on the glucose panel.

`RangeBar` now prints value, bounds and delta through the locale number
formatter and takes an optional `formatValue` for surfaces with their
own precision. Lab values keep their trim rule (whole numbers bare, at
most two decimals) in `formatLabValue`, which now hands the digits to a
caller-supplied locale formatter; `useLabNumber` wires it to the
reader's locale for every lab surface. Inputs are unchanged.
2026-10-01 22:09:30 +02:00
Marc-André Bombeck 75361d0666 fix(glucose): state band edges in the reader's glucose unit
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
2026-10-01 22:07:07 +02:00
Marc-André Bombeck 08d5acdcb5 feat(measurements): log a resting heart rate by hand
The entry form offered pulse but not resting heart rate, which has its own
chart and series, so a value read off a watch or taken on waking could only
land among spot pulses. The form now offers it next to pulse. The server
already gates the type on the recovery module (its resting_hr domain), so
the form, the dashboard summary, the resting-pulse page, its trend slot and
its correlation channel now follow that module too.

Refs #1079
2026-10-01 21:53:38 +02:00
Marc-André Bombeck 661378bfa9 Merge remote-tracking branch 'origin/main' into fix/v1397-units2 2026-10-01 13:47:10 +02:00
Marc-André Bombeck 615f5a8070 test(units): sweep the AI and Coach producers for unit literals
The unit sweep covered the display trees and the insight library; the
prompt and snapshot builders, the text-writing jobs and the target and
digest producers were outside it, which is where a band in mg/dL, a
threshold in kg/week and a distance in metres reached the model. The
sweep now walks those trees too and matches prose units ("kg/week",
"5 metres") as well as symbols. The four allowlist entries for prompts
that stated weight in kilograms are gone with the literals; the new
entries each name why the literal is not a reader-facing unit.

A line scanner cannot see a canonical number printed with no unit at
all, so the guard also names every producer that carries a
preference-dependent figure into a prompt, a snapshot or a stored
sentence and requires each to call a reader-unit resolver. The canonical
weight signal of the day loses its kilogram label; the reader's unit is
attached where the signal is converted.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 43be8ebd43 fix(jobs): hand the reaction line its new datum in the reader's units
The reaction line read the arrived weight with its stored unit (kg) and
a workout distance in metres, whatever the reader had chosen, so the
sentence written about the new reading could quote kilograms to a
reader on pounds. The evidence now converts the reading and the
distance into the reader's units before the prompt is built.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 7cb1b5ba05 fix(insights): write the weight note and its thresholds in the reader's mass unit
The weight status note had its own generator and never took the unit
preference: its signal, summary, graded series, latest-day figure,
target band and correlation pairs were all in kilograms, and the card
prompt stated the normal day-to-day swing, the plateau line and the
loss pace in kilograms too. A reader on pounds was handed both units.

The generator now converts every weight figure once after the canonical
arithmetic (the correlations are scale-free and stay as computed), the
card prompt states its thresholds in the reader's unit, and the unit
preference joins the input fingerprint so a switch is never answered
with the old note. The shared status-card examples and the briefing's
plateau pattern no longer show a weight in kilograms for the model to
copy.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck d11c165675 fix(coach): read the cycle contrast and the metric table in the reader's units
The cycle block's headline contrast labelled weight in kg and the
temperatures in °C, and left a glucose contrast without any unit. The
metric table, which the person sees and whose summary the model reads,
listed weight in kilograms, temperatures in degrees Celsius, walking
speed in metres per second and distance in metres.

The cycle contrast now converts both means and the gap into the
reader's units (the gap by the factor alone) and names the unit in its
sentence. The table resolves each column's unit and decimals from the
reader's preferences and converts every folded period value once; the
snapshot carries the preferences it was written in, so the table and
the snapshot of a turn always agree.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck ca5ce518d8 fix(coach): state the snapshot's weight, body and distance figures in the reader's units
The Coach snapshot carried weight, body composition, temperatures and
walking speed and distance as stored: kilograms, degrees Celsius, metres
per second and metres, with no unit on the block. The evidence-block
rules then listed kg and both glucose units as the units to cite. A
reader on pounds, Fahrenheit or miles was answered in the units they do
not use.

Every value block now converts its timeline (and the weight block its
aggregate and coarse tail) into the reader's units after aggregating on
the canonical value, and names the unit on the block; the grounding
scalars stay canonical. Workout distances arrive in kilometres or miles
with the unit named once. The prompt in every locale tells the model
that a block's figures are already in the reader's unit and takes the
evidence-block unit from the block instead of a fixed list.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck a7e6b89672 fix(insights): hand the briefing its figures in the reader's units
The daily briefing serialised the aggregated feature set as computed:
weight, grip strength and the weight target in kilograms, waist in
centimetres, glucose and the signals of the day in mg/dL (the glucose
signal carried no unit at all), workout distances in kilometres. A
reader on pounds or mmol/L got a briefing in units they do not use.

One step now converts the feature set after all canonical arithmetic,
on both the nightly and the on-demand path: absolute values through the
full transform, differences and slopes through the factor alone, every
converted block labelled, workout distances under unit-neutral keys.
The prompt, the stored fingerprint and the grounding check all read the
converted set, so a unit switch moves the fingerprint and the grounding
check compares the reply against the figures the model was shown. The
weight target sentence, the comparison block and the GLP-1 plateau
context quote weight in the reader's unit; the comparison block also
labelled sleep as hours while carrying minutes.

A unit switch now also re-warms a cached briefing alongside the status
notes and narratives.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck d1b2539b1f fix(coach): state reference and time-in-range bands in the reader's units
The reference grounding printed the fasting glucose bands in mg/dL and
the body-temperature band in °C, and the system prompt described time
in range as 70-180 mg/dL. For a reader on mmol/L or Fahrenheit the
model was handed one quantity in two units: the glucose block in
mmol/L, the band beside it in mg/dL.

The grounding block now takes both unit preferences and prints the
general, the diabetes goal and the temperature band in them, while the
placement is still classified on the canonical value. The glucose
block carries the time-in-range band in its own unit, and the prompt
points the model at it instead of naming a unit.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 6dbf57f4f4 fix(ai): name the acute glucose and fever floors in both units
The acute red-flag clause stated the glucose floors only in mg/dL and the
sustained-fever floor only in °C. The clause is matched against what the
person writes, so a reading of 2.9 mmol/L or 103 °F did not meet any of
its numbers. Each floor now carries both units, mg/dL and °C first, in
the English and German contracts.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 8b84e08145 Merge remote-tracking branch 'origin/main' into fix/v1397-account-backup 2026-10-01 13:21:55 +02:00
Marc-André Bombeck ff8277695a test(backup): round-trip every account setting through a real restore
Fill every User column on the source account, export through the streaming
writer, recreate the account with credentials of its own, restore through the
route, and compare: each setting column must come back with the value it left
with, and every identity, credential and bookkeeping column must still hold
the target's value. The section in the file is checked to carry nothing but
setting columns.
2026-10-01 13:12:25 +02:00
Marc-André Bombeck 7abaa25c14 feat(backup): export the account settings and restore them onto the account
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.
2026-10-01 13:04:41 +02:00
Marc-André Bombeck d731a1b8fe feat(backup): classify every User column and carry the account settings
Every column of the account row now has a backup verdict in
USER_COLUMN_BACKUP_CLASS: SETTING, CREDENTIAL, IDENTITY or OPERATIONAL. A
schema-derived guard fails on an unclassified column, and the setting columns
each get a codec in account-settings-backup.ts, so a column classified as a
setting without a way to travel does not compile.

The restore end checks each value the way the settings route that writes it
does: AI endpoints against this host's private-origin policy, languages, units
and time zones against the known vocabularies, threshold bands against their
bounds, the avatar against the upload's sniff and limits. A refused value is
left out and named; credentials, identity and the host's own cursors are never
admitted.
2026-10-01 13:04:36 +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 383323b542 Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:29:08 +02:00
Marc-André Bombeck 03eced171e Merge remote-tracking branch 'origin/main' into fix/v1397-meta 2026-10-01 11:25:06 +02:00
Marc-André Bombeck d1a7406ff7 test: pin the nutrient source marker and arrival time in the payload builder test 2026-10-01 11:24:25 +02:00
Marc-André Bombeck 3f5bcbd441 fix(backup): carry four columns the restore was dropping
A column-level round trip over every two-ended model found four values
that left with the export and came back empty or re-stamped:

- MenstrualCycle.absorbedIntoId: the disaster-recovery file carried the
  folded, soft-deleted start but not the start that absorbed it, so after
  a restore removing the absorbing start could no longer bring the folded
  one back. Carried in the disaster-recovery arm and written back only
  when the file carries the start it names.
- Workout.externalSourceVersion: the source's version marker, stored so
  the sync can replay it unchanged, was in neither end. Carried beside
  metadata in the disaster-recovery arm.
- NutrientIntakeDay.externalSourceVersion and createdAt: neither was
  selected, so a restored day lost its source marker and its first-arrival
  time. Carried in both arms.

Files written before this change parse as before; every new field is
optional on the way in.
2026-10-01 11:17:44 +02:00
Marc-André Bombeck ada490c7e9 Merge remote-tracking branch 'origin/main' into fix/v1397-tz 2026-10-01 10:59:51 +02:00
Marc-André Bombeck b4577f5c31 test: give every scanning guard a floor on what it found
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.

- requireFloor(label, found, floor) throws below the floor and refuses a
  floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
  and floors both the files read and the hits found.

Floors added, pinned at the current counts:

- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
  (399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
  are equal.

The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
2026-10-01 10:52:48 +02:00
Marc-André Bombeck 27ac4c1d63 Merge origin/main into fix/v1397-tz 2026-10-01 10:39:26 +02:00
Marc-André Bombeck da8cc7a6ee Merge remote-tracking branch 'origin/main' into fix/v1397-derive 2026-10-01 10:34:17 +02:00
Marc-André Bombeck 533b75fdcf Merge remote-tracking branch 'origin/main' into fix/v1397-derive 2026-10-01 10:30:10 +02:00
Marc-André Bombeck 4a012be37e Merge branch 'main' into fix/v1397-units 2026-10-01 10:12:47 +02:00
Marc-André Bombeck 5f548d331f fix(labs): say when a scanned PDF was read from its first pages only
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.
2026-10-01 10:05:35 +02:00
Marc-André Bombeck 5caa15f4e1 fix(google-health): count sleep segments with a stage label the mapper does not know
A segment whose stage label was unknown (SLEEP_STAGE_TYPE_UNSPECIFIED, or a
label added upstream later) was skipped without a trace, so the night total
read short and nothing said why. The mapper now counts those segments and
keeps their labels, and the sleep sync puts both on its wide event.
2026-10-01 10:05:35 +02:00
Marc-André Bombeck 8821fdeb54 test(tz): run core day paths with the host at both edges of the clock
The suite and the production image both sit near UTC, so a UTC day and a
local day agree for most of every day and day-boundary defects kept
passing. The harness sets the host zone in code to Los Angeles and to
Kiritimati, gives the account the same zone, and checks a schedule slot,
a course's last day, a mood entry, the dashboard summary's mood instant
and a document date round trip at the instant where the local and the
UTC day differ. Unit tests cover the new date-only and key helpers.
2026-10-01 10:02:50 +02:00
Marc-André Bombeck a66976874e fix(mood): report the tag and factor keys a mood write did not store
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.
2026-10-01 10:01:06 +02:00
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00
Marc-André Bombeck a1fa84dcdf chore(units): drop the preference loader nothing calls yet 2026-10-01 09:53:45 +02:00
Marc-André Bombeck eae46ed535 fix(medications): preselect only a dose offered today, graded by the account's thresholds
The intake pickers chose their default from every active medication, so a
course that had ended but was not archived could be the preselected dose.
The default now reads the server's intakeActionable answer; the medications
page dialog still lists ended courses for back-filling a past dose, they are
just never the default.

Both pickers also graded "due" against the fixed 120 / 240-minute defaults
while the cards beside them used the account's own late and missed
thresholds. They read the same thresholds now.
2026-10-01 09:48:48 +02:00
Marc-André Bombeck ef420f9e2a fix(insights): draw the blood-pressure zones from the record's resolved band
The blood-pressure chart rebuilt its target zones in the browser from the
signed-in account's date of birth. A band the person had set themselves never
reached the chart, and inside a shared or managed record the zones followed
the viewer's age instead of the record's.

The targets payload already resolves for the record on screen. Its blood-
pressure card now honours the user's own band too (labelled Custom, like the
weight card), and the chart reads its zones from that card, so the chart and
the reference panel under it name one band.
2026-10-01 09:46:16 +02:00
Marc-André Bombeck a67ba9ee66 fix(labs): match scanned duplicates on the reading's calendar day
The scan commit and the review screen looked for an existing reading in
the UTC day of the scanned date. A hand-entered morning draw east of UTC
sits on the previous UTC day, so scanning the same report wrote the
reading a second time. Both checks now compare calendar days: a date-only
reading's stated date, or the user's day of a hand-entered instant.
2026-10-01 09:37:59 +02:00
Marc-André Bombeck 9662a3d104 fix(insights): keep a medication course in the storyline through its last day
endsOn is a calendar date, and the storyline compared it with the instant
now, so a course dropped out on its final day. Compare it with the user's
own today as the column's date value instead.
2026-10-01 09:35:11 +02:00
Marc-André Bombeck 838629276c fix(documents): store stated dates at noon UTC
A report's stated date, a document's filing date and the date a committed
lab reading or condition carries were written as UTC midnight, which is
the previous evening west of UTC: a reading dated the 1st showed on the
31st. Date-only values are now stored at noon UTC through one helper in
the time zone module, the convention the lab scan and the cycle engine
already use. Rows written at midnight still read back to the same date.
A document uploaded without a date files under the uploader's own day
rather than the UTC one.
2026-10-01 09:33:50 +02:00
Marc-André Bombeck 4ac05364fe fix(coach): count GLP-1 injection days on the user's calendar
The snapshot cut the last injection, the dose history and the projected
next injection on UTC days and compared getUTCDay() with the schedule's
local weekday. An evening injection west of UTC landed on the next day,
and east of UTC 'tomorrow' was today. Day keys now come from the user's
zone, which the Coach snapshot already resolves and now passes in.
2026-10-01 09:29:01 +02:00
Marc-André Bombeck 2dc73be233 fix(dashboard): put the mood card's instant on the user's local day
The mood series carries local day keys, and the summary wrote the newest
one out as UTC midnight. West of UTC that instant is the previous evening,
so a client formatting it in its own zone showed today's entry as
yesterday's. Emit the first instant of the day in the account's zone
instead; the field keeps its shape and type.
2026-10-01 09:27:56 +02:00
Marc-André Bombeck 1934b97663 test(guard): declare every queue's runtime and hold long ones to it
Every queue bound with createAndWork is now declared in
src/lib/jobs/queue-runtime.ts as short (cannot come near the default
fifteen minutes, with the reason), budgeted (stops itself at three
quarters of the default and resumes) or long (an explicit expiry, a
stop on the job's budget, and a lock or claim per identity; or a
declared one-shot with no retries).

The guard reads every binding, every boss.send and every schedule tuple
in the tree: each names a queue in the table, every entry is bound, a
long queue's sends and schedules all carry its expiry, its handler
stops on jobBudget / jobDeadline where declared and holds lockedPass or
the named marker, and a short or budgeted queue sets no expiry of its
own. Floors pin the counts (112 bindings, 64 sends, 74 schedules), and
planted long queues missing each protection in turn are caught. Run
against the tree before the previous commit it reports 59 findings;
dropping lockedPass and the cron expiry from pr-detection reports both.
2026-10-01 09:24:15 +02:00
Marc-André Bombeck ac3fdfccc3 fix(jobs): keep long passes inside their expiry and alone
pg-boss gives a job fifteen minutes unless the send says otherwise,
then fails it and retries it while the handler keeps running. Several
passes can legitimately need longer and had no protection at all: the
seven nightly status passes and the pre-generation (a provider call per
account), the period-narrative warm, the record-detection cron (every
account's history, every thirty minutes), the key rotation (every
encrypted column), the content-index backfill (up to 200
transcriptions) and the on-host backup.

Each of them now carries an explicit expiry on every send and schedule,
stops between units once three quarters of it is spent (jobBudget), and
runs under a session advisory lock keyed on queue and identity
(lockedPass), so the retry pg-boss starts after the expiry does nothing
beside a run that is still going. The lock is the one the full-history
imports already held, moved into job-lock.ts.

Where a pass stops early it says so (stopped_early) and the next run
continues: the status passes and the narrative warm leave the rest to
the next night, the pre-generation and the narratives walk the stalest
accounts first, the on-host backup now backs up the oldest copies
first, the key rotation reports a partial run in its audit row and
skips rotated rows when run again, and the dose-history import fails
the delivery so its retry resumes from the committed chunk. The
off-host backup, which already stopped on its budget, gets a four-hour
expiry and the lock.
2026-10-01 09:23:45 +02:00
Marc-André Bombeck 9a282ed698 fix(units): keep converted values on the rounding helpers
The narrative, the workout distance and the elevation chart converted
through the unrounded escape hatch, and the trend caption scaled its
noise floor off the raw factor. They now take the rounded helpers, so a
narrated pound or mmol/L figure reads at the grain every screen shows
it, and the caption's floors are declared canonically (the distance
floor in metres). The trends row joins the chart-feeding surfaces that
may hand a raw scale to a chart.

Refs #1067
2026-10-01 09:17:44 +02:00
Marc-André Bombeck 0039bd1d1f refactor(insights): use the shared rescale predicate in the status note
Refs #1067
2026-10-01 09:11:47 +02:00
Marc-André Bombeck bc2d60cce8 fix(insights): plot the trends row and log fevers in the reader's units
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
2026-10-01 09:08:11 +02:00
Marc-André Bombeck d6f79c8bef fix(workouts): show distance, pace and elevation in the reader's units
The workout list, the detail header, the stat tiles, the sport-average
line and the elevation chart printed kilometres, minutes per kilometre
and metres for every reader. They now read distance, pace and elevation
through the unit registry (km or mi, /km or /mi, m or ft). The splits
table keeps its kilometre rows, which are cut server-side, and says so.
A pace that rounded to sixty seconds now carries into the minute.

Refs #1067
2026-10-01 09:03:51 +02:00
Marc-André Bombeck 5486014360 fix(score): write the glycaemia labels in the reader's glucose unit
The fasting-glucose pillar served its observed and reference labels as
"Fasting median 99 mg/dL" and "70-99 mg/dL fasting" whatever the
account's unit, and clients print those labels as they come. They are
now written in the record's glucose unit. The scored value, its unit
and the band edges stay canonical, so the score does not move and the
web, which converts from the value, cannot convert twice. The schema
now says which fields are canonical and which are display text.

Refs #1067
2026-10-01 09:00:53 +02:00