Commit Graph
3214 Commits
Author SHA1 Message Date
Marc-André Bombeck 1dde3034c6 fix(auth): say a refused sign-in in the sign-in page's own words
The sign-in refusals carry only their status's generic code, so once the
sign-in page read codes, a wrong password, a wrong second-factor code
and a refused security key all said "Your session has ended", and a
lockout said "too many requests just now".

localizedSignInError reads a generic 401 as the step's own failure and a
generic 429 as "Too many attempts. Wait a few minutes and try again.";
a code the step names itself keeps its sentence. The password, passkey,
code and security-key steps use it. The rate-limit sentence moves from
auth.claim to auth.signInRateLimited, since the claim page and the
sign-in page now share it, and the Korean one takes the polite form the
rest of the catalogue uses.
2026-11-12 13:54:34 +01:00
Marc-André Bombeck 367ba65bf6 fix(auth): take the push subscription off on every sign-out button
Only the menu's sign-out removed this browser's push subscription. The
sign-out buttons on the registration page and the profile take-over page,
which exist so someone else can use the browser next, left it behind, and
the next person kept seeing the last account's reminders. All three now
go through one signOutRequest.

The browser's own subscription is now removed only once the server has
confirmed the sign-out; the server row still goes first, while the
session can name it. A refused sign-out stores the row again, so the
person who is still signed in keeps push. The logout test pins the new
order and the refusal.

The helper file is renamed web-push-subscription.ts, since it now also
saves a subscription for the settings card.
2026-11-12 13:51:44 +01:00
Marc-André Bombeck 6914f844be fix(push): send the browser's subscription again when the card opens
The Web Push card reads its state from the browser only. When the server
had lost the row, or the channel had been switched off, the card said
enabled and no reminder came. Opening the card now posts the subscription
the browser holds, which stores the row and turns the channel back on.
A refused post (demo, push off instance-wide) changes nothing on screen.
2026-11-12 13:48:52 +01:00
Marc-André Bombeck 031379d966 Merge branch 'release/v1.47.3' into release/v1.47.4
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-12 13:27:52 +01:00
Marc-André Bombeck 5b8543f471 fix(settings): keep the Coach entry for a managed record
The Settings Coach entry now follows whether the Coach can answer, as
the sidebar does, with Settings, AI keeping the stored memory in reach.
A managed record has no Settings, AI, so while its Coach could not
answer yet (no operator key, no consent) the guardian lost the only way
to the record's Coach data scope and its switch. For a managed record
the entry follows the module switch alone again.
2026-11-12 13:05:37 +01:00
Marc-André Bombeck 8b6d9826ea chore: drop stale references to the onboarding offer and old resolver
The Playwright project list still named the deleted onboarding AI
offer spec. The CoachPlan comments said the review writes
`outcomeEncrypted` and surfaces it behind a flag; nothing writes or
reads it now, and the column is marked internal. Comments that named
`resolveProvider` as the resolver now name the chain, and the function
itself says it is test-only.
2026-11-12 13:03:58 +01:00
Marc-André Bombeck 2af0751b69 test: type the new fixtures instead of casting them to never
The fixtures added with these fixes switched the type checker off with
`as never`, which the realism ratchet refuses to see grow. They now carry
their types, three webhook mocks needed no cast at all, and the lowered
counts, with the targets route's one fewer version stamp, are written
back.
2026-11-12 13:03:51 +01:00
Marc-André Bombeck 8c9fbbdd2b fix(errors): name three refusals that fell back to a generic sentence
Since a codeless refusal reads by its status, three answers lost their
meaning. A failed ChatGPT sign-in poll said "Error saving" instead of
asking to start the sign-in again; it now falls back to the sign-in's
own failure sentence. A dose-history import still running when the
card stopped watching said "Nothing was written" although the job may
still write; that case now has its own sentence. Setting up an
authenticator while one is active answered "setup failed"; the route
now sends `auth.mfa.already_active`, which says to disable the active
one first, in every locale.
2026-11-12 12:58:52 +01:00
Marc-André Bombeck 31c4440154 refactor(custom-metrics): read a refused save in one helper
Matching the name clash by its code grew the form component past its
size baseline. The refusal's sentence now comes from a helper beside it.
2026-11-12 12:58:37 +01:00
Marc-André Bombeck 2d1009136a chore: drop the em dashes this release added to prose and test titles
The notification categories' contract description, which lands in the
published API document, and a handful of new code comments and test
titles used em dashes; the house style keeps them out of new prose.
2026-11-12 12:54:20 +01:00
Marc-André Bombeck cad4c89831 test: mark the visit, dose and address-book deletes restorable
The three deletes gained an Undo toast that calls the restore route,
but the destructive-control registry still listed them as tombstoned
with no restore, with comments saying they would become restorable the
day a surface offered the undo. The correlation card test's header
still described the disabled "Coming soon" action it no longer tests.
2026-11-12 12:53:36 +01:00
Marc-André Bombeck 55b223d4ed fix(onboarding): show the closing AI line as body copy
The done screen's "AI is optional" line is a sentence the person reads,
set in muted text, which the UI standards keep for meta. It is body
text now. Its test asserted the absence of strings deleted from every
bundle in the same change, so it could not fail; it now checks that the
line is not muted, that the screen links to the AI settings once, and
that no button sits beside the link.
2026-11-12 12:53:12 +01:00
Marc-André Bombeck 706af61cdd fix(custom-metrics): say "name taken" only for a name clash
The form read every 409 as "a metric with this name already exists".
Now that it keeps one idempotency key across taps, a second tap while
the first create is still running also answers 409, and the form told
the person their name was taken. The create and rename routes send
`customMetric.name_taken` with the clash, and the form matches the code.
2026-11-12 12:52:39 +01:00
Marc-André Bombeck 671245c782 fix(documents): one cap line for both pickers, with a search-aware hint
The vault picker and the Coach's attachment picker each wrote their own
"showing the newest 50" line, which the UI standards leave to
`ListCapNotice`, and with a search running past fifty matches the line
still said "Search to find an older document". Both pickers now share
`DocumentPickerCapNotice` on `ListCapNotice`, which takes the sentence
to show, and a running search asks for a narrower one.
2026-11-12 12:51:32 +01:00
Marc-André Bombeck 3ed266e281 fix(measurements): drop the stray space in a unit-less range message
A score, rating, count or ratio has no unit label, so the range
message for a manual pain score read "Enter a value between 0 and 10 ."
with a space before the stop, and the same in every locale. A second
sentence without the unit now answers those types.
2026-11-12 12:49:18 +01:00
Marc-André Bombeck 048dbbee9f fix(measurements): keep computed scores out of the bulk delete
The row menu stopped offering edit and delete on a computed score
(recovery, strain, stress), but the list still drew its selection
checkbox and "select all" took it into the page, so the bulk bar could
delete a page of scores, and the tombstones kept the recompute out.

