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:32:17 +00:00
parent f6af6ea5ce
commit dfbced4476
+28 -42
View File
@@ -275,48 +275,34 @@ export function useToggleChecklistItem() {
return changed ? next : items;
};
// Personal notes lists (owner-side cards).
for (const [key, data] of qc.getQueriesData<Note[]>({
queryKey: ["notes"],
})) {
if (!Array.isArray(data)) continue;
snapshots.push([key, data]);
qc.setQueryData<Note[]>(
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<SentNote[]>({
queryKey: sentNotesKey,
})) {
if (!Array.isArray(data)) continue;
snapshots.push([key, data]);
qc.setQueryData<SentNote[]>(
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<ReceivedNote[]>({
queryKey: ["notes", "received"],
})) {
if (!Array.isArray(data)) continue;
snapshots.push([key, data]);
qc.setQueryData<ReceivedNote[]>(
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<readonly unknown[]> = [
["notes"],
sentNotesKey,
];
const seen = new Set<string>();
for (const qk of arrayQueryKeys) {
for (const [key, data] of qc.getQueriesData<
Array<Note | SentNote | ReceivedNote>
>({ 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);