Commit Graph
1890 Commits
Author SHA1 Message Date
Marc-André Bombeck 4be01a67f0 chore(release): fold in the coach discriminant, the dependency bumps, and the notes the audit found wrong 2026-07-26 18:57:21 +02:00
Marc-André Bombeck 5a71f376d4 refactor(coach): keep the manifest's out-of-window rows terse
The preamble already says once what the two states mean and that neither is
absence; repeating it per row would have doubled a manifest that rides every
turn.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 2a9b5ba59d test(coach): prove the availability probe's SQL against Postgres
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.

Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 626365f138 test(coach): assert the removed instruction is gone, not only that the new one is there
The mutation check found both holes: an addendum test that only looked for the
new reason codes passed with the old declare-absence-and-move-on sentence still
in place, and a block-failure check whose window was wide enough to see the
neighbouring block's annotation.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 59a435d5ac test(coach): pin the window/absence discriminant to the reported case
The fixture is the report: a fortnight of glucose readings from April 2024 on an
account that displays them everywhere else. The clock is pinned so it cannot age
out of the window it was written to sit outside of.

Covers the four states an empty window read can resolve to, that a never-recorded
metric and a historical one differ in the SAME run, that a mean is withheld
across mixed units rather than fabricated, that the manifest advertises the range
instead of "absent", that both instructions branch on the reason code, and that a
miss carrying figures reaches the prose verifier's authoritative set. Adds the
medication-compliance page to the failed-read sweep and pins the narration
blocks' failure annotation.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck a402eaaf15 fix(coach): tell a history outside the window apart from a metric never recorded
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.

The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.