A computed row now has no checkbox on either layout and stays out of
the page selection, and the bulk-delete route skips computed rows if a
client sends them anyway. The iOS app treats these rows as read-only
and does not rely on deleting them.
2026-11-12 12:48:03 +01:00
Marc-André Bombeck a4b14609bf fix(practitioners): offer an Undo for every delete, not only the last
Two quick deletes in the address book showed the Undo toast for the
second entry only: the callbacks `mutate` takes belong to the latest
call of the list's one mutation, so the first entry's were dropped. The
toast now hangs on each delete's own promise.
2026-11-11 23:00:43 +01:00
Marc-André Bombeck 17dbb225d4 fix(profile): read and store the date of birth as a calendar day
The iOS app sends the date of birth as the instant its date picker
returned, and the profile writers stored that instant as sent. Since the
report cover and the share page read the date part of the stored value,
an entry made just after midnight in Berlin printed the day before, and a
Californian entry made in the evening printed the day after.

Both profile writers now store the calendar day the value names in the
owner's zone, at noon UTC like other date-only values, so an iOS client
reading the instant back in its own zone keeps the date. Older rows keep
their instant: dateOfBirthKey reads a date-only anchor (UTC midnight or
noon) by its date and any other instant in the owner's zone. The report
collector, the targets age, and the web forms that send the date back
read it through that helper.
2026-11-11 22:37:57 +01:00
Marc-André Bombeck 0d41abcda4 fix(settings): list the Coach entry only while the Coach can answer
The settings nav filtered its Coach entry by the raw module map, which
follows the operator's switch and Hide Coach but not a missing provider
or missing consent, so it kept an entry the sidebar and the bottom bar
had already dropped. It now reads useNavModules like them. Settings, AI
decides from the same map whether to list the stored Coach memory, so
the memory always has one place in Settings where it can be read and
deleted.
2026-11-11 21:50:28 +01:00
Marc-André Bombeck ce377ef56d chore: remove leftovers of the deleted onboarding AI offer and badge
The end-to-end setup still seeded the shared-provider offer's account
and captured its session, although the journey that used it is gone.
Three comments still pointed at what was removed: the tour launcher
cited the deleted personal-record badge as its model, the personal
record worker said the badge still renders, and the morning briefing
push said a concerning reading goes out as an anomaly notification,
which nothing sends.
2026-11-11 21:47:54 +01:00
Marc-André Bombeck aacf06b7b3 fix(coach): say when the attachment picker list is cut
The Coach's "Choose from documents" picker reads the newest fifty
documents and dropped the route's nextCursor, so with a longer vault an
older report seemed not to exist unless one already knew to search for
it. A cut list now ends with the same line the document picker shows:
how many are shown and that the search reaches the rest. The document
row moves into its own component, which takes AttachmentPicker under
the function-size cap.
2026-11-11 21:44:42 +01:00
Marc-André Bombeck 24cca975da fix(charts): draw the BMI of the record on screen
Inside a shared or managed record the BMI chart drew nothing: it only
knew the viewer's height, and dividing someone else's weights by it
would have given a plausible wrong figure, so it divided by nothing.
The chart now takes the record's height from /api/insights/targets,
the cell the BMI page and the reference panel already read, through a
small useBmiDivisor hook; in the viewer's own record it keeps using the
account's height.
2026-11-11 21:43:07 +01:00
Marc-André Bombeck b85989f3df fix(coach): spell the bubble's initials from the display name
The person's chat bubble took the first two letters of the login, so a
managed record read "MA" for its generated handle and everybody else saw
other letters than in the top bar. It now uses nameInitials(accountName)
like the top bar, the sidebar and the settings avatar. The thread test
that pinned the login's two letters now expects the shared spelling.
2026-11-11 21:41:39 +01:00
Marc-André Bombeck b5c97062b8 fix(timeline): close the life event sheet before the timeline refetch
The life event hooks returned the timeline and day refresh from
onSuccess, which a mutation waits for, so saving or removing an event
kept the sheet spinning until the whole timeline had reloaded. The
refresh is now started and not returned, like the other record sheets.
2026-11-11 21:40:02 +01:00
Marc-André Bombeck e68041a3af fix(forms): keep one Idempotency-Key for the remaining create forms
A screening, an illness episode, a life event, a symptom, a custom
metric or an address-book entry created on a weak signal could be
stored twice: the POST committed, the answer missed the timeout, and
the second tap went out under a new key. Their routes already honour
the key; the forms now post through useIdempotentPost like the other
create forms, so the retry replays the first answer.

