Commit Graph
1452 Commits
Author SHA1 Message Date
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 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 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 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 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 18691d4550 fix(medications): scope the intake idempotency key to its owner
`medication_intake_events.idempotency_key` carried a GLOBAL unique from
0001_init. The value is client-supplied, so two people running the same
client can mint the same key — and on a global unique the second one is
simply refused by the constraint. Their dose is not written and nothing
tells them so. The bulk drain made it worse: its replay probe read the
key with no user scope, resolved the first user's row, and reported the
second user's entry as a duplicate carrying a stranger's row id.

Migration 0273 replaces the global unique with `(user_id,
idempotency_key)`, the grain every other client-supplied identity on the
table already uses. Both unscoped reads in the bulk drain now key on the
composite; the other four reads were already narrowed by user.

On existing data this is a relaxation, so nothing needs reconciling: the
old constraint is strictly stronger than the new one, and no pair of rows
can violate an index the previous one already forbade. The migration
still carries a reconciliation step for a database whose global index was
dropped by hand at some point. It never deletes or rewrites a dose — it
clears the dedup HINT on the later row of a colliding pair and keeps
every intake event, timestamp, attribution and inventory stamp intact.
The new index is created before the old one is dropped, so a failure
aborts with the old protection still in place.

A structural guard freezes the read side: no `medicationIntakeEvent`
query may filter on the key without narrowing by `userId`.
2026-07-25 18:41:40 +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 6e4bcffadf test(ingest): prove the external-id floor cannot be reintroduced
Harden the structural guard so it reads the file body with its imports
stripped: grepping the raw source cannot tell a file that APPLIES the
check from one that merely imports it, so deleting the call while
leaving the import slipped straight through.

Add the partial-success case on a second batch envelope — the mood bulk
ingest echoes the offending entry back with its index, status and
reason while the good rows still upsert.
2026-07-25 17:31:39 +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 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 338808eb5f refactor(ui): route the sleep-composition header through the tile header
The stage-composition chart card was the last insights tile with a
hand-rolled title row. It now uses the canonical header primitive; the
subtitle and the window toggles keep the shape they had, since the toggles
have to drop to their own row on a phone.

Also sweeps the last off-scale spacing: the two dense tiles inside a
recommendation move onto the sanctioned inner-tile step, and the not-found
button onto the scale. The banned twenty-pixel step is now absent from the
tree. The chart body is untouched.
2026-07-25 14:12:01 +02:00
Marc-André Bombeck 933dfcfdfb refactor(ui): mint the text-2xs token and the neutral TagChip
Three integration cards carried the same hand-copied chip literal in the
card-header accessory slot, each free to drift on the next touch; the size
itself was an arbitrary value repeated at three slightly different spellings
across the tree.

- Mint `--text-2xs` (0.6875rem with a matched line-height) as the one
  sanctioned step below `text-xs`.
- Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout
  cards. It stays distinct from `Badge`, which carries status meaning.
- Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger
  outline badges move onto the token; the muted target caption and the muted
  ledger meta row move up to the `text-xs` muted floor.

No visible change beyond the two muted rows gaining a fraction of a pixel.
2026-07-25 14:00:25 +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 de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +02:00
Marc-André Bombeck a9f1629f82 feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.

The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.

Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.

The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
2026-07-25 04:43:06 +02:00
Marc-André Bombeck e1ca523e21 fix(insights): route every weight-class and temperature surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
2026-07-25 00:10:53 +02:00
Marc-André Bombeck 1007ca3d1c test(medications): cover the mirror growth guard and provenance echo
A 31st new mirror create at the cap returns 422 with the limit_exceeded
errorCode; an idempotent re-post of an existing mirror at the cap still returns
200; a native create at the cap is unaffected and never runs the count. The
list read exposes externalSource as APPLE_HEALTH for a mirror and null for a
native row.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck dbe3c3dc26 fix(medications): cap externally mirrored rows and surface their provenance
A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.

Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
2026-07-24 23:18:14 +02:00
Marc-André Bombeck 9a69e4e557 fix(whoop): a dead token no longer reports a successful sync
Port the hard-fail ledger the Fitbit transport already carries. A revoked WHOOP
grant makes every resource short-circuit and return zero rows without throwing,
so the cycle read as clean: it stamped success, un-parked the reconnect state,
reset the failure streak, and the hourly cohort hammered the dead refresh token
on every tick. Record the three dead-token null-return paths on the ledger and
fold that verdict into both drivers — the full orchestrator and the per-resource
cron path that runs most often — so a dead-token cycle refuses the success
stamp and the park holds.

