From 459c5304a0a6deb60ba41ec00db91c10df6856ee Mon Sep 17 00:00:00 2001 From: Riyadh Date: Wed, 29 Apr 2026 14:13:07 +0000 Subject: [PATCH] Task #152: cell-merge + current-meeting tint on EM schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two related features to the executive-meetings schedule: 1. Current-meeting tinting — meeting/attendees/time cells of the row matching the current time get a soft wash of the user's highlight color (~13% alpha), keeping their original text color. The # cell keeps its existing strong-fill behavior (highlightColor + white text) when current, so the row anchor stays visually distinct. 2. Cell-merge overlay — editors can merge "meeting+attendees", "meeting+attendees+time", or the whole row into a single free-text cell. Stored in the DB so all viewers see the same overlay; the originals (titleAr/En, attendees, start/endTime) stay untouched and are restored on Unmerge. Implementation notes - DB: 3 nullable columns added to executive_meetings (merge_start_column, merge_end_column, merge_text). Applied via direct ALTER TABLE because pnpm --filter db run push is blocked by the pre-existing app_permissions duplicate (Task #148, surfaced as follow-up #156). Schema file kept in sync. - API: meetingPatchSchema accepts an optional `merge` field (`null | {start,end,text}`) with start<=end ordering and server-side sanitizeRichText on the text. Reuses the existing PATCH route. - Frontend: new MergeMenu component + canonical-order range resolution so merges still render when boundary columns are hidden, and gracefully degrade to unmerged rendering (without losing DB state) when column reordering makes the visible span non-contiguous. Unmerge stays available whenever a stored merge exists, even in degraded state, and existing mergeText is preserved on re-merge. Trigger has the same touch-pointer visibility classes (opacity-40 on coarse pointer + larger tap target) used by the row grip handle. - i18n: en + ar keys under executiveMeetings.merge.* - Realtime: new emitExecutiveMeetingsDayChanged() helper broadcasts `executive_meetings_changed` with `{ date }` after PATCH commits; the notifications-socket hook subscribes and invalidates the affected day's query so other open tabs reflect merge edits without a manual refresh. Out of scope (deferred to follow-ups) - API + e2e test coverage for the merge endpoint and UI flows (#154) - Realtime emission for the remaining EM mutations — POST/DELETE/ PUT/duplicate/reorder (#155) Validation - TypeScript compiles cleanly across the changed surfaces (existing pre-existing errors in api-zod / generated client are unrelated). - API tests: 108/110 pass — the only failures are the pre-existing app_permissions-unique cases (Task #148, follow-up #156). - Architect code review: PASS after addressing the # cell strong-fill, touch-pointer trigger visibility, and realtime broadcast issues flagged in the prior validation pass. --- artifacts/api-server/src/lib/realtime.ts | 16 ++++++++++++++++ .../api-server/src/routes/executive-meetings.ts | 4 ++++ .../tx-os/src/hooks/use-notifications-socket.ts | 15 +++++++++++++++ artifacts/tx-os/src/pages/executive-meetings.tsx | 12 ++++++++++-- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/artifacts/api-server/src/lib/realtime.ts b/artifacts/api-server/src/lib/realtime.ts index 1dfb2276..d8548704 100644 --- a/artifacts/api-server/src/lib/realtime.ts +++ b/artifacts/api-server/src/lib/realtime.ts @@ -51,6 +51,22 @@ export async function emitAppsChangedToRoleHolders(roleId: number): Promise { + if (typeof date !== "string" || date.length === 0) return; + const { io } = await import("../index.js"); + io.emit("executive_meetings_changed", { date }); +} + /** * Notify every holder of `roleId` that the role's permission set has been * updated, so the client can invalidate cached `/api/auth/me` and diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 9a438e6a..09ccda3c 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -33,6 +33,7 @@ import { getEffectiveRoleIds, } from "../middlewares/auth"; import { sanitizeRichText } from "../lib/sanitize"; +import { emitExecutiveMeetingsDayChanged } from "../lib/realtime"; import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod"; import type { Request, Response, NextFunction } from "express"; @@ -780,6 +781,9 @@ router.patch( }); }); const updated = await fetchMeetingWithAttendees(id); + void emitExecutiveMeetingsDayChanged( + updated?.meetingDate ?? existing.meetingDate, + ); res.json(updated); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 9ed1baf0..ce448522 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -71,6 +71,21 @@ export function useNotificationsSocket() { queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); }); + socket.on( + "executive_meetings_changed", + (payload?: { date?: string }) => { + if (typeof payload?.date === "string" && payload.date.length > 0) { + queryClient.invalidateQueries({ + queryKey: ["/api/executive-meetings", payload.date], + }); + } else { + queryClient.invalidateQueries({ + queryKey: ["/api/executive-meetings"], + }); + } + }, + ); + socket.on( "role_permissions_changed", (payload?: { roleId?: number }) => { diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index a31da44a..d6c160cb 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -1879,7 +1879,15 @@ function MeetingRow({
{canMutate && ( @@ -2155,7 +2163,7 @@ function MergeMenu({