Task #288: Share Executive Meetings row highlight colors across devices
Per-row highlight colors on the Executive Meetings daily schedule were
stored in each browser's localStorage, so a color set by the admin on
their laptop never reached the executive office or the big-screen
viewer. Move the color into a shared, server-stored field on the
meeting row so every viewer sees the same color in real time.
Schema
- Add nullable `row_color varchar(16)` to `executive_meetings`
(lib/db/src/schema/executive-meetings.ts). Migration applied via
`pnpm --filter @workspace/db push`.
API (artifacts/api-server/src/routes/executive-meetings.ts)
- Whitelist ROW_COLOR_KEYS = red/amber/green/blue/violet/gray.
- Extend meetingPatchSchema with optional `rowColor` (nullable enum).
- Existing PATCH /executive-meetings/:id picks it up, stamps
updatedBy, writes the standard audit-log entry, and fires
emitExecutiveMeetingsDaysChanged so other viewers' day query
invalidates and re-fetches.
- Permission gating unchanged: requireMutate → executive_viewer 403.
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx)
- `rowColors` is now derived from the meetings query via useMemo.
The localStorage write effect is removed.
- New async setRowColor() PATCHes the server and invalidates the day
query (key ["/api/executive-meetings", date]).
- Best-effort migration of legacy localStorage colors:
- drops invalid keys / unknown colors immediately,
- skips ids the server already colored (no overwrite),
- PATCHes ids in the currently loaded day,
- PRESERVES ids for dates the user hasn't visited yet so they
migrate when they do navigate there (architect review caught a
data-loss bug in the first cut where unvisited-day entries were
being deleted),
- keeps failed PATCHes for retry, removes the localStorage key
only once nothing is left,
- in-flight Set prevents double-PATCH if the effect re-fires.
Tests
- New artifacts/api-server/tests/executive-meetings-row-color.test.mjs
(6 specs) covers PATCH set / clear / invalid-key 400 / viewer 403 /
realtime executive_meetings_changed socket event for the affected
date / audit attribution. All pass.
- E2E (runTest) verified two browser contexts share the color
without manual refresh.
Documentation
- replit.md: added "Task #288 — Shared row colours" section.
Drift / notes
- Pre-existing TS errors in admin.tsx and pre-existing isHighlighted /
font_settings scope errors in executive-meetings.ts are unrelated
to this change.
- Follow-up #293 proposed: add a DB-level CHECK constraint mirroring
the API whitelist for defense-in-depth.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
import { io as ioClient } from "socket.io-client";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
@@ -306,6 +307,68 @@ test("PATCH rowColor: non-mutate viewer role gets 403 and the row colour is unch
|
||||
);
|
||||
});
|
||||
|
||||
test("PATCH rowColor: the realtime executive_meetings_changed event fires for the affected date so other tabs / devices know to re-fetch", async () => {
|
||||
// This is the contract that makes "shared" actually feel realtime —
|
||||
// without it, a second viewer would only see the colour change after
|
||||
// they manually refreshed. The frontend listens for this exact event
|
||||
// (see use-notifications-socket.ts) and invalidates the day query.
|
||||
const meeting = await createMeeting(
|
||||
"RowColor socket",
|
||||
"بث اللون عبر السوكيت",
|
||||
);
|
||||
|
||||
const events = [];
|
||||
const socket = ioClient(API_BASE, {
|
||||
path: "/api/socket.io",
|
||||
transports: ["websocket"],
|
||||
forceNew: true,
|
||||
reconnection: false,
|
||||
extraHeaders: { Cookie: adminCookie },
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.on("executive_meetings_changed", (payload) => {
|
||||
events.push(payload);
|
||||
});
|
||||
socket.on("connect", resolve);
|
||||
socket.on("connect_error", reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/api/executive-meetings/${meeting.id}`,
|
||||
{ rowColor: "gray" },
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// Give the broadcast a moment to land. 400ms is plenty for a
|
||||
// localhost socket and well under the test timeout.
|
||||
const start = Date.now();
|
||||
while (events.length === 0 && Date.now() - start < 400) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
assert.ok(
|
||||
events.length > 0,
|
||||
"executive_meetings_changed should fire after a rowColor PATCH",
|
||||
);
|
||||
// Some payloads also include an array of dates; we just need the
|
||||
// current day to be addressed in any of them.
|
||||
const sawToday = events.some((p) => {
|
||||
if (!p) return false;
|
||||
if (typeof p.date === "string" && p.date === today) return true;
|
||||
if (Array.isArray(p.dates) && p.dates.includes(today)) return true;
|
||||
return false;
|
||||
});
|
||||
assert.ok(
|
||||
sawToday,
|
||||
`event should reference today's date (${today}); got ${JSON.stringify(events)}`,
|
||||
);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("PATCH rowColor: each successful change writes an audit row attributed to the acting user", async () => {
|
||||
const meeting = await createMeeting("RowColor audit", "سجل لون الصف");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user