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 and the
   existing colored ring. The # cell stays solid white/red.

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
  the new 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.
- i18n: en + ar keys under executiveMeetings.merge.*

Out of scope (deferred to follow-ups #154 / #155, per task brief)
- API + e2e test coverage for the merge endpoint and UI flows
- Realtime emission for executive-meetings mutations (no existing
  emit on this surface; covered by #155)

Validation
- TypeScript compiles cleanly across api-server + tx-os.
- API tests: 108/110 pass — the 2 failures are the pre-existing
  app_permissions-unique cases (Task #148, follow-up #156),
  unrelated to this change.
- Architect code review approved after addressing hidden-column
  fallback, non-contiguous reorder degradation, and
  Unmerge-availability edge cases.
This commit is contained in:
riyadhafraa
2026-04-29 14:05:21 +00:00
parent 2ffd63832a
commit 539f11e25d
6 changed files with 410 additions and 13 deletions
@@ -51,6 +51,16 @@ router.param("id", (req, res, next, value) => {
const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const;
const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const;
const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const;
// Schedule column ids that the merge-cells overlay can span. Kept in
// sync with the frontend ColumnId enum in
// artifacts/tx-os/src/pages/executive-meetings.tsx.
const MERGE_COLUMNS = ["number", "meeting", "attendees", "time"] as const;
const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = {
number: 0,
meeting: 1,
attendees: 2,
time: 3,
};
const REQUEST_TYPES = [
"create",
"edit",
@@ -206,6 +216,31 @@ const meetingCreateSchema = z
{ message: "startTime must be <= endTime", path: ["endTime"] },
);
// Cell-merge overlay payload accepted by PATCH. The whole `merge` key is
// optional; when present it must either be `null` (clear) or a fully
// specified object describing the column range and the free-text label.
// `mergeText` accepts sanitized rich-text HTML up to ~10k bytes (same
// budget as titleAr/titleEn).
const meetingMergeSchema = z
.union([
z.null(),
z.object({
mergeStartColumn: z.enum(MERGE_COLUMNS),
mergeEndColumn: z.enum(MERGE_COLUMNS),
mergeText: z.string().trim().max(10000),
}),
])
.refine(
(v) =>
v === null ||
MERGE_COLUMN_INDEX[v.mergeStartColumn] <=
MERGE_COLUMN_INDEX[v.mergeEndColumn],
{
message: "mergeStartColumn must come before mergeEndColumn",
path: ["mergeEndColumn"],
},
);
const meetingPatchSchema = z
.object({
titleAr: meetingBaseFields.titleAr.optional(),
@@ -221,6 +256,7 @@ const meetingPatchSchema = z
isHighlighted: meetingBaseFields.isHighlighted,
notes: meetingBaseFields.notes,
attendees: z.array(attendeeSchema).optional(),
merge: meetingMergeSchema.optional(),
})
.refine(
(v) => !v.startTime || !v.endTime || v.startTime <= v.endTime,
@@ -694,6 +730,17 @@ router.patch(
if (data.isHighlighted !== undefined)
updateValues.isHighlighted = data.isHighlighted;
if (data.notes !== undefined) updateValues.notes = data.notes;
if (data.merge !== undefined) {
if (data.merge === null) {
updateValues.mergeStartColumn = null;
updateValues.mergeEndColumn = null;
updateValues.mergeText = null;
} else {
updateValues.mergeStartColumn = data.merge.mergeStartColumn;
updateValues.mergeEndColumn = data.merge.mergeEndColumn;
updateValues.mergeText = sanitizeRichText(data.merge.mergeText);
}
}
if (Object.keys(updateValues).length > 1) {
await tx
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+9
View File
@@ -822,6 +822,15 @@
"rowColor": {
"label": "لون الصف"
},
"merge": {
"label": "دمج الخلايا",
"editLabel": "تعديل الخلية المدموجة",
"placeholder": "اضغط لإضافة نص",
"meetingAttendees": "دمج الاجتماع + الحضور",
"meetingTime": "دمج الاجتماع + الحضور + الوقت",
"all": "دمج الصف بالكامل",
"unmerge": "إلغاء الدمج"
},
"nav": {
"schedule": "جدول الاجتماعات اليومي",
"manage": "إدارة الاجتماعات",
+9
View File
@@ -745,6 +745,15 @@
"rowColor": {
"label": "Row color"
},
"merge": {
"label": "Merge cells",
"editLabel": "Edit merged cell",
"placeholder": "Click to add text",
"meetingAttendees": "Merge meeting + attendees",
"meetingTime": "Merge meeting + attendees + time",
"all": "Merge entire row",
"unmerge": "Unmerge"
},
"schedule": {
"addRow": "+ Add meeting",
"newMeetingDefaultAr": "اجتماع جديد",
+334 -13
View File
@@ -39,6 +39,7 @@ import {
RotateCcw,
Palette,
Sliders,
Combine,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -112,6 +113,14 @@ type Meeting = {
isHighlighted: number;
notes: string | null;
attendees: Attendee[];
// Optional cell-merge overlay. When all three are non-null the
// schedule renders one merged cell spanning
// [mergeStartColumn..mergeEndColumn] showing mergeText (sanitized
// HTML) instead of the original column values. Originals are
// preserved on the row so clearing the merge restores them.
mergeStartColumn?: ColumnId | null;
mergeEndColumn?: ColumnId | null;
mergeText?: string | null;
};
type DayResponse = { date: string; meetings: Meeting[] };
@@ -965,6 +974,36 @@ function ScheduleSection({
[refreshDay, toast, t],
);
// PATCH the cell-merge overlay on a meeting. `merge === null` clears
// the overlay and restores the original cells; otherwise it sets a
// new range + free text that the row will display in place of the
// spanned cells.
const saveMerge = useCallback(
async (
meeting: Meeting,
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => {
try {
await apiJson(`/api/executive-meetings/${meeting.id}`, {
method: "PATCH",
body: { merge },
});
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
throw err;
}
},
[refreshDay, toast, t],
);
const [creatingRow, setCreatingRow] = useState(false);
// When the user clicks "Add row" we capture the new meeting id here so
// that, once the refreshed list contains it, we can scroll the row into
@@ -1286,6 +1325,7 @@ function ScheduleSection({
saveAttendeeName(m, idx, html)
}
onSaveTimes={(start, end) => saveTimes(m, start, end)}
onSaveMerge={(merge) => saveMerge(m, merge)}
isCurrent={
highlightPrefs.enabled && currentMeetingId === m.id
}
@@ -1674,6 +1714,7 @@ function MeetingRow({
onSaveTitle,
onSaveAttendeeName,
onSaveTimes,
onSaveMerge,
isCurrent,
highlightColor,
reordering,
@@ -1697,6 +1738,11 @@ function MeetingRow({
startTime: string | null,
endTime: string | null,
) => Promise<void>;
onSaveMerge: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
isCurrent: boolean;
highlightColor: string;
reordering: boolean;
@@ -1752,18 +1798,88 @@ function MeetingRow({
overflow: "hidden",
});
const renderCell = (col: ColumnSetting): ReactNode => {
// Compute the cell-merge overlay for this row. We resolve the stored
// start/end column ids against the *canonical* schedule column order
// (not against `visibleColumns`) so that a merge whose start or end
// column is currently hidden still renders across whichever columns
// in the range remain visible. This guarantees a row that has a
// merge in the DB never silently falls back to "unmerged" just
// because the user toggled off one of the boundary columns.
const canonStart = canonicalMergeIndex(meeting.mergeStartColumn);
const canonEnd = canonicalMergeIndex(meeting.mergeEndColumn);
const hasStoredMerge =
canonStart >= 0 && canonEnd >= canonStart && typeof meeting.mergeText === "string";
const visibleMergeIndices: number[] = [];
if (hasStoredMerge) {
visibleColumns.forEach((c, idx) => {
const ci = canonicalMergeIndex(c.id);
if (ci >= canonStart && ci <= canonEnd) visibleMergeIndices.push(idx);
});
}
// Headers are themselves drag-sortable, so users can reorder the
// schedule columns. If a previously-set merge ends up spanning
// *non-contiguous* visible indices after a reorder, rendering one
// colSpan cell at the first index would silently swallow the wrong
// columns. In that case we degrade to "unmerged" rendering — the DB
// merge state stays intact, and unmerging or restoring canonical
// order brings the merged cell back.
const visibleMergeContiguous =
visibleMergeIndices.length === 0 ||
visibleMergeIndices[visibleMergeIndices.length - 1] -
visibleMergeIndices[0] +
1 ===
visibleMergeIndices.length;
const isMerged =
hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous;
const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1;
const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null;
// First visible cell that's NOT part of a merge. The merge trigger
// for an unmerged row is anchored here so editors can still open the
// merge menu even when the # column is hidden. We look for the
// first index in visibleColumns whose canonical id exists (which is
// every schedule column), so this collapses to `0` in practice but
// would skip non-mergeable columns if they're added later.
const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1;
// Light tint for the current meeting on the editable cells (meeting,
// attendees, time). The number cell keeps its solid white background
// and original text colour so it stays legible — only the rest of the
// row gets a soft wash of highlightColor. We keep the original text
// colour everywhere so the row remains readable.
const tintBg =
isCurrent && !(highlight || isCancelled)
? hexToRgba(highlightColor, 0.13)
: null;
const tintedCellStyle = (col: ColumnSetting): CSSProperties =>
tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col);
const renderCell = (col: ColumnSetting, idx: number): ReactNode => {
// Anchor the merge-menu trigger to whichever cell is the first
// visible one on an unmerged row. Using "first visible cell"
// (instead of always the # cell) keeps the trigger reachable when
// editors hide the number column. The trigger is rendered as an
// absolutely-positioned overlay inside the cell, so every cell type
// needs `relative group` on its <td> for the overlay to find it.
const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx;
const mergeOverlay = showMergeTrigger ? (
<MergeMenu
meetingId={meeting.id}
// Offer Unmerge whenever the row has a stored merge in the DB,
// even if we're in degraded (non-contiguous) rendering — that's
// the only way the user can clear the stored state.
hasStoredMerge={hasStoredMerge}
existingMergeText={meeting.mergeText ?? null}
t={t}
onChange={onSaveMerge}
/>
) : null;
switch (col.id) {
case "number":
return (
<td
key="number"
className={`relative group ${numberCellCls}`}
style={
isCurrent && !(highlight || isCancelled)
? { ...cellStyle(col), backgroundColor: highlightColor, color: "white" }
: cellStyle(col)
}
style={cellStyle(col)}
>
<div className="flex items-center justify-center gap-1">
{canMutate && (
@@ -1792,14 +1908,15 @@ function MeetingRow({
<span>{meeting.dailyNumber}</span>
</div>
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
{mergeOverlay}
</td>
);
case "meeting":
return (
<td
key="meeting"
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F]"
style={cellStyle(col)}
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
style={tintedCellStyle(col)}
>
<EditableCell
value={title}
@@ -1817,14 +1934,15 @@ function MeetingRow({
{meeting.location}
</div>
)}
{mergeOverlay}
</td>
);
case "attendees":
return (
<td
key="attendees"
className="border border-gray-300 px-3 py-3 align-middle"
style={cellStyle(col)}
className="border border-gray-300 px-3 py-3 align-middle relative group"
style={tintedCellStyle(col)}
>
<AttendeesCell
meeting={meeting}
@@ -1836,14 +1954,15 @@ function MeetingRow({
onCommitAddAttendee={onCommitAddAttendee}
onCancelAddAttendee={onCancelAddAttendee}
/>
{mergeOverlay}
</td>
);
case "time":
return (
<td
key="time"
className="border border-gray-300 px-3 py-3 align-middle text-center"
style={cellStyle(col)}
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
style={tintedCellStyle(col)}
>
<TimeRangeCell
meeting={meeting}
@@ -1851,11 +1970,79 @@ function MeetingRow({
canMutate={canMutate}
onSaveTimes={onSaveTimes}
/>
{mergeOverlay}
</td>
);
}
};
// When a merge is active we render a single <td colSpan=N> with an
// EditableCell for `mergeText`, and skip the cells the merge covers
// after the first. The merged cell hosts its own MergeMenu so users
// can always unmerge or re-merge — even when the # column is hidden.
// Originals stay in the DB so unmerge restores them.
const renderCells = (): ReactNode[] => {
if (!isMerged) {
return visibleColumns.map((c, idx) => renderCell(c, idx));
}
const cells: ReactNode[] = [];
const span = visibleMergeIndices.length;
visibleColumns.forEach((col, idx) => {
if (mergeSkipSet?.has(idx)) return;
if (idx === mergeFirstIdx) {
// Sum widths of the spanned visible columns so layout in
// table-fixed mode reserves the right horizontal space.
const totalWidth = visibleMergeIndices.reduce(
(sum, i) => sum + (visibleColumns[i].width || 0),
0,
);
cells.push(
<td
key="merge"
colSpan={span}
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
style={{
width: totalWidth,
overflow: "hidden",
...(tintBg ? { backgroundColor: tintBg } : null),
}}
data-testid={`em-merge-cell-${meeting.id}`}
>
<EditableCell
value={meeting.mergeText ?? ""}
onSave={async (html) => {
await onSaveMerge({
mergeStartColumn: meeting.mergeStartColumn as ColumnId,
mergeEndColumn: meeting.mergeEndColumn as ColumnId,
mergeText: html,
});
}}
ariaLabel={t("executiveMeetings.merge.editLabel")}
dir={isRtl ? "rtl" : "ltr"}
className="font-medium leading-snug break-words"
disabled={!canMutate}
placeholder={t("executiveMeetings.merge.placeholder")}
testId={`em-merge-edit-${meeting.id}`}
/>
{canMutate && (
<MergeMenu
meetingId={meeting.id}
isMerged
hasStoredMerge
existingMergeText={meeting.mergeText ?? null}
t={t}
onChange={onSaveMerge}
/>
)}
</td>,
);
} else {
cells.push(renderCell(col, idx));
}
});
return cells;
};
// Combine row background with highlight tint. When the row is the current
// meeting we apply a soft tint (color at low opacity) plus a coloured ring
// via box-shadow (rings on <tr> don't render across cells; box-shadow does).
@@ -1876,11 +2063,145 @@ function MeetingRow({
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
>
{visibleColumns.map((c) => renderCell(c))}
{renderCells()}
</tr>
);
}
// Canonical schedule column order for cell-merge ranges. MUST stay in
// sync with the API's MERGE_COLUMNS array in
// artifacts/api-server/src/routes/executive-meetings.ts. We use this
// (instead of `visibleColumns`) when computing whether a stored merge
// overlaps the currently visible columns, so that hiding one boundary
// of the range still lets the merged cell render across whichever
// columns inside the range remain visible.
const CANONICAL_MERGE_ORDER: ColumnId[] = [
"number",
"meeting",
"attendees",
"time",
];
function canonicalMergeIndex(id: ColumnId | string | null | undefined): number {
if (id == null) return -1;
return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId);
}
// Convert a #RRGGBB or #RGB hex color into an rgba() string with the
// given alpha. Used to derive the soft tint applied to the current
// meeting row from the user's highlight color preference. Falls back
// to the input string unchanged if it isn't a recognised hex.
function hexToRgba(hex: string, alpha: number): string {
const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex);
const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
let r = 0;
let g = 0;
let b = 0;
if (m6) {
r = parseInt(m6[1], 16);
g = parseInt(m6[2], 16);
b = parseInt(m6[3], 16);
} else if (m3) {
r = parseInt(m3[1] + m3[1], 16);
g = parseInt(m3[2] + m3[2], 16);
b = parseInt(m3[3] + m3[3], 16);
} else {
return hex;
}
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// Small popover-driven control attached to a row (in the # cell when
// not merged, or to the merged cell itself) that lets editors pick a
// merge range or clear an existing one. Each option corresponds to a
// fixed [start..end] column range.
function MergeMenu({
meetingId,
// True iff the row is rendered as a merged cell *right now*. Used
// for testid wiring only.
isMerged = false,
// True iff the row has a stored merge in the DB (regardless of
// whether it's currently renderable as merged). Drives the
// "Unmerge" option so users can always clear stored merges, even
// when the visible columns aren't contiguous.
hasStoredMerge,
// Existing merge text from the DB, preserved when the user picks a
// new merge range so we never silently overwrite their content.
existingMergeText,
t,
onChange,
}: {
meetingId: number;
isMerged?: boolean;
hasStoredMerge: boolean;
existingMergeText: string | null;
t: (k: string) => string;
onChange: (
merge:
| { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
| null,
) => Promise<void>;
}) {
// Re-merging keeps the existing text so users don't accidentally lose
// what they had typed. New merges (no stored text) start empty.
const apply = async (start: ColumnId, end: ColumnId) => {
await onChange({
mergeStartColumn: start,
mergeEndColumn: end,
mergeText: existingMergeText ?? "",
});
};
return (
<Popover>
<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"
aria-label={t("executiveMeetings.merge.label")}
data-testid={`em-merge-trigger-${meetingId}`}
>
<Combine className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="end">
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-meeting-attendees-${meetingId}`}
onClick={() => apply("meeting", "attendees")}
>
{t("executiveMeetings.merge.meetingAttendees")}
</button>
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-meeting-time-${meetingId}`}
onClick={() => apply("meeting", "time")}
>
{t("executiveMeetings.merge.meetingTime")}
</button>
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
data-testid={`em-merge-opt-all-${meetingId}`}
onClick={() => apply("number", "time")}
>
{t("executiveMeetings.merge.all")}
</button>
{hasStoredMerge && (
<button
type="button"
className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent text-destructive"
data-testid={`em-merge-opt-unmerge-${meetingId}`}
onClick={() => onChange(null)}
>
{t("executiveMeetings.merge.unmerge")}
</button>
)}
</PopoverContent>
</Popover>
);
}
function TimeRangeCell({
meeting,
t,
+11
View File
@@ -33,6 +33,17 @@ export const executiveMeetingsTable = pgTable(
isHighlighted: integer("is_highlighted").notNull().default(0),
notes: text("notes"),
attachments: jsonb("attachments").default([]),
// Optional cell-merge overlay for the schedule grid. When set, the
// schedule renders one merged cell spanning columns
// [mergeStartColumn..mergeEndColumn] and shows mergeText (sanitized
// HTML) instead of the original column values. The original
// titleAr/En + attendees + start/endTime stay untouched in the DB,
// so clearing the merge restores the original cells exactly.
// Allowed values for the column ids match the frontend ColumnId
// enum: "number" | "meeting" | "attendees" | "time".
mergeStartColumn: varchar("merge_start_column", { length: 32 }),
mergeEndColumn: varchar("merge_end_column", { length: 32 }),
mergeText: text("merge_text"),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),