Task #207: custom subheadings inside executive-meeting attendee cells

Adds a new `kind` column (`varchar(16) NOT NULL DEFAULT 'person'`) on
`executive_meeting_attendees` so users can interleave free-text section
labels with person rows in a meeting's attendee list. Subheadings are
excluded from the running attendee number and from the per-meeting
attendee count surface, but reorder and delete identically to person
rows.

DB
- New `kind` column in `lib/db/src/schema/executive-meetings.ts`,
  defaulting to `"person"`. Applied via direct SQL because
  `drizzle-kit push` trips on a pre-existing duplicate-row issue in
  `app_permissions` (already documented in replit.md).

API (artifacts/api-server/src/routes/executive-meetings.ts)
- `attendeeSchema` accepts `kind: z.enum(["person","subheading"])` with
  default `"person"`.
- All 4 insert paths round-trip `kind`: POST create, PATCH meeting
  update (attendees replacement), PUT `/attendees`, and duplicate.
- `pdf-renderer` mapper forwards `kind`. `PdfMeetingAttendee.kind`
  typed as `string | null` to match the DB column shape.

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx)
- `AttendeeFlow` renders subheadings on a separate full-width row
  (`basis-full`, semibold, centered) and increments the running
  person index only for `kind === "person"`. Pending ghost row branches
  on `pendingKind`.
- Inline "+ subheading" chip via `onStartAdd(type, "subheading")`.
- Manage dialog: addSubheading button, subheading rows hide the title
  field, show a kind badge, and reorder/delete identically. Manage
  list summary count filters to person rows only (architect fix).
- Print page renders subheadings as `.em-print-subheading` and skips
  them in the running counter.
- New locale keys under `executiveMeetings.schedule` and
  `executiveMeetings.manage.attendees` in both `ar.json` and `en.json`.

PDF
- Subheadings print as `— label —` and never advance `personIdx`.

Tests
- New spec `executive-meetings-attendee-subheadings.spec.mjs` seeds
  mixed person+subheading rows and asserts (a) the subheading row
  renders, (b) numbering stays `1-`, `2-`, `3-` with a subheading
  wedged between persons, (c) zero-subheading meetings keep legacy
  numbering. Runs in both AR and EN. All 4 cases pass.

Code review
- Architect found one regression (Manage list summary count included
  subheadings) — fixed.
This commit is contained in:
Riyadh
2026-04-30 11:45:59 +00:00
parent ebd553b84a
commit c986d74f37
8 changed files with 804 additions and 168 deletions
+28 -7
View File
@@ -19,6 +19,12 @@ export type PdfMeetingAttendee = {
name: string;
title: string | null;
attendanceType?: string | null;
// "person" (default) or "subheading". The renderer skips subheadings
// for the running attendee number and prints them as "— label —".
// Typed as a plain string (rather than the literal union) because the
// DB column is just a varchar; the renderer normalises unknown values
// back to "person" at the comparison site.
kind?: string | null;
};
export type PdfMeeting = {
@@ -524,13 +530,28 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer>
text:
meeting.attendees.length === 0
? "—"
: meeting.attendees
.map((a, idx) => {
const name = htmlToPlain(a.name);
const t = a.title?.trim();
return `${idx + 1}- ${name}${t ? ` (${t})` : ""}`;
})
.join("\n"),
: (() => {
// Walking person counter — subheadings are non-numbered
// labels, so they don't advance the index. Same logic as
// the on-screen AttendeeFlow and the print page.
let personIdx = 0;
return meeting.attendees
.map((a) => {
const isSub = (a.kind ?? "person") === "subheading";
const name = htmlToPlain(a.name);
if (isSub) {
// Brackets keep subheadings visually distinct in the
// monospace plain-text PDF cell where bold/colour
// can't carry. Adapter prefix is non-numeric so a
// human reader can scan it as "section label".
return `${name}`;
}
personIdx += 1;
const t = a.title?.trim();
return `${personIdx}- ${name}${t ? ` (${t})` : ""}`;
})
.join("\n");
})(),
align: alignment(input.font, isRtl),
},
{
@@ -61,6 +61,13 @@ 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;
// "person" is the default for backwards compatibility — every existing row
// is backfilled to this kind. "subheading" rows are user-defined section
// labels that live in the same attendees array as persons but must be
// excluded from numbering, attendee counts, and virtual-platform-name
// extraction. They share `attendance_type` and `sort_order` with persons
// so a subheading "belongs" to exactly one attendance-type group.
const ATTENDEE_KINDS = ["person", "subheading"] 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.
@@ -192,10 +199,16 @@ const positiveIntSchema = z.number().int().positive();
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
// sanitizer strips disallowed tags/attrs before persistence.
const attendeeSchema = z.object({
// For persons this is sanitized rich-text HTML (Tiptap output).
// For subheadings this is the user's free-text label, also passed
// through sanitizeRichText to strip any sneaky markup. Both kinds
// require at least one non-whitespace character; an empty string is
// a delete-row gesture in the inline editor and must never reach the DB.
name: z.string().trim().min(1).max(5000),
title: z.string().trim().max(200).nullable().optional(),
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
sortOrder: z.number().int().min(0).optional(),
kind: z.enum(ATTENDEE_KINDS).default("person"),
});
const meetingBaseFields = {
@@ -674,6 +687,7 @@ router.post(
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
kind: a.kind,
})),
);
}
@@ -792,6 +806,7 @@ router.patch(
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
kind: a.kind,
})),
);
}
@@ -891,6 +906,7 @@ router.put(
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
kind: a.kind,
})),
);
}
@@ -965,6 +981,9 @@ router.post(
title: sanitizePlainTextOrNull(a.title),
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
// Carry custom subheadings into the duplicated meeting so the
// user does not lose their group structure on duplicate.
kind: a.kind,
})),
);
}
@@ -2307,6 +2326,9 @@ router.get(
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
// Forward the row kind so the renderer can render subheadings
// as labels rather than numbered attendees.
kind: a.kind,
})),
})),
});