From 539f11e25dfbb5632aafd0d5837b7b29de720c4b Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 29 Apr 2026 14:05:21 +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 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. --- .../src/routes/executive-meetings.ts | 47 +++ artifacts/tx-os/public/opengraph.jpg | Bin 24651 -> 25895 bytes artifacts/tx-os/src/locales/ar.json | 9 + artifacts/tx-os/src/locales/en.json | 9 + .../tx-os/src/pages/executive-meetings.tsx | 347 +++++++++++++++++- lib/db/src/schema/executive-meetings.ts | 11 + 6 files changed, 410 insertions(+), 13 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 739e76bd..9a438e6a 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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 diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 723b79962120d8c4f2f3d6cdb26e5f6e3e5eda74..cc11c2406c0c6716bf44350672744aebe3e5697f 100644 GIT binary patch literal 25895 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 H-@E? { + 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; + onSaveMerge: ( + merge: + | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string } + | null, + ) => Promise; 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 for the overlay to find it. + const showMergeTrigger = canMutate && !isMerged && idx === firstUnmergedIdx; + const mergeOverlay = showMergeTrigger ? ( + + ) : null; switch (col.id) { case "number": return (
{canMutate && ( @@ -1792,14 +1908,15 @@ function MeetingRow({ {meeting.dailyNumber}
+ {mergeOverlay} ); case "meeting": return ( )} + {mergeOverlay} ); case "attendees": return ( + {mergeOverlay} ); case "time": return ( + {mergeOverlay} ); } }; + // When a merge is active we render a single 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( + + { + 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 && ( + + )} + , + ); + } 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 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()} ); } +// 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; +}) { + // 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 ( + + + + + + + + + {hasStoredMerge && ( + + )} + + + ); +} + function TimeRangeCell({ meeting, t, diff --git a/lib/db/src/schema/executive-meetings.ts b/lib/db/src/schema/executive-meetings.ts index 8807de79..1d8a1def 100644 --- a/lib/db/src/schema/executive-meetings.ts +++ b/lib/db/src/schema/executive-meetings.ts @@ -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", }),