Reminders: Remove, Done and Snooze have no Undo action #768

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

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

Decided behavior

DESIGN §42 (block reminders and per-person Done/Snooze), §34 (8 s Undo toast), and the owner UX completeness rule of 2026-10-02 require Undo for destructive or state-changing actions.

Evidence

apps/web/src/lib/editor/format/BlockReminderPicker.svelte:212–218 deletes an existing reminder and calls onset without an Undo toast. Creation in the same file at lines 167–179 already offers Undo. apps/web/src/lib/notifications/inbox.svelte.ts:194–218 commits Done or Snooze and refreshes, with only an announcement and no inverse action.

Observed behavior

An accidental removal cancels the reminder. An accidental Done or Snooze changes delivery for this User. The picker and inbox offer no immediate way back.

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

Expected

Use the shared Undo toast. Removal must restore the original block, instant, setter and audience. Done/Snooze Undo must restore only this User's state. Keep concurrent reminders and other recipients intact.

Test idea

Set a reminder, remove it and undo. Fire another reminder, then undo Done and Snooze. Assert that other recipients and another reminder on the block keep their state. Check keyboard and touch actions.

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 and reminder results. Read #191 and #22. #191 is closed and creation Undo exists; neither ticket names this missing inverse for Remove/Done/Snooze. #667 is the shared mutation architecture umbrella, not this per-surface gap.

Found during the source review for #427 on `origin/dev` at `c4a61e8cf090170f35b1bed3350d9de20c83ecd5`. ## Decided behavior DESIGN §42 (block reminders and per-person Done/Snooze), §34 (8 s Undo toast), and the owner UX completeness rule of 2026-10-02 require Undo for destructive or state-changing actions. ## Evidence `apps/web/src/lib/editor/format/BlockReminderPicker.svelte:212–218` deletes an existing reminder and calls onset without an Undo toast. Creation in the same file at lines 167–179 already offers Undo. `apps/web/src/lib/notifications/inbox.svelte.ts:194–218` commits Done or Snooze and refreshes, with only an announcement and no inverse action. ## Observed behavior An accidental removal cancels the reminder. An accidental Done or Snooze changes delivery for this User. The picker and inbox offer no immediate way back. This is a source finding. No production build or live-server run was made in this review job. ## Expected Use the shared Undo toast. Removal must restore the original block, instant, setter and audience. Done/Snooze Undo must restore only this User's state. Keep concurrent reminders and other recipients intact. ## Test idea Set a reminder, remove it and undo. Fire another reminder, then undo Done and Snooze. Assert that other recipients and another reminder on the block keep their state. Check keyboard and touch actions. 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 and reminder results. Read #191 and #22. #191 is closed and creation Undo exists; neither ticket names this missing inverse for Remove/Done/Snooze. #667 is the shared mutation architecture umbrella, not this per-surface gap.
Author
Owner

Additional source check: removing a reminder records it as cancelled and cancels active recipient rows. Re-projecting the reminder does not reactivate those rows; it also must not reset a recipient's Done or Snooze state. Undo needs to preserve the reminder ID and each recipient's current state while restoring only the cancelled delivery state.

Additional source check: removing a reminder records it as cancelled and cancels active recipient rows. Re-projecting the reminder does not reactivate those rows; it also must not reset a recipient's Done or Snooze state. Undo needs to preserve the reminder ID and each recipient's current state while restoring only the cancelled delivery state.
Author
Owner

Started #768 on branch job/undo-a11y-reminders, based at 9281e63b0164fd0297d959f37eb0008bfa0aa4d3. The slice covers revision-fenced block reminder Remove/Undo and User-scoped reminder Done/Snooze Undo.

Started #768 on branch `job/undo-a11y-reminders`, based at `9281e63b0164fd0297d959f37eb0008bfa0aa4d3`. The slice covers revision-fenced block reminder Remove/Undo and User-scoped reminder Done/Snooze Undo.
Author
Owner

Evidence: the active-source recipient UPSERT changed cancelled to active in its CASE expression, but its WHERE clause admitted only active and paused. After Remove Undo restored the Note frontmatter, the existing cancelled recipient row was therefore never updated. The predicate now includes cancelled; the regression checks that only that recipient reactivates and its next-delivery time and delivery number stay intact.

