Commit Graph
6244 Commits
Author SHA1 Message Date
Marc-André Bombeck 89ec49cd42 Merge the medication adherence end-to-end journey 2026-09-10 04:20:01 +02:00
Marc-André Bombeck 06d2104808 Merge managed-profile editing, sex at creation and module switches per record 2026-09-10 04:19:35 +02:00
Marc-André Bombeck db8ac95342 test: cover the tile's rate attribute in the unit gate, and correct two notes
`data-latest-rate` is a production attribute whose only reader was the browser
suite, so a change to the rate's shape would have surfaced three minutes and a
whole server away from the gate that should own it. One unit test now renders
the chart over a loaded window and asserts the root carries the last day's
rate.

Two comments were wrong. The card's take / skip buttons do not collapse to
icons at narrow widths — both render their label unconditionally beside the
icon — so only the true half of the reason for addressing them by `data-slot`
survives: the labels are translated copy.

And the Enter press in the wizard's times-of-day helper is not decoration.
The field's overlay input holds what is being typed and hands it to the form
only on Enter or on blur (`src/components/ui/time-field.tsx:175-184`), while
the add button is disabled on an empty draft — so a fill followed straight by
a click waits out the actionability timeout on a button that never enables.
Proven by removing it: the flow died at exactly that click. The line stays,
with the reason written beside it.
2026-09-10 03:45:32 +02:00
Marc-André Bombeck c39bf0d0ed test(e2e): name the tile's disagreement about a skip, and assert both refusals
The dashboard assertion called itself "the assertion that they cannot drift
apart silently" and then pinned 50. They have already drifted. The tile's
denominator is the day's expected slots, which a deliberate skip does not
leave, and its numerator counts only rows carrying a `takenAt`
(`src/lib/analytics/schedule-anchored-compliance.ts:141-164`). The card's
engine — `src/lib/analytics/compliance.ts`, the documented single source of
the percentage — drops a skip from the denominator outright
(`src/lib/analytics/compliance/ledger.ts:43-47`), which the counts nine lines
earlier already assert. One afternoon, two answers. The note now says that,
names which engine is authoritative, and the number stays pinned as what the
server's daily buckets really produce rather than as agreement.

The refusal flow was named for a schedule that does not exist and proved
something else: the 404 comes from the ownership guard, before the body is
read and before any schedule code runs. `expect(refused.data).toBeNull()`
proved nothing either — the error envelope always carries a null `data`. So
the test is renamed for what it does prove, the empty assertion is dropped,
and the case the old title promised is added: a pin onto an instant that is
not one of the medication's slots, refused with the documented 422.
2026-09-10 03:45:20 +02:00
Marc-André Bombeck f544cae9a9 test(e2e): read the card's rate once, and keep the journey inside one day
Two separate ways the flows could not deliver what they promised.

The card read polled for up to seventy seconds and escaped a missing figure by
reloading the page — which re-mounts the list and issues the very batched
compliance call it was waiting out, so each poll spent one more of the
allowance it was short of. Meanwhile the budget is thirty seconds tripled by
`test.slow()`, so one ride-out consumed the whole test and the run died on a
Playwright timeout rather than on the "the card never painted an adherence
figure" message the note promised. The inner poll was also wrapped in a second
one with a thirty-second budget, and `expect.poll` does not abort an in-flight
callback, so a seventy-second call ran to completion inside it.

With the journey on its own account there is nothing to ride out: the read now
waits twenty seconds for the attribute and fails with the diagnosis attached.
The nesting is gone too — the step that waits for the card to re-read its
schedule polls the attribute directly.