Return an imported-plus-failed verdict from the orchestrator and gate the
history backfill on it: an interrupted full-history walk now throws for a
pg-boss retry instead of being stamped complete, and a connection parked at
reauth returns without burning the retry budget. Thread the same verdict into
the one-shot sleep-timeline backfill.

Also catch a write-path throw on both drivers so a persistent measurement-write
failure surfaces on the status ledger instead of leaving a stale-green pill, and
widen the cycle overlap to the seven-day re-score window the other collections
use so a late-revised day's strain and energy are re-fetched rather than
stepped past.
2026-07-24 20:05:41 +02:00
Marc-André Bombeck 8be7458c7a fix(sync): surface poll-cohort sync failures that previously vanished
Introduce a recorded-marker primitive on the integration ledger so a provider
that records-then-rethrows a sync failure is not recorded a second time at the
poll-cohort boundary, while an unrecorded escape (an Oura or Polar write-path
throw, or any future path) lands on the ledger exactly once, correctly
classified and redacted by the source that owns it. Without the marker a
boundary-blind recorder would double-count the failure streak and could persist
a Nightscout token-bearing URL into the stored error.

Give the poll-cohort factory a required per-provider failure recorder and wire
one for Oura, Polar, Nightscout, and Strava.

Nightscout now counts only freshly inserted readings instead of insert plus
re-confirm (the inflated count fired the reminder trigger every tick), records a
transient failure and skips the success stamp when some rows fail to write, and
emits the measurement-arrival spine for its inserts. Correct the docstring that
promised a far-history backfill that does not exist: the hourly window imports
about 48 hours and nothing deeper.

Strava now enqueues its history backfill at connect time, mirroring the WHOOP
and Fitbit callbacks, so deep history imports within the hour instead of waiting
for the next worker restart.
2026-07-24 20:05:23 +02:00
Marc-André Bombeck 574367737f fix(coach-prefs): return the pure default with no token for a never-saved user
The GET spread the optimistic-concurrency `updatedAt` into the response even
when the user had never saved preferences, which broke the strict-equality
contract that a never-saved read is exactly `DEFAULT_COACH_PREFS`. Mirror the
insights-layout never-saved GET: a null `coachPrefsJson` returns the pure
default with no token (the first save is the tokenless unconditional write), and
only a saved row and the PUT response carry `updatedAt`. Update the integration
round-trip to expect the token on the saved GET and PUT while keeping the
`coachPrefsJson` column assertion pure.
2026-07-24 18:28:41 +02:00
Marc-André Bombeck f9afa794e9 test(concurrency): cover the prefs guards, the inventory lock, and the privacy re-check
Add interleaved-writer, tokenless-compat, and malformed-token coverage for the
notification-prefs, modules, coach-prefs, and mood-tag-layout write guards,
including the notification-prefs case where a stale web write must not revert
the app-managed medication flag. Pin the inventory PATCH and DELETE routing
through the advisory lock, and cover the insight-generation privacy re-check on
both the main commit and the reroll arm.
2026-07-24 18:08:01 +02:00
Marc-André Bombeck 8849f2cecd feat(concurrency): guard the prefs writes and serialise inventory + privacy races
Apply the shared optimistic-concurrency guard (takeBaseToken + guardedUserUpdate)
to the notification-prefs, mood-tag-layout, modules, and coach-prefs write
endpoints so an interleaved edit can no longer silently overwrite a category a
fresher writer just set; a tokenless request keeps the prior unconditional
write. Route the inventory stock-correction PATCH and DELETE through the same
per-medication advisory lock intake consumption takes, so an absolute stock
write can no longer clobber a concurrent dose decrement. Guard every insight
cache commit on the privacy mode still being what the generation read, so a
privacy-mode change is respected by a generation that was already running.
2026-07-24 17:31:34 +02:00
Marc-André Bombeck 572f5176bd test(concurrency): cover the layout and about-me write guards
Add per-endpoint unit tests for the insights layout, medications layout, and
coach about-me write guards: an interleaved stale-base writer gets a 409 and
changes nothing, a tokenless request still writes (older-client compatibility),
the fresh token is echoed, and a malformed token 422s. Cover the client token
echo, the two 409 dispositions, and the insights read that must keep the token
through the resolver. Refresh the CHANGELOG and the generated OpenAPI spec.
2026-07-24 16:39:39 +02:00
Marc-André Bombeck ab03bc96f1 feat(concurrency): guard layout and about-me writes against a concurrent overwrite
Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.

