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:
Riyadh
2026-05-07 11:34:55 +00:00
parent 1c50a6f5b2
commit c40737e904
@@ -0,0 +1,116 @@
// Task #438: lightweight render test for the upcoming-meeting alert
// entrance animation. Asserts the alert panel renders with the
// matching scale-in/opacity transition, an accent ring (boxShadow with
// the user's accent color), and a pulsing animate-ping halo around the
// drag handle — the visual language we adopted from the incoming-note
// popup.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) throw new Error("DATABASE_URL must be set");
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
function todayLocal() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function timePlusMinutes(delta) {
const d = new Date(Date.now() + delta * 60_000);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:00`;
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_alert_state WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("upcoming-meeting alert renders with scale-in entrance, accent ring, and ping halo", async ({
page,
}) => {
test.setTimeout(60_000);
const date = todayLocal();
const startTime = timePlusMinutes(3);
const endTime = timePlusMinutes(33);
const { rows: dnRows } = await pool.query(
`SELECT COALESCE(MAX(daily_number), 0) + 1 AS n FROM executive_meetings WHERE meeting_date = $1`,
[date],
);
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
RETURNING id`,
[
dnRows[0].n,
"ALERT_ANIM_TEST AR",
"ALERT_ANIM_TEST EN",
date,
startTime,
endTime,
],
);
createdMeetingIds.push(rows[0].id);
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
const alert = page.getByTestId("upcoming-meeting-alert");
await expect(alert).toBeVisible({ timeout: 15_000 });
// After the entrance animation settles the panel is fully visible
// (opacity-100 / scale-100). We assert the resolved styles describe
// the scale + opacity transition we configured.
const transition = await alert.evaluate(
(el) => getComputedStyle(el).transition,
);
expect(transition).toMatch(/transform/);
expect(transition).toMatch(/opacity/);
// The cubic-bezier spring curve we use for the entrance.
expect(transition).toMatch(/cubic-bezier\(0\.34,\s*1\.56,\s*0\.64,\s*1\)/);
// Accent ring is implemented via boxShadow (so the color tracks the
// user's accent). Verify a 4px inset-less colored ring is the first
// shadow layer alongside the existing drop shadow.
const boxShadow = await alert.evaluate(
(el) => getComputedStyle(el).boxShadow,
);
expect(boxShadow).toMatch(/rgba?\([^)]*\)\s+0px\s+0px\s+0px\s+4px/);
// Pulsing accent halo around the drag handle (animate-ping element).
const handle = page.getByTestId("alert-drag-handle");
await expect(handle).toBeVisible();
const halo = handle.locator(".animate-ping").first();
await expect(halo).toHaveCount(1);
});