The other one is the day boundary. A clock-anchored slot is inside its own
on-time window by construction, so the TAKE is always today's; the dashboard
assertion is not, because it reads the latest day and a day's expected count
carries no now-cap. Cross midnight between the take and the final read and the
latest day is a fresh one with two doses scheduled and none taken, which
answers 0 where 50 is asserted. The flow is a couple of minutes long, so it
declines to start in the last ten minutes of the day and says why.
2026-09-10 03:45:09 +02:00
Marc-André Bombeck d529d5976c test(e2e): move the adherence journey onto its own account
The batched compliance read allows thirty calls a minute per ACTOR, and the
journey spends that allowance twice over: it writes doses and then reads the
resulting rates back off the same cabinet. On the shared account two siblings
spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and
drives a client-side round trip back to it, and `delegated-writes.spec.ts`
opens `/medications?new=1` on a SECOND session row of the same account, which
is a different session but the same actor and therefore the same bucket. Add a
serial group's retries and thirty reads inside one minute is reachable rather
than theoretical.

The cabinet has the same shape of problem in the other direction: the clear
before each test deletes every medication row of the account it runs on, and
that is today the account every other authenticated spec holds.

So the journey gets its own account and its own jar. The rate-limit bucket,
the medication cabinet and the dashboard tile's denominator become private at
once.

Two fixture repairs ride along, because they are the same helper:

- The clear now goes out as the app's own DELETE per row instead of one raw
  `DELETE FROM medications`. Three fifteen-minute per-user cells hold
  medication state and a raw statement evicts none of them; the file survived
  only because every test happened to write through the API afterwards, which
  is an accident of ordering rather than a property. The rows are still
  enumerated in SQL, because the list route serves a memoised shape and a row
  it has not caught up with would hide from it.

- `ageMedication` takes both stamps from its caller instead of deriving them
  from `CURRENT_DATE`. That resolves in the database session's zone — UTC in
  CI — while the plan dates the spec sends are computed in the browser's
  pinned Europe/Berlin, so between midnight and 02:00 the row silently started
  a day earlier than the caller asked for. The docstring now says `starts_on`
  is overwritten, not adjusted.
2026-09-10 03:44:48 +02:00
Marc-André Bombeck 297395372b Merge pull request #945 from MBombeck/release/v1.38.14
v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf
v1.38.14
2026-09-10 03:43:31 +02:00
Marc-André Bombeck fd337a4e7a fix(import): match the molar-mass annotation by the shape Apple writes
The catch-all angle-bracket pattern read as an HTML sanitiser to static analysis. The annotation Apple writes is a number, so the pattern now matches exactly that, and a unit with anything else between the brackets stays unknown instead of being silently trimmed into a lookup.
2026-09-10 03:25:16 +02:00
Marc-André Bombeck 0c46df1413 fix(api): put a ceiling on editing a managed record's identity
`PATCH /api/managed-profiles/{id}` had none, while creating a record and
inviting a Guardian have both been capped at ten an hour since they shipped —
one family answering the same abuse three ways. It matters more here than the
count suggests: `updateManagedProfile` opens a transaction and takes an
advisory lock on the id it was HANDED before it decides the caller is not a
Guardian of it, so an unthrottled caller can name any id and make the database
hold a lock about it.

The refusal now lands before the id is read, on the same bucket convention and
with the same ceiling as creation. The GET beside it stays unlimited, matching
the guardian roster read next to it: both are cheap identity reads a Guardian
may already make. The 429 is documented on the contract and the edit form's
message mapping grew the arm creation already had.
2026-09-10 03:24:50 +02:00
Marc-André Bombeck 3d163d3602 chore(release): v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf
Version anchors, the OpenAPI document and the changelog entry. Refs #943, #944.
2026-09-10 03:05:20 +02:00
Marc-André Bombeck 25ba09e714 Merge the precise off-host deletion note 2026-09-10 02:19:00 +02:00
Marc-André Bombeck 242be8100a Merge the truthful batch unit description and the dry-run count fix 2026-09-10 02:19:00 +02:00
Marc-André Bombeck 92e43ebf67 Merge the synthetic journey's host allowlist and cleanup 2026-09-10 02:18:59 +02:00
Marc-André Bombeck c673255355 fix(settings): stop a timezone edit writing the actor's language into a record
A managed record with no recorded language opens the edit form's language
control on the actor's, which is the same starting point the creation form
uses. The diff then compared that control against the record's NULL column, so
every save of such a record carried `locale` — a guardian correcting only the
timezone silently recorded their own language as the record's, and the audit
row said the language changed.