A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
2026-07-24 16:26:58 +02:00
Marc-André Bombeck 25b7ada8bf fix(fitbit): hold the sync verdict when a token is dead or a write fails
A revoked or expired Fitbit refresh token used to fail the cycle's status
ledger and then stamp the same cycle a success: the connection flipped back
to connected, the watermark advanced, and after a reconnect the 24h overlap
could no longer reach the missed days. getValidToken's three null-return
paths (credentials missing, refresh failure, vanished connection) now register
on the per-cycle hard-fail ledger, so the cycle's verdict fails, the park at
error_reauth holds, and lastSyncedAt does not move. The two measurement write
catches do the same, so fetched-but-unwritten rows are re-fetched next tick
instead of aging out of the overlap.

syncUserFitbit now returns { imported, failed }; the manual-sync route, the
poll cohort adapter, and the backfill consume the new shape. The backfill
stamps backfillCompletedAt only after a clean full-history walk: a parked
connection returns without stamping, and a failed verdict throws so pg-boss
retries on the already-configured retry budget.
2026-07-24 12:47:27 +02:00
Marc-André Bombeck 0ee5081766 test(insights): cover local-day mood correlation pairing
Berlin boundary pairs (previously dropped), the gone New York lag-1
offset, all three metric partners moving, UTC-user invariance, and a
metric-by-metric guard proving the weight x BP and BP-medication joins
stay byte-identical across timezones. Update the mocked aggregates for
the additive weightRawRows field and pin the widened raw pull in the
aggregator test.
2026-07-24 12:11:10 +02:00
Marc-André Bombeck 1a08083436 fix(insights): pair mood correlations on the local logging day
The mood series has been local-day-keyed since v1.32.12, but the metric
partners it joins against (blood pressure, weight, resting pulse) were
still keyed on the UTC calendar day. The join is an exact day-string
match, so for anyone outside UTC an evening reading landed on the next
UTC day and paired with the wrong mood entry — a one-day misalignment
sold as a same-day correlation.

Derive the three mood pairing partners as exact local-day means, keyed in
the user's own timezone via userDayKey (Intl-based, DST-safe). Blood
pressure and resting pulse reuse raw rows already on the request path;
weight rides one additive column on the existing raw-rows query. The
UTC-consistent weight x BP and BP-medication joins are left untouched —
both their sides share one UTC key space and they migrate with the
rollup tier later.
2026-07-24 12:04:40 +02:00
Marc-André Bombeck cd344047dd test(dashboard): cover optimistic-concurrency + the serialised ring/Save writes
Server: a write with the current base token succeeds and returns the advanced
token; a stale base returns 409 and writes nothing (no unconditional fallback);
a malformed base 422s; an omitted base keeps the unconditional write. Client:
the ring payload never carries widgets and threads the base token, the shared
busy gate covers either write, and the Save/ring wiring is source-pinned.

Refs #581
2026-07-24 11:09:30 +02:00
Marc-André Bombeck 69359a609a fix(dashboard): keep a saved layout authoritative against a concurrent ring save
A dashboard layout change could be silently reverted when a hero score-ring
toggle was made around the same moment. Both writes PUT the whole layout to
/api/dashboard/widgets, and whichever reached the server last won, so a ring
write built from a pre-save snapshot overwrote a fresher Save.

Guard the write with optimistic concurrency. The client sends the updatedAt it
based its edit on; the route writes conditionally on the stored row still
carrying that token (updateMany where updatedAt = base) and returns 409 with a
typed body on a stale base, writing nothing. The advanced token rides back on
every GET/PUT response so the client can rebase. No migration: the User row
already has an @updatedAt column.

On the client, the ring PUT carries only its own fields plus the base token,
the Save and ring writes share one mutation scope so they serialise, the Save
button and ring controls disable while either write is in flight, and a 409
refetches the winning layout and asks the user to redo the change.

Refs #581
2026-07-24 11:07:09 +02:00
Marc-André Bombeck 5ad66af565 fix(coach): mark withheld figures with an elision mark plus a per-message notice
Replace the internal "[unverified]" token the grounding guard left in a Coach
reply with the editorial elision mark […], and add a quiet muted caption
under the bubble explaining that some figures could not be checked against the
user's data. The mark holds the slot grammar-safely mid-sentence, before a
surviving unit, and inside a range; the notice carries the meaning. The withheld
count rides the existing provenance envelope (unverifiedFigures) through the
stream, persistence, and reload, and the fenced document-chat path carries a
minimal envelope so its notice survives too. New i18n key across all six locales.

Also fix an adjacent reload gap: provenanceFromJson restored only a subset of the
serialised envelope, so a persisted suggestion card, confirm action card, and the
tool trace were dropped on conversation reload despite the render layer expecting
them to survive. Restore them defensively alongside the new field.
2026-07-24 09:29:20 +02:00