Commit Graph
7198 Commits
Author SHA1 Message Date
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 9cab8e74da Merge pull request #1075 from MBombeck/fix/v1397-ecg-ingest
Accept the fractional heart rate and frequency HealthKit reports for ECGs
2026-10-01 13:21:31 +02:00
Marc-André Bombeck 729401e032 chore(openapi): describe the rounded ECG heart rate and frequency 2026-10-01 12:53:25 +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 28054e69c2 Merge pull request #1074 from MBombeck/fix/v1397-meta
Every guard must be able to fail; every backed-up column must round-trip
2026-10-01 11:57:12 +02:00
Marc-André Bombeck 6725110b25 chore(openapi): regenerate for the backup fields that now round-trip 2026-10-01 11:34:19 +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 4e4bc6f8be Merge pull request #1073 from MBombeck/fix/v1397-tz
Compute every user-facing day in the user's zone, and refuse new UTC day keys
2026-10-01 11:28:57 +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 b7a3b0d331 test: round-trip every column of every two-ended model
The round-trip test proved that a row of each two-ended model comes back,
not that it comes back whole. A restore that writes the row with a column
defaulted returns the right count and loses the value; the medication
category went missing that way.

The new case lists each model's columns from the client's data model,
with optional/default/key attributes from schema.prisma (the two field
lists must agree). Columns the fixture left empty get a value that cannot
pass for a default, one column at a time so a refused value names its
column. Then it exports, deletes the account, restores, and compares each
column as a multiset of values: sealed columns by their plaintext, a
legacy plaintext column together with its sealed twin, declared foreign
keys by presence, primary keys, owner and @updatedAt excluded by rule.

A column added tomorrow is compared automatically: it comes back, or it
is named in COLUMN_EXCLUSIONS with a reason. Entries naming a column that
no longer exists fail. A pure case shows the comparison catches a column
it was never told about, and the run asserts at least 620 columns
compared (632 today). Removing the workout source marker, a schedule
label, the folded-cycle pointer or the nutrient createdAt from either end
each fails the run naming that column.
2026-10-01 11:17:44 +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 e2685cec67 Merge pull request #1072 from MBombeck/fix/v1397-derive
Serve resolved values to the client and report what a write left out
2026-10-01 10:59:31 +02:00
Marc-André Bombeck f3bf65fb4e test: fail any structural guard that cannot fail on an empty sweep
guard-floor-guard reads every *-guard(s).test file under src/ and every
test that walks a directory, parses it with the TypeScript compiler, and
fails unless it asserts a floor on what it found: requireFloor,
scanSourceMatches, toBeGreaterThan, an exact non-zero count, or an exact
non-empty set. A floor on the walk alone does not count; the Bearer-scope
guard had one and still matched nothing for weeks.

Files that are not scanners (unit tests of runtime functions named
"guard", fixed file lists with a positive match per file, temp-directory
listings) are named with a reason. An exemption that goes stale fails the
run. The reader's own shapes are pinned by probe sources, including the
ones it must not count: comments, strings, .not, zero floors, it.skip.

It answers per file, not per matcher, and says so.
2026-10-01 10:52:48 +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 a3b5480d04 Merge pull request #1069 from MBombeck/fix/v1397-units
One unit system: every surface follows the reader's glucose and metric/imperial preference
2026-10-01 10:34:06 +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 9b9e1929b5 Merge pull request #1071 from MBombeck/fix/v1397-mobile
Make the signed-in app work on a phone, starting with lab results
2026-10-01 10:27:06 +02:00
Marc-André Bombeck 4a012be37e Merge branch 'main' into fix/v1397-units 2026-10-01 10:12:47 +02:00
Marc-André Bombeck 414b099972 test(guard): freeze client reads of server-resolved fields
A per-field registry of the answers the server resolves for the record on
screen (the blood-pressure target band, intakeActionable and courseStatus,
the AI capability, the module surfaces) and the ingredient shapes that
rebuild them in the browser: getBpTargets() on the viewer's age, a dose
offered from active and trackIntake, course state from endsOn against the
clock, the raw disableCoach column, server resolvers imported into client
code. A match in client code fails unless the file is allowlisted with the
reason it is not a rebuild; the switches that edit a stored value are the
allowlisted readers.

The walk carries a floor, every pattern is checked against a sample of the
shape it catches, a stale allowlist entry fails, and the total match count
must be non-zero. Putting back each of the three rebuilds this release
removed fails the guard.
2026-10-01 10:09:28 +02:00
Marc-André Bombeck 1200d1bb05 Merge pull request #1070 from MBombeck/fix/v1397-dense
Fold dense reads in requests and give every job queue a declared runtime
2026-10-01 10:07:08 +02:00
Marc-André Bombeck 5e21363e0f docs(comments): correct four comments that describe gates and mints that no longer exist
- /api/auth/me and the AuthUser type described disableCoach as the gate
  every Coach surface checks below a flags.coach short-circuit. Neither has
  been true since the AI capability resolution: it is the account's stored
  opt-out, and surfaces read ai.capabilities.coach or modules.coach.
- /api/mcp/tokens claimed /api/tokens mints medication:ingest; that mint was
  removed, and the medication pair now comes from the per-medication
  endpoint toggle.
- /api/meta/capabilities listed the token kinds as wildcard, medication
  ingest or MCP, missing measurements:write, workouts:write and
  documents:write. Comment only: the served scopeMintable answer for the
  FHIR face is unchanged and still right.
