A medication's units per dose, a schedule's override and a restored
backup now accept any value above 0 and at most 100 with at most four
decimal places, which is exactly what the Decimal(10,4) column stores.
Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3,
3/4 passed, so one and a half tablets could not be recorded. A restore
now refuses a value no write route would accept.
The medication editor keeps its buttons and adds an Other field that
reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not
read, and previews the value it will save. The supply tab's packaging
dialog uses the same reader; it refused every non-whole value before,
so a medication at half a tablet could not have its packaging saved.
Cards and the supply tab show a whole number plus a fraction as 1½.
The rule, the input reader and the display format live in one module
without imports so the editor and the validators share them.
Refs #1034
The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
The detail page leads the schedule tab with the switch and a one-line
explanation of each state; with tracking off it drops the reminder card,
the next-dose and adherence rows, and shows the stored schedule as the
record. The wizard summary carries the same switch. Cards, the table and
the detail header mark a record-only medication, which leaves the take and
skip buttons, the take-all set, the log-dose dialog and the dashboard quick
add. Strings in all seven locales.
Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.
Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
The Verlauf ledger dropped doses the full history showed. Three windows
ended too early or started too late:
- The server clamped the window floor to the medication's creation
instant and filtered recorded doses by the same floor, so a dose
logged for earlier the same day on a medication added afterwards
fell out of "Last 90 days". The creation floor now bounds minted
slots only; recorded doses use the requested window.
- A dose taken inside its window before the slot's own time is stored
on the slot anchor, which still lay past the window end. The read
horizon now reaches the latest recorded anchor within a day, without
minting later upcoming slots.
- The client pinned from/to at mount and reused them on every refetch,
so a dose added after the tab opened stayed hidden until a reload.
The window is now resolved when the request fires and only "from"
travels; the query key carries the window length instead of instants.
Refs #1028
Four journeys, one account each, from the welcome screen to the
dashboard: blood pressure and weight typed in, a daily medication added
inline, a visit within a month with labs, and a child's managed profile
created on the confirm screen. Each asserts what the walk wrote — the
module map, the tile order, the checklist, the settled ledger — and the
first proves that leaving after Q3 resumes at Q4.
Two sweeps on accounts of their own drive the state through the answers
route and visit every screen by URL: axe at both widths plus one
keyboard-only answer, and every locale with every text node lengthened
by 30% before the page, the shell, every clipping box and every control
are measured. All six run in one project because each mutates the
account it walks.
The medication wizard learns to stay where it was mounted after a
create, which the first-result screen needs and the medications list
does not.
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.
Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.
So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.
The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
`data-latest-rate` is a production attribute whose only reader was the browser
suite, so a change to the rate's shape would have surfaced three minutes and a
whole server away from the gate that should own it. One unit test now renders
the chart over a loaded window and asserts the root carries the last day's
rate.
Two comments were wrong. The card's take / skip buttons do not collapse to
icons at narrow widths — both render their label unconditionally beside the
icon — so only the true half of the reason for addressing them by `data-slot`
survives: the labels are translated copy.
And the Enter press in the wizard's times-of-day helper is not decoration.
The field's overlay input holds what is being typed and hands it to the form
only on Enter or on blur (`src/components/ui/time-field.tsx:175-184`), while
the add button is disabled on an empty draft — so a fill followed straight by
a click waits out the actionability timeout on a button that never enables.
Proven by removing it: the flow died at exactly that click. The line stays,
with the reason written beside it.
The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.
Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:
- a twice-daily medication created through the real wizard, one dose taken
and one skipped from the card, and the figure read back on the card, in
the intake history and on the dashboard tile;
- a dose recorded against a schedule that does not exist, refused, with the
rate exactly what it was before;
- a plan due on two weekdays whose other five days stay out of the
denominator, with a daily twin given the identical doses as the control —
without it, "100 %" would also be what an engine that counted no days at
all would say.
The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.
Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
useRecordCapabilities answers per section now. canWriteDomain and
canManageDomain read the two lists the account payload publishes; in
one's own record they are always true, and under a refused, pending or
unproven context always false. canManage becomes the coarse switch,
true when any section is manageable, and is what the banner and the
navigation read.
Every add, edit and delete control names its own section: labs, mind,
profile, illness, cycle, medications, measurements, documents. Creates
that a WRITE route answers (visits, practitioners) ask canWriteDomain.
The generic delete button, the selection bar, the capture picker, the
dashboard quick entry and the bottom nav take the section from their
callers. Controls whose routes resolve the caller rather than the
record (settings shortcuts, chart overlay preferences, custom metrics,
the Today rail, OCR, efficacy targets, credential tabs) ask for the
caller's own record instead, and the customise-shortcut guard now
requires that shape.
The vault has no delegated write route, so its controls stay withheld
under every grant, and the documents page says so in one sentence
where the upload control would be, in all seven locales.
The per-user date order reached the entry form and nothing else. A
reporter switched away from MM/DD/YYYY, the date picker followed, and
the dashboard chart axes and the measurements list kept the old order.
`makeFormatters` took `timeFormat` and `dateFormat` as optional
parameters defaulting to "AUTO", so six of the seven constructions in
the tree silently rendered the locale default and nothing anywhere
said the setting had been dropped. Drop both defaults. The compiler
then names every caller: the chart bucket labels, the legacy
`@/lib/format` helpers the measurements list renders through, the
medication compliance and efficacy charts, the doctor-report PDF and
the clinician share view.
Thread the preference:
- `makeBucketLabelFormatters` takes the order; the health and mood
charts read `useDateFormatPreference()`. The UTC pin decides which
day the label names, the preference decides how it is spelled, and
the two are separate questions.
- `format.ts` reads `readStoredDateFormat()` beside the timezone and
hour-cycle mirrors it already read.
- The PDF takes `dateFormat` as a required render option, alongside
`timeFormat`, which was optional for the same reason.
- The clinician share view spells the OWNER's dates the owner's way.
It also picked up the owner's hour cycle, which it had been ignoring
while the PDF route beside it already honoured it. Both resolve
through the new `resolveUserFormatPreferences`, the counterpart to
`resolveUserTimezone`.
Textual months stay on the UI locale. The preference pins numeric
field order by rendering through de-DE / en-US / en-CA, and a textual
month through those would change the month name's language.
Add `date-order-explicit-formatter-guard`: a raw numeric date renderer
must take its locale from the preference or carry a written reason,
and a formatter construction may not hard-code "AUTO" for the order.
Its allowlist holds thirteen calendar-day key builders, each with the
reason it has no order to get wrong. The sibling timezone guard passes
every one of them, because naming a calendar says nothing about
spelling a date.
Refs #922
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.
The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.
Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.
Definitions are untouched; imports that existed only to feed a removed
re-export go too.
The German bundle's 109 spaced en-dash pauses join the em-dash norm (the
two date-range placeholders stay ranges); the three straight-quote
stragglers and three mixed-quote strings take German quotes; the eleven
Bitte-Infinitiv validation errors speak du-imperative like the rest of
the app; the two wirklich confirms become bare questions; the
notification-status description trades its semicolon telegram style for
two sentences in every locale; the Withings setup action shortens to the
sibling single word. Twelve arbitrary text-[10px]/[11px] sites move onto
the text-2xs/text-xs tokens; chart tooltips and the vendor calendar stay
as they are.
- account-sharing polls the offline persister's own IndexedDB store for
the flushed snapshot instead of sleeping past the debounce.
- The record-session-fence cases poll for the observable halves of their
setups (the switch round-trip landing, the exit switch being issued);
the one remaining fixed window is a documented absence-assertion grace
with nothing to poll on.
- The documents suite's two remaining sleeps are named for what they
are: a focus-dance grace the outer document cannot observe, and the
long-press gesture duration itself.
- The inventory-date fixtures derive their printed expiry from now
instead of a fixed 2027 date that would silently flip the fixture to
expired when the calendar catches up.
A medication whose slots carry different doses (a whole tablet in the
morning, a half at noon — #219) was consumed correctly but reported
wrongly: the wire shipped only the raw nullable per-slot column (clients
had to derive the inheritance), stockDosesRemaining divided by the
medication-level units alone, and no runway figure existed outside the
low-stock push.
- Every schedule now carries resolvedUnitsPerDose, resolved server-side
exactly like the intake consumption resolver.
- stockDosesRemaining divides the units pool by the schedule-weighted
average units per dose.
- runwayDays (whole days of supply under the slot-aware burn rate) rides
the list AND detail payloads; the detail GET also gains the stock
aggregates the list already had.
- The low-stock engine runs the same slot-aware math, so the push and
the wire cannot disagree; the web cards, table, and detail Übersicht
prefer the server figure over their client estimate.
- OpenAPI regenerated with the new fields.
- Singular variants for the bulk-delete toast and confirm dialog
(measurements + mood); the call sites branch on a count of one.
- Consent confirm buttons shortened so they no longer overflow the
small button on phones.
- Workouts empty-state CTA is source-neutral instead of assuming an
iPhone with Apple Health.
- "Pills" is no longer a navigation term in a medication app: DE says
Navigations-Chips, EN chips, PL chipy; ES/FR/IT subtitles align with
their own established terms.
- Health-score insufficient hint drops the translationese and fixes the
pronoun.
- Side-effect tag helper uses the established Stimmungs-Tags term.
- Success toasts that are single fragments lose the terminal period
(37 keys, all locales).
- Integration pill parked state short enough to survive truncation.
- The iPhone-managed reminder badge carries a short label; the full
sentence moved to a helper line below instead of clipping mid-word.
The titration-curve docblock contrasted itself against an estimated-PK
chart 'behind the Research-Mode gate' — that switch was retired as inert
and the columns dropped with migration 0321. Describe the real contract:
both charts render ungated, the estimated one carrying the persistent
modelled-not-measured disclaimer.
Integration: a medication with a morning slot at 1 unit and a noon slot
at 0.5 draws the right count for each taken dose against a real
Postgres. Unit: a per-slot dose + units set in the wizard survives
create and round-trips on edit-hydrate.
Surface the per-schedule units-per-dose beside the per-slot dose the
card already prints, identically on the standard and the GLP-1 card,
so a split plan reads at a glance which time takes a half. Muted
addendum, no width movement.
Give each parallel schedule its own optional dose and units-per-dose
input on the times step, so a split plan (a whole tablet in the
morning, a half at noon) is ONE medication with three schedules
instead of two drugs that double-count. Leaving both blank inherits
the medication-level values, so a single-dose plan is unchanged. An
as-needed slot that sits alongside scheduled ones now rides the same
medication as a PRN schedule rather than flipping the whole-medication
as-needed flag. New keys land in all six locales.
- mood-insights-sections: the docblock claimed the sections render nothing
on a failed read; the rest region now surfaces a QueryErrorRow with retry
- band-tokens / coverage-meter: the raw dracula primitives now carry their
own AA-tuned light overrides, so the no-override claim no longer holds;
the band still rides the semantic token for concept cohesion
- medication-card-body: drop the pb-2.5 header claim the header no longer sets
- modules-section: the body follows the card gap rhythm, not an mt-4 margin
Research Mode was the opt-in behind the estimated GLP-1 drug-level curve:
turn it on, read a disclaimer, get the chart. The chart stopped consulting
the flag several releases ago and has painted for every account since, so
the switch on Settings → Advanced changed no screen in either position. An
inert control that promises to hide a chart is worse than no control.
The curve is simply part of the medication page now. Gone with the switch:
the acknowledgment dialog, `/api/auth/me/research-mode`, the shared status
DTO and its gate helper, the disclaimer-version stamp, the query key, and
the three user columns the acknowledgment wrote (migration 0321). The
Privacy page's second button pointed at the retired control and goes too.
Settings → Advanced is now what it reads as: reset the data, delete the
account.
Marking an older missed dose as taken from the Verlauf pinned the
right slot but stamped the intake with the current moment. The body
builder is a pure helper now, anchored on the slot instant, matching
both the optimistic paint and the skipped-to-taken flip that already
behaved this way.
- attach the asserted context to every same-origin request inside the
three fetch entries, so no call site classifies itself
- discard a response whose echoed context contradicts the adopted one;
serve one that carries no echo, which is what keeps the public poll
and every actor surface working
- adopt from /api/auth/me and the switch response, and from nothing else
- send the epoch on every switch and retry once after reconciling
- hold and reconcile on the 409 instead of leaving the record
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.
The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.
Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.
Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.
The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.
Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
Three surfaces record a dose and all three had to reach the same two
conclusions inside somebody else's record: name the record, and drop an
Undo the server refuses. Two learned it and the dose ledger did not,
because the ternary was written out three times. It is written once now,
in the file the other two already share, with its own test.
POST /api/allergies and POST /api/family-history leave the frozen
delegable write set. The argument that admitted them still stands — an
allergy is the single most useful thing a caregiver can contribute — but
the only surface that posts to either lives in Settings, which a switch
closes, so no delegate could reach the form at any level. That is a
permission frozen ahead of the caller for it, and the list is built the
other way round on purpose.
Both delegable READ arms stay. A caregiver reading the allergy list is
what the feature is for; only the contribute step waits, and it comes
back in the same diff as the surface that offers it.
Nine surfaces offered a delegate an action the server declines, and the
switch had never been asked about any of them.
The Today rail's dismiss and the coach check-in's keep / let-go write
through routes that resolve the caller, so both refuse under a switch;
they now ask canManage and are absent at either grant level. A priority
action with no href and no handler is no longer rendered as an inert
button at all — the type carries that rule now.
The Vorsorge dashboard card offered the mark-done that /checkups already
withholds, and the checkups list branch inlined its own ungated copy of
the button the cards branch gates, so which view a browser last chose
decided whether the action appeared. Both bind canManage.
The chart overlay cog persists through a route that resolves the caller
and stores a preference belonging to the person rather than the record.
Gated once in the control so the three chart wrappers cannot drift.
Three query parameters opened sheets past a gated button: ?add=<TYPE> on
measurements, ?new=1 on medications, and ?edit=1 on a medication detail,
the last of which opens a wizard refused at both levels. A deep link is
the same affordance as the control that produces it.
The efficacy retarget dial rewrites a setting the owner chose. The
episode documents card offered a link and an upload the vault gates on
the same endpoint throughout. And the dose ledger built its own success
toast, missing both the Undo suppression and the "saved to" receipt its
two siblings already carry — the one a delegate met on every dose.
The Coach drawer is not mounted inside a shared record, so every button
calling askCoach() opened nothing. The launch provider now publishes no
value there and each entry point's existing null check does the rest;
the documents sheet, the one that rendered without checking, now checks.
Marking a dose taken or skipped is the verb the delegation was written for:
somebody looking after a parent confirms the morning tablet. Adding a
medication and noting a side effect come with it. The card menu keeps only
what survives and disappears entirely when nothing does.
Four detail tabs leave the registry inside somebody else's record, because
their whole content is owner work: the schedule editor and its plan history,
the supply ledger, the external-token row, and the advanced surface holding
the wizard, the import and the destructive zone. A stale deep link into one
lands on the landing tab, the way an inapplicable tab already does.
The take-all-due sweep goes too. It rides the bulk intake route, which the
delegation does not name, and a control that half-works is worse than one
that is not offered.
The rule that a write which wrote nothing must not read as a success was
scoped to the import panel. Provider syncs need the same rule, so the
classifier moves to src/lib/outcome/written-outcome.ts and the tick plus the
success colour move to one presentation component the whole tree renders
through. Adds the sync shape: a run reports what it imported and whether any
part of it failed, and the outcome is resolved from both.
The Bestand tab was 1,139 lines of one client file holding two concerns that
only meet at the row shape: the list of containers a person holds, and the
three dialogs that change it. Registration, per-item correction, packaging
economics, the summary and the row rendering all shared a scope, so reading
any one of them meant scrolling past the rest.
Move-only. The dialogs go to `inventory-dialogs.tsx` with the day-input
conversions their fields produce; the row shape and the cache-invalidation
they both need go to `inventory-shared.ts`; the section keeps the summary and
the list at 458 lines. Every export the tab offered is still offered here, so
no caller changes. No copy changes, no new keys.
Two suites pin these contracts by reading the file's own source — the register
dialog's unit conversion and the correction dialog's sparse patch. Their
`readFileSync` now names the file the code moved to. The assertions are
unchanged, byte for byte; only the path they read is.
The supply tab carried its own copy of the two container kinds whose first
opening starts a degradation clock, with a comment saying it mirrored the
state machine's list. Nothing enforced the mirror — the state machine types
itself against the generated Prisma client, which the browser bundle cannot
carry, so a copy was the only way to say it.
Move the list into a client-safe module with no imports and let both sides
read it. The state machine re-exports it under the Prisma enum, so a renamed
or dropped container kind is now a compile error rather than a member that
quietly stops matching.
`Partial<typeof item>` inferred the literal types off the base fixture, so an
override could not swap the container kind or null a date. The fixture takes
an explicit row shape instead.
Submitting the correction dialog is out of reach for the SSR component
tests, so the two behaviours that only show up on submit are pinned against
the module source: a date rides along only when it changed, and a cleared
field sends null rather than an empty string. The route side of the same
contract is covered by a real request test.
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.
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.
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.
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.
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
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.
A dose logged from another screen marked the Today hero and the dashboard
stale without re-reading them, so returning to the home page kept showing the
old state until the 120 s poll or a hard reload. The daily reads run with
refetchOnMount off, so a bundle invalidation with the default active
refetch-type never touched them while they were unmounted.
Route every intake-affecting medication write through one blessed
invalidateMedicationReads (bundle invalidation plus a forced inactive refetch
of the digest and snapshot): Take all due, the intake edit dialog, the history
bulk/row delete, the dose-history ledger and its add dialog, the lifecycle and
danger-zone actions, and the intake import. Give the Vorsorge reminder
mutations the same daily-rail refresh, bust the biomarker assessment and the
labs-changes read after a lab write, and refetch the daily reads after a
briefing regenerate so the Today lead updates without a reload.
Restore the dashboard-layout concurrency belt: the settings layout query now
always loads its own tokened copy, so a home-page visit can no longer strand a
save without its base token and silently drop the v1.32.16 conflict guard.
Add a structural guard that fails CI when a new site invalidates a daily-reads
bundle without the paired inactive refetch, so this class cannot recur a
fourth time, plus behavioural coverage for the batch take and the Vorsorge
refresh.
Refs #581
Seven near-identical copies of the HH:mm to minutes-since-midnight parser
had accumulated across the scheduling surfaces in two flavours: a strict one
that trusts an already-validated literal and a null-returning one for
unvalidated input. Both now live in src/lib/medications/scheduling/hhmm.ts as
hhmmToMinutes and hhmmToMinutesOrNull, and every call site imports from there.
Behaviour is identical: the per-medication route keeps its malformed-to-zero
fallback as a thin adapter over the shared core, and dose-window re-exports
the strict variant so its consumers and unit suite keep their import site.