#329 Add a meeting search box to the schedule toolbar

Lets users find a meeting in the day's schedule by typing part of its
title, an attendee name, or other text — instead of scrolling.

artifacts/tx-os/src/pages/executive-meetings.tsx
- Added two top-level helpers near translateSaveError:
  - normalizeForSearch(input): strips HTML tags + entities, drops
    Arabic diacritics + tatweel, folds common letter variants
    (آأإٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي), lowercases.
  - meetingMatchesSearch(meeting, needle): tests title (AR/EN),
    attendee names + titles, location, notes, and merge text via
    normalized substring containment.
- ScheduleSection: added searchQuery state, useEffect that resets it
  whenever the active `date` changes, normalizedSearch memo, and a
  filteredMeetings memo derived from orderedMeetings.
- Switched the row render path to filteredMeetings: SortableContext
  items, the .map render, and the select-all overlay's "anything to
  select" check now read from the filtered list. The
  setAllMeetingsSelected and allSelectedState helpers also operate
  on filteredMeetings so the header checkbox + select-all reflect
  what's actually visible.
- Added a new "no matches" empty-state row that appears only when
  the day has meetings but the active search filters them all out
  (the original "no meetings today" row is preserved for empty days).
- New search Input + clear-X button placed in the schedule toolbar,
  next to the existing date picker. Hidden in print so PDFs export
  the full schedule.
- MeetingRow gained an optional `displayNumber` prop. The "#" cell
  now renders displayNumber when provided, falling back to the
  server's dailyNumber. The schedule passes idx+1, so visible rows
  always show 1, 2, 3… without gaps when the list is filtered.

artifacts/tx-os/src/locales/ar.json + en.json
- New keys under executiveMeetings.schedule: searchPlaceholder,
  searchAria, searchClear, searchNoMatches.

