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:
Riyadh
2026-04-29 14:05:21 +00:00
parent ec2797bba3
commit 19c8c146c0
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