Commit Graph
4775 Commits
Author SHA1 Message Date
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 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 64a92fd392 Publish which blood-pressure axis set the pillar score
The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
2026-07-31 11:04:20 +02:00
Marc-André Bombeck 4d6091ec45 Merge pull request #670 from MBombeck/release/v1.34.4
release/v1.34.4
v1.34.4
2026-07-31 08:53:46 +02:00
Marc-André Bombeck 001641f57e chore(release): v1.34.4 — one question, one answer
Every fix in this release is the same shape underneath. Two places in the code
were each deciding something they both had to agree on, and nothing made them
agree: two copies of the due-day rule, two clocks on one card, two server caches
behind one page, two ideas of where a nap belongs.

The version anchors, the changelog and the published specification move together
as usual.
2026-07-31 08:29:37 +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 ee0375142c Merge work/cache-instance-proof 2026-07-31 08:21:18 +02:00
Marc-André Bombeck 8a316f5820 Record that the cache pin was checked against the entrypoint the image runs
The reading behind the pin was taken under `next start`. The container runs
`node .next/standalone/server.js`, and a fix that is green everywhere and
inert in production is the failure this project has paid for before, so the
same probe was re-run under the standalone entrypoint with static assets and
public copied the way the Dockerfile copies them.

No difference: three module instantiations either way, one shared registry
with the pin, separate registries without it, and a measurement write made
through a route handler reaches the page render's cache only when the pin is
there. The note says which entrypoint the claim rests on so the next person
does not have to wonder.
2026-07-31 08:20:34 +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 0fbaf6b2d3 Build the dashboard snapshot cache key from the one function that owns it
`dashboardSnapshotCacheKey` documents itself as the single source of truth
for the key, and the invalidators sweep by it, but the read spelled the same
string out by hand. The two matched, so nothing was broken today — edit one
side and the other stops matching silently, and the only symptom is a
dashboard that keeps painting the body from before the write.

The read now calls the function. The guard warms the cell through the real
read and then asserts the entry sits at the composed key exactly, so a
suffix change on either side is caught rather than forgiven by the prefix
sweep; two more cases cover a snapshot invalidation and a recorded dose.

The header on the shared read claimed both entry points ride the same cell.
That claim now holds, but only because the cache registry is pinned to the
global, so the header says so.
2026-07-31 08:07:04 +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 96a3743009 Share one server cache between the home page and the API routes
A production build instantiates a module imported from both a Server
Component and a route handler once per bundler layer. The cache registry
sat in plain module state, so the home page's server render held its own
set of Maps and every API route held another. Recording a dose evicted
the route handlers' copy; the home page then rendered from a copy nothing
had touched and painted the dose as still due until the entry aged out on
its own, two or three minutes later. Because that render is dehydrated
into the client query cache, it also overwrote the fresh value the browser
had already fetched.

Measured on `next build && next start`, single process: the page render
and the snapshot route reported different cache instances, and after an
interactive write the route handlers' copy dropped to zero entries while
the page's copy stayed full.

The registry now lives on globalThis under a Symbol.for slot, which is the
only scope both layers share. db.ts guards its own global assignment with
a non-production check because it is only defending against dev reloads;
this one has to hold in production, so it is unconditional.
2026-07-31 08:03:21 +02:00
Marc-André Bombeck 8b3c0369d1 Merge work/vorsorge-due-day zone fix 2026-07-31 08:01:54 +02:00
Marc-André Bombeck d0a42908fe Hold the due phrase to the profile clock and to whole calendar days
Moves the host zone under the function to prove the answer does not follow it, since the suite otherwise runs pinned to UTC and would not have noticed. Adds the two days a year that are not twenty-four hours long, where counting hours rounds the evening into tomorrow. The structural check that the shared body never reads the device clock was looking for the wrong shape and let a deliberate break through; it now looks for the word itself.
2026-07-31 08:00:28 +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 f322736074 Merge work/vorsorge-due-day 2026-07-31 07:50:14 +02:00
Marc-André Bombeck c1b372de9c Stop a second copy of the checkup due line from appearing
Two screens once carried their own copy of the same date arithmetic and quietly disagreed for a year. This fails the moment a second one is written, whatever it is named, and says where the real one lives.
2026-07-31 07:48:57 +02:00
Marc-André Bombeck 0471c7cb9b Merge work/dose-taken-lag 2026-07-31 07:48:39 +02:00
Marc-André Bombeck d432842a73 Stop the dashboard answering a refresh with a reading it fetched earlier
The dashboard starts its snapshot request the moment the router commits to
"/", and parks the response in a slot outside the query cache until the
mounted cell asks for it. The cell only asks on its first fetch, so on every
return to the dashboard — where it already holds data and does not refetch on
mount — the parked response simply sat there unclaimed.

The next thing to call the query function was then whatever came along: the
two-minute poll, a focus refetch, or the invalidation that follows recording
something. All three exist to fetch data newer than what is on screen, and all
three were handed a response that had been requested before the entry was
written. So a value recorded within a minute of arriving on the dashboard
could keep reading as the old one until the following poll replaced it.

