From 1c50a6f5b2de067e4dcc5019de543c67e936ca13 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Thu, 7 May 2026 11:32:17 +0000 Subject: [PATCH] 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. --- artifacts/tx-os/src/lib/notes-api.ts | 70 +++++++++++----------------- 1 file changed, 28 insertions(+), 42 deletions(-) diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 5d37aa8a..475d2ae4 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -275,48 +275,34 @@ export function useToggleChecklistItem() { return changed ? next : items; }; - // Personal notes lists (owner-side cards). - for (const [key, data] of qc.getQueriesData({ - queryKey: ["notes"], - })) { - if (!Array.isArray(data)) continue; - snapshots.push([key, data]); - qc.setQueryData( - key, - data.map((n) => - n.id === id ? { ...n, items: patchItems(n.items) as ChecklistItem[] | null } : n, - ), - ); - } - // Sent grid: same shape as Note, items field at top level. - for (const [key, data] of qc.getQueriesData({ - queryKey: sentNotesKey, - })) { - if (!Array.isArray(data)) continue; - snapshots.push([key, data]); - qc.setQueryData( - key, - data.map((n) => - n.id === id - ? { ...n, items: patchItems(n.items) as ChecklistItem[] | null } - : n, - ), - ); - } - // Received inbox (recipient-side snapshots). - for (const [key, data] of qc.getQueriesData({ - queryKey: ["notes", "received"], - })) { - if (!Array.isArray(data)) continue; - snapshots.push([key, data]); - qc.setQueryData( - key, - data.map((n) => - n.id === id - ? { ...n, items: patchItems(n.items) as ChecklistItem[] | null } - : n, - ), - ); + // Single broad pass over every cached note-list query + // (`["notes"]`, `["notes", "received"]`, sent grid, etc.) — react- + // query does prefix matching on the queryKey, so this catches all + // array-of-note caches in one place. Each entry is snapshotted + // exactly once so rollback on error is deterministic. + const arrayQueryKeys: ReadonlyArray = [ + ["notes"], + sentNotesKey, + ]; + const seen = new Set(); + for (const qk of arrayQueryKeys) { + for (const [key, data] of qc.getQueriesData< + Array + >({ queryKey: qk })) { + if (!Array.isArray(data)) continue; + const sig = JSON.stringify(key); + if (seen.has(sig)) continue; + seen.add(sig); + snapshots.push([key, data]); + qc.setQueryData( + key, + data.map((n) => + n.id === id + ? { ...n, items: patchItems(n.items) as ChecklistItem[] | null } + : n, + ), + ); + } } // Open thread dialog (single object, not an array). const threadKey = noteThreadKey(id);