The screening's save moves into a small hook beside MentalWellbeing,
which sat at the function-size cap and now drops below it; the custom
metric form's create-or-update call moves into one as well.
2026-11-11 21:39:19 +01:00
Marc-André Bombeck 4485dfb3a9 fix(forms): close episode, mood and dose dialogs before the refetch
The illness episode hooks returned the refresh of the illness reads and
the dashboard from onSuccess, so every episode sheet kept spinning until
those had refetched. The mood edit dialog, the mood and measurement bulk
deletes and their Undo toasts did the same with an await, and so did the
dose edit dialog, the log-intake dialog and the ledger's add dialog.
They now answer and close as soon as the write lands and start the
refresh without waiting for it. A scheduled dose lands on its slot, so a
card that is still stale for a moment only repeats it.

The take and skip buttons on a medication card keep waiting: nothing
marks the dose in the cache meanwhile, so releasing them early would
offer a Take for a dose already recorded.
2026-11-11 21:36:37 +01:00
Marc-André Bombeck 212ff9ee55 Merge branch 'fix/v1474-auth' into release/v1.47.4
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:32:28 +01:00
Marc-André Bombeck 188d21d6ac Merge branch 'fix/v1474-offline' into release/v1.47.4
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:32:11 +01:00
Marc-André Bombeck 0b7afd8460 fix(errors): show the catalogue sentence, not the server's text
About forty refusal messages still printed the error string the server
sent: English in every locale, and for a dropped connection the
browser's own TypeError text. Mood tags, targets and thresholds, family
history, medication side effects, phases, API tokens and dose imports,
the Coach model picker, the AI provider, fallback chain, timeout and
Codex cards, the notification channel cards, the avatar, passkeys,
security keys, the authenticator card, the re-proof dialog and the
admin pages now resolve the refusal's code through localizedApiError
and fall back to the surface's own sentence.

