Task #438: collaborative checklists + meeting alert animation parity

Backend
- New POST /notes/:id/checklist/:itemId/toggle endpoint. Owner or any
  active (non-archived) recipient can flip an item's done flag.
- Wrapped in a DB transaction with SELECT ... FOR UPDATE on the live
  note row so concurrent toggles from multiple collaborators can't
  lose each other's updates. Live notes.items + every
  note_recipients.items snapshot are mirrored atomically in the same
  tx.
- Emits note_checklist_changed to the audience minus the actor with
  the full updated items array so receivers can patch state without
  an extra fetch.

Frontend
- useToggleChecklistItem mutation hook with optimistic updates across
  notes/sent/received/thread caches and rollback on error.
- Incoming-note popup checklist is now interactive with a local
  override for instant visual feedback (popup renders from socket
  payload, not from the query cache).
- Thread checklist toggles are gated by effective permission (owner,
  admin, or non-archived recipient) to mirror server auth.
- Socket handler for note_checklist_changed invalidates relevant
  query keys AND patches the open popup payload directly via a new
  IncomingNotePopupContext.updateChecklistItems action so
  collaborators' open popups stay in sync.

Meeting alert animation parity (upcoming-meeting-alert.tsx)
- Added scale-95 -> scale-100 + fade entrance with the same spring
  cubic-bezier the note popup uses, retriggered per eligible meeting.
- Replaced the 1px border with a ring-4 colored frame driven by
  alertPrefs.accent (via boxShadow so the color is dynamic).
- Added an animate-ping accent halo around the drag-handle icon to
  match the popup's avatar pulse.

Tests
- artifacts/api-server/tests/notes-checklist-toggle.test.mjs (7 tests:
  owner/recipient toggle + mirroring, 403 non-recipient, 403
  archived, 400 non-checklist, 404 unknown item, 400 invalid body).
- artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs (e2e:
  recipient ticks an item from the popup, server + sender snapshot
  both reflect the change).

Code review (architect) findings addressed:
- (critical) lost-update race -> tx with row lock.
- (critical) non-atomic snapshot mirror -> same tx.
- (critical) open popup didn't re-render on socket fanout ->
  updateChecklistItems context action.
- UI permission parity for archived recipients -> thread gates
  onToggle.
This commit is contained in:
riyadhafraa
2026-05-07 11:42:38 +00:00
parent 10c1e641b4
commit 093f42665c
2 changed files with 38 additions and 12 deletions
@@ -118,6 +118,12 @@ function PopupChecklistPreview({
noteId: number;
}) {
const toggle = useToggleChecklistItem();
// The server intentionally does NOT echo `note_checklist_changed`
// back to the actor, so the popup's queued snapshot would stay stale
// for our own toggles. We patch the queue ourselves with the
// authoritative `items` from the mutation response so the popup's
// source-of-truth reflects what we just persisted.
const { updateChecklistItems } = useIncomingNotePopup();
// The popup's `items` come from the original socket payload (snapshot
// at enqueue time), NOT from the React Query cache — so optimistic
// cache updates won't flip the checkbox here. We keep a local
@@ -172,20 +178,31 @@ function PopupChecklistPreview({
onChange={(e) => {
const next = e.target.checked;
setOverrides((o) => ({ ...o, [it.id]: next }));
const drop = () =>
// Always clear the override once the mutation
// settles so the next render reflects authoritative
// state (either the optimistic cache write that
// already landed, or — for a concurrent collaborator
// toggle that arrived during our roundtrip — the
// socket-pushed value that supersedes ours).
setOverrides((o) => {
const { [it.id]: _drop, ...rest } = o;
return rest;
});
toggle.mutate(
{ id: noteId, itemId: it.id, done: next },
{ onSuccess: drop, onError: drop },
{
onSuccess: (data) => {
// Patch the popup queue with the authoritative
// items so the next render reads the saved value
// from `items` (the override-reconciler effect
// will then drop the matching override).
if (Array.isArray(data?.items)) {
updateChecklistItems(noteId, data.items);
} else {
setOverrides((o) => {
const { [it.id]: _drop, ...rest } = o;
return rest;
});
}
},
onError: () =>
// Roll back the local override on failure so the
// checkbox snaps back to authoritative state.
setOverrides((o) => {
const { [it.id]: _drop, ...rest } = o;
return rest;
}),
},
);
}}
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500"
@@ -141,6 +141,15 @@ test("recipient ticks a checklist item in the popup; sender sees the update", as
await firstCheckbox.check();
await togglePromise;
// After the mutation settles the popup checkbox must remain
// checked — the actor doesn't get a socket echo, so this verifies
// the popup queue patch driven by the mutation response.
await expect(firstCheckbox).toBeChecked({ timeout: 3_000 });
// Wait one paint frame and re-assert to guard against late state
// resets that would only manifest after the mutation finishes.
await recipientPage.waitForTimeout(400);
await expect(firstCheckbox).toBeChecked();
// Server-side state confirms the toggle persisted (and was mirrored
// to the recipient snapshot).
const { rows } = await pool.query(`SELECT items FROM notes WHERE id = $1`, [