Commit Graph
1749 Commits
Author SHA1 Message Date
Marc-André Bombeck dd3d5646de feat(sharing): the switch, the banner, and the nav inside another record
Switching is three acts in a fixed order, and the order is the safety
argument. Ask the server, so a refused switch leaves the browser where it was.
Empty the caches a reload cannot reach, awaited rather than fired, because a
wipe racing the navigation is a wipe that sometimes does not happen. Then
reload the document, which is what discards the in-memory cache, the rendered
React trees, and every request already in flight against the old record.

The banner is the feature, not decoration. Someone who forgets they are
switched will log their own blood pressure into another person's record, and
there is no undo for a reading that was never theirs. So it states whose
record is open, that it is read-only, and carries the way out — in the one
visual register reserved for "something about this screen is not normal", at
the top of the shell above every other strip. A subtle tint is what people
stop seeing on the third switch.

The switcher lives in one component mounted in both user menus. A way in on
the laptop and no way out on the phone is how the two menus have drifted
before.

Nav entries that sharing does not cover drop out, and a page reached by
bookmark says so instead of painting a row of refusals. Both are paint: the
server refuses those routes on its own, from a frozen list this code cannot
reach. Settings, connected services, notifications and the AI surfaces are the
account around a record rather than the record itself.

A grant can end while a browser is inside the record. Revocation clears the
delegate's sessions in the same transaction; expiry does not, and leaves a
session stamped with an account it may no longer act on — correct refusals
under a screen that lies. One cache subscriber notices the stable errorCode
and leaves, rather than asking two hundred call sites to remember.
2026-08-02 14:26:26 +02:00
Marc-André Bombeck 63075c47d4 test(backup): prove the skip report reaches the screen, not just the wire
The server names every unresolved catalogue key in its response and in the
audit row, and the integration suite holds that end. This is the other one:
a report nobody sees is the same silence the naming replaced.

The panel moves into its own exported component so it can be rendered
against a report directly. The SSR smoke tests in this directory cannot
drive a state transition, and a render proven only by reading it is not
proven. Pinned: every key is printed rather than summed away, each key
carries its own count and catalogue, an empty report renders nothing at
all, and the copy resolves in a second locale.
2026-08-02 09:10:58 +02:00
Marc-André Bombeck 9c4235b66d fix(backup): a retired catalogue key must not cost the whole file
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.

The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.

A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.