Only preload when no snapshot cell has produced data yet. That is the cold
first load the preload was built for, and it is the only moment the mounted
cell will claim the response promptly.
2026-07-31 07:47:00 +02:00
Marc-André Bombeck 70cab3c885 Pin the checkup due line to calendar days
The cases that used to read wrong: an evening look at this morning's checkup, a Sunday early-hours checkup seen late on Friday, a Saturday late-night one seen just after Friday midnight, and one from yesterday that only slipped past a few minutes ago. Each is a case where counting hours and counting dates give different answers.
2026-07-31 07:46:55 +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 1a0d2795c3 Merge work/backup-severity 2026-07-31 07:44:09 +02:00
Marc-André Bombeck 8f10ae011d Merge work/reminder-midnight 2026-07-31 07:44:09 +02:00
Marc-André Bombeck 92da02f824 fix(backup): carry how hard a symptom hit through export and restore
A cycle symptom can be recorded with a 1-4 intensity. The log-day sheet
writes it, the day-log reads it back, and the backup never carried it:
the file held the symptom keys and nothing else. Export a day recorded
as "cramps, and they were a 4", restore it, and you get "cramps". The
symptom is there, the number is gone, and nothing fails, which is why
nobody noticed.

The file now carries `symptomSeverities` alongside `symptomKeys` — one
entry per symptom that was actually rated. `symptomKeys` still decides
which symptoms a day had, so a release that only knows that field reads
every symptom out of a new file unchanged, and the two lists can never
disagree about presence. A link nobody rated is simply not listed: no
entry means never rated, which is not the same as rated zero.

A file written before this restores exactly as it did, with every
intensity absent rather than invented. An intensity outside 1-4, from a
hand-edited file or some later scale, costs that one number and leaves
the symptom and the rest of the restore alone.
2026-07-31 07:37:48 +02:00
Marc-André Bombeck 8488092d48 test(medications): prove a dose taken before midnight really stops reminding
The overnight reminder tests handed the check every intake row the
fixture held, whatever date range it had actually asked for. That left
the suppression assertion unable to fail. Narrow the read back to the
day the check happens to run in and the 23:45 dose logged at 23:50 drops
straight out of it, yet the test stayed green. The fake now honours the
scheduledFor range it is given, so the assertion answers the question a
person really asks: does it stop pestering me about a dose I already
took.

Two cases were missing at the other end of the same boundary. A dose
genuinely missed at 23:45 still has to escalate in the small hours, and
the missed-dose row it leaves behind has to name the evening it belongs
to rather than the morning the check runs in. Both are pinned now, next
to the case where the dose was taken and no missed row may be written at
all.
2026-07-31 07:36:17 +02:00
Marc-André Bombeck 340d1db6d9 Merge pull request #669 from MBombeck/release/v1.34.3
release/v1.34.3
v1.34.3
2026-07-31 07:12:59 +02:00
Marc-André Bombeck f7d846458a fix(nutrients): never let the empty-state explanation break the read it explains
The last-attempt lookup runs on the zero-rows path, which is exactly the path a
fresh account takes. When it threw, the whole nutrients read returned 500 — so an
account with nothing to show got an error instead of an empty page, and the
feature that exists to explain an empty surface was the thing preventing it from
rendering.

Wrapped so a failed ledger read degrades to the plain empty state and is recorded
for the operator rather than shown to the person. The route's shape assertion now
carries `lastAttempt: null` explicitly, so a field cannot join this response
without someone deciding it should.
2026-07-31 05:09:36 +02:00
Marc-André Bombeck 4c35c16d64 chore(release): v1.34.3 — say the thing the surface already knew
Thirty-four changes, most of them one shape: something the server or the screen
already knew and did not say. A closed schema that omitted three fields it sends.
A fence around sensitive data that the contract never described, so the native
client dropped an affordance rather than risk crossing it. An ingest whose only
durable trace fired on success, leaving "did the phone even post" unanswerable. A
profile save that discarded valid fields to punish an invalid neighbour. A card
titled "that day" that received the day and threw it away.
2026-07-31 01:27:29 +02:00
Marc-André Bombeck 9b69be8ab5 fix(openapi): declare shrunkR and tier on DiscoveredCorrelation
The correlation-discovery engine always sets shrunkR (the
sample-size-shrunk effect) and tier (the phrasing-confidence class)
on every surfaced pair, but the closed DiscoveredCorrelation schema
never declared either — same defect class as the dashboard snapshot:
additionalProperties: false rejecting a field the runtime actually
sends. Both are required, matching src/lib/insights/correlation-discovery.ts.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck d04eab71a3 fix(openapi): declare the dashboard snapshot fields the route actually emits
GET /api/dashboard/snapshot is additionalProperties: false in the spec
but the runtime emits three blocks the schema never declared: the
band/target math (targetBands, always present), the score-card
narrative (healthScore.tension / healthScore.returnToBand, additive
optional fields), and the briefing recall/forward-look
(briefingMemory). A strictly generated client rejects the real
response as a result.

