/insights/mood was on its 460 KB ceiling. The route paints a calendar, a line
chart, a target card and an assessment; under them sit a dozen breakdown cards
— both tag axes, the influence board, the two crosstabs, the day-context
comparison, four Recharts mini-charts, the correlation cards — and every one of
them was a static import, so a cold mount paid for all of it before painting
anything.
The cluster is its own module now, loaded through next/dynamic with a skeleton,
the way the insights mother page already treats its below-the-hero blocks. It
takes the resolved aggregate as a prop rather than running a second query, so
it cannot draw a different snapshot from the regions above it.
The better-days card follows, for one specific reason: it resolves catalogue
tag icons, and that resolver statically imports about a hundred and sixty
lucide icons. The card sits a screen down and the map now rides the same lazy
group as the breakdowns.
Only mood-only modules moved. Deferring the shared target and assessment cards
was tried and reverted: it bought ten kilobytes on this route and cost
thirty-three on the whole-build total, because both are imported by a dozen
other insight routes and a route-local lazy group duplicates them.
/insights/mood 459 -> 451 KB gz (budget 460, unchanged)
total client JS 3296 -> 3297 KB gz (budget 3310, unchanged)
The payload shape and the section card frame move to a shared module both
halves import; the two structural tests that read the old file now read the
union of the two, so a guard cannot go quiet by pointing at the wrong half.
The hook always sent the browser's zone while the route's comment said the
detail view sent the entry's stored one. Nothing was red, because nothing
asserted it: correcting an entry logged in another country looked its day up
under whichever zone the correction was being made from, and the night shown
beside it could be hours out with nothing on screen saying so.
The edit dialog passes the entry's `tz` now. The capture sheet keeps the
browser's and passes no third argument, which is correct rather than an
omission: the entry does not exist yet and will be anchored to that zone.
A legacy row with no zone falls back to the browser's rather than to
Europe/Berlin — the server owns that legacy rule and a guess sent from the
client would override the one place it is written down.
The resolution is a pure helper so both branches can be asserted, and both
call sites are pinned structurally, because the resolution being right is
worth nothing if the dialog never hands it the entry's zone.
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.
- one comparison per context value, reusing the Welch test, the day floors and
the Benjamini-Hochberg family the tag board already runs; a wide sweep
filtered on a raw p < 0.05 would surface one-in-twenty noise as a finding
- two entries on one day count as one day
- every statement names how many days it compared, on the same surface as the
claim, and says it shows a connection rather than a cause
- the assistant snapshot carries the same rows with that constraint written
out beside them
- work, contacts, leisure and one notable event, each a closed accordion with
a count badge, on both the capture sheet and the edit dialog
- sleep, activity, vitals and the illness journal render read-only beside
them, each row naming where its figure comes from and offering a way in to
change it there
- the body line links into the illness module and captures nothing
- six locales for every label and every anchor
The dead-code sweep found the window type exported and unread. It was
unread because the surface that offers the choice carried its own copy
of the same three numbers: the chart could not import the calculator's,
since that module reaches the database client two hops down and a client
component pulling it in would ship Prisma to the browser.
Both now come from the dimension table, which is pure by construction
and which the chart already imported. One declaration of 7 / 30 / 90,
read by the calculator that averages over them and by the control that
offers them, so the two cannot drift.
An entry always carries a five-point label, so it always implies a
pleasantness value: the create path derived one from a null and the
capture sheet still showed a clear control for it, which meant a button
the server undid on the way to the database.
One rule on both routes now — a null asks for a value that cannot exist
and gets the derivation, on create and on edit alike — and the control
is gone for that field. The other four are genuinely optional and keep
theirs.
- one engine: the dimension series is a sibling calculator of the mood
aggregate hub and rides the same bounded read, so no second summary of
mood can disagree with the first
- 7 / 30 / 90-day means per dimension, plus the daily series behind them
- a dimension nobody answered is named as not recorded rather than drawn
flat through the middle of the scale, and a zero counts as an answer
- each line keeps the orientation the person answered in; the legend
says which way the inverse one runs
- the card rides the existing module-gated aggregate read and query key
- the five faces are untouched: pick one, save, done in a tap
- a closed section below them opens onto five sliders; picking a face
suggests a pleasantness value and moving that slider takes the
suggestion over, while the face selection stays where it is
- an untouched slider writes nothing, and clearing one takes the answer
back rather than leaving a middle value behind
- the list's edit dialog shows and changes the same five, so nothing is
written on one screen and invisible on the other
- correcting an entry no longer re-derives pleasantness when the label
did not move, which used to overwrite a hand-set value on any edit
- factor ratings are no longer offered on a new entry and nothing was
deleted: existing ratings still render, edit and feed the crosstab
- six locales, and the dimension table's keys are declared dynamic
- Slider wraps the Radix control the way Switch and Progress already do;
no new dependency
- SliderField adds the label, the value and the two scale anchors, which
are what make a 7 mean anything
- a null value renders as unanswered rather than as the value the parked
thumb sits on, and zero renders as the answer it is
- the announced text carries the scale, not only the number
The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.
The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.
- AccountGrant is the one difference, with its reason in the plan: it is
the only access path to a managed profile, and this action preserves
accounts by definition. Deleting every grant would leave records
nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
did what from where, which is exactly what clearing a box is for; the
receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
half-applied wipe is worse than none, because the person is told it
succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
which is how nobody noticed: it read the same whether the wipe had
cleared nine tables or ninety.
Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
Measuring the seven `/settings/layout/<child>` routes was meant to pin the
back-link spacing. It found there was no back-link to pin.
`SettingsHubBackLink` and `settings.sections.layout.backToHub` have existed
since the sub-pages were split out, and the only thing that mounted them was
the shell's slug FALLBACK — the branch that runs when a page passes no
`heading`. These pages pass an explicit one, so the fallback never ran and no
child route has painted a back-link since. The component was written, the
string was written, the reverse-coverage guard saw a call site and was
satisfied, and the way back to the hub was not on the page. Two ends, no pipe.
It now comes from the page, where the heading it belongs to comes from.
The geometry spec grows the seven routes and a second family. A route with a
back-link carries an extra row above the title, so it is taller by
construction; comparing it against a plain route would report structure as
drift. Each family is checked for its own constant heading top, and the step
between the back-link and the title is asserted at 6 px — `PageHeader`'s
documented `space-y-1.5`, which is what the shell's hand-rolled `space-y-6`
would have drifted back to.
Measured, both languages: plain routes 24.0 px, back-link routes 51.0 px,
spread 0.0 px in both families, step 6.0 px on all seven.
The back-link is also painted twice, once per breakpoint, so the measurement
picks the copy with a box — `querySelector` returns document order, which on
a desktop width is the hidden mobile one, and reading it reported no
back-link at all.
Every item here is a check that could pass while the thing it checks is
broken, or a margin the sweep's codemod could not reach.
Guards:
- The access-payload matcher accepted `.level` on ANY object. A chart's zoom
level or a log record's level would have satisfied it and reported a
consumer that does not exist. It now names the bindings the entry actually
arrives under; a third binding is a deliberate edit rather than a silent
widening. Re-falsified: rename both readers, it goes red.
- The `pl-7` sweep skipped any line CONTAINING `//`, so a class string sharing
a line with a URL was waved through. Only the part after a real line-comment
marker is exempt now. Proven by putting `pl-7` on a line with an
`https://` comment: caught.
- `LEGIT_IDENTICAL` in the English-leak guard had no staleness check, unlike
both of its siblings, and carried two dead keys — one from a removal long
before this work, one from the duplicated version fields that came off
`/admin`. Both pruned, the check added, falsified with a made-up key.
- `split-description.mjs` split inside `z. B.`, `d. h.`, `u. a.` and `p. ex.`
and produced a head ending mid-abbreviation. Both halves have to be caught,
which is why the rule is a shape (single letter + stop) rather than a list,
and the merge re-tests its own tail. Exercised against eight cases including
the ones that used to break.
Margins the codemod could not see, because the child lives in a different
component from the card that spaces it: six form roots under managed record
settings (one of which also carried the banned `space-y-5` step), the
low-stock status line, the avatar card's explainer, and the backups restore
report. Each was adding a margin on top of the card's own gap.
Two facts the text diet cut too close, both restored inside the guideline:
that an excluded injection site wins over a medication that prefers it, and
that the privacy card's second button leads to a full health record.
The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.
Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.
`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.
The five, all six locales:
- full backup — the twelve-item enumeration was the description. It is now
the card's opening body paragraph, where a list of what is in the file
belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
paragraph rather than riding the page explainer.
Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
Both ends of the divider removal were green and the pipe between them was
never run. The `<hr>` came out of every integration card, all three unit
tests were updated to expect none, and this browser spec kept asserting nine.
It is the same shape as the failure this project keeps rediscovering: two
correct halves and nothing exercising the assembly.
The cost was not just a red spec. The Pixel-5 horizontal-overflow assertion
sits AFTER it in the same test, so `/settings/integrations` had no proven
mobile-overflow check for the whole sweep — the test never reached it.
The second assertion read a sentence's bytes and broke on the text diet while
the thing it was about — the card rendering its explainer at all — never
changed. `SettingsCardHeader` now names its description slot
(`data-slot="settings-card-description"`) and the spec targets that: the slot
is present, non-empty, and not a raw i18n key. The product name stays
assertable because a proper noun is not prose. The wording itself is pinned
where it belongs, in the unit test that reads the same bundle it renders from.
Thirty-four routes, both consoles, English and German, desktop and phone.
The claims this program makes are geometric, so they are checked with
geometry rather than by looking at screenshots.
What it found, which is the reason it exists:
- `SettingsCard as="section"` sat one spacing step tighter than its
neighbour on every desktop width. The `<Card>`-backed variant inherits
`gap-4 md:gap-6`; the landmark variant wrote a flat `gap-4`. Seventy-five
of eighty-six cards measured 24 px and eleven measured 16 px, directly
above and below each other. The expected value in the spec is a function
of the viewport for exactly this reason — a constant would have agreed
with whichever half it was written against.
- One German page subtitle wrapped to a second line where English fit on
one. Shortened in all six languages.
After both: heading top 24.0 px on every route, spread 0.0 px, in both
languages and at both widths — that is the no-jump claim, measured. Card
header-to-body distance equals the card's own gap on all 86 cards, and no
card body sits off the card's inner edge.
The header's HEIGHT is pinned only where one line is achievable. At 393 px a
70-character sentence needs about 380 px of column, so most explainers wrap
there; the spec asserts the wrap away at desktop width and only forbids a
third line on a phone. The alternative is truncating the sentence, which is
the defect this program removed.
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.
`moodEntrySchema` gains `note`, `noteEncrypted`, `tz`, `syncedAt`,
`syncVersion`, `createdAt`, `updatedAt` and `structuredTags`; every one of
them optional or defaulted, so a backup file written before this parses
unchanged and restores exactly as it did.
The restore writes them. The note follows the measurement pair: ciphertext
verbatim when the file carries it, a portable file's plaintext re-encrypted
on the way in, legacy column null either way. `tz` absent still means the
legacy Europe/Berlin reading, which is what those rows meant. `syncVersion`
comes back at the value the file recorded — restoring at the default handed
the next round to any paired device holding a higher number, and the device
would then overwrite the recovered entry with what it had.
The link rebuild re-creates both arms: rated factors from `factors`, ticked
tags from `structuredTags` with a NULL rating. An unresolvable tag key is
reported under its own `moodTag` family, beside the rated one, because an
operator needs to know which of the two an entry lost.
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.
Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.
Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.
On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.
The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
Twenty-six of twenty-eight admin files already used the Settings card and
header. What made the console read as its own system was everything on top of
that: three hand-rolled cards, a header slot that meant five different things,
and a heading the frame took but never painted.
- `SectionFrame` drops `title` and `subtitle`. Seventeen call sites passed
both into a component that has rendered neither since v1.18.6.1 — the shell
reads the same keys itself. A prop contract nothing honours is a promise the
next reader believes.
- The invite card, the four host-metric headers, and the `/admin` welcome
card stop hand-rolling `bg-card border rounded-xl` shells with their own
padding steps and their own heading weights. `text-sm` as a card title on
the first card of a page, directly above a correct one, was the first thing
the eye landed on.
- The header status slot carries status. The backups run, the invite create,
the danger-zone wipe, and the user filters come out of it; the app-log
refresh stays but reads as the secondary it is.
- Loading states paint their card and header. Encryption returned a bare
spinner row and then a card, so the section jumped when the status landed —
the fix `coach-feedback-section.tsx` already carried, applied where it was
missing. Same for the notification-status card.
- One label/value tile. `StatusItem`, the encryption section's private `Stat`,
the user-management inline panels and the backups upload box were four
fills and three paddings for one role.
- The overview stopped printing the running version, build SHA and build time
twice, thirty pixels apart. The version tile owns build identity — it is the
one that checks for a newer release; the snapshot owns what is running.
- `ai-server-key` had three paragraphs stacked in a muted `text-xs` slot;
one sentence stays there and the two that carry information move into the
body as foreground. The central-codex consent copy moves the same way, and
its disconnect is `variant="destructive"` — which `ConfirmButton` refused to
accept, so every destructive confirm in the tree had been hand-tinting an
outline instead.
- Leftovers: the `-mt-2` correcting the card's own rhythm, two alpha hovers on
text, `text-[10px]` badges, the redundant `space-y-6` wrapper, the empty
state repeating the header's CTA, and the `mt-6` a card added inside a
frame that already spaces its children.
The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.
Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.
Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.
Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.
Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
The "looking after" block sat directly under the greeting and above this
account's own day, permanently, for anyone with a single shared record. The
dashboard is this account's day; other people's records are a destination,
and the account switcher in the top bar and the sidebar already is that
door — with the same access level and record kind on each entry.
Drop the card, its test, and the five strings only it used, across all six
locales.
The access-payload consumer guard named that one component as the reader of
`level` / `sections` / `recordKind`, so it went red on the deletion, which is
what it is for. It now names the surfaces that actually adopt those fields —
the presentation resolver and the switcher — and matches the property access
rather than one variable name, so a rename of the binding cannot make it
match nothing and pass. Verified by removing every reader: it fails.
Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all
six bundles in one go.
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.
Move the decisions into the primitives so a call site cannot re-open them:
- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
header-to-body step is the container's, not a margin somebody remembers.
A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
and it mis-aligned action rows against their own forms wherever two
neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
licence call sites used to stack explainer paragraphs into a muted
`text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
the card. `align="start"` spells the one exception, a card whose only
actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
a group of cards, which is what Settings needs; Settings had been using a
card header with no card, which reads as a card that failed to paint.
Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.
Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
Two things about the card that shows what changed since the last panel.
The comparison had no expiry. Someone whose newest panel is years old was
still reading "since your last panel" about a draw from another year — not
stale UI state but a stale sentence, and one the app kept repeating on the
overview every day.
The window is a year, applied to the data rather than to a per-user
dismissal. A year because routine panels are commonly drawn annually, so a
shorter window would hide a comparison that is genuinely the newest thing
somebody has for months out of every year, while past a year the wording
cannot be defended at all. Doing it on the data means nothing to
acknowledge, nothing to persist, no housekeeping asked of anyone, and one
answer for every client through the existing absent contract — the card
already un-mounts on it, and so does anything else reading that route.
Verified in both directions over the real endpoint.
The delta on the right was muted at the smaller size, the same typography
as the meta line under the analyte, so it optically hung off that lower
line and the row read as bottom-aligned even though it measures centred to
the pixel at every width. A delta is the number the row exists to report,
so it takes the content tier, and the cell states its own centring rather
than inheriting it.
The existing cases pinned their clock at the same time: fixed fixture days
against a relative window is a test that passes until a date in the future
and then fails for reasons unrelated to the code.
The three entries stacked two full-width buttons under the value — a
disabled grey Save above a red-outlined "Remove smoking status" — so each
one read as a slab of controls with the same weight as the field it
belonged to, and scanning them ran the two labels together.
Each entry now ends in one right-aligned action row: the quiet destructive
first, the primary Save last, both at the mobile tap-target floor. The
remove control drops the field name from its visible label, which the
Label directly above already carries, and keeps the full phrase as its
accessible name and in the dialog it opens — so three of them side by side
stay distinguishable to a screen reader. Six languages.
Follows the card action-row convention: primary bottom right, secondary
beside it, nothing after the row.
On a phone the placeholder sat noticeably low. The composer row was
bottom-aligned so the send button would stay beside the last line as the
textarea grows, but that also bottom-aligned a single-line textarea against
a 44 px touch target: 7 px of space above the text and 1 px below.
The row centres its textarea now and the controls keep their own bottom
alignment, so both properties hold. Measured empty, one line and four
lines, phone and desktop: the single-line gap goes 7/1 to 4/4 on a phone
and 2/1 to 2/2 on desktop, and the multi-line case is unchanged with the
send button still level with the last line.
The accepted formats and the size limit were a caption of their own under
the upload buttons, so the card read as an explanation, a control, and then
a second explanation. They now finish the sentence that says what the card
does, which is where someone looks before choosing a file rather than
after. Same two strings, six languages unchanged.
The intro listed all four instruments with their subject in brackets and
then explained itself, which ran to two lines on a desktop header and five
or six on a phone — a paragraph to read before reaching the four cards that
say the same thing. It keeps its opening claim and the one caveat that
matters, and loses the enumeration the cards below already carry: one line
on desktop in all six languages, measured at 1280 and 1440.
The pointer to mood history at the foot of the page is gone. The link on
the mood page pointing here stays, because that direction earns its keep:
someone logging a mood may not know the screeners exist, while someone who
came here for a screener knows where their diary is.
The Coach button showed its unread dot over an empty conversation, and
opening the Coach neither showed anything nor cleared the dot.
Two paths fed one indicator. The nightly sweep flipped a "remind me"
reminder from active to due and stopped there — no conversation, no
message, and `surfaced` had no writer at all. The launcher lit its dot off
a second query that asked only whether anything was due. So the dot
promised content that was never written, and because opening the Coach
clears the message-based signal and not the reminder one, the only way out
was to resolve the reminder in Settings, which nothing on the badge
suggested. A reminder minted for a plan review put a user who had never
typed in the Coach in exactly that state.
The sweep now writes the reminder into the user's Coach thread as a real
assistant message and marks it surfaced in the same transaction: no
message, no status change, and nothing to point at. The badge derives from
that message like every other proactive one, so opening the Coach shows the
note and clears the dot. The launcher's second query is gone.
Reminders an older build left stuck on due are picked up by the same pass,
so an indicator that has been sitting on an empty conversation resolves
into the message it always implied.
No provider call and no push: the body is a deterministic localized
template around the user's own note text, six languages.
Proven end to end against Postgres — the row exists, the badge reads that
row, opening clears it, a second tick writes nothing, and a failed write
leaves the reminder untouched with the badge dark. All five fail against
the previous sweep.
The second indicator in the report, under the bottom bar's overflow entry,
is the active-route marker for that group and not a badge.
Refs #715
The add and edit surfaces could be scrolled sideways and the time picker
sat past the right edge: measurements, mood, the medication intake editor
and the cycle log sheet all showed it, so the cause is in what they share.
Two mechanisms, both in shared primitives.
DateField and TimeField wrap a text input. A flex item's min-width:auto
resolves to a content-based minimum, and for a text input that is its
intrinsic size attribute — 20 characters, about 180px. Neither field could
render below ~230px whatever width the parent offered, so the date + time
row demanded ~470px inside a 400px dialog body and pushed the clock button
58px past the edge. min-w-0 on the field box and on the overlay input
releases that floor; the row now fits at every width from 360px up.
The scrolling body only declares overflow-y. CSS promotes the other axis
from visible to auto, so every deliberate outward bleed in the tree turned
the form into a pannable surface — SheetSection's trigger row was 8px wider
than its own container for no benefit (a focus ring already draws outside
the box), and Switch's hit-target pseudo reaches 13px past the track. The
bodies and the dialog surface now state overflow-x explicitly, the section
trigger no longer bleeds, and the dialog's grid column is pinned so an
over-wide child cannot drag the surface out with it.
Measured before and after on the four reported surfaces at 360 / 390 / 430
/ 768 / 1280 / 1440: the date/time row spilled 83px on every width from
640px up and none of it remains.
An e2e spec asserts the two harms directly — the surface is not a
horizontal scroll container, and no focusable control lies outside its
visible box — with a canary that proves the reachability check can fail.
Two repo-wide guards caught the additions rather than a reviewer, which
is what they are for.
The success toasts reached for sonner directly. The success affordance
belongs to the outcome module, which binds it to the success key and
nothing else, so a surface cannot raise a green toast for a write that
wrote nothing. They go through toastWrittenOutcome now.
The sheet's date header formatted a day key in the browser's zone. A
cycle day key is a calendar day, not an instant, so it is anchored at
noon UTC and rendered in UTC, the same anchor the domain's day
arithmetic uses. Otherwise a reader east or west of the browser zone
could see the sheet name a different day than the one it writes to.
The page reads one window anchored on today, ninety days back and half a
year forward, and the month grid navigates freely past it. A month
outside the window came back with no days: the cells still rendered
(they come from the month, not the read) and still opened the log sheet,
and the write behind it still landed, because the period route is built
for back-dating and re-anchors the neighbouring cycles for it. What did
not happen is anything visible. Saving an April flow in August closed
the sheet onto a month the read never carried, so the entry looked like
it had not been made, and entering it again did not help.
The grid now reports the month it moved to and gets its own bounded read
for it. The anchored window is untouched, because every other surface on
the page means today and the route caps a span at 400 days, so a union
with a month two years back would simply be refused.
A write also says so now. Saving, deleting and both period boundaries
raise a confirmation; the sheet's inline strip already named a failure.
Between the invisible month and the silent success there was no way to
tell a save that worked from one that never happened.
The sheet header carries the date it is writing to, and the one-tap
boundaries drop the word today when the day is not today. It always
wrote to the selected date; saying otherwise made a correction read as a
mistake.
The gear beside the log button is gone. It was a second door to the tab
directly below it and no other page carries one in that corner.
- calendar days carry isCycleStart, so the delete dialog can say that
removing this day removes the period start with it
- window arithmetic is a pure module with its own tests, including the
400-day span the anchor cannot be widened past
- integration: a day 111 back is written, is absent from the anchored
read, and comes back complete on a window over its month
- new strings in all six languages
The heart-rate card hid itself whenever the server resolved no curve.
That is right for a session with no heart rate at all, but wrong for the
common case it also covered: a ride logged from a chest strap through a
provider that reports an average and a peak and nothing in between. The
stats grid shows those two numbers, the card next to them renders
nothing, and the page reads as if it has no heart-rate section rather
than as if the shape behind the numbers is not on record.
Three states now. A curve when there is one; a named absence when the
session carries heart-rate figures without a profile; nothing at all when
nothing was ever measured, since there is no absence to report there and
an empty card would be a shell.
The min-to-max envelope shipped as a stacked pair: a transparent base at
the bucket minimum plus a `max - min` delta above it. Stacked series are
measured from zero — recharts derives a stacked axis domain from the
stack offsets, whose baseline is 0 — so a chart asking for `dataMin - 10`
resolved to -10 rather than to the bottom of the band. A session spent
between 120 and 170 bpm was drawn on a -10 to 180 axis and the shape of
the effort, which is the entire point of the curve, squashed into the top
quarter of the plot. It only happened on the dense sessions the envelope
gate lets through, which are the ones with the most shape to lose.
The band is now one range area over the bucket's own `[min, max]` pair.
No stack, so the domain stays on the bpm values that are in the data.
The axis domain is computed rather than inferred, because the session's
peak has to be inside it: recharts discards an out-of-range reference
line instead of clamping it, and a reported peak is a single reading
while the curve plots bucket means, so a peak marker over a data-derived
domain would go missing on most sessions that have one.
Adds that peak marker alongside the average one, both labelled, and reads
the envelope back in the tooltip as the spread it is. The mapping moves
into a pure module so the values the axis sees can be asserted without
mounting recharts.
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.
Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.
Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.
The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.
The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.
`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.