2026-10-01 10:06:39 +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 e37263e8cc fix(vaccinations): render a dose date through the calendar-date formatter
The noon-UTC rendering trick fixes the day west of UTC and moves it east
of UTC+12. A dose date is a calendar date, so it goes through the
UTC-pinned formatter the date fields already use, which is right in every
zone.
2026-10-01 10:01:17 +02:00
Marc-André Bombeck 06e86ef584 fix(ui): keep the page-header title a sibling of its description
The grid change wrapped the heading in a cell div, which separated it
from the description paragraph the settings-score spec reads as its
following sibling. The heading is the grid item itself now.
2026-10-01 10:01:14 +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 fdbbd3a84a fix(settings): gate the Coach tuning on the record's module state
Settings → Coach hid its preference and nudge cards on the raw disableCoach
column, which /me publishes for the signed-in person. Inside a managed or
shared record that answered for the wrong person: a guardian with the Coach
on saw the tuning for a record that has it off, and the other way round.

The cards now read the module map the server resolves for the record on
screen, the same map the navigation and the settings entry already follow.
2026-10-01 09:50:41 +02:00
Marc-André Bombeck 64c0b25411 test(e2e): open the labs sheet in German in the dialog overflow spec
The three-button footer fits a phone row in English, so the English
case never saw Cancel leave the sheet. A German case at every width
fails against the old footer (checked) and passes against the fix.
2026-10-01 09:49:19 +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 567fd8d51c test(e2e): sweep every signed-in route for sideways scroll at 390px
The lab list panned sideways on a phone while the overflow guard was
green, because /labs was not on its hand-kept list. The new sweep reads
the routes from src/app: static routes are visited as is, every dynamic
route needs a resolver to real paths, and routes that are not signed-in
surfaces are listed with their reason. A route added without either
fails the bookkeeping test by name.

It runs on an account of its own, filled once from globalSetup with one
or two of everything a route renders, in German, so the narrow-column
and long-string cases are present. Two lab tests assert at the elements
that broke: the marker name keeps a readable width, and the values
list's actions stay on screen. The dialog spec gains a 360px width,
where the labs sheet's three-button footer overflowed in English too.
2026-10-01 09:45:15 +02:00
Marc-André Bombeck 537ac3c7a1 fix(settings): stop settings and admin cards widening the page on a phone
Three cards put a full sentence into the SettingsCardHeader status slot
(mood reminder, low-stock warning, the app-log refresh button), which
does not wrap: the row grew to 1000px and /settings/notifications
panned sideways at every width. The sentence moves into the card body,
and the slot is capped at the row so a long status wraps instead.

Also on a phone: the provider-chain add control wraps, the report
picker's standard-template action drops under its sentence instead of
running past the card, and the dashboard layout rows put their switches
and arrows on a second line so the tile name is no longer cut to five
characters.
2026-10-01 09:44:43 +02:00
Marc-André Bombeck dc38540573 fix(ui): give the page-header description the full row on a phone
With three or four icon actions beside the title, the description was
left a ~130px column that wrapped five or six times. Below sm it now
spans the row under the title and actions; from sm up the actions span
both rows exactly as before.

A one-word German title wider than the column beside the actions ran
underneath them; the heading hyphenates by the page language and breaks
as a last resort. Notifications, whose two actions are text links,
moves them under the description on a phone (stackActionsOnPhone).
2026-10-01 09:44:43 +02:00
Marc-André Bombeck 24e6972f95 fix(labs): keep the biomarker chart axis at zero for a range starting at zero
A reference window of 0-130 padded the axis to -13, a tick that reads
like a value for a concentration that cannot be negative. The pad only
goes below zero when the data already does.
2026-10-01 09:44:30 +02:00
Marc-André Bombeck 4a13004917 fix(labs): fit the lab list, values list and add sheet to a phone
The compact list was a five-column table at every width. Below the fixed
columns (badge, 12rem range bar, 72px trend, chevron) the name column
collapsed to nothing, the badge sat over the value and the row panned
sideways off the card. Below lg the same cells now fold into two lines
per row; the desktop subgrid is unchanged.

The values table needed ~510px, so on a phone its edit and delete
buttons sat in a sideways-scrolling strip and the range bar labels were
clipped. Below lg each reading is a stacked row with its actions beside
it.

The add sheet's three footer buttons did not fit a phone row in German
and pushed Cancel off the left edge; the secondary save takes a row of
its own there, and the footer wraps instead of overflowing on desktop.
The scan review's 'links to existing marker' chip truncates instead of
running past its row.
2026-10-01 09:44:29 +02:00
Marc-André Bombeck 5820078008 fix(environment): default the date fields to the user's own day
The travel and backfill pickers defaulted to the UTC day, which was
tomorrow east of UTC in the small hours and yesterday west of it in the
evening, and the backfill start cut the home's effective instant the same
way. Both now read the day in the account's zone.
2026-10-01 09:39:09 +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 67d88de3d5 fix(glp1): file timeline side effects under the mood entry's own day
The timeline grouped side effects by the UTC day of moodLoggedAt, so an
evening entry west of UTC (or a morning one east of it) showed on the
neighbouring day. The entry's stored date is already the writer's local
day; group by that.
2026-10-01 09:34:13 +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