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:
@@ -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`, [
|
||||
|
||||
Reference in New Issue
Block a user