Two refusals carried something a person needs and had no code: a
private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last
passkey. Both now send a code with a sentence of their own. A throttled
re-proof says so in the reader's language. Raw fetch sites throw the
same ApiError as the verb helpers through the exported throwApiError,
which replaces the admin's getApiErrorMessage; the authenticator card's
copy of describeStepUp is gone, and describeStepUp no longer passes a
non-step-up refusal's prose through (its test pinned that).

The admin password reset keeps the server's verdict on a 422: the
server words the strength and breach verdict in the account's language.
2026-11-11 21:29:33 +01:00
Marc-André Bombeck 5029b30b2b fix(i18n): drop the translation notice from the sign-in pages
Eight of ten languages opened the sign-in, registration and profile
take-over pages with the "machine translated" strip above the form, the
first thing a visitor read before having an account. The strip stays in
the app shell, where the language can be changed and a correction
reported; its text is unchanged.
2026-11-11 21:27:54 +01:00
Marc-André Bombeck 62b9e404ef fix(shell): return to the list's scroll position on Back
Back from a detail page (a biomarker, a medication, a workout) opened
the list at the top. The shell scrolls inside `<main>`, which stays
mounted across routes, and the browser's own restoration only moves
the document. Nothing kept the list's offset, so `<main>` stayed where
the detail page had left it; and the one-shot skip on `popstate` was
cleared by a timer that could run before the restored page rendered,
in which case the forward-navigation reset fired anyway.

The shell now records `<main>`'s offset per address while scrolling,
reads it back on `popstate` and applies it once the page has rendered,
retrying for up to a second while a list is still shorter than the
offset. A forward navigation still starts at the top.
2026-11-11 21:23:17 +01:00
Marc-André Bombeck ecb219f346 fix(auth): say a refused sign-in in the reader's language
The sign-in page (password and passkey), the second-factor step and
registration printed the server's English error text, so a German or
French page said "Invalid credentials" or "Invalid code". They now
resolve the error code through localizedApiError like the other forms
and fall back to the page's own sentence. Registration still shows a
422 as sent: that is the password feedback, which the server already
words in the reader's language.

A 429 carries the generic `http.rate_limited` code, which had no
sentence, so a person locked out by the limiter would have read only
"Login failed". The catalog now has one, reusing the wording of the
profile take-over's rate-limit message.
2026-11-11 21:21:20 +01:00
Marc-André Bombeck a4766824ef fix(auth): keep the page when a sign-out from an auth page is refused
The sign-out buttons on the registration and profile take-over pages
took every HTTP answer as success. A refused sign-out (the demo's 403,
a 429, a 5xx) wiped the client caches and reloaded the page while the
session stayed valid; on the take-over page a network failure was an
unhandled rejection with no message at all.

Both go through apiFetch now, which throws on a non-2xx answer, and say
that the person is still signed in: inline on the registration page, as
a toast on the take-over page. The message is the one the account
menu's sign-out uses.
2026-11-11 21:18:46 +01:00
Marc-André Bombeck 8fdc238981 fix(auth): replace the sign-in page in the history after signing in
Back after signing in, registering or taking over a profile showed the
form that had just been submitted, because each pushed the next page
on top of it. Password, passkey and second-factor sign-in, registration
and the password take-over now replace the page instead.
2026-11-11 21:15:05 +01:00
Marc-André Bombeck 6c5a6eacbb fix(auth): bring a deep link back to its page after signing in
A home-screen shortcut, a notification tap or a bookmark opened without
a session landed on the sign-in page with a bare URL, and after signing
in the person was on the dashboard instead of the page they asked for.
The sign-in page already honoured a sanitised `next`; neither redirect
set one.

