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:
riyadhafraa
2026-04-30 11:45:59 +00:00
parent 986bb8e7b2
commit 89df2385a8
9 changed files with 808 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,
})),
})),
});
+6
View File
@@ -883,11 +883,14 @@
"timeEnd": "وقت النهاية",
"timeOrderError": "وقت النهاية قبل وقت البداية",
"addAttendee": "أضف حاضر",
"addSubheading": "+ عنوان فرعي",
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
"addInternalAttendee": "+ حاضر داخلي",
"addExternalAttendee": "+ حاضر خارجي",
"newAttendeeAria": "اسم الحاضر الجديد",
"editAttendeeAria": "تعديل اسم الحاضر",
"newSubheadingAria": "نص العنوان الفرعي الجديد",
"editSubheadingAria": "تعديل نص العنوان الفرعي",
"deleteRow": "حذف الاجتماع",
"deleteRowConfirm": "حذف هذا الاجتماع؟\n\n\"{{title}}\"\n\nلا يمكن التراجع عن هذا الإجراء.",
"deleted": "تم حذف الاجتماع",
@@ -983,6 +986,9 @@
"id": "رقم الحاضر",
"type": "نوع الحضور",
"add": "إضافة حاضر",
"addSubheading": "إضافة عنوان فرعي",
"subheadingName": "نص العنوان الفرعي",
"subheadingBadge": "عنوان فرعي",
"remove": "إزالة",
"moveUp": "تحريك للأعلى",
"moveDown": "تحريك للأسفل",
+6
View File
@@ -836,11 +836,14 @@
"timeEnd": "End time",
"timeOrderError": "End time is before start time",
"addAttendee": "Add attendee",
"addSubheading": "+ subheading",
"addVirtualAttendee": "+ Virtual",
"addInternalAttendee": "+ Internal",
"addExternalAttendee": "+ External",
"newAttendeeAria": "New attendee name",
"editAttendeeAria": "Edit attendee name",
"newSubheadingAria": "New subheading label",
"editSubheadingAria": "Edit subheading label",
"deleteRow": "Delete meeting",
"deleteRowConfirm": "Delete this meeting?\n\n\"{{title}}\"\n\nThis cannot be undone.",
"deleted": "Meeting deleted",
@@ -920,6 +923,9 @@
"id": "Attendee ID",
"type": "Attendance type",
"add": "Add attendee",
"addSubheading": "Add subheading",
"subheadingName": "Subheading label",
"subheadingBadge": "Subheading",
"remove": "Remove",
"moveUp": "Move up",
"moveDown": "Move down",
@@ -26,6 +26,9 @@ type Attendee = {
title: string | null;
attendanceType: "internal" | "external" | "online";
sortOrder: number;
// Optional so older API snapshots without the column still parse;
// missing values are treated as "person" downstream.
kind?: "person" | "subheading";
};
type Meeting = {
@@ -173,6 +176,19 @@ export default function ExecutiveMeetingsPrintPage() {
color: #6b7280;
margin-inline-end: 4px;
}
/* Subheading rows: bold separator labels between persons. */
.em-print-attendees .em-print-subheading {
font-weight: 700;
color: #0B1E3F;
margin-top: 4px;
padding-top: 2px;
border-top: 1px dashed #cbd5e1;
}
.em-print-attendees .em-print-subheading:first-child {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
`}</style>
<div className="no-print flex items-center justify-between px-6 py-3 border-b border-gray-200 bg-gray-50">
@@ -258,26 +274,53 @@ export default function ExecutiveMeetingsPrintPage() {
className="em-print-attendees"
data-testid={`em-print-attendees-${m.id}`}
>
{attendees.map((a, idx) => (
<div key={a.id ?? idx}>
<span className="em-print-attendee-num">
{idx + 1}-
</span>
{/* a.name comes from the API already
sanitized by sanitizeRichText. a.title
is *not* rich text — render it as a
plain text node so an attacker who
smuggles HTML into the title field
cannot escape into <script> tags on
the print page. */}
<span
dangerouslySetInnerHTML={{
__html: safeHtml(a.name),
}}
/>
{a.title ? <span> ({a.title})</span> : null}
</div>
))}
{(() => {
// Walking person counter — subheadings are
// labels, so they don't advance the print
// index. Mirrors AttendeeFlow on screen.
let personIdx = 0;
return attendees.map((a, idx) => {
const isSub =
(a.kind ?? "person") === "subheading";
if (isSub) {
return (
<div
key={a.id ?? `sub-${idx}`}
className="em-print-subheading"
data-testid={`em-print-subheading-${m.id}-${idx}`}
>
<span
dangerouslySetInnerHTML={{
__html: safeHtml(a.name),
}}
/>
</div>
);
}
const myIdx = personIdx;
personIdx += 1;
return (
<div key={a.id ?? idx}>
<span className="em-print-attendee-num">
{myIdx + 1}-
</span>
{/* a.name comes from the API already
sanitized by sanitizeRichText.
a.title is *not* rich text —
render it as a plain text node so
an attacker who smuggles HTML into
the title field cannot escape into
<script> tags on the print page. */}
<span
dangerouslySetInnerHTML={{
__html: safeHtml(a.name),
}}
/>
{a.title ? <span> ({a.title})</span> : null}
</div>
);
});
})()}
</div>
)}
</td>
+371 -141
View File
@@ -95,10 +95,21 @@ import { formatTime as i18nFormatTime } from "@/lib/i18n-format";
type Attendee = {
id?: number;
meetingId?: number;
// For persons: sanitized rich-text HTML name (Tiptap output).
// For subheadings: the user's free-text section label.
name: string;
title: string | null;
attendanceType: "internal" | "virtual" | "external";
sortOrder: number;
// "person" — a normal attendee row (rendered in AttendeeFlow with a
// running "1-, 2-…" index that skips subheadings).
// "subheading" — a user-defined label that lives between persons inside
// the same attendance-type group. Excluded from numbering,
// attendee count, and virtual-platform-name extraction.
// Optional in the type so older snapshots and any in-flight code paths
// that don't set it explicitly default to "person" — same as the DB and
// Zod defaults.
kind?: "person" | "subheading";
};
type Meeting = {
@@ -900,7 +911,8 @@ function ScheduleSection({
// Detect "truly empty" content: Tiptap empty paragraphs, &nbsp;, or
// pure whitespace. When the attendee's name is cleared we treat the
// edit as a delete-row gesture and remove the attendee instead of
// saving an empty record.
// saving an empty record. The same gesture deletes a custom
// subheading row (clear the label text → row disappears on blur).
const isEmpty =
html
.replace(/<[^>]*>/g, "")
@@ -926,6 +938,9 @@ function ScheduleSection({
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder,
// Round-trip the row kind so an inline edit of a person row
// can't silently demote a sibling subheading back to default.
kind: a.kind ?? "person",
})),
},
});
@@ -956,9 +971,12 @@ function ScheduleSection({
// We allow at most one pending attendee at a time across the whole
// page — the AttendeesCell hides "+" buttons elsewhere while a ghost
// row is open so the user can't accidentally orphan their typing.
// The same ghost is reused for "+ subheading" (kind = "subheading")
// so the one-at-a-time rule covers both gestures uniformly.
type PendingAttendee = {
meetingId: number;
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
// Bumped each time the user clicks "+" so React remounts the ghost
// EditableCell (giving it a fresh editor and focus).
key: number;
@@ -978,14 +996,18 @@ function ScheduleSection({
}, [effectiveCanMutate, pendingAttendee]);
const startAddAttendee = useCallback(
(meetingId: number, attendanceType: Attendee["attendanceType"]) => {
(
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind: "person" | "subheading" = "person",
) => {
// Only one ghost row may be open at a time across the whole page.
// We refuse a new start while another is pending — the user must
// first commit (blur with text) or cancel (Escape / blur empty).
// The "+" buttons are already hidden in this state, but a stale
// click that beats the re-render is still possible.
setPendingAttendee((prev) =>
prev ? prev : { meetingId, attendanceType, key: Date.now() },
prev ? prev : { meetingId, attendanceType, kind, key: Date.now() },
);
},
[],
@@ -1000,6 +1022,7 @@ function ScheduleSection({
meeting: Meeting,
attendanceType: Attendee["attendanceType"],
html: string,
kind: "person" | "subheading" = "person",
) => {
// Always clear pending — whether we end up persisting or not.
setPendingAttendee(null);
@@ -1020,12 +1043,14 @@ function ScheduleSection({
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder,
kind: a.kind ?? "person",
})),
{
name: html,
title: null as string | null,
attendanceType,
sortOrder: maxSort + 1,
kind,
},
];
try {
@@ -2372,15 +2397,18 @@ function MeetingRow({
pendingAttendee?: {
meetingId: number;
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
key: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind?: "person" | "subheading",
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
) => Promise<void>;
onCancelAddAttendee?: () => void;
bulkSelectable: boolean;
@@ -3052,27 +3080,38 @@ function AttendeesCell({
pendingAttendee?: {
meetingId: number;
attendanceType: Attendee["attendanceType"];
kind: "person" | "subheading";
key: number;
} | null;
onStartAddAttendee?: (
meetingId: number,
attendanceType: Attendee["attendanceType"],
kind?: "person" | "subheading",
) => void;
onCommitAddAttendee?: (
attendanceType: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
) => Promise<void>;
onCancelAddAttendee?: () => void;
}) {
// Build (attendee, originalIndex) tuples so attendees can be sliced into
// virtual/internal/external groups while still referring back to their
// index in `meeting.attendees` (needed by the PUT save endpoint).
// index in `meeting.attendees` (needed by the PUT save endpoint). Each
// tuple keeps both person and subheading rows; AttendeeFlow is in charge
// of skipping subheadings when computing the running "1-, 2-…" index.
const tagged = meeting.attendees.map((a, i) => ({ a, i }));
const virtual = tagged.filter(({ a }) => a.attendanceType === "virtual");
const internal = tagged.filter(({ a }) => a.attendanceType === "internal");
const external = tagged.filter(({ a }) => a.attendanceType === "external");
// A group "exists" only if it has at least one person — a section that
// contains nothing but stray subheadings would not warrant its own
// header label, so the split layout shouldn't switch on for it alone.
const hasPersons = (
rows: { a: Attendee; i: number }[],
) => rows.some(({ a }) => (a.kind ?? "person") === "person");
const hasSplit =
virtual.length > 0 && (internal.length > 0 || external.length > 0);
hasPersons(virtual) && (hasPersons(internal) || hasPersons(external));
const platformLabel: Record<Meeting["platform"], string> = {
none: "",
@@ -3091,13 +3130,19 @@ function AttendeesCell({
// by clicking "+" in a different row while one is already open.
const isPendingForThisMeeting =
!!pendingAttendee && pendingAttendee.meetingId === meeting.id;
const isPendingFor = (type: Attendee["attendanceType"]) =>
// True when the ghost is open in THIS meeting + group, regardless of
// kind. Used by the parent layout to decide whether to render the
// group at all (a brand-new "+ subheading" inside a so-far-empty
// virtual group still needs the virtual section to mount).
const isPendingForType = (type: Attendee["attendanceType"]) =>
isPendingForThisMeeting && pendingAttendee!.attendanceType === type;
const hasAnyPending = !!pendingAttendee;
const startAdd = onStartAddAttendee
? (type: Attendee["attendanceType"]) =>
onStartAddAttendee(meeting.id, type)
? (
type: Attendee["attendanceType"],
kind: "person" | "subheading" = "person",
) => onStartAddAttendee(meeting.id, type, kind)
: undefined;
const commitAdd = onCommitAddAttendee;
@@ -3106,21 +3151,29 @@ function AttendeesCell({
const flowProps = {
canMutate,
onSaveAttendeeName,
pendingKind: (pendingAttendee?.kind ?? "person") as "person" | "subheading",
isPending: false,
pendingKey: pendingAttendee?.key ?? 0,
onStartAdd: hasAnyPending ? undefined : startAdd,
onCommitAdd: commitAdd,
onCancelAdd: cancelAdd,
addLabel: t("executiveMeetings.schedule.addAttendee"),
addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
editSubheadingAriaLabel: t(
"executiveMeetings.schedule.editSubheadingAria",
),
newSubheadingAriaLabel: t(
"executiveMeetings.schedule.newSubheadingAria",
),
meetingId: meeting.id,
};
if (hasSplit) {
return (
<div className="space-y-2">
{(virtual.length > 0 || isPendingFor("virtual")) && (
{(virtual.length > 0 || isPendingForType("virtual")) && (
<AttendeeGroup
label={
meeting.platform !== "none"
@@ -3130,25 +3183,25 @@ function AttendeesCell({
items={virtual}
addType="virtual"
{...flowProps}
isPending={isPendingFor("virtual")}
isPending={isPendingForType("virtual")}
/>
)}
{(internal.length > 0 || isPendingFor("internal")) && (
{(internal.length > 0 || isPendingForType("internal")) && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
addType="internal"
{...flowProps}
isPending={isPendingFor("internal")}
isPending={isPendingForType("internal")}
/>
)}
{(external.length > 0 || isPendingFor("external")) && (
{(external.length > 0 || isPendingForType("external")) && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
addType="external"
{...flowProps}
isPending={isPendingFor("external")}
isPending={isPendingForType("external")}
/>
)}
{/* Add chips for groups that don't yet exist for this meeting. */}
@@ -3196,7 +3249,7 @@ function AttendeesCell({
items={virtual}
addType="virtual"
{...flowProps}
isPending={isPendingFor("virtual")}
isPending={isPendingForType("virtual")}
/>
);
}
@@ -3207,7 +3260,7 @@ function AttendeesCell({
items={tagged}
addType={singleGroupType}
{...flowProps}
isPending={isPendingFor(singleGroupType)}
isPending={isPendingForType(singleGroupType)}
/>
);
}
@@ -3240,16 +3293,27 @@ type AttendeeFlowSharedProps = {
onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
addType: Attendee["attendanceType"];
isPending: boolean;
// Which kind of ghost is being added — drives where in the flex flow
// the pending EditableCell is rendered (inline person at the end of the
// numbered list, or a full-width subheading line).
pendingKind: "person" | "subheading";
pendingKey: number;
onStartAdd?: (type: Attendee["attendanceType"]) => void;
onStartAdd?: (
type: Attendee["attendanceType"],
kind?: "person" | "subheading",
) => void;
onCommitAdd?: (
type: Attendee["attendanceType"],
html: string,
kind?: "person" | "subheading",
) => Promise<void>;
onCancelAdd?: () => void;
addLabel: string;
addSubheadingLabel: string;
editAriaLabel: string;
newAriaLabel: string;
editSubheadingAriaLabel: string;
newSubheadingAriaLabel: string;
meetingId: number;
};
@@ -3283,76 +3347,130 @@ function AttendeeFlow({
onSaveAttendeeName,
addType,
isPending,
pendingKind,
pendingKey,
onStartAdd,
onCommitAdd,
onCancelAdd,
addLabel,
addSubheadingLabel,
editAriaLabel,
newAriaLabel,
editSubheadingAriaLabel,
newSubheadingAriaLabel,
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
const editable = canMutate && !!onSaveAttendeeName;
const showAdd = canMutate && !!onStartAdd && !isPending;
// Person count drives the running "1-, 2-…" index. Subheadings are
// labels, not attendees, so they must NOT advance the counter.
const personCount = items.reduce(
(n, { a }) => n + ((a.kind ?? "person") === "person" ? 1 : 0),
0,
);
if (items.length === 0 && !showAdd && !isPending) {
return <span className="text-xs text-gray-500"></span>;
}
// Walking counter — incremented only as we render person rows.
let personIdx = 0;
return (
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
{items.map(({ a, i }, displayIdx) => (
<li
key={a.id ?? i}
data-testid={`em-attendee-row-${i}`}
// `inline-flex items-baseline` keeps the index prefix and the
// name as flex siblings on the same line. The `[&_p]` utilities
// on the inner wrappers below flatten tiptap's block `<p>` (with
// its default 1em margins) so the rendered name stays inline.
className={
"inline-flex items-baseline whitespace-nowrap" +
(editable ? " min-w-[3rem]" : "")
}
>
{items.length > 1 && (
<span
data-testid={`em-attendee-index-${i}`}
className="text-gray-500 me-1"
{items.map(({ a, i }) => {
const isSub = (a.kind ?? "person") === "subheading";
if (isSub) {
// Subheading row: full-width line break inside the flex-wrap so
// it visually separates the persons before/after. No leading
// index, no inline-flex — pure label.
return (
<li
key={a.id ?? `sub-${i}`}
data-testid={`em-attendee-subheading-${i}`}
data-attendee-kind="subheading"
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5 leading-snug"
>
{displayIdx + 1}-
</span>
)}
{editable ? (
<EditableCell
value={a.name}
onSave={(html) => onSaveAttendeeName!(i, html)}
ariaLabel={editAriaLabel}
dir="auto"
placeholder="…"
// `[&>span_p]:inline [&>span_p]:m-0` only matches the view-mode
// shell (`div > span > p`); the active editor's
// `div > div > EditorContent` does NOT match, so Enter still
// creates a real new paragraph inside the editor.
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-edit-attendee-${i}`}
/>
) : (
<span
data-testid={`em-attendee-name-${i}`}
className="[&_p]:inline [&_p]:m-0"
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
))}
{canMutate && isPending && onCommitAdd && (
{editable ? (
<EditableCell
value={a.name}
onSave={(html) => onSaveAttendeeName!(i, html)}
ariaLabel={editSubheadingAriaLabel}
dir="auto"
placeholder="…"
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-edit-subheading-${i}`}
/>
) : (
<span
data-testid={`em-subheading-name-${i}`}
className="[&_p]:inline [&_p]:m-0"
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
);
}
// Person row — capture index BEFORE incrementing so the displayed
// number matches what the user expects (1-based).
const myDisplayIdx = personIdx;
personIdx += 1;
return (
<li
key={a.id ?? i}
data-testid={`em-attendee-row-${i}`}
// `inline-flex items-baseline` keeps the index prefix and the
// name as flex siblings on the same line. The `[&_p]` utilities
// on the inner wrappers below flatten tiptap's block `<p>` (with
// its default 1em margins) so the rendered name stays inline.
className={
"inline-flex items-baseline whitespace-nowrap" +
(editable ? " min-w-[3rem]" : "")
}
>
{personCount > 1 && (
<span
data-testid={`em-attendee-index-${i}`}
className="text-gray-500 me-1"
>
{myDisplayIdx + 1}-
</span>
)}
{editable ? (
<EditableCell
value={a.name}
onSave={(html) => onSaveAttendeeName!(i, html)}
ariaLabel={editAriaLabel}
dir="auto"
placeholder="…"
// `[&>span_p]:inline [&>span_p]:m-0` only matches the view-mode
// shell (`div > span > p`); the active editor's
// `div > div > EditorContent` does NOT match, so Enter still
// creates a real new paragraph inside the editor.
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-edit-attendee-${i}`}
/>
) : (
<span
data-testid={`em-attendee-name-${i}`}
className="[&_p]:inline [&_p]:m-0"
dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
/>
)}
</li>
);
})}
{canMutate && isPending && pendingKind === "person" && onCommitAdd && (
<li className="min-w-[6rem]" data-testid={`em-add-attendee-pending-${addType}`}>
<span className="text-gray-500 me-1">{items.length + 1}-</span>
{personCount > 0 && (
<span className="text-gray-500 me-1">{personCount + 1}-</span>
)}
<EditableCell
// pendingKey changes every time the user clicks "+" so React
// remounts the editor with a fresh focus.
key={`pending-${pendingKey}`}
value=""
onSave={(html) => onCommitAdd(addType, html)}
onSave={(html) => onCommitAdd(addType, html, "person")}
ariaLabel={newAriaLabel}
dir="auto"
placeholder="…"
@@ -3367,18 +3485,54 @@ function AttendeeFlow({
/>
</li>
)}
{showAdd && (
<li className="print:hidden">
<button
type="button"
onClick={() => onStartAdd!(addType)}
data-testid={`em-add-attendee-${addType}`}
aria-label={addLabel}
className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-2 py-1 min-h-[1.5rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
{canMutate &&
isPending &&
pendingKind === "subheading" &&
onCommitAdd && (
<li
className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
data-testid={`em-add-subheading-pending-${addType}`}
>
+
</button>
</li>
<EditableCell
key={`pending-sub-${pendingKey}`}
value=""
onSave={(html) => onCommitAdd(addType, html, "subheading")}
ariaLabel={newSubheadingAriaLabel}
dir="auto"
placeholder="…"
className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
testId={`em-add-subheading-pending-input-${addType}`}
startInEditMode
forceSaveOnBlur
onCancel={onCancelAdd}
/>
</li>
)}
{showAdd && (
<>
<li className="print:hidden">
<button
type="button"
onClick={() => onStartAdd!(addType, "person")}
data-testid={`em-add-attendee-${addType}`}
aria-label={addLabel}
className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-2 py-1 min-h-[1.5rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
>
+
</button>
</li>
<li className="print:hidden">
<button
type="button"
onClick={() => onStartAdd!(addType, "subheading")}
data-testid={`em-add-subheading-${addType}`}
aria-label={addSubheadingLabel}
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
>
{addSubheadingLabel}
</button>
</li>
</>
)}
</ul>
);
@@ -3530,6 +3684,9 @@ function ManageSection({
title: a.title,
attendanceType: a.attendanceType,
sortOrder: idx,
// Round-trip the row kind so subheadings created in the inline
// schedule survive a save through the Manage dialog.
kind: a.kind ?? "person",
})),
};
if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
@@ -3632,7 +3789,12 @@ function ManageSection({
{isRtl ? m.titleAr : m.titleEn || m.titleAr}
</div>
<div className="text-xs text-gray-500">
{m.attendees.length} · {m.platform}
{
m.attendees.filter(
(a) => (a.kind ?? "person") === "person",
).length
}{" "}
· {m.platform}
</div>
</td>
<td className="px-3 py-2 align-middle font-mono text-xs whitespace-nowrap" dir="ltr">
@@ -3758,7 +3920,33 @@ function MeetingFormDialog({
...state,
attendees: [
...state.attendees,
{ name: "", title: null, attendanceType: "internal", sortOrder: state.attendees.length },
{
name: "",
title: null,
attendanceType: "internal",
sortOrder: state.attendees.length,
kind: "person",
},
],
});
}
function addSubheading() {
onChange({
...state,
attendees: [
...state.attendees,
{
name: "",
// Subheadings have no separate title field — they are a single
// free-text label. Keep title null on the row to match what the
// server stores.
title: null,
// Default new subheadings into "internal" — the user can switch
// the section via the same Select that persons use.
attendanceType: "internal",
sortOrder: state.attendees.length,
kind: "subheading",
},
],
});
}
@@ -3904,7 +4092,14 @@ function MeetingFormDialog({
<div className="border-t border-gray-200 pt-3 mt-1">
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap">
<h4 className="font-semibold text-sm text-[#0B1E3F]">
{t("executiveMeetings.manage.attendees.label")} ({state.attendees.length})
{t("executiveMeetings.manage.attendees.label")} (
{
// Persons only — subheadings are labels, not attendees.
state.attendees.filter(
(a) => (a.kind ?? "person") === "person",
).length
}
)
</h4>
<div className="flex items-center gap-2">
{state.attendees.length > 0 && (
@@ -3919,6 +4114,16 @@ function MeetingFormDialog({
{t("executiveMeetings.manage.attendees.removeAll")}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={addSubheading}
className="gap-1"
data-testid="em-attendee-add-subheading"
>
<Plus className="w-4 h-4" />
{t("executiveMeetings.manage.attendees.addSubheading")}
</Button>
<Button
size="sm"
variant="outline"
@@ -3932,73 +4137,98 @@ function MeetingFormDialog({
</div>
</div>
<div className="space-y-2">
{state.attendees.map((a, i) => (
<div
key={i}
className="flex items-center gap-2"
data-testid={`em-attendee-row-${i}`}
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, -1)}
disabled={i === 0}
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
data-testid={`em-attendee-up-${i}`}
{state.attendees.map((a, i) => {
const isSub = (a.kind ?? "person") === "subheading";
return (
<div
key={i}
className={
"flex items-center gap-2" +
(isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
}
data-testid={`em-attendee-row-${i}`}
data-attendee-kind={a.kind ?? "person"}
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, -1)}
disabled={i === 0}
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
data-testid={`em-attendee-up-${i}`}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, 1)}
disabled={i === state.attendees.length - 1}
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
data-testid={`em-attendee-down-${i}`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</div>
{isSub && (
// Tiny chip so the row visually reads as "this is a
// subheading" instead of just an empty-title attendee.
<span
className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wide text-blue-700 bg-blue-100 rounded-full px-2 py-0.5"
data-testid={`em-attendee-kind-badge-${i}`}
>
{t("executiveMeetings.manage.attendees.subheadingBadge")}
</span>
)}
<Input
className={isSub ? "flex-[2]" : "flex-1"}
placeholder={
isSub
? t("executiveMeetings.manage.attendees.subheadingName")
: t("executiveMeetings.manage.attendees.name")
}
value={a.name}
onChange={(e) => setAttendee(i, { name: e.target.value })}
/>
{!isSub && (
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.title")}
value={a.title ?? ""}
onChange={(e) =>
setAttendee(i, { title: e.target.value || null })
}
data-testid={`em-attendee-title-${i}`}
/>
)}
<Select
value={a.attendanceType}
onValueChange={(v) =>
setAttendee(i, {
attendanceType: v as Attendee["attendanceType"],
})
}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => moveAttendee(i, 1)}
disabled={i === state.attendees.length - 1}
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
data-testid={`em-attendee-down-${i}`}
>
<ChevronDown className="w-3 h-3" />
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["internal", "virtual", "external"] as const).map((tp) => (
<SelectItem key={tp} value={tp}>
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="icon" variant="ghost" onClick={() => removeAttendee(i)}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.name")}
value={a.name}
onChange={(e) => setAttendee(i, { name: e.target.value })}
/>
<Input
className="flex-1"
placeholder={t("executiveMeetings.manage.attendees.title")}
value={a.title ?? ""}
onChange={(e) =>
setAttendee(i, { title: e.target.value || null })
}
data-testid={`em-attendee-title-${i}`}
/>
<Select
value={a.attendanceType}
onValueChange={(v) =>
setAttendee(i, { attendanceType: v as Attendee["attendanceType"] })
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(["internal", "virtual", "external"] as const).map((tp) => (
<SelectItem key={tp} value={tp}>
{t(`executiveMeetings.manage.attendanceType.${tp}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="icon" variant="ghost" onClick={() => removeAttendee(i)}>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
))}
);
})}
{state.attendees.length === 0 && (
<div className="text-xs text-gray-500"></div>
)}
@@ -0,0 +1,300 @@
import { test, expect } from "@playwright/test";
import pg from "pg";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load the same locale bundles the app reads from so any copy edits to
// the subheading labels stay covered without test churn.
function loadLocale(lang) {
const p = join(__dirname, "..", "src", "locales", `${lang}.json`);
return JSON.parse(readFileSync(p, "utf8"));
}
const localeBundles = { en: loadLocale("en"), ar: loadLocale("ar") };
// E2E coverage for Task #207 — custom subheadings inside the attendees
// cell. Subheadings are user-defined section labels that live alongside
// person rows in a meeting's attendee list, but are NOT counted as
// attendees and do NOT advance the running "1-, 2-, 3-…" person index.
//
// What we verify:
// 1. Mixed group: a meeting with 2 internal persons + 1 subheading
// between them + 1 more person renders all four rows. The
// subheading carries `data-testid="em-attendee-subheading-…"`,
// and the three persons get the indices 1-, 2-, 3-.
// 2. Zero subheading: a meeting with only persons keeps the legacy
// numbering exactly as before (no extra "subheading" testids,
// no off-by-one).
// Both scenarios run in BOTH locales so the RTL/LTR mirror behaves the
// same.
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the executive-meetings subheading UI tests",
);
}
// Parse to silence the "unused" lint until we use the bundle below.
void localeBundles;
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
// Pick a far-future, randomised base so we never collide with real
// scheduled meetings while running side by side with other specs that
// also seed in the future.
const RANDOM_DATE_BASE_DAYS =
900 + Math.floor(Math.random() * 1000) + ((Date.now() / 1000) | 0) % 500;
function uniqueFutureDate(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + RANDOM_DATE_BASE_DAYS + offsetDays * 7);
return d.toISOString().slice(0, 10);
}
async function insertMeeting({
meetingDate,
dailyNumber,
titleEn,
titleAr,
startTime,
endTime,
}) {
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $3, $4, $5, $6, 'scheduled')
RETURNING id`,
[dailyNumber, titleAr, titleEn, meetingDate, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function insertAttendee({
meetingId,
name,
attendanceType,
sortOrder,
kind,
}) {
await pool.query(
`INSERT INTO executive_meeting_attendees
(meeting_id, name, attendance_type, sort_order, kind)
VALUES ($1, $2, $3, $4, $5)`,
[meetingId, name, attendanceType, sortOrder, kind ?? "person"],
);
}
async function loginViaUi(page) {
await page.goto("/login");
await page.locator("#username").fill("admin");
await page.locator("#password").fill("admin123");
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
let originalAdminPreferredLanguage = null;
async function setAdminPreferredLanguage(lang) {
const { rows } = await pool.query(
`UPDATE users SET preferred_language = $1
WHERE username = 'admin'
RETURNING preferred_language`,
[lang],
);
return rows[0]?.preferred_language;
}
test.beforeAll(async () => {
const { rows } = await pool.query(
`SELECT preferred_language FROM users WHERE username = 'admin'`,
);
originalAdminPreferredLanguage = rows[0]?.preferred_language ?? "ar";
});
test.afterAll(async () => {
if (originalAdminPreferredLanguage) {
await pool
.query(`UPDATE users SET preferred_language = $1 WHERE username = 'admin'`, [
originalAdminPreferredLanguage,
])
.catch(() => {
/* best-effort */
});
}
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
const langOffsets = { en: 1, ar: 2 };
for (const lang of ["en", "ar"]) {
test(`Schedule [${lang}]: subheading rows render and do not advance the person index`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang]);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Subheading Mixed ${lang} ${Date.now().toString(36)}`,
titleAr: `عنوان فرعي مختلط ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
// Internal group with a subheading wedged between the second and
// third person. After rendering we should see persons numbered
// 1-, 2-, 3- (subheading skipped) PLUS the subheading row itself.
await insertAttendee({
meetingId,
name: "Person Alpha",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Person Beta",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await insertAttendee({
meetingId,
// Distinctive label so the assertion below cannot trip on a
// person name that incidentally contains the same word.
name: "GuestsLabel-X9",
attendanceType: "internal",
sortOrder: 2,
kind: "subheading",
});
await insertAttendee({
meetingId,
name: "Person Gamma",
attendanceType: "internal",
sortOrder: 3,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
// Exactly one subheading row in this meeting.
const subheadings = row.locator(
'[data-testid^="em-attendee-subheading-"]',
);
await expect(subheadings).toHaveCount(1, { timeout: 10_000 });
await expect(subheadings.first()).toContainText("GuestsLabel-X9");
// Three person rows rendered (subheading is excluded by the
// person-only `em-attendee-row-…` testid).
const personRows = row.locator('[data-testid^="em-attendee-row-"]');
await expect(personRows).toHaveCount(3);
// Three person indices in order — the subheading must not have
// bumped the counter to 4.
const indices = row.locator('[data-testid^="em-attendee-index-"]');
await expect(indices).toHaveCount(3);
const indexTexts = await indices.allInnerTexts();
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
expect(trimmed).toEqual(["1-", "2-", "3-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort; afterAll also restores */
},
);
}
}
});
test(`Schedule [${lang}]: meeting with no subheadings still numbers persons 1..N with no stray subheading rows`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang] + 5);
const meetingId = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `No Subheadings ${lang} ${Date.now().toString(36)}`,
titleAr: `بدون عناوين فرعية ${lang} ${Date.now().toString(36)}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
await insertAttendee({
meetingId,
name: "Solo Person A",
attendanceType: "internal",
sortOrder: 0,
kind: "person",
});
await insertAttendee({
meetingId,
name: "Solo Person B",
attendanceType: "internal",
sortOrder: 1,
kind: "person",
});
await setAdminPreferredLanguage(lang);
try {
await loginViaUi(page);
await page.goto("/executive-meetings");
await expect(page.locator("html")).toHaveAttribute("lang", lang, {
timeout: 5_000,
});
await page.locator('input[type="date"]').first().fill(date);
const row = page.getByTestId(`em-row-${meetingId}`);
await expect(row).toBeVisible({ timeout: 15_000 });
// Zero subheadings — make sure the new code path didn't sneak
// one in for legacy "person"-only meetings.
const subheadings = row.locator(
'[data-testid^="em-attendee-subheading-"]',
);
await expect(subheadings).toHaveCount(0);
const indices = row.locator('[data-testid^="em-attendee-index-"]');
await expect(indices).toHaveCount(2);
const trimmed = (await indices.allInnerTexts()).map((s) =>
s.trim().replace(/\s+/g, ""),
);
expect(trimmed).toEqual(["1-", "2-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(
() => {
/* best-effort */
},
);
}
}
});
}
+8
View File
@@ -74,12 +74,20 @@ export const executiveMeetingAttendeesTable = pgTable(
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
// attendee name holds sanitized rich-text HTML so it needs unbounded
// length. Plain-text rows still render correctly (no tags = no parse).
// For `kind = "subheading"` rows, this column stores the user-written
// section label (free text, single value rendered as-is in AR and EN).
name: text("name").notNull(),
title: varchar("title", { length: 200 }),
attendanceType: varchar("attendance_type", { length: 32 })
.notNull()
.default("internal"),
sortOrder: integer("sort_order").notNull().default(0),
// "person" — a real attendee row (default; matches all pre-existing
// data after backfill).
// "subheading" — a user-defined section label inserted between attendees
// inside the same attendance-type group. Numbering, count,
// and virtual-platform extraction MUST ignore these rows.
kind: varchar("kind", { length: 16 }).notNull().default("person"),
},
(t) => ({
meetingIdx: index("executive_meeting_attendees_meeting_idx").on(t.meetingId),
+4
View File
@@ -98,3 +98,7 @@
```
Then run `pnpm --filter @workspace/db run push-force`. The push has been verified end-to-end against the dev database and is idempotent on subsequent runs.
## Task #207 — Custom subheadings inside attendee cells (April 2026)
`executive_meeting_attendees.kind` (`varchar(16) NOT NULL DEFAULT 'person'`) lets meetings interleave free-text section headers ("subheadings") with person rows. Subheadings are excluded from the running attendee number and from the per-meeting attendee count surface, but reorder/delete identically to person rows. The schema lives in `lib/db/src/schema/executive-meetings.ts`. All four insert paths (POST, PATCH attendees replace, PUT attendees, duplicate) round-trip `kind`. The PDF renderer (`artifacts/api-server/src/lib/pdf-renderer.ts`) prints subheadings as `— label —` and skips them when incrementing `personIdx`.