Both report paths dropped them: the raw path iterated the named contexts,
and the dense path skipped any row without a context outright, so a report
built for a meter that records no meal time carried an empty glucose panel
while the readings sat in the database. The FHIR export dropped them for
the third time, at the LOINC lookup — its own comment already said an
unspecified reading codes as 2339-0, but the table had no entry to hit.
The insights context breakdown names the bucket, and the Coach's glucose
block now resolves through the shared helper rather than its own copy.
Refs #943
Three surfaces built the label key by interpolation — the doctor-report
PDF's visit table, the daily digest's upcoming-visit line and the
clinician share view. `EncounterKind` is `ROUTINE` and the bundle leaf is
`routine`, so all three rendered `encounters.kind.ROUTINE` verbatim: into
a document a practice files, onto a lock screen, and onto the page.
Route them through `encounterKindLabelKey`, a literal switch beside the
existing `encounterKindLabel`, so the mapping is written down once.
The enum-derived i18n guard could not have caught this: it only knows
key spaces of the form `prefix + member`, and this one is not. It gains a
second arm for spaces whose mapping is a function, with `encounters.kind`
as its first entry. Break-proof: point one arm of the resolver back at
the uppercase key and six locale cases go red.
The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.
The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.
All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.
A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.
The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.
The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
Add an EMERGENCY leaf to the identity group of the report catalogue,
default-on, no module gate. When the leaf is admitted and the profile
holds emergency data, the aggregator collects it and the PDF renders an
emergency sheet as page one (banner, blood type, severe allergies, active
medications, chronic conditions, implants, advance directive and organ
donor, contacts, notes), then breaks to send the rest of the report to
page two. The section is withheld when the leaf is not admitted or no
data is present. Guards updated for the new leaf counts.
- IMMUNIZATIONS report leaf in the history group, gated both ways: the
vaccinations module and the leaf selection each exclude independently
(WR-7 seen red on the selection gate, reverted by content hash)
- collector loads the whole live history (reference data, not windowed, like
allergies) with server-resolved series positions; the PDF renders a compact
per-dose table — date, vaccine, dose, lot — with no due-status or gap analysis
- added to the default template so a new report offers it; stored selections
and frozen share links never widen
- leaf label and section copy in six locales
The VISITS leaf ships in the release that made a visit possible, because a
selectable checkbox that produces nothing is worse than no checkbox. Leaf,
collector and PDF renderer land together, and the leaf carries no LEAF_MODULE
entry — like ALLERGIES beside it — because a visit is core and the whole report
is already gated by doctorReport. The absence is commented at the map so a
later reader does not read it as an oversight.
The rows print the date, the practice with its specialty, the kind, the reason,
the outcome and the conditions the visit was filed against. The two encrypted
columns decrypt at render, fail-soft, and never enter the stored selection. A
condition the person has since deleted is not named: the link row survives a
soft delete, and printing a label they withdrew would put it in front of a
doctor.
No FHIR resource type is added. That deferral is already written at the
Encounter model, and the bundle builder now says so at the point a reader would
look for the missing section: FHIR splits the future from the past across
Appointment and Encounter, which is a serialisation question this release does
not answer.
Two properties are free and worth stating: a stored selection never grows
(unknown ids drop) and a clinician share link's sections are frozen write-once,
so no existing report and no already-shared link gains a section.
The panel also offers a booked visit as the report's reason — one query, one
prefilled window end, one dismissible offer that names its own source and never
applies itself. A window control that moves on its own is what makes an export
panel untrustworthy.
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.
Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.
`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.
The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.
Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.
The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
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
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
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.