The proxy now passes the request's own path and query as `next` (the
bare start page goes without one). The shell does the same when the
account answer ends a session on a protected page, except after a
deliberate sign-out: the marker a confirmed session leaves is gone by
then, so the next person on the device starts from a bare sign-in page
rather than the page the previous one left.
2026-11-11 21:14:04 +01:00
Marc-André Bombeck 75b879279f fix(auth): leave nothing of the last account behind on any sign-in
Only the password and passkey success path cleared the offline copies
on this device. A session that ended by expiry reached the sign-in page
without any wipe, and single sign-on and both profile take-over paths
started the next account on top of the previous one's IndexedDB
snapshot and worker caches.

The shell now clears those copies on every /auth/* page once
/api/auth/me answers 401 or 403: once per signed-out stretch, never on
an answer without a verdict. Single sign-on and the take-over by single
sign-on clear them before leaving for the identity provider, and
registration and the password take-over start from an empty query cache
like the sign-in page does.
2026-11-11 21:10:38 +01:00
Marc-André Bombeck 9227844f42 Merge branch 'fix/v1473-ai' into release/v1.47.3
# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-11-11 21:07:27 +01:00
Marc-André Bombeck 926c427907 fix(coach): stop computing the plan review verdict nobody sees
The nightly plan review read a before/after window of the plan's metric
and stored an English verdict in `outcomeEncrypted` for every plan. No
screen and no route returned it; only the Coach snapshot read it, behind
the `COACH_EXPERIMENT_VERDICT` flag that is off by default. The worker now
only moves an active plan whose review fired to `reviewed`, which is what
takes it out of the standing group. The flag, its snapshot block, the
three eval cases for that block and the compose and env entries go with
it. The Coach prompt keeps its experiment rules and now ties the
read-back to the plan's progress line instead of the removed block.
Existing stored outcomes stay in backups untouched.

The plans page and the Coach reminders card printed the metric as a raw
key in capitals (WEIGHT, BLOOD_PRESSURE) in every language. Both now show
the measurement's name from the catalogue, through the plan metric
resolver moved to `plan-metric.ts`; a key HealthLog does not read shows
as written. The unused `coach.plans.proposalTitle` string goes.
2026-11-11 20:51:18 +01:00
Marc-André Bombeck 7f8ba0ae98 fix(push): tell iPhone Safari users to open the Home Screen app
iOS offers Web Push only to a web app opened from the Home Screen. In a
Safari tab the push card said the browser does not support push
notifications, and the step that fixes it was only in the docs. On an
iPhone or iPad outside the installed app the card now says to add
HealthLog to the Home Screen, open it from there and turn push on.

WebPushCard grows by two lines for the second sentence; its baseline
goes from 201 to 203, still below the 215 it had before the previous
change.
2026-11-11 20:46:58 +01:00
Marc-André Bombeck 32911fea37 fix(push): remove this browser's subscription on sign-out
Signing out ended the session and wiped the caches but left the Web
Push subscription in place, in the browser and on the server. The
browser kept showing the signed-out account's medication reminders,
also after someone else signed in on it, and the next account's
Settings showed push as already on.

Sign-out now runs the same steps as "Disable push" before the logout
call: DELETE the server row while the session is still valid, then
unsubscribe in the browser. Both callers share one helper. A failure
there does not block signing out. With the dispatcher no longer
counting "no subscription" as a failed send, signing out does not
start the five-failure countdown on the account's channel.
2026-11-11 20:44:24 +01:00
Marc-André Bombeck b793be3f84 fix(onboarding): shorten the AI pitch on the last setup screen
Every new account finished setup on a full AI panel: a gradient badge, a
sample briefing, three ways to connect a provider, a one-tap offer for the
operator's key and a disclaimer, the most prominent block on the screen.
It is now one muted line with a link to Settings, AI, shown under the same
rule as the Insights hint (could be set up here, nothing configured yet,
not opted out). That hint and the consent request on Insights and
Settings, AI stay the places that invite.

The sample briefing card, the `onboarding.ai.*` strings in ten locales and
the end-to-end spec for the removed offer go with it. The consent-grant
guard's floor drops to the two callers left.
2026-11-11 20:38:37 +01:00
Marc-André Bombeck 433b92a67f fix(pwa): restore the dashboard snapshot only for its own account
On a cold start the IndexedDB snapshot was restored before
/api/auth/me had answered, and the owner check was skipped while the
account was unknown. On a shared browser the previous account's
dashboard could be put into the next account's cache until the refetch
landed.

The restore now waits until the signed-in account is known and runs
again when it changes. A snapshot whose owner is not that account, or
that was written before any account was known, is discarded and wiped.

Because the restore now lands after the page has mounted, it fills
queries that have no data yet instead of skipping every query the cache
already knows; data the page holds is still never overwritten.
2026-11-11 20:36:25 +01:00
Marc-André Bombeck 0df65ce396 fix(pwa): keep the open page when a link is tapped offline
Without a connection the strip said "Showing your last synced data",
but one tap on a tab or link traded the readable page for the worker's
bare offline page: signed-in pages are no-store, so the document
navigation Next falls back to has nothing to load.

While the browser reports offline, a tap on a link to another page of
the app now stays on the open page and repeats the hint in a toast. The
strip says what is true: this page stays readable, other pages open once
the connection is back. The README stops promising offline support.

A cold start or a typed URL without a connection still gets the offline
page; the e2e spec keeps that and adds the tap.
2026-11-11 20:33:57 +01:00
Marc-André Bombeck 17fd1a0906 chore(insights): remove the "Try a 7-day experiment" placeholder
Every correlation card carried a permanently disabled button with the
tooltip "Coming soon", which on a phone (no hover) was just a dead
control. The feature it stood for was never built; the button and its
two strings go in all ten locales. The Coach hand-off and the dismiss
action stay.
2026-11-11 20:30:24 +01:00
Marc-André Bombeck f519df4433 Merge branch 'fix/v1473-record' into release/v1.47.3 2026-11-11 20:18:59 +01:00
Marc-André Bombeck 6a02cda210 Merge branch 'fix/v1473-writes' into release/v1.47.3 2026-11-11 20:18:58 +01:00
Marc-André Bombeck de8eadbf2f fix(ai): keep setup invitations away from people who opted out
A person who switched both the Coach and AI analysis off under Settings,
Modules still got invited to set AI up: the Insights hint, its copy in
Settings, AI, the checklist row and the onboarding panel all keyed on
`provider.canConfigure`, which only knows the record kind and the
operator switch.

The flag itself stays as it is, because the AI usage card and the consent
request read it too and still serve document reading, lab photos and
medication extraction. The three invitation readers now also check the
module map through `hasOptedOutOfAi`.
2026-11-11 20:18:10 +01:00
Marc-André Bombeck da671f74aa refactor(day): share one window for date-only columns
The day view, the day index and the visit preparation each built the
UTC calendar window for a date-only column by hand (documents had a
fourth copy). `dateOnlyWindow()` beside the other date-only helpers
replaces them. The report name select becomes one spread, the share
page builds its calendar formatter outside the view, and the PDF
section reads it off the context, so the functions these fixes touched
stay within their size budget; `DocumentRecordLinks` drops under it.
2026-11-11 20:12:44 +01:00
Marc-André Bombeck 53f4ecc2c5 fix(documents): say when the document picker list is cut
The picker for linking documents to an illness and for choosing
documents for a share link reads the newest fifty and dropped the
route's nextCursor, so with a longer vault an older report seemed not
to exist unless one already knew to search for it. A cut list now ends
with a line that says how many are shown and that the search reaches
the rest. Paging was left out: the coach attachment picker shares the
picker's cache key with a single-page read.
2026-11-11 20:08:24 +01:00
Marc-André Bombeck d204b4344b fix(records): offer Undo after deleting a vaccination, visit or entry
Deleting a vaccination, a doctor visit or an address-book entry closed
the sheet with no way back, although all three are soft deletes with a
documented restore route. The vaccination hook had a restore mutation
nobody called; visits and the address book had none.

The two hooks gain a restore mutation, and each delete now confirms
with an Undo toast, the same pattern the illness episode uses. The Undo
runs after the sheet has closed, so it awaits the restore itself and
the hook's own onSuccess refreshes the lists, instead of relying on
callbacks a closed sheet never receives. Restoring an address-book
entry brings the entry back, not the links the delete cleared on the
visits that named it. The three new success toasts are pinned in the
success-affordance guard.
2026-11-11 20:06:02 +01:00