Notifications: Delete has no Undo action #776

Open
opened 2026-10-02 13:10:21 +00:00 by kayg · 4 comments
Owner

Found during the source review for #427 on origin/dev at c4a61e8cf090170f35b1bed3350d9de20c83ecd5.

Decided behavior

DESIGN §30 C11 (in-app inbox), §34 (8 s Undo toast), and the owner UX completeness rule of 2026-10-02 require Undo for destructive actions.

Evidence

apps/web/src/lib/notifications/InboxPanel.svelte:166–177 calls inbox.remove for all Delete entry points. apps/web/src/lib/notifications/inbox.svelte.ts:252–265 removes the row, calls DELETE and announces success. It restores state only on a failed DELETE; a successful DELETE has no Undo toast or recovery receipt.

Observed behavior

A swipe, menu choice or keyboard Delete removes a notification with no way to undo it. This also removes reminder history from the visible inbox.

This is a source finding. No production build or live-server run was made in this review job.

Expected

Make successful inbox deletion recoverable through the shared Undo toast. Restore the same notification and its read/reminder state. Preserve stable target links and current list focus.

Test idea

Delete unread, read and reminder notifications by keyboard, menu and touch swipe, then undo. Check identity, list position, unread count and reminder state. Include a failure during Undo.

For UI evidence, use a production build at 390, 820 and 1440 px in light and dark, with macOS platform hints. Check pointer, keyboard and touch, plus reduced motion.

Duplicate check

Searched all issue titles for notification Delete and Undo. Read #22. #722 concerns existing Files/Photos delete Undo that fails to repaint; notification deletion has no Undo at all.

Found during the source review for #427 on `origin/dev` at `c4a61e8cf090170f35b1bed3350d9de20c83ecd5`. ## Decided behavior DESIGN §30 C11 (in-app inbox), §34 (8 s Undo toast), and the owner UX completeness rule of 2026-10-02 require Undo for destructive actions. ## Evidence `apps/web/src/lib/notifications/InboxPanel.svelte:166–177` calls inbox.remove for all Delete entry points. `apps/web/src/lib/notifications/inbox.svelte.ts:252–265` removes the row, calls DELETE and announces success. It restores state only on a failed DELETE; a successful DELETE has no Undo toast or recovery receipt. ## Observed behavior A swipe, menu choice or keyboard Delete removes a notification with no way to undo it. This also removes reminder history from the visible inbox. This is a source finding. No production build or live-server run was made in this review job. ## Expected Make successful inbox deletion recoverable through the shared Undo toast. Restore the same notification and its read/reminder state. Preserve stable target links and current list focus. ## Test idea Delete unread, read and reminder notifications by keyboard, menu and touch swipe, then undo. Check identity, list position, unread count and reminder state. Include a failure during Undo. For UI evidence, use a production build at 390, 820 and 1440 px in light and dark, with macOS platform hints. Check pointer, keyboard and touch, plus reduced motion. ## Duplicate check Searched all issue titles for notification Delete and Undo. Read #22. #722 concerns existing Files/Photos delete Undo that fails to repaint; notification deletion has no Undo at all.
Author
Owner

Additional source check: Delete currently marks the active reminder recipient done and deletes the notification in separate autocommit queries. The delete then cascades push-delivery rows and clears the recipient link. The inverse must capture and restore these fields atomically, for this User only.

Additional source check: Delete currently marks the active reminder recipient done and deletes the notification in separate autocommit queries. The delete then cascades push-delivery rows and clears the recipient link. The inverse must capture and restore these fields atomically, for this User only.
Author
Owner

Started #776 on branch job/undo-a11y-reminders, based at 9281e63b0164fd0297d959f37eb0008bfa0aa4d3. The slice covers transactional, User-scoped inbox Delete/Undo with stable notification and reminder state.

Started #776 on branch `job/undo-a11y-reminders`, based at `9281e63b0164fd0297d959f37eb0008bfa0aa4d3`. The slice covers transactional, User-scoped inbox Delete/Undo with stable notification and reminder state.
Author
Owner