The comparison is against the value the control opened on, frozen at mount.
Nothing is sent unless somebody moves it.
2026-09-10 01:59:08 +02:00
Marc-André Bombeck 038fc72be2 fix(settings): keep a managed record's edit form across a refetch
The fields component was keyed on the query's `dataUpdatedAt` under a comment
saying the key stopped a refetch landing mid-edit from replacing what somebody
is typing. It did the reverse: the timestamp moves every time the query
resolves, so invalidating the `managed-profiles` prefix from anywhere — the
guardian list on another row is enough — remounted the fields and threw the
half-typed name away.

Keyed on the record's id instead, which is stable for as long as the form is
about the same record.
2026-09-10 01:57:42 +02:00
Marc-André Bombeck 0bae48180a docs(api): say which account payload fields describe the record
The `AccountPayload` description still claimed that under an active switch the
payload describes the caller "their preferences, their modules, their
identity", which stopped being true when the module map and the cycle flag
started answering for the active record — `ModuleMap` on the same spec said
the opposite. The iOS client reads this file.

Both descriptions now say the same thing: identity and preferences are the
caller's, `modules` and `cycleTrackingEnabled` are the active record's, and
both are masked to the sections the active grant opens. Spec regenerated.
2026-09-10 01:57:12 +02:00
Marc-André Bombeck 8ff1b865fc fix(settings): send a managed record's cycle flag only when it moved
The Modules save posted `cycleTrackingEnabled` on every submit, and the route
writes it whenever it is present. An explicit boolean beats the sex-derived
default in the cycle gate, so a guardian turning Labs off for a record created
without a recorded sex wrote cycle OFF as a deliberate answer; setting the
record's sex to FEMALE afterwards then left cycle off with nothing on screen
saying why. It also listed `cycleTrackingEnabled` as changed in the record's
audit trail on every save of any module.

The flag now rides only when it differs from the value the form opened on,
which is what the edit form's `managedProfileEditPatch` already does. The
decision moved into a pure `managedModulesPatch` so a test can see it — what a
form does NOT send is invisible in its markup.
2026-09-10 01:56:17 +02:00
Marc-André Bombeck 0cccb4de99 fix(sharing): keep a scoped grant out of the record's module configuration
Publishing the active record's module map and cycle flag on the account
payload was right for a whole-record grant and too generous for a narrowed
one. `accountAccess.active` is set for any live grant, level and scope
included, so a delegate scoped to `measurements` read whether the owner
tracks their cycle, their screeners, their illness episodes and their
supplement intake, and the cycle flag derives from the owner's recorded sex.

Both fields are now masked to the sections the active grant names: a closed
section answers `false`, which is the answer a module that is off already
gives and the answer the browser's route inventory has always acted on. An
unscoped grant names the whole record and is unaffected, so the own-record
and whole-record payloads are byte-identical to what they were.

The masking table lives beside the sharing vocabulary rather than inside it,
and runs one way only: no value in it can turn a module on, admit a read or
widen a grant.
2026-09-10 01:54:29 +02:00
Marc-André Bombeck 617beb40dc test(e2e): cover the medication-adherence journey end to end
The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.

Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:

  - a twice-daily medication created through the real wizard, one dose taken
    and one skipped from the card, and the figure read back on the card, in
    the intake history and on the dashboard tile;
  - a dose recorded against a schedule that does not exist, refused, with the
    rate exactly what it was before;
  - a plan due on two weekdays whose other five days stay out of the
    denominator, with a daily twin given the identical doses as the control —
    without it, "100 %" would also be what an engine that counted no days at
    all would say.

The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.

Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
2026-09-10 01:46:13 +02:00
Marc-André Bombeck 7f54f339b2 refactor(backup): drop the retention value nothing could ever read
E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a
minimum of one day and put it on the config object, where no caller read
it. Neither annotation fits: it is not deliberately internal, because
there is no internal use, and no consumer is pending. The design forbids
one outright — the worker's grant excludes DeleteObject on purpose, so
nothing here can act on a retention window without giving a compromised
worker the ability to wipe the history. A clamp that guards nothing only
read as an enforcer the worker is not.