Evidence: the active-source recipient UPSERT changed `cancelled` to `active` in its CASE expression, but its WHERE clause admitted only `active` and `paused`. After Remove Undo restored the Note frontmatter, the existing cancelled recipient row was therefore never updated. The predicate now includes `cancelled`; the regression checks that only that recipient reactivates and its next-delivery time and delivery number stay intact.
Author
Owner

#768 implementation report

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

What changed

  • Note reminder Remove returns its cancelled revision. Undo requires the current Note ETag, the same reminder ID, the exact cancelled revision and cancelReason=removed, and the original block anchor exactly once. It restores the same reminder fields and identity, then advances the revision.
  • Reminder Done and Snooze now use the shared #667 receipt path. Undo restores the same notification and this User's prior reminder and read state in one Index transaction. It rejects stale post-action state and a Note reminder that is no longer active.
  • Fixed the recipient UPSERT predicate: the update already described reactivating cancelled rows, but its WHERE clause excluded them. Active source projection now reactivates that User's same row without resetting delivery number or schedule.

Changed files

  • apps/web/e2e/notes.mjs
  • apps/web/src/lib/editor/format/BlockReminderPicker.svelte
  • apps/web/src/lib/notes/api.ts
  • apps/web/src/lib/notifications/inbox.svelte.ts
  • apps/web/src/lib/notifications/inbox.svelte.test.ts
  • crates/plugins/notes/src/lib.rs
  • crates/plugins/notes/src/reminders_api.rs
  • crates/plugins/notifications/src/routes.rs
  • crates/plugins/notifications/src/store.rs

Decisions

  • Note reminders stay on the filesystem-authoritative ETag/revision path. A #667 SQLite receipt cannot atomically cover the Markdown replacement and Index rebuild.
  • Reminder and inbox actions share the existing mutation controller and a stable User storage key so an older tab can reconcile a pending operation.

UX gaps closed / left

Closed: Remove and Done/Snooze offer an Undo action; inbox state updates optimistically, then reconciles against a User-scoped receipt; restored rows keep their stable identity, unread state and reminder 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 for reminder Done/Snooze and Undo. I did not measure it: this branch has no rebuilt server binary, and the parent is coordinating the Cargo build. docs/perf/baseline.json has no operation-specific reminder mutation baseline, 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.

## #768 implementation report Implemented on `job/undo-a11y-reminders`. Head: `5ad4e8a43e6ebcd3802b8a15d77bb1d878b90afb`. ### What changed - Note reminder Remove returns its cancelled revision. Undo requires the current Note ETag, the same reminder ID, the exact cancelled revision and `cancelReason=removed`, and the original block anchor exactly once. It restores the same reminder fields and identity, then advances the revision. - Reminder Done and Snooze now use the shared #667 receipt path. Undo restores the same notification and this User's prior reminder and read state in one Index transaction. It rejects stale post-action state and a Note reminder that is no longer active. - Fixed the recipient UPSERT predicate: the update already described reactivating cancelled rows, but its WHERE clause excluded them. Active source projection now reactivates that User's same row without resetting delivery number or schedule. ### Changed files - `apps/web/e2e/notes.mjs` - `apps/web/src/lib/editor/format/BlockReminderPicker.svelte` - `apps/web/src/lib/notes/api.ts` - `apps/web/src/lib/notifications/inbox.svelte.ts` - `apps/web/src/lib/notifications/inbox.svelte.test.ts` - `crates/plugins/notes/src/lib.rs` - `crates/plugins/notes/src/reminders_api.rs` - `crates/plugins/notifications/src/routes.rs` - `crates/plugins/notifications/src/store.rs` ### Decisions - Note reminders stay on the filesystem-authoritative ETag/revision path. A #667 SQLite receipt cannot atomically cover the Markdown replacement and Index rebuild. - Reminder and inbox actions share the existing mutation controller and a stable User storage key so an older tab can reconcile a pending operation. ### UX gaps closed / left Closed: Remove and Done/Snooze offer an Undo action; inbox state updates optimistically, then reconciles against a User-scoped receipt; restored rows keep their stable identity, unread state and reminder 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` for reminder Done/Snooze and Undo. I did not measure it: this branch has no rebuilt server binary, and the parent is coordinating the Cargo build. `docs/perf/baseline.json` has no operation-specific reminder mutation baseline, 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#768
No description provided.