Adds all four to dashboardSnapshotResponse with the exact runtime
type, optionality, and nullability, matching DashboardSnapshot in
src/lib/dashboard/snapshot.ts and the DTOs in
src/lib/dashboard/score-narrative.ts. No route behaviour changes.
2026-07-31 01:26:21 +02:00
Marc-André Bombeck d8ddf97d9e test(step-up): wait for the audit breadcrumb instead of sleeping and hoping
Five assertions slept a fixed 100 ms and then read the audit ledger. The write is
fire-and-forget, so on a busy runner the read lands first and the assertion fails
with "expected [] to include 'consumed'" — which reads as a broken refusal when
the refusal worked and only its breadcrumb was still in flight. CI failed on
exactly that while nothing in the branch touches step-up or MFA.

Replaced with a poll that returns the moment the reason appears and, on timeout,
asserts against the real list so a genuine regression still says what WAS
written rather than reporting a bare timeout.
2026-07-31 01:12:22 +02:00
Marc-André Bombeck cbfbf359e7 test(nutrients): assert the audit fires on every ingest, not only on a landing
The existing test encoded the behaviour this release deliberately changed: the
breadcrumb used to fire only when a row landed, which made the one durable trace
of an ingest conditional on its success. It failed for the right reason.

Rewritten to assert the new intent with the reasoning kept, and it goes red again
if the condition returns.
2026-07-31 01:09:52 +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
Marc-André Bombeck 356888134d fix(profile): localize the email-conflict message too
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.

Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck 6f5935aeb4 i18n(profile): add strings for the partial-save and nothing-saved cases
apiErrors.profile.update.{nothingSaved,invalidBody} let the web client
resolve applyProfileUpdate's new errorCode into a localized sentence.
settings.profilePartiallySaved / profileNothingSaved carry a {field}
placeholder so the account settings screen can name the specific field
that blocked or was skipped, in the active locale, instead of a raw
validator string. All six locales.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck e49bd866dd fix(profile): save every valid field even when a sibling is invalid
A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.

The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck bed634fd67 Put the new workout blocks on the page's reading edge
SubPageShell insets its own heading to the card body edge rather than the
container edge, so a section heading or a meta line rendered flush left sat
16 px to the left of everything above it. The day-context heading, the sport
chips and the count line now take the same responsive inset.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 0784cbcbb1 Say how many workouts the list holds and let a sport narrow it
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.

The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 52cccb3835 Move the workout zone range label onto the type scale
The bpm range under each effort zone sat at an arbitrary text-[10px]. It is
a micro-label on a card, not a chart axis, so it takes the text-2xs token
the standards mint for exactly this.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck a61cdae437 Offer the previous session of the same sport from the workout page
"How does this compare to last time?" is the most likely next question
after reading a workout, and answering it meant going back to the list and
scanning for the one before.

Resolve the previous session server-side, through the same canonical picker
the list uses, so the link can never land on a duplicate row recorded by a
second watch, and render it beside the own-history average line. Null when
nothing older exists, and then no link is shown.

The stats card's hand-rolled heading moves to the icon-less TileHeader
while the file is open.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck 76b407b6de Render the day around a workout instead of linking away from it
The "That day" card took the workout, dropped it, and offered three chips
to Pulse, Sleep and Mood. None of those pages accepts a date, so every chip
landed on an all-time trend surface with the day nowhere in sight.

Replace it with the day itself: the intraday pulse chart pinned to the
session's day, the night the person woke from into it, and that day's mood
entries. All three reads are addressed by the server-resolved dayKey, so
nothing here decides which day a workout belongs to.

The two outbound links that remain are undated and say what they lead to,
Sleep trends and Mood history. Sleep and the intraday chart mount only when
their module is on, so a switched-off module cannot paint as a failed read,
and a query that has not answered yet shows a skeleton rather than claiming
the night was empty.

The detail page also drops the description it borrowed from the list.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck 0094445f07 Date a workout by the user's timezone on the detail endpoint
The detail payload carried the session's start timestamp but nothing that
said which local day it belongs to. Anything that wants the day around a
workout had to slice the ISO string, which is the browser's day, not the
person's: a 20:00 UTC session reads as the 15th in London and the 16th in
Auckland.

Resolve the day server-side from the user's timezone and ship it as
dayKey. Additive, so a native client that does not read it is untouched.
2026-07-30 23:23:36 +02:00
Marc-André Bombeck a4cffbbea5 Keep the pillar reference formatter module-private
Only `pillarDetailLines` composes it, and the card reads the lines
rather than the individual band string.
2026-07-30 23:20:02 +02:00
Marc-André Bombeck eb55c235c6 Rebuild the Insights health score as one pinned card
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.

Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.

The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.

A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
2026-07-30 23:20:02 +02:00