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