`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.

The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
2026-08-02 09:04:36 +02:00
Marc-André Bombeck 57fbbcdef8 Give the app-chrome header band one owner
The top bar's bottom border and the sidebar logo band's bottom border draw
one horizontal line across the top of the app, and they sat a pixel apart.
Both declared h-16, so the number was never the problem. Every box in the
app is border-box: the top bar carried `h-16 border-b` on one element, which
puts the line inside the 4rem at 63 to 64 px, while the sidebar put `border-b`
on the wrapper and `h-16` on the link inside it, which puts the line after the
4rem at 64 to 65 px. Same number, different line.

The height and the border now travel together as SHELL_HEADER_BAND in
shell-metrics.ts, next to the sub-shell scroll floor that exists for the same
reason. The element carrying that class is the band; neither call site
restates a height, and the sidebar link stretches with h-full. Border colour
stays per-surface, because the two chrome surfaces are tinted apart by design
and only the geometry has to match.

Two guards. chrome-header-seam.test.tsx asserts the structural property, that
each band renders every token of the shared constant and declares no height of
its own; a second height class on either element replaces the shared one
through twMerge, so both halves fire on the same edit. The e2e spec measures
the painted result at 768, 900, 1024 and 1280 px in both the expanded sidebar
and the collapsed rail, and checks each band actually paints a border at the
same weight first, so two missing lines cannot pass as two aligned ones.
2026-08-02 07:12:19 +02:00
Marc-André Bombeck c682145627 Merge work/score-version-fixtures 2026-08-02 00:37:11 +02:00
Marc-André Bombeck 9fb0f8b798 Anchor the checkup due fixtures to the zone the component reads
The two calendar-day cases built their due instant with setHours on the
process clock, which vitest pins to UTC, while the component floors the due
date in the profile zone. Those two disagree about the date for the two hours
before UTC midnight, so both cases went red every night in that window and
green again by morning.

Deriving the date parts from the display zone removes the disagreement rather
than hoping the suite never runs at night. Noon is chosen deliberately: at any
offset the zone uses, 12:00 UTC lands on the same calendar date there, so the
instant cannot slide either way.

Verified inside the failing window: red before, green after, and putting
setHours back turns it red again.
2026-08-02 00:19:59 +02:00
Marc-André Bombeck af35346687 Derive score-version fixtures from the SCORE_VERSION constant
Eight test and fixture files hardcoded scoreVersion: 2, a value the
constant has not held since v1.35.0. Now they import SCORE_VERSION and
use it, so a future bump cannot leave a fixture pinned to a stale
number.

The one genuinely historical case (record.test.ts, proving the input
fingerprint moves when the algorithm version moves) now derives both
its "previous" and "current" markers from SCORE_VERSION instead of two
unrelated literals, so the pair keeps its meaning after the next bump.
2026-08-02 00:15:29 +02:00
Marc-André Bombeck 1b7dff411a Give the scroll container its own containing block
`<main id="main-content">` scrolls the authenticated shell, but it was
`position: static`, so an absolutely-positioned descendant with no nearer
positioned ancestor resolved against the initial containing block. That
block sits outside the scroll container: `overflow-y-auto` never clipped
those boxes, and their static position extended the DOCUMENT's scrollable
overflow instead. The result is a second vertical scroll surface beside
`<main>`, which UI-STANDARDS §9 forbids.

The escapees are library markup, so no per-component wrapper would have
caught them. A Radix `<Switch>` outside a `<form>` renders a hidden
`position:absolute` bubble input on its first pass and drops it only once
the button ref resolves, and the root `loading.tsx` chart skeleton carries
an `sr-only` announcement that lives until the real content streams in.
Both are gone within a few hundred milliseconds of hydration, so the page
carried a full-height document scrollbar for the whole pre-hydration
window and looked clean immediately after. On /settings/modules at a
390x844 viewport the document measured 1952 px against an 844 px viewport
while the switches were still bubble-inputs.

Anchoring the scroll container closes the escape route for every such box
at once. The over-scroll guard grows the matching structural assertion:
`<main>` must not compute to `position: static`. The height read it
already had can only see an escapee that happens to be mounted at measure
time, which is why it went red on a loaded CI runner and green on the same
tree twenty minutes earlier; the computed style is timing-free and fails
on every route and both viewports the moment the anchor is removed.
2026-08-01 21:24:24 +02:00
Marc-André Bombeck 96c10f07e6 Give the today-just-in spec an account that earns a hero
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.

Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.

So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.

While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.

The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
2026-08-01 13:21:04 +02:00
Marc-André Bombeck f6bd8be6d5 Merge work/fhir-retire 2026-08-01 11:38:55 +02:00
Marc-André Bombeck 5767e0330b Retire allowFhirApi and resourceTypes off the share-link response
iOS green-lit removal on 2026-07-30 (their decoder treats both as
optional, confirmed at ShareLinkDTO.swift:105-106) and confirmed no
shipped build breaks. Drops RETIRED_FHIR_SCOPE_FIELDS and its spread
from the create and list responses, removes the two properties and
their required-list entries from ShareLinkCreated/ShareLinkList in
the OpenAPI source, and regenerates the spec. The request side was
already closed off by .strict() in an earlier pass; this finishes it
on the response side.

Route tests now assert the fields are absent from both responses
instead of asserting the constants they used to carry.
2026-08-01 11:38:16 +02:00
Marc-André Bombeck 4f79b9d49a Merge work/cycle-verdict 2026-08-01 11:32:07 +02:00
Marc-André Bombeck 9bcd5dfaf5 Turn the health score green at 70
The app carries three scores and meant two things by a green dot.
Readiness and the sleep score both call 70 green; the health score alone
wanted 75. One app cannot do that, so the outlier moved. The yellow floor
stays at 50: raising the bar for a reward and lowering the bar for a
warning are different decisions, and only the first one was on the table.

The band is still not a function of the number. The composite takes the
worse of the mean's band and the worst counted pillar's, so a composite
carrying a red pillar stays red however high the mean sits - the stored
band on a recorded day exists for exactly that reason. Only the mean-side
thresholds moved.

The method version moves with it. A day already recorded keeps the band
it was actually shown under, and the notice key carries the version, so
each account is told once that the rules moved. The week-over-week
comparison is left alone on purpose: the number itself does not change,
only the colour it is drawn in, so a subtraction across the change still
compares two numbers worked out the same way.
2026-08-01 11:09:38 +02:00
Marc-André Bombeck 4e934a191d Drop the just-in line from the Today hero
Under a number, 'Just in' did not say what had arrived - the score? a
reading? - and the clock face beside it changed nothing a person would
then do. The line is gone, and with it the only wall clock this surface
rendered.

What it was there for survives: the day still flips in place when last
night's sleep lands, and the pending-night note still says the number is
reading an incomplete day. An arrival on its own is no longer a reason
to paint a hero at all, so an account whose only news was the arrival
now gets nothing rather than a bordered empty card.

The digest still carries the arrival, which the morning line and the
wide event both read.
2026-08-01 11:09:37 +02:00
Marc-André Bombeck ee63b09e59 Rebuild the score settings card the way the other settings cards are built
Save moves out of the header and into its own row at the bottom right,
where every other settings card that saves a form puts it. The body
starts on the title's reading edge instead of the card edge, so the text
lines up with the cards above and below it.

The rules between the rows are gone; the gap between groups does the
grouping. Every row said 'Being recorded - Shown in the app' under the
pillar name, which is the modules screen's business and was the same two
words on every line - the sentence at the top of the card already says
it once. The paragraph explaining that heart, glucose and cholesterol
are three pillars in one area is gone too.

The area headings read in the content colour now rather than the muted
one, so they stop looking switched off beside the rows they head.
2026-08-01 10:53:44 +02:00
Marc-André Bombeck c60689affd Take the fitness pillar out of the health score
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.

So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.

That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
2026-08-01 10:51:01 +02:00
Marc-André Bombeck 043d2ba143 Give the mobile cycle smoke test a verdict to render
The spec hand-builds a calendar response, and the wheel now reads the
resolved verdict from it. Without the field the ring paints empty, so the
fixture derives the verdict from the same phase plan it derives the day
grid from and the two cannot drift apart.
2026-08-01 10:48:51 +02:00
Marc-André Bombeck dbecbbcbdb Publish the resolved cycle verdict instead of letting each client work it out
The cycle ring's day count, its phase arcs, and the judgement that a period
is late all used to be worked out in web client code, including the
fourteen-day grace window that decides when the count stops being an
observed fact. Anything else that wanted the same answer had to reproduce
that rule, and a second copy of it is how a ring ends up showing someone a
cycle day their record does not hold.

GET /api/cycle/calendar now carries a verdict: the state, the cycle day,
the ring's arcs, the cycle start, how many days until the next period, and
when the period is late, how late. It is resolved against the user's own
timezone day, which a browser in another zone cannot do. The web wheel,
the wellness-strip tile, the insights teaser and the temperature chart all
render it, the grace constant is gone from the components directory, and
the spec says the field is required.

The overdue caption now names the number of days instead of only saying
later than usual.
2026-08-01 10:37:18 +02:00
Marc-André Bombeck 84706196dd Let the score settings announce a save the way every other surface does
The new page wrote its own success toast. The outcome module owns that
affordance so a saved thing looks the same everywhere, and a structural check
catches anyone reaching around it. That check offers an allowlist entry as a
way out; taking it would have left two places deciding what a success looks
like, which is the thing the module exists to prevent.
2026-07-31 21:17:01 +02:00
Marc-André Bombeck cdfe4ec114 Merge work/score-settings-ui 2026-07-31 20:31:02 +02:00
Marc-André Bombeck f42d423edd Merge work/score-coverage-contract 2026-07-31 20:31:01 +02:00
Marc-André Bombeck bf0a637bab Keep the score's own coverage honest about what it counts
The composite's coverage counts distinct areas of health, not pillars.
The learning line called them pillars, so an account recording blood
pressure, glycaemia and cholesterol read that it had one eligible pillar
while three sat in front of it. The count was right and stays; the noun
was wrong.

The meter beneath the score carried the same confusion further. Its
fraction is the three-area minimum the score requires, and a score only
exists once that floor is met, so it rendered as full coverage for every
account that has one. The arithmetic is untouched. Below the floor the
fraction is a real number and is shown as one; at or above it, the floor
is stated. The dots and the percentage were always honest, they track how
much history is behind the number, and they stay.

Both are held down by an AST guard rather than a text search, with a
counter-test for the neighbouring lines that are legitimately about
pillars and legitimately about a real fraction.
2026-07-31 19:05:20 +02:00
Marc-André Bombeck bd4a6d14d5 Let a person choose what counts toward their Health Score
A dedicated settings page lists every scorable pillar grouped by the area
of health it speaks to, so the fact that blood pressure, glycaemia and
lipids are three pillars of one area is visible rather than buried. Each
row shows the three things that were never separable before: whether the
pillar is being recorded, whether it is shown, and whether it counts
toward the score. Only the last is writable here, and the page says so.

A pillar whose tracking is switched off is not listed at all, because it
is not waiting for anything. A pillar this build cannot score yet is
listed, disabled, and says why. Wellbeing under safety signposting is
named as safety guidance and never as a mistake in the selection. The
refusal for a selection too narrow to produce a score comes from the
server and is shown in the reader's own language.

The score's foot disclosure gains one link into the page.
2026-07-31 18:49:29 +02:00
Marc-André Bombeck 308908c226 Stop the score from describing a recipe the person may have changed
Two sentences about the Health Score stopped being true the moment
someone could choose which pillars count toward it.

The one people read said the score averaged "every pillar that has
enough recent data". For an account that took a pillar out, that
describes ground the number does not cover, and it reads as a
reassurance rather than a description. It now says the average is over
the pillars that count, that a pillar without recent data is still left
out rather than zeroed, and that which pillars count is the reader's own
choice, so the combined number is not itself a clinical standard. All
six locales.

The one nobody reads named the WHO HEARTS technical package flat, as
though a standards body had signed off on whatever selection the account
holds. HEARTS names the risk factors the pillars are drawn from; it
prescribes no average over them. The entry now says what it is cited
for. While checking where that citation surfaces it turned out it
surfaces nowhere: ProvenanceExplainer takes the prop and renders only
the method caption, which v1.22 did on purpose when the info popover
went away. That is written down in the file now, because the map's own
header implies otherwise and the next person would trust it.

The registry entry for HEALTH_SCORE lists all eight pillars with a
minimum of three, which reads like the recipe and like a threshold. It
is neither. The published coverage and confidence are built in
computeComposite over the resolved composition, so a person counting
four pillars is not measured against a denominator of eight, and the
real floor is the breadth rule, which counts distinct domains rather
than inputs. Both facts are now stated at the entry instead of being
inferable only by following the call chain.

Tests: the published denominator is never the eight-pillar catalogue; a
pillar somebody took out of their score is never listed back as missing
coverage; a pillar they kept and have no data for still is; the registry
list stays equal to the score catalogue; the choice sentence is present
in all six locales.
2026-07-31 18:10:39 +02:00
Marc-André Bombeck 0276ca9121 State an authored composition in the score's method footer
One line under the method version, shown only when the server says the
composition is the person's own. It states the fact and stops there:
which pillars they took out belongs to the settings surface, and
working it out here from a configuration blob would put a second
decider behind one sentence.
2026-07-31 18:06:09 +02:00
Marc-André Bombeck 5d7d00949c Resolve the configured flag on the score composite
One boolean on the composite: true when the account's resolved
composition differs from the composition its defaults would resolve to
today, both narrowed by the same modules. Decided once in the score
reader, from the same config and module map the composition came from,
so no client interprets a config blob to answer it.

This is not hasSelection. An account that opened the surface and kept
every pillar has written a selection and is not configured; an account
whose modules alone narrow the set has authored nothing and is not
configured either.
2026-07-31 18:02:22 +02:00
Marc-André Bombeck f8074e1b07 Merge work/hero-score-panel 2026-07-31 11:48:22 +02:00
Marc-André Bombeck 9982f87dc9 Merge work/bp-score-basis 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 2d01b9846d Merge work/coach-read-locale 2026-07-31 11:48:21 +02:00
Marc-André Bombeck b697a0a10e Measure the panel against the band, not against the page
The sideways check could not fail for the case it was written for. A panel
wider than the phone kept it green: the hero band wears overflow-hidden for
its glow, so an over-wide panel is clipped rather than scrolled and the
document never widens by a pixel. The person loses the right-hand end of
every row and nothing reports it.

It now compares the panel with the room the band actually offers, which is
the comparison the clipping makes meaningful.
2026-07-31 11:32:44 +02:00
Marc-André Bombeck cc7978a573 Measure the hero panel instead of describing it
The reserve and the row's label column were carried as comments with pixel
figures nobody could reproduce: the tool that produced them was never
committed. This adds one that is, and the figures change.

The reserve did not hold anything. Its declared minimum was 30rem while its
own blocks added up to more than that, so the height was whatever the blocks
happened to be and the constant was decoration. The blocks are back under the
floor and the floor is 28rem, which sits between the shortest and the tallest
panel a real report produces: three scored rows come to 410 pixels at the
352 pixel column, four 438, five 466, six 494, and the reserve is 448. A short
report is no longer pushed down by 74 pixels when the score lands.

The label column goes back to the 7rem the design specifies. It had been
widened to 8rem on the grounds that 7rem clipped the longest label in five of
six languages and 8rem cleared them; measured, the longest label overruns both
widths in five of six, and the only difference is English, by a margin smaller
than the error introduced by measuring in a substitute typeface. The extra
width came off the fill bar for nothing.

The new check renders the real band with the compiled stylesheet and reads the
boxes back in a browser: the reserve against the panel it stands in for, every
row in all six languages on one line with an ellipsis rather than a push, and
no sideways scroll at the 352 pixel column or at 360 pixels stacked. It states
plainly that it cannot know the shipped typeface, since the font is fetched at
build time and no font file is committed, so every assertion in it is one that
holds in any typeface.
2026-07-31 11:29:06 +02:00
Marc-André Bombeck 06fdeb0336 Pin the panel's rules with tests
Four things a person notices if they break: a pillar never shows up twice
anywhere in the panel, a failed read never lands in the not-scored list, the
panel does not push the page sideways at 360 pixels, and the whole thing is
there with the briefing switched off. Every one of them was broken on purpose
first to check the test actually goes red.

The label column is 8rem, not 7. Rendered in all six languages, 7rem clipped
the longest label in five of them, two of those by two pixels, which reads as
a bug rather than a truncation. The reserve is 480 pixels, measured against
the built panel rather than guessed: three rows come to 440 and six to 524.

The overview fixture now carries a health score, so the phone-width guard
measures a band with the panel in it instead of an empty column.
2026-07-31 11:23:05 +02:00
Marc-André Bombeck cf7bb13ee8 Explain a blood-pressure score in its own popover
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
2026-07-31 11:08:54 +02:00
Marc-André Bombeck 7233845b10 Close four ways around the locale guard
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.

A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
2026-07-31 11:08:06 +02:00
Marc-André Bombeck 0ce40b0950 Say why the blood-pressure pillar scores what it does
The score popover now opens with three sentences before the reference
line: which axis set the number and how far it sat from its boundary,
what the scale means at that boundary, and that the pair behind it is a
recency-weighted average rather than a single reading someone took.

The lines read the basis the grader already publishes, so nothing here
recomputes anything. A pillar that arrives without a basis contributes no
lines at all instead of an empty one.
2026-07-31 11:07:39 +02:00
Marc-André Bombeck 63c7f63f99 Put the health score back beside the greeting
The score returns to the right-hand column of the hero band, where it sat
for eleven weeks: the number at the top, then one thin line per pillar that
actually counts toward it. The card that was pinned below the greeting is
gone, so the score has one place on the page again.

Each contributor line now takes its own colour. A pillar scoring 92 used to
be painted red just because the overall score was red.

Anything not contributing is one quiet line instead of a block: a failed
read is named once for all the pillars it took down and carries the retry,
safety guidance is named in full, and everything simply not recorded yet is
counted, with the names a tap away under the score.
2026-07-31 11:02:21 +02:00
Marc-André Bombeck 271a49a822 Pin the Coach read strip's language, and keep the default out
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.

The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
2026-07-31 09:07:50 +02:00
Marc-André Bombeck 4ed6f7d1ce Write the Coach read strip's second line in the reader's language
On a German metric page the strip printed its own summary in German and
the pattern line under it in English. The pattern line is a finished
sentence the server writes, and the route never told the server who was
reading, so it wrote English.

The route now resolves the reader's language the same way the
correlations and analytics routes do and passes it down. The language is
a required argument the whole way through — it used to be optional with
an English fallback, which is why a caller could stay silent and still
get a sentence back. The cycle page's lagged rows had the same gap and
are fixed with it.

The strip's browser cache is keyed by language too, so switching
language no longer re-uses the sentence written for the previous one.
2026-07-31 08:52:23 +02:00
Marc-André Bombeck 36ab24727f Merge work/nap-separation contrast fix 2026-07-31 08:28:10 +02:00
Marc-André Bombeck b53ab36c4b Separate the nap band from the stage beneath it with a visible edge
The nap fill and a wake bout measured 1.52:1 in the dark theme and 1.16:1
in the light one, so on any night with a wake bout the two bands read as a
single block. That defeats the point of pulling the nap out in the first
place.

No fill colour can fix this. To clear the 3:1 asked between neighbouring
shapes, a nap colour would have to beat all five stage colours and the card
behind them, and in both themes those two demands point in opposite
directions with nothing left in between. The band now carries a card-
coloured outline instead, which is the usual answer for stacked bars and
leaves the stage colours exactly where they were. The tightest pair it
leaves is 4.81:1.

The legend and the tooltip already name the nap, so the distinction never
rests on colour alone.
2026-07-31 08:27:19 +02:00
Marc-André Bombeck 1c5191d1e3 Merge work/nap-separation 2026-07-31 08:21:55 +02:00
Marc-André Bombeck a99e98332c Pin the nap split against the night view, and the empty case against itself
Three things are worth not regressing: a big afternoon nap must not land in
the night's stage buckets, a day without a nap must come out exactly as the
old per-wake-day path produced it, and the two surfaces must name the same
session as the night for the same rows. The last one drives the real
/api/sleep/night handler rather than re-deriving its answer.

The chart's row assembly moved into a plain function so the nap band can be
tested at all; Recharts draws nothing under server rendering.
2026-07-31 08:20:05 +02:00
Marc-André Bombeck 1f8c31d29e Keep a daytime nap out of the night in the sleep stage composition
The stage-composition chart totalled each wake day, and a nap ends on the
same wake day as that morning's night. An afternoon nap was added straight
into the night's stage buckets, so the column read longer and shallower
than the night actually was.

The composition now asks the same question the night view already asks:
which session was the night. The night's stages are the night's, and the
nap sits on its own band above them, in its own colour, with its own line
in the tooltip. Days without a nap look exactly as they did.
2026-07-31 08:13:58 +02:00
Marc-André Bombeck af70312306 Merge work/onboarding-honesty 2026-07-31 08:10:39 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.

The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.

A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 8e6880b04c Say when the onboarding profile step could not save a field
The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.

It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
2026-07-31 07:58:45 +02:00
Marc-André Bombeck e1ca6d9da2 Work out the checkup due phrase on the same clock as the date beside it
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
2026-07-31 07:55:20 +02:00
Marc-André Bombeck a2497cf21d Make the checkup due line say the same thing on both screens
The dashboard summary card and the checkups page each worked out "today",
"tomorrow", "in N days" and "overdue by N days" from the same due date, and
each did it with its own copy of the arithmetic. The page compares calendar
days. The card subtracted one instant from the other and divided, so a checkup
due Sunday at 01:00 read "tomorrow" when you looked on Friday evening, one due
Saturday at 23:59 read "in 2 days" when it was in fact tomorrow, and one due at
09:00 quietly stopped saying "today" as the afternoon wore on.

Both now read from one place, so they cannot drift apart again.
2026-07-31 07:44:20 +02:00
Marc-André Bombeck 12d3239de0 feat(nutrients): tell the account holder when a sync arrived but nothing landed
GET /api/nutrients now looks up the most recent nutrient.batch.ingest
audit row when the window is genuinely empty, and surfaces it as
lastAttempt only when that call landed nothing (inserted === 0 &&
updated === 0). A real success elsewhere is left alone, so an empty
window for an unrelated reason never gets reframed as a failure.

The lookup runs on the zero-rows path only, never on the happy path,
and stays counts-and-reason-codes only - no user-typed content, no
per-entry health values. The reason code never reaches the page as
text: each of the four closed skip reasons maps to its own human
sentence, and the two that have no useful action for the account
holder (unit_mismatch, upsert_failed) say plainly that the entries
couldn't be stored rather than inventing advice. day_invalid is the
one case worth naming a real cause for - a clock or time zone mismatch
on the sending side - without claiming it was a phone, since any
token holder can call this route.

This depends on AuditLog retention: cleanupOldAuditLogs prunes rows
past 365 days by default, so a dormant account's last failed attempt
can age out and the explanation silently reverts to the plain empty
state. Documented at the lookup for whoever next touches that job.
2026-07-30 23:30:28 +02:00
Marc-André Bombeck 3ec3a8c6aa fix(profile): stop echoing the raw save error verbatim on Settings
The Settings > Account save handler read json.error straight off the
wire and rendered it as-is - the exact path a raw validator message
would have reached a person's screen through, and the only place the
report's symptom actually reproduces (baseline-form already goes
through the errorCode-aware localizedApiError helper).

It now resolves meta.errorCode into a localized sentence, the same
convention used everywhere else in the app, and never touches the
top-level string. A partial save (some fields landed, one didn't)
gets its own message naming the rejected field via the same label
already shown next to that input on this form, and reseeds the
form from what the server actually persisted so a field that was
silently skipped can't read as saved.

describeRejectedProfileField lives in account-section-utils so both
the warning and the failure path share one field-name-to-label
mapping instead of duplicating it.
2026-07-30 23:25:18 +02:00