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);
|
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
|
* Notify every holder of `roleId` that the role's permission set has been
|
||||||
* updated, so the client can invalidate cached `/api/auth/me` and
|
* updated, so the client can invalidate cached `/api/auth/me` and
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
getEffectiveRoleIds,
|
getEffectiveRoleIds,
|
||||||
} from "../middlewares/auth";
|
} from "../middlewares/auth";
|
||||||
import { sanitizeRichText } from "../lib/sanitize";
|
import { sanitizeRichText } from "../lib/sanitize";
|
||||||
|
import { emitExecutiveMeetingsDayChanged } from "../lib/realtime";
|
||||||
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
|
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
@@ -780,6 +781,9 @@ router.patch(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const updated = await fetchMeetingWithAttendees(id);
|
const updated = await fetchMeetingWithAttendees(id);
|
||||||
|
void emitExecutiveMeetingsDayChanged(
|
||||||
|
updated?.meetingDate ?? existing.meetingDate,
|
||||||
|
);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ export function useNotificationsSocket() {
|
|||||||
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
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(
|
socket.on(
|
||||||
"role_permissions_changed",
|
"role_permissions_changed",
|
||||||
(payload?: { roleId?: number }) => {
|
(payload?: { roleId?: number }) => {
|
||||||
|
|||||||
@@ -1879,7 +1879,15 @@ function MeetingRow({
|
|||||||
<td
|
<td
|
||||||
key="number"
|
key="number"
|
||||||
className={`relative group ${numberCellCls}`}
|
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">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{canMutate && (
|
{canMutate && (
|
||||||
@@ -2155,7 +2163,7 @@ function MergeMenu({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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")}
|
aria-label={t("executiveMeetings.merge.label")}
|
||||||
data-testid={`em-merge-trigger-${meetingId}`}
|
data-testid={`em-merge-trigger-${meetingId}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user