So the field and the clamp go, and the reason sits where the parse was.
The variable keeps its place in the two example files and on the compose
whitelist: it is the number the operator sets the bucket's own lifecycle
rule to. The docs table said "defaults to 30", which implied the app had
an opinion; it now says the app does not read it.
2026-09-10 01:38:40 +02:00
Marc-André Bombeck a4909d3e00 docs(env): say exactly which object the off-host worker deletes
E1. Two halves of one correction. `.env.example` still said backup objects
older than BACKUP_RETENTION_DAYS are pruned, which no code does, so a
self-hoster reading that file alone still expected the worker to keep the
bucket tidy. It now carries the same sentence as the production example.

And that sentence overreached in the other direction: "the worker never
deletes an object" is not true of `_healthcheck/<ts>.bin`, the 1-byte key
the Test-connection button writes and then deletes. The delete swallows
its own failure, so an operator who granted exactly the three documented
actions leaves one orphan per press and is never told. Both files now say
"a BACKUP object", and name the one key and what happens without
DeleteObject on that prefix.
2026-09-10 01:31:17 +02:00
Marc-André Bombeck c3b5585fd6 fix(ops): stop the distance dry run promising work it will refuse
Low, review 2. An account with one out-of-range candidate is refused
whole, rows and audit row included, but the dry run counted its rows into
"N row(s) would be repaired" — so the number an operator read before
--apply was larger than the number --apply could ever produce. The refused
accounts are now counted and reported separately, and the per-account line
says its rows are not in the total.

The refusal itself told the operator to check the Health app "before
forcing anything". There is no --force and never was. It now names the
only route that exists: re-run with the matching --unit if the archive
really was in another one.
2026-09-10 01:30:26 +02:00
Marc-André Bombeck e4333f8f71 docs(api): say which unit the batch route reads a sample in
U1. The new `unit` description on the batch entry said `value` is read in
the unit HealthLog stores the identifier in. Sixteen mappings disagree:
`convertToDbUnit` runs on every entry, and a waist circumference arrives
in metres to be stored in centimetres, an oxygen saturation and a body fat
arrive as a fraction to be stored as a percent. A client generated against
that sentence sends 98 and stores 9800.

The description now says what happens: `value` is read in Apple's own
HKUnit for the identifier, HealthLog applies its storage scaling
afterwards, and `unit` is recorded for audit rather than validated
against. Two worked examples, because the percent-shaped ones are the pair
that bites. YAML regenerated.
2026-09-10 01:29:08 +02:00
Marc-André Bombeck 79cb08efc7 test(ops): prove the journey's redactor on a key-shaped body
Low, review 2. The self-test walked every leg but never once asked whether
the redactor actually holds. The excerpt path is the only thing between a
token in a failure body and the run log, so it gets its own case: a body
carrying this project's own key prefix must come back with the token
replaced.
2026-09-10 01:26:40 +02:00
Marc-André Bombeck 3ba2fe3b82 fix(ops): prove the journey's reading is gone, not just accepted
Low, review 2. The delete leg asserted HTTP 200 and printed "account left
clean". An instance that answers 200 and keeps the row would have printed
the same line, which is the failure mode the journey exists to catch.

The leg now reads the id back and requires a 404. The self-test carries a
mock that answers 200 without deleting; with the read-back removed that
case goes green, which is the proof the check can fail.
2026-09-10 01:26:24 +02:00
Marc-André Bombeck 25cb79ecfc fix(ops): let no failed journey leg leave a reading behind
S2. The legs ran in one loop that returned on the first red one, so a
failed read back never reached the delete and the probe account kept the
row. Worse, the id was assigned only after the write's await returned: a
POST that timed out once the server had already committed left a row the
script could not even name.