Evidence: inbox DELETE previously updated a linked active reminder recipient and deleted the notification in separate autocommit statements, returned 204, and had no inverse. A failure between those statements could leave reminder state changed while the inbox row remained deleted. The new path stores the User-scoped delete, exact notification snapshot and linked recipient inverse in one #667 writer transaction. Undo restores the same ID, order, read state and reminder link; cascading push rows remain removed, so Undo does not send another push. The regression also checks another User's reminder row remains active.

Evidence: inbox DELETE previously updated a linked active reminder recipient and deleted the notification in separate autocommit statements, returned 204, and had no inverse. A failure between those statements could leave reminder state changed while the inbox row remained deleted. The new path stores the User-scoped delete, exact notification snapshot and linked recipient inverse in one #667 writer transaction. Undo restores the same ID, order, read state and reminder link; cascading push rows remain removed, so Undo does not send another push. The regression also checks another User's reminder row remains active.
Author
Owner

#776 implementation report

Implemented on job/undo-a11y-reminders. Head: 5ad4e8a43e6ebcd3802b8a15d77bb1d878b90afb.

What changed

  • Inbox Delete now creates a User-scoped #667 receipt in the same SQLite writer transaction as the deletion. The inverse retains the notification ID, content, stable order, unread state and at most one linked reminder recipient.
  • Deleting an active reminder notification marks only that User's recipient done before the notification is removed. Undo restores the same row and recipient only while the source reminder is active and the post-delete state is exact. It does not recreate push deliveries or resend a push.
  • The shared reminder and inbox mutation controller performs optimistic updates and reconciles uncertain requests through the receipt endpoint. Undo restores the row in its original order and updates the unread count.
  • Delete moves focus to a live inbox row before waiting on the request, including the row menu path. The E2E regression checks identity, order, read state and focus.

Changed files

  • apps/web/e2e/notifications.mjs
  • apps/web/src/lib/notifications/InboxPanel.svelte
  • apps/web/src/lib/notifications/inbox.svelte.ts
  • apps/web/src/lib/notifications/inbox.svelte.test.ts
  • bench/mutation-receipts.mjs
  • crates/plugins/notifications/src/routes.rs
  • crates/plugins/notifications/src/store.rs

