Task #152: cell-merge + current-meeting tint on EM schedule

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.
This commit is contained in:
riyadhafraa
2026-04-29 14:13:07 +00:00
parent 539f11e25d
commit 43aa0c72a0
4 changed files with 45 additions and 2 deletions
+16
View File
@@ -51,6 +51,22 @@ export async function emitAppsChangedToRoleHolders(roleId: number): Promise<void
await emitAppsChangedToUsers(userIds);
}
/**
* Broadcast that an executive-meetings day has changed (a meeting was
* created/updated/deleted/duplicated, attendees changed, or a cell-merge
* was set/cleared). Listeners on the executive-meetings page invalidate
* their cached query for the affected day so all viewers stay in sync
* without a manual refresh. The payload is just the ISO date string —
* no PII — and clients without executive access simply ignore it.
*/
export async function emitExecutiveMeetingsDayChanged(
date: string | null | undefined,
): Promise<void> {
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
@@ -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);
@@ -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 }) => {
@@ -1879,7 +1879,15 @@ function MeetingRow({
<td
key="number"
className={`relative group ${numberCellCls}`}
style={cellStyle(col)}
style={
tintBg
? {
...cellStyle(col),
backgroundColor: highlightColor,
color: "white",
}
: cellStyle(col)
}
>
<div className="flex items-center justify-center gap-1">
{canMutate && (
@@ -2155,7 +2163,7 @@ function MergeMenu({
<PopoverTrigger asChild>
<button
type="button"
className="absolute bottom-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/70 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity print:hidden"
className="absolute bottom-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/70 opacity-0 group-hover:opacity-100 focus:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 transition-opacity print:hidden"
aria-label={t("executiveMeetings.merge.label")}
data-testid={`em-merge-trigger-${meetingId}`}
>