Legs 3 to 5 now sit in a try/finally. The finally sweeps whatever the
delete leg did not, and when there is no id it asks the instance for the
row by a marker the write puts in `notes`, unique per run and known before
the request goes out. The sweep never throws — the red leg is the verdict,
and it says out loud what it could not remove.

--self-test asserts the mock holds no rows after every case that got as
far as the write, so a red read that skipped the delete is caught.
2026-09-10 01:26:04 +02:00
Marc-André Bombeck 742eeaad3c fix(ops): refuse a synthetic-journey target that is not ours
S1. The journey signs in with a real account and POSTs that account's
password to whatever BASE_URL names, and the workflow took BASE_URL as
free text. Anyone with repo write, or a leaked token, could dispatch the
run at a collector of their own and keep a working credential; secret
masking guards the log, never the egress.

The host now has to be on a short const allowlist in the script, checked
before anything is sent, with exit 2 and a named refusal otherwise. The
workflow input is a choice between the two hosted instances, so the form
cannot offer a third; the script's own check still stands behind it for a
dispatch that goes round the form.

--self-test covers the refusal, the prefix lookalike a startsWith check
would have waved through included.
2026-09-10 01:25:01 +02:00
Marc-André Bombeck ccca056f64 feat: let the record's module map filter its own navigation
The other half of the switched-payload change, found by the integration
test that asserted the door was gone and got the door. `isNavDestinationVisible`
short-circuited on `sharedRecord`, so a switched session skipped the module
filter entirely and every destination the grant's scope allowed was offered.

That was right while the map was the actor's: a delegate's own preferences
must not hide a domain the record granted. The map is the record's now, so
the objection is gone and the line is what makes a guardian's toggle visible
inside the profile it was set for. Scope decides which doors the grant opens;
the map decides which of them the record uses at all.

The read-only profile summary needs its marker checked against `!sharedRecord`
rather than reached through the removed short-circuit, and the case that
pinned the old reading is turned round with the reason written out.

Refs #939
2026-09-10 01:09:50 +02:00
Marc-André Bombeck bed2ab568e fix(test): read the audit details as the string the writer stores
`auditLog` serialises `details` with `JSON.stringify` into a text column,
so the assertion was reading a field off a string and finding nothing.