Decisions

  • Done/Snooze and Delete share one receipt controller and one transactional Undo route. Their core is in commit 352411542 (references #768 and #776); the focus, E2E and bench slice is in 2a06455e6.
  • The existing mutation controller key stays notifications.reminders; this lets an older tab resume a pending reminder action after upgrade.
  • Delete Undo restores the inbox row but leaves the push outbox empty. Replaying a push would deliver the same event twice.

UX gaps closed / left

Closed: Delete works through the existing button, row menu, touch swipe and keyboard shortcut. The row is removed immediately; keyboard focus moves to a live row; Undo restores the same row and unread state.

Left: the required macOS screenshots at 390/820/1440 px in light and dark were not captured. A rebuilt server and regenerated API client are still needed before the production E2E flow can run.

Performance

Extended bench/mutation-receipts.mjs with Delete, Done/Snooze and Undo at average inbox size and the 10,000-item retention bound, including a five-action burst. I did not measure it: this branch has no rebuilt server binary, and the parent is coordinating the Cargo build. The baseline has no operation-specific inbox mutation profile, so no before/after numbers are available.

Gate output

bun run test -- src/lib/notifications/inbox.svelte.test.ts --pool=threads --maxWorkers=1:

$ vitest run src/lib/notifications/inbox.svelte.test.ts "--pool=threads" "--maxWorkers=1"

 RUN  v5.0.1 /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  19:09:53
   Duration  121.22s (transform 75%, environment 18%, setup 5%, import 1%)

  Transform  |component| transforming modules took 91.14s · 75% of tracked time, re-done on every run
             persist transforms across runs with fsModuleCache: true
             learn more: https://vitest.dev/guide/improving-performance#caching-between-reruns

rustfmt --edition 2024 --config skip_children=true --check crates/plugins/notes/src/reminders_api.rs crates/plugins/notes/src/lib.rs crates/plugins/notifications/src/store.rs crates/plugins/notifications/src/routes.rs: exit 0; no stdout/stderr.

node --check bench/mutation-receipts.mjs && node --check apps/web/e2e/notes.mjs && node --check apps/web/e2e/notifications.mjs: exit 0; no stdout/stderr.

git diff --check: exit 0; no stdout/stderr.

bun run check after the InboxMutationInput union fix:

$ node scripts/check-user-storage.mjs && node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
User browser caches use userStorage; only documented device/public-link exceptions remain.
Text sizes and UI shape values use shared role tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web
Getting Svelte diagnostics...

/home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:242:29
Error: Argument of type '"/api/v1/notifications/inbox/mutations/{id}/undo"' is not assignable to parameter of type 'ApiPath'.
					if (intent.undoOf) {
						return await apiFetch('/api/v1/notifications/inbox/mutations/{id}/undo', 'post', {
							params: { path: { id: intent.undoOf } },

/home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:249:54
Error: Type '{ operation_id: string; }' is not assignable to type 'undefined'.
						return await apiFetch('/api/v1/notifications/inbox/{id}', 'delete', {
							params: { path: { id: intent.input.item.id }, query: { operation_id: intent.id } }
						}) as Receipt<InboxMutationResult>;

/home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:254:54
Error: Type '{ operation_id: string; }' is not assignable to type 'undefined'.
						return await apiFetch('/api/v1/notifications/inbox/{id}/reminder/done', 'post', {
							params: { path: { id: intent.input.item.id }, query: { operation_id: intent.id } }
						}) as Receipt<InboxMutationResult>;

/home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:258:53
Error: Type '{ operation_id: string; }' is not assignable to type 'undefined'.
					return await apiFetch('/api/v1/notifications/inbox/{id}/reminder/snooze', 'post', {
						params: { path: { id: intent.input.item.id }, query: { operation_id: intent.id } },
						body: { minutes: intent.input.minutes }

/home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:263:28
Error: Argument of type '"/api/v1/notifications/inbox/mutations/{id}"' is not assignable to parameter of type 'ApiPath'.
				async lookup(id) {
					return await apiFetch('/api/v1/notifications/inbox/mutations/{id}', 'get', {
						params: { path: { id } }

====================================
svelte-check found 5 errors and 0 warnings in 1 file
error: script "check" exited with code 1

Cargo fmt/clippy/test were not queued in this worktree, per the parent instruction to defer Rust gates until the coordinated merge/build window. The parent will regenerate contracts/openapi.json and the API client for the new routes and query parameters.

## #776 implementation report Implemented on `job/undo-a11y-reminders`. Head: `5ad4e8a43e6ebcd3802b8a15d77bb1d878b90afb`. ### What changed - Inbox Delete now creates a User-scoped #667 receipt in the same SQLite writer transaction as the deletion. The inverse retains the notification ID, content, stable order, unread state and at most one linked reminder recipient. - Deleting an active reminder notification marks only that User's recipient done before the notification is removed. Undo restores the same row and recipient only while the source reminder is active and the post-delete state is exact. It does not recreate push deliveries or resend a push. - The shared reminder and inbox mutation controller performs optimistic updates and reconciles uncertain requests through the receipt endpoint. Undo restores the row in its original order and updates the unread count. - Delete moves focus to a live inbox row before waiting on the request, including the row menu path. The E2E regression checks identity, order, read state and focus. ### Changed files - `apps/web/e2e/notifications.mjs` - `apps/web/src/lib/notifications/InboxPanel.svelte` - `apps/web/src/lib/notifications/inbox.svelte.ts` - `apps/web/src/lib/notifications/inbox.svelte.test.ts` - `bench/mutation-receipts.mjs` - `crates/plugins/notifications/src/routes.rs` - `crates/plugins/notifications/src/store.rs` ### Decisions - Done/Snooze and Delete share one receipt controller and one transactional Undo route. Their core is in commit `352411542` (references #768 and #776); the focus, E2E and bench slice is in `2a06455e6`. - The existing mutation controller key stays `notifications.reminders`; this lets an older tab resume a pending reminder action after upgrade. - Delete Undo restores the inbox row but leaves the push outbox empty. Replaying a push would deliver the same event twice. ### UX gaps closed / left Closed: Delete works through the existing button, row menu, touch swipe and keyboard shortcut. The row is removed immediately; keyboard focus moves to a live row; Undo restores the same row and unread state. Left: the required macOS screenshots at 390/820/1440 px in light and dark were not captured. A rebuilt server and regenerated API client are still needed before the production E2E flow can run. ### Performance Extended `bench/mutation-receipts.mjs` with Delete, Done/Snooze and Undo at average inbox size and the 10,000-item retention bound, including a five-action burst. I did not measure it: this branch has no rebuilt server binary, and the parent is coordinating the Cargo build. The baseline has no operation-specific inbox mutation profile, so no before/after numbers are available. ### Gate output `bun run test -- src/lib/notifications/inbox.svelte.test.ts --pool=threads --maxWorkers=1`: ```text $ vitest run src/lib/notifications/inbox.svelte.test.ts "--pool=threads" "--maxWorkers=1" RUN v5.0.1 /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web Test Files 1 passed (1) Tests 3 passed (3) Start at 19:09:53 Duration 121.22s (transform 75%, environment 18%, setup 5%, import 1%) Transform |component| transforming modules took 91.14s · 75% of tracked time, re-done on every run persist transforms across runs with fsModuleCache: true learn more: https://vitest.dev/guide/improving-performance#caching-between-reruns ``` `rustfmt --edition 2024 --config skip_children=true --check crates/plugins/notes/src/reminders_api.rs crates/plugins/notes/src/lib.rs crates/plugins/notifications/src/store.rs crates/plugins/notifications/src/routes.rs`: exit 0; no stdout/stderr. `node --check bench/mutation-receipts.mjs && node --check apps/web/e2e/notes.mjs && node --check apps/web/e2e/notifications.mjs`: exit 0; no stdout/stderr. `git diff --check`: exit 0; no stdout/stderr. `bun run check` after the `InboxMutationInput` union fix: ```text $ node scripts/check-user-storage.mjs && node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json User browser caches use userStorage; only documented device/public-link exceptions remain. Text sizes and UI shape values use shared role tokens. UI transitions and animation options use shared motion tokens or documented exceptions. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web Getting Svelte diagnostics... /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:242:29 Error: Argument of type '"/api/v1/notifications/inbox/mutations/{id}/undo"' is not assignable to parameter of type 'ApiPath'. if (intent.undoOf) { return await apiFetch('/api/v1/notifications/inbox/mutations/{id}/undo', 'post', { params: { path: { id: intent.undoOf } }, /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:249:54 Error: Type '{ operation_id: string; }' is not assignable to type 'undefined'. return await apiFetch('/api/v1/notifications/inbox/{id}', 'delete', { params: { path: { id: intent.input.item.id }, query: { operation_id: intent.id } } }) as Receipt<InboxMutationResult>; /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:254:54 Error: Type '{ operation_id: string; }' is not assignable to type 'undefined'. return await apiFetch('/api/v1/notifications/inbox/{id}/reminder/done', 'post', { params: { path: { id: intent.input.item.id }, query: { operation_id: intent.id } } }) as Receipt<InboxMutationResult>; /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:258:53 Error: Type '{ operation_id: string; }' is not assignable to type 'undefined'. return await apiFetch('/api/v1/notifications/inbox/{id}/reminder/snooze', 'post', { params: { path: { id: intent.input.item.id }, query: { operation_id: intent.id } }, body: { minutes: intent.input.minutes } /home/kayg/Developer/calternal-wt/undo-a11y-reminders/apps/web/src/lib/notifications/inbox.svelte.ts:263:28 Error: Argument of type '"/api/v1/notifications/inbox/mutations/{id}"' is not assignable to parameter of type 'ApiPath'. async lookup(id) { return await apiFetch('/api/v1/notifications/inbox/mutations/{id}', 'get', { params: { path: { id } } ==================================== svelte-check found 5 errors and 0 warnings in 1 file error: script "check" exited with code 1 ``` Cargo fmt/clippy/test were not queued in this worktree, per the parent instruction to defer Rust gates until the coordinated merge/build window. The parent will regenerate `contracts/openapi.json` and the API client for the new routes and query parameters.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#776
No description provided.