Task #357: Show attendee groups correctly in the upcoming meeting alert

The upcoming meeting alert's details panel was grouping attendees by
the `attendanceType` field, but in practice every attendee is stored
as type=internal regardless of their actual group. Users define groups
via subheading rows in the schedule editor (e.g. "الحضور الخارجي",
"الحضور الداخلي"), but the alert stripped subheadings and lumped
everyone under "داخلي".

Fix: Rewrote the grouping logic in DetailsPanel to walk through the
attendees array in order, using subheading rows as natural group
separators. Each subheading becomes a bold, clearly visible group
header (text-xs font-bold) instead of the old tiny 10px/60% opacity
labels. Person rows are listed under their preceding subheading.

Edge cases handled:
- Persons before any subheading: shown without a group header
- No subheadings at all: flat list without misleading "داخلي" label
- Empty subheading groups (heading with no persons): skipped
- Person count still counts only persons, not subheadings

Changed file:
- artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
  (DetailsPanel component, lines ~1744-1885)
This commit is contained in:
riyadhafraa
2026-05-04 07:17:51 +00:00
parent 17f1771659
commit ec7bbb229d
@@ -1741,27 +1741,32 @@ function DetailsPanel({
dividerColor,
sectionBg,
}: DetailsPanelProps) {
// Group attendees by attendanceType (internal/external/virtual) and
// skip subheading rows — those are user-defined section labels in the
// schedule editor, not actual people, so they shouldn't appear in a
// "who is attending" details panel.
const persons = (meeting.attendees ?? []).filter(
(a) => a.kind !== "subheading" && a.name && a.name.trim().length > 0,
);
const groups: Record<string, Attendee[]> = {
internal: [],
external: [],
virtual: [],
};
for (const p of persons) {
const key =
p.attendanceType === "internal" ||
p.attendanceType === "external" ||
p.attendanceType === "virtual"
? p.attendanceType
: "internal";
groups[key].push(p);
// Walk through attendees in order. Subheading rows act as group
// headers (e.g. "الحضور الخارجي", "الحضور الداخلي"); person rows
// following a subheading belong to that group. If persons appear
// before any subheading they land in a header-less group.
type AttendeeGroup = { heading: string | null; members: Attendee[] };
const attendeeGroups: AttendeeGroup[] = [];
let currentGroup: AttendeeGroup = { heading: null, members: [] };
for (const a of meeting.attendees ?? []) {
const cleanName = (a.name ?? "").replace(/<[^>]+>/g, "").trim();
if (!cleanName) continue;
if (a.kind === "subheading") {
if (currentGroup.heading !== null || currentGroup.members.length > 0) {
attendeeGroups.push(currentGroup);
}
currentGroup = { heading: cleanName, members: [] };
} else {
currentGroup.members.push(a);
}
}
if (currentGroup.heading !== null || currentGroup.members.length > 0) {
attendeeGroups.push(currentGroup);
}
const personCount = attendeeGroups.reduce(
(sum, g) => sum + g.members.length,
0,
);
const safeUrl = (() => {
const raw = (meeting.meetingUrl ?? "").trim();
@@ -1831,16 +1836,14 @@ function DetailsPanel({
});
}
// Attendees row is always shown (even when empty) so the user can
// distinguish "no one yet" from "field not loaded".
rows.push({
key: "attendees",
icon: <Users className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
label: t("executiveMeetings.alert.detailsAttendees", {
n: persons.length,
n: personCount,
}),
value:
persons.length === 0 ? (
personCount === 0 ? (
<div className="opacity-80" data-testid="alert-details-no-attendees">
{t("executiveMeetings.alert.detailsNoAttendees")}
</div>
@@ -1849,27 +1852,23 @@ function DetailsPanel({
className="max-h-40 overflow-y-auto"
data-testid="alert-details-attendees"
>
{(["internal", "external", "virtual"] as const).map((g) => {
const list = groups[g];
if (list.length === 0) return null;
{attendeeGroups.map((group, gIdx) => {
if (group.members.length === 0) return null;
return (
<div key={g} className="mb-1.5 last:mb-0">
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60">
{t(`executiveMeetings.alert.detailsGroup.${g}`)}
</div>
<div key={`group-${gIdx}`} className="mb-2 last:mb-0">
{group.heading ? (
<div className="mb-0.5 text-xs font-bold">
{group.heading}
</div>
) : null}
<ul className="list-disc ps-4 leading-snug">
{list.map((p, idx) => {
// Attendee names are stored as sanitized rich-text
// HTML; strip every tag for the popup. If the input
// is tag-only (e.g. "<p></p>") we render nothing
// rather than fall back to the raw value, so HTML
// can never appear here.
{group.members.map((p, idx) => {
const cleanName = p.name
.replace(/<[^>]+>/g, "")
.trim();
if (!cleanName) return null;
return (
<li key={`${g}-${p.id ?? idx}-${p.name}`}>
<li key={`${gIdx}-${p.id ?? idx}-${p.name}`}>
{cleanName}
</li>
);