- Bulk delete now intersects the selection with filteredMeetings at
  action time. The selection set is intentionally NOT auto-pruned
  when the search narrows (so clearing the search restores the
  user's earlier picks), but destructive actions only ever target
  rows the user can currently see.

Out of scope: cross-day search, prev/next-day chevron buttons,
search history, fuzzy matching.

Verified: `tsc --noEmit` clean.
This commit is contained in:
riyadhafraa
2026-05-03 10:56:54 +00:00
parent c9ffb09e36
commit 0fda08169a
3 changed files with 153 additions and 15 deletions
+5 -1
View File
@@ -1203,7 +1203,11 @@
"bulkDeleteUndo": "تراجع",
"bulkDeleteUndone": "تمت استعادة {{n}} اجتماعاً",
"bulkDeleteUndoPartial": "تمت استعادة {{ok}} من {{total}} اجتماعاً",
"bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات"
"bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات",
"searchPlaceholder": "ابحث عن اجتماع…",
"searchAria": "ابحث في اجتماعات اليوم",
"searchClear": "مسح البحث",
"searchNoMatches": "لا توجد اجتماعات تطابق البحث."
},
"rowColor": {
"label": "لون الصف"
+5 -1
View File
@@ -1086,7 +1086,11 @@
"bulkDeleteUndo": "Undo",
"bulkDeleteUndone": "Restored {{n}} meetings",
"bulkDeleteUndoPartial": "Restored {{ok}} of {{total}} meetings",
"bulkDeleteUndoFailed": "Could not restore meetings"
"bulkDeleteUndoFailed": "Could not restore meetings",
"searchPlaceholder": "Search a meeting…",
"searchAria": "Search today's meetings",
"searchClear": "Clear search",
"searchNoMatches": "No meetings match your search."
},
"nav": {
"schedule": "Daily Schedule",
+143 -13
View File
@@ -433,6 +433,51 @@ function translateSaveError(rawMsg: string, t: (k: string) => string): string {
return rawMsg;
}
// #329: Normalize a free-text string so the schedule's search box can match
// across HTML markup (titles + attendee names are sanitized Tiptap rich text)
// and across the most common Arabic spelling variants. Strips:
// • HTML tags + common entities (  / U+00A0)
// • Arabic diacritics (\u064B\u065F) and tatweel (\u0640)
// • Folds alef variants (آ أ إ ٱ → ا), ya (ى → ي), ta marbouta (ة → ه),
// hamza on waw/ya (ؤ ئ → و / ي)
// Then lowercases for case-insensitive English matching. Empty/whitespace
// input becomes "" so the caller can short-circuit.
function normalizeForSearch(input: string | null | undefined): string {
if (!input) return "";
let s = String(input);
// Strip tags + common HTML entities first so we don't index markup.
s = s.replace(/<[^>]*>/g, " ").replace(/&nbsp;/gi, " ").replace(/\u00a0/g, " ");
// Drop Arabic diacritics + tatweel.
s = s.replace(/[\u064B-\u065F\u0640]/g, "");
// Fold common Arabic letter variants.
s = s
.replace(/[\u0622\u0623\u0625\u0671]/g, "\u0627") // alef variants → ا
.replace(/\u0649/g, "\u064A") // ى → ي
.replace(/\u0629/g, "\u0647") // ة → ه
.replace(/\u0624/g, "\u0648") // ؤ → و
.replace(/\u0626/g, "\u064A"); // ئ → ي
return s.toLowerCase().replace(/\s+/g, " ").trim();
}
function meetingMatchesSearch(m: Meeting, needle: string): boolean {
if (!needle) return true;
const haystacks: string[] = [
normalizeForSearch(m.titleAr),
normalizeForSearch(m.titleEn),
normalizeForSearch(m.location),
normalizeForSearch(m.notes),
normalizeForSearch(m.mergeText ?? ""),
];
for (const a of m.attendees) {
haystacks.push(normalizeForSearch(a.name));
haystacks.push(normalizeForSearch(a.title));
}
for (const h of haystacks) {
if (h && h.includes(needle)) return true;
}
return false;
}
function tWithFallback(
t: (key: string) => string,
key: string,
@@ -893,6 +938,27 @@ function ScheduleSection({
return out;
}, [meetings, optimisticOrder]);
// #329: in-day search. Free-text needle is normalized once (Arabic
// diacritics + letter variants folded, lowercased, HTML stripped) and
// matched against title, attendee names/titles, location, notes, and
// any merge text on the row. Empty needle → pass-through.
const [searchQuery, setSearchQuery] = useState("");
// Reset whenever the active day changes — a search is "scoped to the
// currently visible date" per the spec, so jumping days clears it.
useEffect(() => {
setSearchQuery("");
}, [date]);
const normalizedSearch = useMemo(
() => normalizeForSearch(searchQuery),
[searchQuery],
);
const filteredMeetings = useMemo(() => {
if (!normalizedSearch) return orderedMeetings;
return orderedMeetings.filter((m) =>
meetingMatchesSearch(m, normalizedSearch),
);
}, [orderedMeetings, normalizedSearch]);
const refreshDay = useCallback(() => {
void qc.invalidateQueries({
queryKey: ["/api/executive-meetings", date],
@@ -1412,23 +1478,32 @@ function ScheduleSection({
);
const setAllMeetingsSelected = useCallback(
(on: boolean) => {
setSelectedMeetingIds(
on ? new Set(orderedMeetings.map((m) => m.id)) : new Set(),
);
// #329: "select all" operates on the currently visible (filtered)
// rows so users can't accidentally bulk-act on rows they hid via
// the search box. Turning it off always clears the entire selection
// (including any rows that may be hidden by an active search).
setSelectedMeetingIds((prev) => {
if (!on) return new Set();
const next = new Set(prev);
for (const m of filteredMeetings) next.add(m.id);
return next;
});
},
[orderedMeetings],
[filteredMeetings],
);
// Tri-state hint for the "select all" checkbox in the toolbar.
// "all" → fully checked, "some" → indeterminate, "none" → unchecked.
// Computed against the visible (filtered) row set so the header
// checkbox reflects what the user can actually see.
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
const total = orderedMeetings.length;
const total = filteredMeetings.length;
if (total === 0) return "none";
let count = 0;
for (const m of orderedMeetings) if (selectedMeetingIds.has(m.id)) count++;
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) count++;
if (count === 0) return "none";
if (count === total) return "all";
return "some";
}, [orderedMeetings, selectedMeetingIds]);
}, [filteredMeetings, selectedMeetingIds]);
const [bulkDeleting, setBulkDeleting] = useState(false);
// #199: undo via recreate. dailyNumber is omitted so a stolen slot
// doesn't 409 the restore — the row gets the next available number.
@@ -1474,14 +1549,23 @@ function ScheduleSection({
);
const deleteSelectedMeetings = useCallback(async () => {
if (bulkDeleting) return;
const ids = Array.from(selectedMeetingIds);
// #329: only act on rows that are currently visible. The selection
// set may legitimately contain ids that an active search filter is
// hiding (we don't auto-prune so that clearing the search restores
// the user's selection). Intersecting at action time prevents an
// accidental destructive op against rows the user can't even see.
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
const ids = Array.from(selectedMeetingIds).filter((id) =>
visibleIds.has(id),
);
if (ids.length === 0) return;
const message = t(
"executiveMeetings.schedule.bulkDeleteConfirm",
).replace("{{n}}", String(ids.length));
if (!window.confirm(message)) return;
// Snapshot before deletion so undo can reconstruct the rows.
const snapshots = meetings.filter((m) => selectedMeetingIds.has(m.id));
const idsSet = new Set(ids);
const snapshots = meetings.filter((m) => idsSet.has(m.id));
setBulkDeleting(true);
try {
// Deliberately allSettled, not all: a single failed DELETE shouldn't
@@ -1575,6 +1659,7 @@ function ScheduleSection({
}, [
bulkDeleting,
selectedMeetingIds,
filteredMeetings,
meetings,
t,
toast,
@@ -2082,6 +2167,36 @@ function ScheduleSection({
</span>
</button>
)}
{/* #329: in-day search box. Sits next to the date picker so
the user finds a meeting in the currently visible day
without having to scroll. Hidden in print so PDFs still
show the full schedule. */}
<div className="relative print:hidden">
<Input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
aria-label={t("executiveMeetings.schedule.searchAria")}
data-testid="em-schedule-search"
className="h-8 w-44 sm:w-56 text-sm bg-white"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
aria-label={t("executiveMeetings.schedule.searchClear")}
title={t("executiveMeetings.schedule.searchClear")}
data-testid="em-schedule-search-clear"
className={
"absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-[#0B1E3F] " +
(isRtl ? "left-1.5" : "right-1.5")
}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
@@ -2209,7 +2324,7 @@ function ScheduleSection({
selectAllOverlay={
idx === 0 &&
effectiveCanMutate &&
orderedMeetings.length > 0 ? (
filteredMeetings.length > 0 ? (
<div
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
onClick={(e) => e.stopPropagation()}
@@ -2313,14 +2428,27 @@ function ScheduleSection({
</td>
</tr>
)}
{!isLoading &&
orderedMeetings.length > 0 &&
filteredMeetings.length === 0 && (
<tr data-testid="em-search-no-matches">
<td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("executiveMeetings.schedule.searchNoMatches")}
</td>
</tr>
)}
<SortableContext
items={orderedMeetings.map((m) => m.id)}
items={filteredMeetings.map((m) => m.id)}
strategy={verticalListSortingStrategy}
>
{orderedMeetings.map((m) => (
{filteredMeetings.map((m, idx) => (
<MeetingRow
key={m.id}
meeting={m}
displayNumber={idx + 1}
isRtl={isRtl}
t={t}
visibleColumns={visibleColumns}
@@ -2983,8 +3111,10 @@ function MeetingRow({
bulkSelectable,
bulkSelected,
onBulkSelectChange,
displayNumber,
}: {
meeting: Meeting;
displayNumber?: number;
isRtl: boolean;
t: (k: string) => string;
visibleColumns: ColumnSetting[];
@@ -3251,7 +3381,7 @@ function MeetingRow({
<GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
</button>
)}
<span>{meeting.dailyNumber}</span>
<span>{displayNumber ?? meeting.dailyNumber}</span>
</div>
{cellBulkOverlay}
{actionsOverlay}