- `tools/availability.ts` — one grouped aggregate per miss, no rows
  materialised, resolving four states: `no_data` (probe confirmed empty),
  `outside_window` (rows exist, older than the searched window; carries the
  count, the date range in the user's own timezone, and a bounded per-series
  mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
  domain still produced none — a module is off), and `no_data_unconfirmed` (the
  probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
  longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
  cannot reach is advertised with what it holds instead of as absent — the model
  no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
  branch on the reason code, and forbid answering about one metric by reporting
  another.
- The out-of-window aggregate joins the authoritative set for the prose
  verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
  vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
  no medications, instead of inviting someone to re-enter what it already holds.

Refs #648
2026-07-26 18:34:42 +02:00
Marc-André Bombeck d91b7cb57a test(medications): cover the container kind on the import and refund paths
Hardcoding the container kind in the batch-import consumption path or in the
refund path left the whole suite green — neither had a scenario that read a
clock-less container. Both now do: a back-dated import chunk against a tablet
pack stays IN_USE with no deadline, and a refund that brings units back to a
long-opened pack reads IN_USE rather than expired. The pen counterpart of the
import case is pinned alongside.

The fake client's `updateMany` learns the `id: { in: [...] }` form the batch
claim uses; it only understood a single id before.

The no-runway arm of the low-stock evaluation now has its written-off count
asserted too.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck fd1464867d fix(medications): separate the two container dates and name the ampoule caveat
The row printed the opening date and the printed expiry with nothing between
them. It carries the same middle dot the unit figures use.

The docblock now says out loud what the enum cannot distinguish: AMPOULE
covers both a punctured multi-dose vial, where the post-opening window is
real, and a carton of single-use glass ampoules, where it is not. The kind is
never auto-selected, so picking it is a statement about an opened container,
and the opening date is editable when it turns out not to be.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck a4ff11f10d fix(medications): surface and correct the dates that decide a container's state
The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.

Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.

The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck a1dbd9c831 fix(medications): apply the post-opening clock only where a container has one
A supply container went EXPIRED 30 days after its first use no matter what
kind of container it was. That window exists because puncturing an injection
pen exposes its contents and the product information caps how long the rest
stays usable; a blister of tablets has no such limit, every tablet sits in
its own sealed cavity, and pressing the first one out says nothing about the
rest. The state machine never read `containerType`, so a tablet pack opened
five weeks ago had its remaining stock written off.

The clock now keys off the container kind. PEN and AMPOULE carry it: both are
a single sealed reservoir that the first dose breaches. BLISTER, INHALER,
BOTTLE and OTHER do not: their units stay individually sealed, or the
arrangement is simply unknown, and a fixed 30 days would be a number nobody
entered. The printed expiry still applies to every kind, so an unknown
container is not an unbounded one.

`computeExpiresAt` takes the kind too, so a clock-less container with no
printed date now persists no `expiresAt` at all and the daily expire scan
never sees it.

The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and
clears the opening date, which is the correction a container needs after the
intake hook auto-opened it from a back-dated dose.

Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and
its docblock described a chip that does not exist.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck 0afd217f9f docs(api): say which profile fields an empty string actually clears
The description promised that null or an empty string clears any field.
An empty string clears the free-text ones and now gender, but email is
validated as an address and 422s on it, and the display preferences are
not clearable at all. Name them rather than let a client find out.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 349dac3a07 test(profile): cover the third gender value where it used to be lost
Adds the cases that were missing everywhere except the FHIR conformance
suite: an empty string clears without taking its siblings down, OTHER
round-trips through the profile and onboarding writes, an unsupported
value is still refused and says which field it is about, the Strain
model scores an unanswered account on neither side, the dashboard bands
give OTHER the neutral fallback, the snapshot payload carries the value
instead of flattening it, and the doctor-report cover omits the row
rather than claiming a value the account never chose.

Refs #645
2026-07-26 17:09:03 +02:00
Marc-André Bombeck f74efc44aa fix(profile): let the third gender value through every surface that handles it
The profile validator has accepted MALE, FEMALE and OTHER for a long
time, but the published contract documented two, and a client written
against that contract had no way to know the third existed. The profile
PATCH is one write, so a rejected gender took every sibling field with
it - a time-format change lost to a field the person never touched.

Gender now clears on an empty string, the same contract every other
profile field documents, and an unsupported value is refused with a
message that names the field and its accepted values instead of the
validator's own prose. Onboarding validated against a narrower enum
than the profile schema and 422'd the whole submission; its form could
not record the third value at all.

The rest is the same defect in other clothes. The Strain model scored
an unanswered or OTHER account on the male Banister coefficients; it
now uses the midpoint of both published models, matching every other
sex-split formula here. The dashboard snapshot emitted null for an
account that answered OTHER, which is indistinguishable from no answer.
Three casts asserted a two-value type over a three-value column. All of
them now carry the stored value and narrow once, at the boundary of a
reference table that genuinely has no third row, through one named
helper in src/lib/profile/sex.ts.

Refs #645
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 0ce510fa68 refactor(sleep): pin the in-bed legend before the per-stage loop reads it
The envelope was assigned after the loop, so the loop's IN_BED skip had
no observable effect - removing it changed nothing, which makes it a
dead branch rather than a guarantee. Assign the envelope first and let
the skip protect it.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck a3b0af577f fix(sleep): count a re-exported sleep period once
A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.

Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.

stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck b627b92809 feat(medications): rule on each export status for what it actually is
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.

Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.

Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.

All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.

An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 36d589e11b test(medications): close the gaps the mutation pass found
Three things the tests said were covered and were not.

A column flipped from honoured to ignored moved both sides of its own
assertion, because the check read the verdict off the table it was checking.
The verdicts are now written out as literals, so changing one is a decision
somebody states twice.

The preview block was unreachable from an SSR-only test, so nothing would
have noticed it rendering through the outcome view — which would classify
what a preview *would* write as a success and put a tick on a run that never
ran. It moves into its own component that takes the verdict as a prop, the way
the CSV result view already does, and the preview and the archived-row report
are asserted directly.

The account-wide import narrows its in-progress check to account-wide jobs, so
an import running on one medication's page does not refuse it. Nothing pinned
that; now something does.

Also correct two comments about the earlier replay-key fault. The repeated
value in the reported file was not a dose quantity — it was copied from the
example this dialog itself displays. The field never carried identity, and the
submission was never at fault.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 8a36117a0c feat(medications): show what the importer does with each column
The dose-history import gets its own card in Settings, because the file
covers a whole regimen and the per-medication dialog on a medication's page
covers one medication.

Preview before writing. The dry run parses the file, matches every row
against the record, and answers with the whole verdict: rows read, rows that
would land, rows refused grouped by reason, and the medication names that
match nothing — verbatim, so they can be added and the file re-run. Nothing
is written until the second button.

The column rulings sit on the card before a file is chosen, one line each,
naming whether the column is used, reported, or not read and why. A column
read and dropped in silence is indistinguishable from a column honoured, and
that is how somebody comes to believe an importer looked at something it
never did.

Outcomes render through the same primitive the other three import surfaces
use, so a run that wrote nothing cannot carry a success tick. The preview
borrows the reason lines and deliberately not the outcome sentence: a preview
has written nothing, and classifying it by the counts it would produce would
put a tick on a run that never ran.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 34a220421c test(medications): prove the reported export lands, row for row
The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.

The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.

Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 11e6402b5a feat(medications): read a dose-history export directly
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck f0c5b5ae94 fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.

The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.

The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.

Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.

Refs #650, #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck e0ae866f32 test(whoop): assert the per-resource failure carries its leg 2026-07-26 14:19:13 +02:00
Marc-André Bombeck 4e3b029c38 test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.

Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck 527dd6c1fa fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck dccd2f320b test(settings): prove the wipe against real Postgres, and state what deletion means
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.

It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.

The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 5e66ebaa54 fix(jobs): let the retention purges finish, and let them fail loudly
The nightly tombstone purge asked for `deleted_at IS NOT NULL AND deleted_at
< cutoff` and the audit-log purge asked for `created_at < cutoff`. Neither
predicate had an index behind it: every deleted_at index in the schema is
partial on IS NULL, the live-rows predicate, and every audit_logs index leads
with user_id or action. Both purges planned a sequential scan and then paid
index maintenance per removed row, in one unbounded statement, on a
connection carrying statement_timeout=60000. Past a certain backlog the
statement aborts, the transaction rolls back, nothing is purged, and the next
night runs the identical statement against the same rows. The mechanism that
gives deletion a horizon could not close on the instances that needed it.

Migration 0277 adds the indexes the predicates ask for; the three tombstone
ones are partial on IS NOT NULL, so on a healthy instance they cover only the
tombstones and shrink as the purge drains. The purges now walk the backlog in
batches of 5 000 by primary key, capped at 200 000 rows a run.

Both handlers used to swallow every error into a wide-event warning and
return, so pg-boss recorded a successful job either way: a purge failing
every night looked exactly like a purge with nothing to do. They now rethrow,
and a run that stops at the cap records that a backlog remains.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 0919d8b962 fix(settings): make Delete All Data delete what it says
The wipe deleted thirteen tables against a schema that carries more than
eighty user-scoped ones. Mental-health assessments, lab results, the
menstrual record, allergies, family history, the document vault, every AI
conversation and live clinician share links all survived a confirmation
that promised to permanently delete all health data. So did
MeasurementRollup, the per-day reconstruction of the measurements the same
transaction had just removed.

The list now lives in src/lib/data-wipe/wipe-plan.ts next to its own
exemption list, and a structural guard enumerates schema.prisma and fails
when a model belongs to neither. Adding a model can no longer widen the set
of things that survive a wipe by default. The same guard covers the User
row's own columns, which had drifted the same way.

The boundary is explicit: everything the account owns goes, except what is
needed to sign in again. The confirmation and the toast say so before the
person types the confirmation rather than after.

Checkup reminders and allergies promised permanence over a soft delete with
no restore path; both now hard-delete, so the dialog is true. The lab
biomarker dialog was checked and is already accurate — it maps to the
hard-deleting /api/biomarkers/[id] route, not to the soft-deleting lab
result route.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 84d78d2926 fix(units): round a converted value where the conversion happens
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.

Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.

Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.

`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.

The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.

Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.

Refs #627
2026-07-26 00:46:03 +02:00
Marc-André Bombeck bc1633db40 fix(export): make the standard report a click, not a preselection
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 59acbed5ab fix(i18n): deliver the locale catalog before the client bundle takes its first render
The root layout shipped the active locale's catalog as `<script defer>` and
counted on it executing ahead of Next's hydration scripts. It does not: Next
emits its own chunks as `async` tags AHEAD of anything the layout renders into
`<head>`, and an `async` script runs the moment its fetch settles. So the
370 KB catalog was racing the app bundle, and on a cold load it lost about a
third of the time — `load-locale` initialised with an empty cache, every `t()`
in the hydration render returned its raw key against the server's real text,
and React tore the tree down with a text hydration mismatch. The user saw a
frame of `nav.skipToContent` before the provider's backfill landed.

Register the catalog through `next/script` at `beforeInteractive` instead.
Next's own bootstrap awaits those scripts before requiring a single app module,
and pairs the registration with a `<link rel=preload>` so the download still
starts with the document rather than after it. The CSP nonce rides along, so
the posture is unchanged.

Fold the boot global into the cache lazily rather than snapshotting it at
module evaluation. The snapshot made correctness depend on an ordering the
module cannot see; reading on demand reduces the invariant to something local
and testable — whatever the boot script assigns is visible to the first `t()`
that asks, whenever it lands.

Measured on the dashboard render spec, 30 repeats per project against a
production build: 11 of 30 failed on desktop before, 0 of 60 after.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck ffe99caccd fix(fhir): stop advertising a credential no client can obtain
The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.

End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f12290532f fix(sharing): let the owner replace a link the upgrade closed, and count only what is shown
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.

Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 9e68ff525f feat(share): serve the record a link already implies, and say when there is none
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.

The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.

The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.

Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.

The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f42934487d test(export): prove the scope holds at the artefact, not just at the switch
Five layers, because the defect had five places to hide.

The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.

The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.

The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.

The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.

The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.

Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.

The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.

Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.

The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 582db8a538 feat(export): one grouped scope picker, on both surfaces that choose a scope
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.

The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.

First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.

Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.

The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.

The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.

The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 5e490f31ec feat(export): make the doctor report carry only what was chosen
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f90219784b test(export): pin the selection controls against going inert again
Two layers, because the defect had two halves and each needs its own kind of
proof.

The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".

The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.

Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.

The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 68b9903966 fix(export): make the doctor-report selection controls tell the truth
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.

Three more controls were untruthful in their own way:

- The medications switch documented itself as excluding all medication data
  and excluded the adherence figures only. The drug list and the per-dose
  administration ledger rode along regardless. Both follow the switch now;
  the rows are still read, because compliance and the GLP-1 block are computed
  from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
  unconditionally for the FHIR bundle, so a package could carry a compliant
  PDF and a violating bundle in the same zip. The reads are gated, and a
  deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
  They rode the mental-health module alone, which has been default-on since
  v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
  links and the MCP doctor-visit resource on accounts that never chose to send
  them. They get their own section key, defaulting off, with the module gate
  kept as the second lock, and the stale privacy-by-default comment that
  argued otherwise is corrected.

The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.

Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 4e95bd28f8 fix(import): stop the Apple Health card ticking an archive that landed nothing
Same shape as the CSV and JSON cards: reaching the done state rendered the
success tick whether or not a single row was upserted. The archive's count
picks the tone now, and an archive that wrote nothing says so.

The clinical-record count stays out of that decision. Skipping
electrocardiograms and lab documents is a documented, deliberate exclusion,
not a refused row, so it must not read as a warning.

Refs #640
2026-07-25 21:45:10 +02:00
Marc-André Bombeck 0c9832eb8d fix(import): accept a blood-glucose reading that carries no context
A CSV import of a continuous-sensor export wrote nothing and rendered a
green success state. Three faults compounded, and the reporter took all
three apart before filing.

The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext.
Behind it, the CHECK constraint from migration 0021 read both ways at
once: a glucose row MUST carry a context, every other type MUST NOT. The
second half is right and stays. The first was written when the only
glucose writer was the entry form, where a person picks fasting or
post-meal at the moment of the reading. Every machine writer that
arrived since has no such answer per sample, so the Nightscout pull, the
MCP log tool, the Telegram capture and both bulk importers were all
building rows the database then refused.

A missing context now stores as NULL, never as a fifth enum member.
Every per-context reader selects by equality against the four members,
so the reading stays out of the doctor report's fasting rows, the
matching FHIR observations and the per-context targets, while the chart,
the all-time summary and the clinical panel keep it in full. Hand entry
still asks for a context, because at that moment you know it.

The import cards now read the counts rather than the status code.
Nothing written is a failure, a mixed file is a warning, and the refused
rows are grouped by reason with a count instead of fifty identical
lines, with the per-line detail one disclosure away. Both cards render
through one outcome table, so a tick cannot reach a result that wrote
nothing.

Refs #640
2026-07-25 21:23:26 +02:00
Marc-André Bombeck 15b5f6f92f fix(ingest): hold the stability floor for identities no request schema sees
The dedup key on the medication intake surfaces is not only `externalId`.
`MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted
with the dose row forever, with no TTL and no sweep, matched by plain
equality on the per-medication POST, the bulk drain and the external
ingest bridge. That makes it an identity, not a replay token, and it
carries the same failure mode: a key that rotates per client launch never
matches its own earlier post, so every retry logs the dose again. The
blank case is sharper still — the column is unique table-wide, so an
empty key resolves to whichever unrelated row stored it first. Refuse
both shapes at the schema on the single-entry paths and per entry on the
bulk drain.

The MCP write path hashes a host-supplied key into the `externalId` half
of its dedup key, so the shape is laundered before anything downstream
could see it and the check has to run on the input. That closes a live
one: every tool handler falls back to `""` when the host omits the
argument, and `""` digests to a constant, so each keyless write after the
first reported `already_logged` and was silently dropped.

The HTTP `Idempotency-Key` header stays out, and now says why in the
guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps
it, so it is a windowed replay token and rotation is its design.

Extend the structural guard with a provenance rule for identities that
never pass a Zod request schema — a spreadsheet column, an MCP tool
argument. Inside the import and MCP trees, a module handling an identity
must apply the floor itself or import a module that does. That is a real
chain rather than a list of known offenders: a new importer that mints an
`externalId` from file input and imports nothing checked fails it, with
no allowlist to escape into.
2026-07-25 18:07:13 +02:00
Marc-André Bombeck 2ab66ab2e3 fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.

Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.

Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.

A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
2026-07-25 17:21:00 +02:00
Marc-André Bombeck 99b8dfd566 test(guards): bind the account payload and the backup select to their pairs
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
2026-07-25 16:24:12 +02:00
Marc-André Bombeck d8a3c08e12 fix(i18n): stop one key template from whitelisting a whole namespace
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
2026-07-25 16:23:58 +02:00
Marc-André Bombeck 2a396dd23e fix(backup): carry the aggregation provenance through backup and restore
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
2026-07-25 15:20:47 +02:00
Marc-André Bombeck f8f460bb66 refactor(measurements): drop the write-only aggregation columns (migration 0272)
Migration 0263 added three columns beside the Apple Health `stats:*`
authority ladder. Only `aggregation_provenance` is ever read back: the
external-measurement reconciler selects it and refuses a lower-authority write
against a higher-authority canonical row, and the cumulative drain reads it
too. That column stays.

`aggregation_contributor_count` and `aggregation_selected_source_hash` are
written by three call sites and read by none. No select, no raw SQL, no export,
no backup, no API contract, no repair path. The import-time selection keeps its
deterministic tiebreak, which compares an in-memory map key computed per flush
rather than a value read back from the table.

Both are plain nullable scalars with no index, unique, check or foreign key, so
the drop is a catalog-only operation that touches no measured value.
2026-07-25 15:03:44 +02:00
Marc-André Bombeck a3f1d780e7 feat(export): remember the practice name between doctor reports
The doctor-report contract has always described the practice / clinic name as
a stored preference, and `/api/auth/me` has returned
`User.lastReportPracticeName` since it shipped, but nothing ever wrote the
column. Every report asked for the clinic name again.

The export route now persists the sanitised name after the requested artefact
has actually been produced, so a generation that throws cannot overwrite the
remembered value with a half-typed one. Clearing the field persists null, which
keeps the cover line cleared next time. The export panel seeds its input from
the auth payload during render, gated per user id, so a late resolve never
clobbers a field the user is already typing into, and a finished export
invalidates the cached payload so a remount reads the new value.
2026-07-25 15:00:27 +02:00
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck 10593a11a8 test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.

Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.

The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
2026-07-25 09:41:53 +02:00
Marc-André Bombeck ce74fd52a2 feat(settings): render the sync verdict and per-metric freshness
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.

Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.

Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.

The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.

Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
2026-07-25 09:41:37 +02:00