Refs #939
2026-09-10 01:09:50 +02:00
Marc-André Bombeck 1ab2e1b524 chore: regenerate the OpenAPI document and list the page-side seed after integration 2026-09-10 01:09:39 +02:00
Marc-André Bombeck 1dc4f70fed Merge the Apple Health import end-to-end journey 2026-09-10 01:09:27 +02:00
Marc-André Bombeck 80bcf75786 Merge the glucose end-to-end journey 2026-09-10 01:07:00 +02:00
Marc-André Bombeck 05e5e0d6c4 Merge the synthetic post-deploy journey 2026-09-10 01:07:00 +02:00
Marc-André Bombeck 7d9532b53c Merge the off-host backup variable documentation 2026-09-10 01:07:00 +02:00
Marc-André Bombeck e06c42d261 Merge the Nightscout connect registration 2026-09-10 01:07:00 +02:00
Marc-André Bombeck 122d57c376 Merge idempotency on the three medication create routes 2026-09-10 01:06:59 +02:00
Marc-André Bombeck 6338da8cf6 Merge the per-field profile rejection surfaces 2026-09-10 01:06:59 +02:00
Marc-André Bombeck f3209f0a93 Merge the release-gate noise removal 2026-09-10 01:06:59 +02:00
Marc-André Bombeck 7036b2d225 Merge the de-flaked end-to-end specs 2026-09-10 01:06:58 +02:00
Marc-André Bombeck 3bd0664edf Merge the untagged glucose bucket across every consumer (#943) 2026-09-10 01:06:58 +02:00
Marc-André Bombeck 071b110e8b Merge the Apple Health record-unit fix and the distance repair (#944) 2026-09-10 01:06:58 +02:00
Marc-André Bombeck 8702d85c9d test(notifications): pin aps.mutable-content on the medication reminder
The client's notification service extension replaces the medication name
on the lock screen, and iOS routes a payload through an extension only
when the alert carries `mutable-content`. The dispatcher has set the flag
on every alert it builds since the category work for the v0.5.4 client,
but the only assertion on it sat inside a category test, where dropping
it would have failed nothing and the name would have quietly reappeared
on the lock screen of a device that asked for it to be hidden.

Two cases now say it out loud: the medication reminder carries the flag,
and so does another event type, because that is the shipped behaviour and
narrowing it would take a capability away from the client rather than
tidy something up.
2026-09-10 01:05:03 +02:00
Marc-André Bombeck 6fe1897941 fix(medications): accept an Idempotency-Key on the three replayed creates
The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.

All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.

An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
2026-09-10 01:04:57 +02:00
Marc-André Bombeck 8995bef61c feat(ops): synthetic post-deploy journey
Post-publish verify proves the published image boots. Nothing proved that
on a running instance a person can sign in, write a reading and read it
back — which is exactly the shape of the defects this line keeps
rediscovering: a value written and never read.

`scripts/synthetic-journey.mjs` walks that journey in plain Node 22 with no
dependencies, so it runs from a workstation, a runner or the deploy host.
It warms `/`, `/api/health`, `/api/version` and the measurements list first
and discards those timings (the first hit on each route after a deploy is a
cold start), then asserts five legs in order: the served version equals the
expected one, the password flow signs in through a cookie jar, a weight
reading is written with an Idempotency-Key, the same row reads back with
its value, unit and source, and the reversible delete leaves the account
clean. One line per leg with the elapsed time; the first failure ends the
run non-zero with the status and a body excerpt that has every token shape
redacted out of it.

The workflow is `workflow_dispatch` only, with `base_url` and
`expected_version` as inputs and the credentials read from repository
secrets. It is deliberately not wired into the deploy path: the target
instance and the secrets are the maintainer's call.

Mutation proof, against a production build served from
`.next/standalone/server.js` on a throwaway Postgres.

Green:

    warm      35 ms  (discarded)
    ok        version         2 ms  serves 1.38.13
    ok        sign in        78 ms  session cookie held
    ok        write          64 ms  wrote cmtup9lwu0007zhovn0mxajdz
    ok        read back       8 ms  77.7 kg (MANUAL)
    ok        delete         27 ms  account left clean

    synthetic journey green

Read leg broken deliberately — the same server behind a throwaway forwarder
that empties the `GET /api/measurements` list and changes nothing else:

    ok        version         4 ms  serves 1.38.13
    ok        sign in        38 ms  session cookie held
    ok        write          40 ms  wrote cmtupa4ck000gzhovwnij64fz
    FAILED    read back       8 ms  read back: the list came back without the reading
              status 200
              body   {"data":{"measurements":[],"meta":{"total":0}},"error":null}

    synthetic journey RED on "read back"

Version leg broken the same way, by asking the instance for the previous
tag:

    FAILED    version         3 ms  version: instance serves "1.38.13", expected "v1.38.12"

    synthetic journey RED on "version"

`--self-test` carries that proof in the repository: it runs the journey
against an in-process instance nine times, once with nothing broken and
once per broken leg, and fails if a broken leg still comes back green. The
workflow runs it before it touches the real instance.

Gates: typecheck, `node --check`, lint (the three known warnings, no
errors), prettier, the workflow and CI-determinism guards.
2026-09-10 01:04:30 +02:00
Marc-André Bombeck 1de3681c81 test(e2e): cover the blood-glucose journey across a unit switch
The glucose display unit had thirty-odd readers and, until recently, no
control to set it. Nothing in the browser suite walks the path a person
actually takes: capture a reading in mg/dL, switch the account to mmol/L,
capture a second one, and read both back — each converted, none of them
reinterpreted — in the list and on the dashboard strip.

The journey covers that end to end, plus the boundary: a value past the
plausible ceiling is refused by the server, the capture keeps what was typed,
and the list is exactly what it was.

One reading arrives with no meal-time context, the way a meter export or a
device sync delivers one. It reads back correctly in the list; the tile it
should paint does not exist yet (#943), so the tile assertion is skipped by
issue number rather than softened into something that passes.

Three supporting changes:

- The list rows carry what they hold — type and resolved display unit on the
  row, a slot on the value cell — so the journey pins "this reading now reads
  in mmol/L" structurally instead of matching translated copy that also paints
  through a different subtree on the card list. The meal-time options carry
  their enum for the same reason.
- The sharing-marker weight rows were seeded by handing a Date to a
  `timestamp without time zone` column, which stamps them in the host's zone.
  On a machine east of UTC that put them hours in the future, where every fold
  whose window ends "now" skips them, and the type stayed uncovered — which
  keeps the snapshot's thick phase cold and the whole tile strip short. Seed
  the instant as UTC, the shape the app's own writes use.
- The file mutates one shared account and reads global verdicts off it, so it
  runs in a single project alongside the visits and immunization journeys.
2026-09-10 01:01:11 +02:00
Marc-André Bombeck 2c740f04ac test(e2e): cover the Apple Health export import end to end
Upload a synthetic export.zip on the import card, wait out the queued
job through the card's own status poll, and read the imported readings
back off the measurements list with the units the parser resolved.
Weight, the blood-pressure pair and the step count are asserted; the
walking-distance case is written and skipped, naming issue #944 — a
record written in km lands as metres today, and the assertion is kept
at full strength so the fix has something to turn green.

The refusing control uploads a valid ZIP with no export.xml: the card
surfaces the extractor's own refusal and the account's Apple Health row
count does not move.

Measurement rows now carry data-measurement-type / -value / -unit
beside the rendered cell, on both the desktop table and the mobile
list, so an assertion binds to the reading rather than to whatever the
locale formatter made of it. The import card's error line gets a test
id for the same reason.
2026-09-10 00:58:12 +02:00
Marc-André Bombeck 8ad5b90f88 docs(ops): the off-host worker never prunes; the bucket lifecycle does
The runbook said the worker prunes objects older than BACKUP_RETENTION_DAYS. It never calls DeleteObject; its grant is PutObject, GetObject and AbortMultipartUpload, on purpose, so a compromised worker cannot wipe the history. The sentence now says what expires objects and what the variable is for.
2026-09-10 00:48:31 +02:00
Marc-André Bombeck 91ae90836b docs(env): document the two optional off-host backup variables
BACKUP_S3_REGION and BACKUP_RETENTION_DAYS are both on the compose whitelist and
both read by the backup job, but the example file listed neither — so an
operator following it had no way to learn that the region defaults to auto, or
that the retention number is what the bucket's own lifecycle rule has to be set
to, since the worker never deletes an object itself.
2026-09-10 00:47:16 +02:00
Marc-André Bombeck eea0f2a532 Clear a refused profile field as it is edited
The settings profile form painted the server's refusal under the input
and left it there until the next save, so a value the person had already
corrected still read as refused. The onboarding baseline step drops that
sentence while they type; the account card now does the same.

Only the edited field's slot goes: the other fields the same save
refused were not touched, so their sentences still stand. The guard
derives both sets from the form itself, so a new rejection slot without
a clear fails.

Review finding L3.
2026-09-10 00:42:45 +02:00
Marc-André Bombeck 779cd3a0c8 test: ban a length rescale, not every literal thousand
The guard read `* 1000` anywhere in the two mapping files as a
re-implemented kilometre factor. Both files also turn seconds into
milliseconds, so an unrelated conversion would have failed it for the
wrong reason and the next person would have deleted the rule.

A rescale now counts only in a length context: a distance identifier on
the same line or within the five above it, which covers the body of a
distance-named helper. The offending line is named in the failure. The
matcher's limit is written next to it — a length conversion in a helper
named nothing like a distance, more than five lines below its signature,
still slips through.

Checked both ways against the real file: a millisecond conversion passes,
a `km * 1000` helper fails.

Review finding L4.
2026-09-10 00:37:22 +02:00