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.
The native pen list renders these two as its headline and subhead. The
server held neither, so a container registered on the web could not be
shown there without fabricating an empty card, and the client kept its
own copy locally with no back-fill possible.
Both columns are additive and nullable (migration 0255). Every existing
row is truthfully NULL — the values were never collected, and a plain
supply row such as a blister pack or a bottle has no use for either.
Strength stays free text rather than a parsed number + unit: pens state
it per dose, per ml, or per cartridge depending on the product, and
forcing a numeric split would lose which of the three was printed.
On PATCH an omitted field leaves the stored value untouched, the same
rule notes already followed, so a client that does not know the fields
cannot blank what another populated; an explicit null still clears. The
web add-container form collects both for a PEN, so the columns do not
ship empty forever.
The card and the table carried byte-identical copies of the day-label
arithmetic. Two copies is how they drift, and neither was reachable from a
test, which is why the double conversion in both survived review.
Lift it into resolveNextDueDayLabel, which returns a discriminated label the
callers map onto their own strings, and cover the case the old code could
not get right: a browser zone that differs from the profile zone. Those
tests pin the host zone explicitly — the defect was invisible whenever host
and profile agree, which is the only configuration a UTC-only suite runs in.
Also pin that the window-status verdict is identical from every host zone,
including one whose spring-forward gap swallows the profile wall clock.
toZonedDate rendered an instant as an en-US string in the target zone and
re-parsed it as host-local. Two defects rode on that.
When the fabricated wall clock landed in the HOST zone's spring-forward gap
the engine silently advanced it an hour, so a dose inside its window could
be classified late and a just-taken dose could fail to suppress the take-now
pill — for users whose device zone differs from their profile zone.
The medication card and table then passed that already-shifted value into
dateWithWeekdaySmart, which applies the profile zone itself, so the next-
intake day label converted twice and could name the wrong calendar day.
Invisible whenever browser zone and profile zone agree.
Migrate every consumer to wallClockInTz, which returns the fields directly.
The status math only ever reads year/month/day/hour/minute/weekday, so it
now runs on WallClockParts throughout and reduceCurrentWindowStatus takes
the real instant instead of a pre-shifted Date — caller and helper can no
longer disagree about which zone the comparison runs in. Day labels use a
calendar-day distance (localDayIndex) rather than an hour count, so a 23 h
or 25 h day cannot round tomorrow into today, and the formatter receives
the raw instant.
Fixtures name the zone they mean instead of building host-local Dates.
Extend the dashboard's RSC server-prefetch pattern to three more route roots
so their above-the-fold content paints populated on first load instead of
flashing a shell that fills in on mount.
Each route gains a thin RSC wrapper that runs the same cached read its API
route uses, dehydrates the result under the exact client query key, and hands
it to the client leaf through a HydrationBoundary. Every wrapper fails soft to
today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH
kill-switch the e2e server sets.
- /insights: prefetch the dashboard snapshot — the same locale-keyed cell the
dashboard warms, resolved once server-side so the key matches by
construction. The AI briefing and the heavy analytics aggregations stay
client-lazy (the briefing must never trigger generation; a cold analytics
read would trade FCP for TTFB).
- /medications: prefetch the medications list through the shared
caches.medications cell (extracted to src/lib/medications/list-read.ts). The
list cell keeps refetchOnMount: "always" — a cross-device take/skip produces
no client mutation event, so the mount refetch is a correctness gate; the
prefetch removes the initial skeleton flash without dropping that guarantee.
- /coach: prefetch the nudge status (extracted to
src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is
ready at hydrate, collapsing the one sequential waterfall. Only runs when the
Coach surface is reachable; the streaming conversation is never warmed.
Add per-route tests pinning server-key === client-key, JSON wire-shape
round-trip, and the fail-soft / module-gate paths. Repoint the source-reading
insights, take-all-due, and Coach gate-cascade tests at the client leaves.
Bump text-[10px]/text-[11px] to text-xs on muted description, hint, and
status-label surfaces that had drifted below the type-scale floor —
coach conversation captions, settings/admin status badges, mood
confidence pills, side-effect and cycle meta lines. Tabular-nums
badges, chart-plot legend/axis chrome, ring geometry, and
width-constrained tag tiles keep their compact size by design.
Metric catalog page painted every metric as "Not tracked yet" (with
Connect CTAs) while its four availability probes were still in flight,
and stayed there forever on a failed batch — a load failure indistinguishable
from an honest absence. Gate rows on the probes' aggregate loading/error
state: row skeletons while loading, a QueryErrorCard with retry on failure.
Repoint ComplianceInfoTip onto InfoPopover instead of carrying a verbatim
structural copy (adds icon/align/data-slot override props to the shared
primitive); migrate the cycle log-sheet's FieldInfo off a hover Tooltip
onto the same click-opened InfoPopover, since a hover trigger never opens
reliably on the sheet's thumb-first touch surface.
Lift ViewToggle/SegmentedToggle off their stale 40px tap floor onto the
44px/36px floor the sibling intake and history-rail controls already
carry, and correct the docstrings to match. Restore the dropped
safe-area-inset-top term in /coach/conversations' height calc so it
matches /coach on notched devices. Bring the coach history rail's
off-scale spacing and sub-floor timestamp text onto the standard scale.
Sweeps the banned gap-5/space-y-5/pl-5 spacing residue the audit found
scattered across coach pages, settings coach cards, the labs/records/
custom-metrics form family, onboarding, and a handful of one-off
surfaces (glucose panel, clinician view, illness correlation card,
invite-tokens, the mental-health check-in wizard including its stray
sm:p-6 shell steps) down onto the 4/6 scale.
Two medication chart colours (dose-strength-curve, drug-level-chart)
painted raw --dracula-purple with no light-theme override; both now
read --chart-1, the same alias the mood-chart fix already established.
Raises ~40 sub-text-xs muted-prose sites below the UI-STANDARDS §3/§5
floor (settings API/MCP/sharing/coach-prefs cards, insights workout
detail, suggested prompts, tab-strip group popovers, sources rail,
metric-stat-strip, the health-score provisional pill) to text-xs,
leaving genuine tabular-nums Badges untouched — the one exemption the
standard carries.
Routes the 10 hand-rolled settings/insights "no data yet" paragraphs
(coach memory, coach reminders, security sessions/activity/keys, API
tokens, sharing, insights pill order, per-metric overrides, nutrient
intake) through the shared EmptyState primitive instead of a bare
muted <p>, so they read the same, centered, icon-ready shape every
other empty list in the app already uses. The notification-status
card's empty copy stays on SettingsCardHeader's description slot —
converting it would mean restructuring the card, not routing it.
Logging a dose via Medications → Add → "Log intake" (incl. backdated)
or the dashboard's medication-intake quick-add toasted success with no
Undo, unlike the card's own take/skip path — a dose logged against the
wrong medication (easy on the quick-add, which auto-picks) was only
correctable via detail page → history → find row → delete. Both paths
now capture the created event id and attach the same Undo action.
WorkoutDetailHeader held three columns at every width, wrapping the
long-format date to 4-5 lines beside a text-2xl duration at 360px;
stack below sm with duration moving under the title block. Same pass
drops a stray text-[11px] muted hint below the size floor.
The intraday pulse chart and the VO2/cardio-fitness link card carried
a static p-4 shell with no md:p-6 step, and the intraday loading state
was a raw pulse div instead of the Skeleton primitive under-reserving
the chart height — both now match the card inset contract used
elsewhere. Same one-line fix on the insights hero shell (sm-stepped
inset instead of the standard md-stepped one) and the glucose advanced
panel's 3-up grid at a 320px fold.
The medication efficacy tab's retarget control failed saves silently —
no toast, no inline alert, an unhandled rejection escaping past the
disabled-button reset. Surface a toast on failure. Also swaps the
level-shift date off a raw toISOString slice onto the locale
formatter, and the primary-target tag off bare muted text onto the
Badge component the rest of the app uses for this kind of marker.
The caffeine card collapsed its 280px skeleton to nothing on every
nutrients-page visit for an account with no logged caffeine; a 60s
staleTime (already the convention on the sibling insights queries)
keeps the flash to once per session. The efficacy chart's dynamic-
import fallback was the full card-chrome ChartSkeleton nested inside
its own Card, snapping ~100px when the real bare chart mounted — swap
for a plain Skeleton sized to the actual chart.
Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.
The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.
Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).
Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.
Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.
Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
Sweeps every surface that renders a date from before this year without
a year suffix — illness episodes/flares/day-logs, lab and custom-metric
charts, the mood chart family, medication titration/side-effects/dose-
history/next-dose captions (incl. the Dec→Jan rollover), cycle
predictions and history, the BBT chart, the intraday-pulse day-nav, the
date-range filter chip, and the nutrient/compliance charts. Each now
routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts)
or the matching date-format.ts helper, which add the year only when it
differs from the current one — mirroring the workout list's existing
model instead of a bare "19.02." that reads as this year's.
Also fixes two sites that bypassed the app's locale entirely: the
trusted-devices expiry (bare toLocaleDateString, host locale) and the
admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both
render through the user's own locale, hour-cycle, and timezone
preference.
The Today digest's dose-window rail item linked to the bare /medications
list even though the overdue medication's id was already resolved
server-side alongside its name — a tap landed the user on a generic list
they then had to scan themselves.
buildMedsTodayBlock now carries nextDueMedicationId; the digest's
"Log dose" action links to /medications?highlight=<id> when the id is
known, falling back to the bare list for an older cached block. The
medications page reads the highlight param, scrolls the matching card
into view, and rings it briefly (mirrors the vault's duplicate-upload
highlight) in both the card grid and the table view. Regenerates
docs/api/openapi.yaml for the new medsToday field.
Intraday pulse rendered sparse spot readings as a smooth interpolated
curve across hours-wide gaps, implying continuous monitoring the data
never had. The chart now grids one point per bucket, breaks the area
over any missing bucket (connectNulls={false}), and discloses coverage
("based on N readings across M hours") whenever that produces a visible
break.
Four surfaces also collapsed a failed fetch into the honest empty/no-
data state instead of surfacing the error: the Today hero, the
medication efficacy tab (previously stuck on the loading skeleton
forever on error), the intraday pulse chart, and the recent-workouts
dashboard tile. All four now branch on isError and render
QueryErrorCard with a retry action; the tile's "loading…" text line is
replaced with row-shaped skeletons.
- Efficacy target chart: replace the implicit zero-based y-axis with a
padded data-range domain so a small relative trend fills the plot
instead of compressing into the top band.
- Add a "% since start" chart mode that reindexes the target series to a
percentage of the start-anchored baseline; the absolute reference band
is hidden in that mode.
- Intake history tab: toggle between the trailing 90-day dose ledger and
the full paginated intake history so older doses stay reachable.
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.
Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.
Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
Verified UI-consistency findings from the campaign's design audit, each
pixel-faithful except the intended correction:
- insights hero band: off-scale py-5/gap-5 → the standard scale; H1
font-semibold → font-bold; sm:text-[28px] → sm:text-3xl.
- medication-compliance-chart + recent-achievements-card: hand-rolled
muted h-4 icon + text-sm headers → the standard TileHeader primitive so
they match sibling tiles.
- achievements stat tiles + clinician-view shells: p-5 → p-4 md:p-6.
- two text-primary header glyphs (research-mode dialog, enroll-mfa) →
text-foreground (never accent on a header glyph); the deliberate
badge-boxed icons left untouched.
- SheetHeader now reserves the trailing close-X gutter like DialogHeader,
closing the latent overlap class for future raw-Sheet consumers.
- five raw Dialogs on phone-reachable surfaces → ResponsiveSheet.
- new bg-card-scoped healthlog/spacing-scale lint (flags p-5/gap-5 etc.
only on card shells; list-marker insets + half-steps excluded) at error,
with the five offenders it flagged swept.
- dropped the stale CLAUDE.md '18 PascalCase outliers' line (0 remain).
Every data-slot preserved. No migration.
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).
Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
The third and final layer of the stale-tile bug: saving invalidated the
dashboard snapshot with the default refetchType 'active', but the dashboard
page is unmounted while the user sits in settings — the cell only went stale,
and the snapshot query's deliberate refetchOnMount: false then suppressed the
re-read on same-tab navigation back. The save, reset and ring mutations now
invalidate with refetchType 'all', refreshing the cell mounted or not
(proven live in one SPA session against the real query client).
The sortable settings editors (dashboard layout, medication order, modules,
mood tags x2) painted a second page scrollbar: the sr-only drag hint below
each list is position: absolute, no ancestor was positioned, so it resolved
against the initial containing block, escaped the shell's overflow clipping
and lengthened the DOCUMENT by its static offset. The list wrappers are now
relative; the e2e overscroll guard asserts the one-scroll-floor on these
routes and was negative-tested against the revert.