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:
@@ -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}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user