PageSourceSearch

https://smrtbld.ai/js/pages/calendar.js

js smrtbld.ai collected 2026-10-05 15:07:23 UTC 75,555 bytes, 1,580 lines download raw bytes

1/**
2 * calendar.js — Standalone tenant calendar page (/calendar).
3 *
4 * One unified card: a custom header (nav + title + filter + My-Tasks + Month/Week/Day +
5 * Add) over a body that shows either FullCalendar (Month/Week) or a custom 2-column Day
6 * view (components/DayScheduleView.js). Merges three legacy event sources:
7 *   • calendar events        (Task / Call / Meeting)  → preview popover → detail modal
8 *   • meeting-minute topics                           → preview popover → "Go to meeting"
9 *   • project task due-dates (RFI / SI / CHG / …)     → preview popover → "Go to module"
10 *
11 * Click is preview-first everywhere: every event opens an anchored popover (never punishes
12 * a click by navigating away). Reuses createPortalPopover, the SearchableDropdown filter, the project
13 * Tasks detail machinery (api/project-calendar.js), and the .view-toggle-st / .popover-st
14 * design-system classes.
15 */
16
17// ========================================
18// IMPORTS
19// ========================================
20
21import { calendarApi } from '../api/calendar.js';
22import { projectCalendarApi, statusOf } from '../api/project-calendar.js';
23import { projectMeetingsApi } from '../api/project-meetings.js';
24import { projectsApi } from '../api/projects.js';
25import { contactsApi } from '../api/contacts.js';
26import { showModal, confirm } from '../utils/modal.js';
27import { renderTo, createTemplateLoader } from '../utils/templates.js';
28import { validateRequired } from '../utils/form-validation.js';
29import { runWithBusyButton, setLoading } from '../utils/dom.js';
30import { formatDate, formatTimeDisplay } from '../utils/jsrender-helpers.js';
31import { SearchableDropdown } from '../components/SearchableDropdown.js';
32import { subscribeTaskUpdates } from '../realtime/TaskGroupSync.js';
33import { createMobilePopoverBackdrop, createPortalPopover } from '../utils/popover-position.js';
34import { renderDayScheduleView, eventIcon, eventType } from '../components/DayScheduleView.js';
35import { renderMonthScheduleView, monthGridRange } from '../components/MonthScheduleView.js';
36
37// ── Event duration — CA-138 ─────────────────────────────────────────────────
38// `#calendar-event-duration` was `<input type="number" min="0" step="1">` until this card, and
39// `min`/`step` were affordances, never enforcement — the real filter was the ambient
40// `app.js:107-135` sanitizer this card narrows away from it. The read was
41// `durationRaw ? Number(durationRaw) : null`, so unreadable text became `NaN`, and the publication
42// guard's `(duration && duration > 0)` then quietly turned that NaN into `null`: everything the
43// user typed was DELETED on save with no message at all.
44//
45// Derived from `CalendarEvent.event_duration` — `decimal?`, and legacy's own insert takes it
46// through `Convert.ToDecimal` with no sign or precision constraint. So: a number, not negative, and
47// deliberately NOT integer — the unit selector beside it offers Hours and Days, where 1.5 is a real
48// duration. Optional; blankness stays the descriptor's business, and the blank/zero publication
49// contract below is preserved exactly.
50const CALENDAR_EVENT_DURATION = { minimum: 0 };
51
52// ========================================
53// MODULE STATE
54// ========================================
55
56let calendar = null;
57let abortController = null;
58let projectsCache = [];        // [{ projectId, projectTitle, projectNumber }] — filter + create picker
59let currentFilterId = '';      // '' = All, '0' = Personal, '{projectId}' = a project
60let assignedToMe = false;      // "My Tasks" toggle
61
62let currentView = 'month';     // 'month' | 'week' | 'day'
63let currentDate = new Date();  // source of truth for the active month/day (month + day views own it; week syncs from FC)
64let currentDensity = 'standard'; // month chip detail: 'compact' | 'standard' | 'full' (CSS class on .month-scroll)
65let viewRenderGen = 0;         // bumps each renderMonth/renderDay; a stale fetch resolving late (incl. across a view switch) is discarded
66
67let detailSub = null;          // TaskGroupSync subscription while a detail modal is open
68let detailModalRef = null;     // the open detail modal (so live refresh / actions can target it)
69let detailEventId = null;      // event id of the open detail modal (URL sync + re-dispatch no-op guard)
70let detailOpenGen = 0;         // bumps per detail-open attempt; a teardown/route-out/newer open invalidates an in-flight one
71let activePopover = null;      // { cleanup, backdropCleanup, el, anchorEl, onDocClick, onKey } — the open event popover
72let popoverOpenGen = 0;        // invalidates a detached render when a newer open or page teardown wins
73
74// True while applying a route (init / navigateCalendarPage, incl. browser Back/Forward). User-driven
75// view/date changes push the URL via window.page(); route-driven changes run under this flag and stay
76// silent, so the two never feed each other into a popstate loop. (page-js skill: #1 correctness rule.)
77let _routing = false;
78
79const ensureTemplates = createTemplateLoader('components/calendar-templates');
80
81const STORAGE_ASSIGNED = 'calendarAssignedToMe';   // legacy localStorage key (parity)
82const STORAGE_DENSITY = 'calendarDensity';         // month chip density preference (per user)
83const DURATION_TYPE_LABELS = { M: 'Minutes', H: 'Hours', D: 'Days' };
84const STATUS_TEXT = { completed: 'Completed', overdue: 'Overdue', today: 'Due Today', upcoming: 'Upcoming' };
85// pageUrl → human label for the "Go to {module}" popover action.
86const MODULE_LABELS = {
87    rfi: 'RFI', si: 'Site Instruction', chg: 'Change', sub: 'Submittal',
88    meetings: 'Meeting', meeting: 'Meeting', invoices: 'Invoices',
89    desclar: 'Clarification', tenderclar: 'Tender Clarification', caltsk: 'Task',
90};
91// pageUrl → full type label for the mini-detail header (mirrors the task-detail "entityLabel").
92const MODULE_TYPE_LABELS = {
93    rfi: 'Request for Information', si: 'Field Memo / S.I.', chg: 'Change', sub: 'Submittal',
94    desclar: 'Design Clarification', tenderclar: 'Tender Clarification',
95};
96
97// ========================================
98// PUBLIC API
99// ========================================
100
101/**
102 * Initialize Calendar Page (cold mount). Restores view + anchor date (+ optional event) from the route.
103 * @param {{ view?: 'month'|'week'|'day', date?: string, eventId?: string|number }} [options]
104 */
105export async function initCalendarPage(options = {}) {
106    const calendarEl = document.getElementById('calendar-el');
107    if (!calendarEl) return;
108
109    abortController = new AbortController();
110    await ensureTemplates();
111    assignedToMe = localStorage.getItem(STORAGE_ASSIGNED) === 'true';
112    currentDensity = localStorage.getItem(STORAGE_DENSITY) || 'standard';
113
114    // Restore view + anchor date from the route (deep-link / refresh / shared link) instead of
115    // resetting to month/today. _routing keeps the initial FC render + the restore from pushing URLs.
116    _routing = true;
117    currentView = options.view || 'month';
118    currentDate = options.date ? new Date(options.date + 'T00:00:00') : new Date();
119
120    initFullCalendar(calendarEl);
121    applyView();               // match the DOM/FC to the restored view + date
122    _routing = false;
123
124    bindHeader();
125    updateViewToggle();
126    updateDensityToggle();
127    await loadProjects();
128    syncMyTasksToggle();
129
130    if (options.eventId) openEventDetail(options.eventId, { gotoDate: true });
131}
132
133/** Clean up Calendar Page */
134export function destroyCalendarPage() {
135    abortController?.abort();
136    abortController = null;
137    closePopover({ restoreFocus: false });
138    detailSub?.dispose();
139    detailSub = null;
140    detailModalRef = null;
141    detailEventId = null;   // §1 reset: cannot depend on onClose, which never runs if the modal never opened
142    const filterEl = document.getElementById('calendar-container');
143    if (filterEl) SearchableDropdown.destroyAll(filterEl);
144    currentFilterId = '';
145    projectsCache = [];
146    currentView = 'month';
147    viewRenderGen = 0;
148    if (calendar) {
149        calendar.destroy();
150        calendar = null;
151    }
152}
153
154/**
155 * Same-page route change — runs on browser Back/Forward and on our own page() pushes, via routeGuard
156 * (no FullCalendar teardown). Applies the URL's view/date/event under _routing so nothing re-pushes.
157 * @param {{ view?: 'month'|'week'|'day', date?: string, eventId?: string|number }} p
158 */
159export function navigateCalendarPage(p = {}) {
160    if (!document.getElementById('calendar-el')) return;
161    _routing = true;
162    try {
163        if (p.eventId) { openEventDetail(p.eventId, { gotoDate: true }); return; }
164        // Route carries no event id → if the detail modal is open, the user Back'd out of it. Close it.
165        // If an open is still IN FLIGHT (claimed, modal not painted yet), cancel it instead — otherwise
166        // it paints onto a URL the user has already left. No teardown is involved in this variant.
167        if (detailModalRef) detailModalRef.close();
168        else if (detailEventId) { detailEventId = null; detailOpenGen++; }
169        const targetView = p.view || 'month';
170        const targetDate = p.date || isoToday();
171        // No-op when the DOM already reflects this view+date — i.e. the re-dispatch immediately after
172        // our own page() push. Only a genuine Back/Forward to a different state re-applies.
173        if (targetView === currentView && targetDate === isoLocal(currentDate)) return;
174        currentDate = new Date(targetDate + 'T00:00:00');
175        if (targetView !== currentView) { currentView = targetView; updateViewToggle(); }
176        applyView();
177    } finally {
178        _routing = false;
179    }
180}
181
182// ========================================
183// CALENDAR SETUP (FullCalendar — Month / Week)
184// ========================================
185
186/**
187 * ApiClient.toCamelCase() has already converted all keys. Remap to FullCalendar's shape,
188 * carrying the full extendedProps through (used by click routing + detail). Colors come
189 * from per-event classes (eventClassNames), not the raw backend hex.
190 */
191function transformEvent(ev) {
192    const ext = ev.extendedProps ?? {};
193    return {
194        id: ext.calendarEventId,
195        title: ev.title,
196        start: ev.start || ext.eventDateStart || null,
197        end: ev.end || ext.eventDateEnd || null,
198        extendedProps: ext,
199    };
200}
201
202/** Shared event fetch for both FullCalendar and the day view. */
203async function fetchEvents(startStr, endStr) {
204    const events = await calendarApi.getEvents({
205        start: startStr, end: endStr, filterId: currentFilterId, assignedToMe,
206    });
207    return events || [];
208}
209
210function initFullCalendar(el) {
211    calendar = new FullCalendar.Calendar(el, {
212        initialView: 'dayGridMonth',
213        headerToolbar: false,            // our custom header drives nav/views
214        themeSystem: 'standard',
215        dayMaxEvents: true,              // cap busy month cells → "+N more" pill (legacy parity)
216        fixedWeekCount: false,           // variable 5–6 week rows, not always 6 (legacy parity)
217        eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: true },  // 12h chip times (legacy parity)
218        height: '100%',
219        events: (info, success, failure) => {
220            fetchEvents(info.startStr, info.endStr)
221                .then(evs => success(evs.map(transformEvent)))
222                .catch(err => {
223                    if (err.name === 'AbortError') return;
224                    window.showToast?.('Failed to load events', 'error', { description: err.message });
225                    failure(err);
226                });
227        },
228        eventClick: (info) => {
229            info.jsEvent?.preventDefault();
230            handleEventClick(info.event.extendedProps || {}, info.el, info.event.title);
231        },
232        dateClick: (info) => openEventModal(null, info.dateStr),
233        eventClassNames: eventClasses,
234        eventContent: renderChip,
235        eventDidMount: (info) => { window.lucide?.createIcons?.({ root: info.el }); },
236        datesSet: (info) => {
237            // FC now drives ONLY the Week view (Month is the custom MonthScheduleView, Day is
238            // DayScheduleView). So we act on datesSet only while Week is active — FC's transient initial
239            // render at today (fired while currentView is 'month') is correctly ignored, which removes the
240            // old month-restore clobber gotcha entirely. For Week: mirror FC's date for USER moves, but
241            // not during a route apply (_routing) where WE drive the date via gotoDate. syncUrl no-ops under _routing.
242            if (currentView === 'week') {
243                if (!_routing) currentDate = calendar.getDate();
244                setTitle(info.view.title);
245                syncUrl();
246            }
247        },
248    });
249    calendar.render();
250}
251
252/**
253 * Per-event CSS classes drive the on-brand styling (FC theme block in tailwind.css):
254 * source → fill family, priority (real calendar events) → left accent.
255 */
256function eventClasses(arg) {
257    const ext = arg.event.extendedProps || {};
258    const dt = (ext.displayType || '').toLowerCase();
259    let src = 'event';
260    if (dt.includes('meetingminutes')) src = 'meeting';
261    else if (dt === 'accounting') src = 'accounting';
262    else if (dt.includes('task')) src = 'task';
263
264    const classes = ['cal-ev', `cal-ev-${src}`, `cal-ev-t-${eventType(ext)}`];
265    const prio = (ext.priorityKey || '').toUpperCase();
266    if (src === 'event' && (prio === 'H' || prio === 'M' || prio === 'L')) {
267        classes.push(`cal-ev-prio-${prio.toLowerCase()}`);
268    }
269    return classes;
270}
271
272/** Custom, informative event chip (time + title + affordance; richer meta in week view). */
273function renderChip(arg) {
274    const ext = arg.event.extendedProps || {};
275    const wrap = document.createElement('div');
276    wrap.className = 'cal-chip';
277
278    // Leading type icon (RFI / SI / CHG / Submittal / Meeting / Call / Task / …) — same vocabulary
279    // as the day view (shared eventIcon) so the type is scannable in the month grid at a glance.
280    const ic = document.createElement('i');
281    ic.setAttribute('data-lucide', eventIcon(ext));
282    ic.className = 'cal-chip-icon';
283    ic.setAttribute('aria-hidden', 'true');
284    wrap.appendChild(ic);
285
286    if (arg.timeText) {
287        const t = document.createElement('span');
288        t.className = 'cal-chip-time';
289        t.textContent = arg.timeText;
290        wrap.appendChild(t);
291    }
292    const title = document.createElement('span');
293    title.className = 'cal-chip-title';
294    title.textContent = arg.event.title || '(untitled)';
295    wrap.appendChild(title);
296
297    const dt = (ext.displayType || '').toLowerCase();
298    const isModule = !(dt === 'calendarevent' || dt === 'preconcalendarevent');
299    if (isModule) {
300        const a = document.createElement('span');
301        a.className = 'cal-chip-affordance';
302        a.textContent = '↗';
303        a.setAttribute('aria-hidden', 'true');
304        wrap.appendChild(a);
305    }
306    if ((arg.view?.type || '').startsWith('timeGrid')) {
307        const meta = [ext.type, ext.assigned, ext.location].filter(Boolean).join(' · ');
308        if (meta) {
309            const m = document.createElement('div');
310            m.className = 'cal-chip-meta';
311            m.textContent = meta;
312            wrap.appendChild(m);
313        }
314    }
315    return { domNodes: [wrap] };
316}
317
318// ========================================
319// HEADER + VIEW ORCHESTRATION
320// ========================================
321
322function bindHeader() {
323    const opts = abortController ? { signal: abortController.signal } : {};
324
325    document.getElementById('cal-prev')?.addEventListener('click', () => navigate(-1), opts);
326    document.getElementById('cal-next')?.addEventListener('click', () => navigate(1), opts);
327    document.getElementById('cal-today')?.addEventListener('click', () => goToday(), opts);
328    document.getElementById('btn-add-event')?.addEventListener('click', () => openEventModal(), opts);
329
330    document.getElementById('calendar-view-toggle')?.addEventListener('click', (e) => {
331        const btn = e.target.closest('[data-view]');
332        if (btn) setView(btn.dataset.view);
333    }, opts);
334
335    // Month chip density — an instant CSS class swap on .month-scroll (no re-render, no refetch).
336    document.getElementById('calendar-density-toggle')?.addEventListener('click', (e) => {
337        const btn = e.target.closest('[data-density]');
338        if (!btn || btn.dataset.density === currentDensity) return;
339        currentDensity = btn.dataset.density;
340        localStorage.setItem(STORAGE_DENSITY, currentDensity);
341        updateDensityToggle();
342        applyDensity();
343    }, opts);
344
345    // Project / Personal filter (searchable select). Select values: 'all' (real, visible
346    // option — not an empty placeholder), '0' = Personal, {id} = project. 'all' maps to the
347    // backend's '' (All). currentFilterId stays '' / '0' / {id}.
348    document.getElementById('calendar-filter')?.addEventListener('change', (e) => {
349        currentFilterId = e.target.value === 'all' ? '' : (e.target.value ?? '');
350        refresh();
351    }, opts);
352
353    document.getElementById('my-tasks-toggle')?.addEventListener('change', (e) => {
354        assignedToMe = e.target.checked;
355        localStorage.setItem(STORAGE_ASSIGNED, assignedToMe ? 'true' : 'false');
356        refresh();
357    }, opts);
358}
359
360async function navigate(delta) {
361    const pageSignal = abortController?.signal;
362    if (!await requestClosePopover()) return;
363    if (!pageSignal || pageSignal.aborted || pageSignal !== abortController?.signal) return;
364    if (currentView === 'day') {
365        const d = new Date(currentDate);
366        d.setDate(d.getDate() + delta);
367        currentDate = d;
368        renderDay();
369    } else if (currentView === 'month') {
370        // Step whole months off the 1st (avoids the Jan-31 + 1mo → Mar overflow); monthGridRange
371        // derives the visible window from whatever day this is.
372        currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + delta, 1);
373        renderMonth();
374    } else if (calendar) {
375        delta < 0 ? calendar.prev() : calendar.next();   // week → datesSet syncs currentDate + URL
376        currentDate = calendar.getDate();
377    }
378    syncUrl();   // idempotent; the custom month/day views (no datesSet) rely on this
379}
380
381async function goToday() {
382    const pageSignal = abortController?.signal;
383    if (!await requestClosePopover()) return;
384    if (!pageSignal || pageSignal.aborted || pageSignal !== abortController?.signal) return;
385    currentDate = new Date();
386    if (currentView === 'day') renderDay();
387    else if (currentView === 'month') renderMonth();
388    else if (calendar) { calendar.today(); currentDate = calendar.getDate(); }
389    syncUrl();
390}
391
392async function setView(v, { date = null } = {}) {
393    if (!v || v === currentView) return;
394    const pageSignal = abortController?.signal;
395    if (!await requestClosePopover()) return;
396    if (!pageSignal || pageSignal.aborted || pageSignal !== abortController?.signal) return;
397    // Only Week is FC-driven now — grab its date before leaving so the next view opens in the same place.
398    if (date) currentDate = date;
399    else if (currentView === 'week' && calendar) currentDate = calendar.getDate();
400    currentView = v;
401    updateViewToggle();
402    applyView();
403    syncUrl();
404}
405
406/** Match the DOM + FullCalendar to currentView + currentDate. No URL push, no same-view guard —
407 *  shared by setView (user), initCalendarPage (cold restore), and navigateCalendarPage (Back/Forward). */
408function applyView() {
409    const fcEl = document.getElementById('calendar-el');
410    const dayEl = document.getElementById('calendar-day-view');
411    const monthEl = document.getElementById('calendar-month-view');
412    const densityEl = document.getElementById('calendar-density-toggle');
413
414    // Hide all three mounts + the density dial; the active branch reveals what it needs.
415    monthEl?.classList.add('hidden');
416    fcEl?.classList.add('hidden');
417    dayEl?.classList.add('hidden');
418    densityEl?.classList.add('hidden');
419
420    if (currentView === 'day') {
421        dayEl?.classList.remove('hidden');
422        renderDay();
423    } else if (currentView === 'week') {
424        fcEl?.classList.remove('hidden');
425        calendar.changeView('timeGridWeek');
426        calendar.gotoDate(currentDate);
427        calendar.updateSize();   // FC needs a resize after being un-hidden
428    } else {
429        // month — our custom grid; the density dial only applies here.
430        monthEl?.classList.remove('hidden');
431        densityEl?.classList.remove('hidden');
432        renderMonth();
433    }
434}
435
436function buildCalendarUrl() {
437    return `/calendar/${currentView}/${isoLocal(currentDate)}`;
438}
439
440/** Push the URL to match current view + date — USER-driven only. Route-driven changes run under
441 *  _routing (silent). page() re-dispatches → navigateCalendarPage, which no-ops when state already
442 *  matches (so this never loops and never double-renders). */
443function syncUrl() {
444    if (_routing) return;
445    const url = buildCalendarUrl();
446    if (url !== window.location.pathname) window.page(url);
447}
448
449function updateViewToggle() {
450    document.querySelectorAll('#calendar-view-toggle [data-view]').forEach((b) => {
451        b.setAttribute('aria-pressed', b.dataset.view === currentView ? 'true' : 'false');
452    });
453}
454
455function updateDensityToggle() {
456    document.querySelectorAll('#calendar-density-toggle [data-density]').forEach((b) => {
457        b.setAttribute('aria-pressed', b.dataset.density === currentDensity ? 'true' : 'false');
458    });
459}
460
461/** Apply the current density as a class on the live .month-scroll root (instant; no re-render). */
462function applyDensity() {
463    const scroll = document.querySelector('#calendar-month-view .month-scroll');
464    if (!scroll) return;
465    scroll.classList.remove('dense-compact', 'dense-standard', 'dense-full');
466    scroll.classList.add(`dense-${currentDensity}`);
467}
468
469/** Re-load the active view (after a filter change or a create/edit/complete/delete). */
470function refresh() {
471    if (currentView === 'day') renderDay();
472    else if (currentView === 'month') renderMonth();
473    else calendar?.refetchEvents();
474}
475
476/**
477 * Render the custom month grid (MonthScheduleView). Mirrors renderDay: we own currentDate, compute the
478 * visible window via monthGridRange, fetch that range, render, then apply the density class. The click
479 * path is unchanged — handleEventClick → openEventPopover → the existing mini-detail.
480 */
481async function renderMonth() {
482    const monthEl = document.getElementById('calendar-month-view');
483    if (!monthEl) return;
484
485    const gen = ++viewRenderGen;   // snapshot before the await; a newer render (this or renderDay) invalidates this one
486
487    setTitle(currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }));
488
489    const { startIso, endIso } = monthGridRange(currentDate);
490    let events = [];
491    try {
492        events = await fetchEvents(startIso, endIso);
493    } catch (err) {
494        if (err.name === 'AbortError') return;
495        window.showToast?.('Failed to load events', 'error', { description: err.message });
496    }
497
498    // Rapid month nav fires several fetches; if an older one resolves after a newer render, drop it so we
499    // never paint the wrong month's events under the right title.
500    if (gen !== viewRenderGen) return;
501
502    await renderMonthScheduleView(monthEl, currentDate, events, {
503        onEventClick: (ev, anchorEl) => handleEventClick(ev.extendedProps || {}, anchorEl, ev.title),
504        onEmptyClick: (dateISO) => openEventModal(null, dateISO),
505        // Day number → jump to that day's Day view after any open-draft decision.
506        onDayClick: (dateISO) => setView('day', { date: new Date(dateISO + 'T00:00:00') }),
507    });
508    applyDensity();
509}
510
511async function renderDay() {
512    const dayEl = document.getElementById('calendar-day-view');
513    if (!dayEl) return;
514
515    const gen = ++viewRenderGen;   // same stale-fetch guard as renderMonth (shared counter)
516
517    const iso = isoLocal(currentDate);
518    setTitle(currentDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }));
519
520    const next = new Date(currentDate);
521    next.setDate(next.getDate() + 1);
522
523    let events = [];
524    try {
525        events = await fetchEvents(iso, isoLocal(next));
526    } catch (err) {
527        if (err.name === 'AbortError') return;
528        window.showToast?.('Failed to load events', 'error', { description: err.message });
529    }
530
531    // Drop a fetch that resolved after a newer render (rapid day nav, or a switch to another view).
532    if (gen !== viewRenderGen) return;
533
534    await renderDayScheduleView(dayEl, iso, events, {
535        onEventClick: (ev, anchorEl) => handleEventClick(ev.extendedProps || {}, anchorEl, ev.title),
536        onEmptyClick: (dateISO) => openEventModal(null, dateISO),
537    });
538}
539
540function setTitle(text) {
541    const el = document.getElementById('calendar-title');
542    if (el) el.textContent = text || '';
543}
544
545// ========================================
546// EVENT CLICK → PREVIEW POPOVER (preview-first for everything)
547// ========================================
548
549function handleEventClick(ext, anchorEl, title) {
550    if (anchorEl) {
551        openEventPopover(ext, anchorEl, title);
552        return;
553    }
554    // Fallback (e.g. deep-link with no anchor element).
555    const dt = (ext.displayType || '').toLowerCase();
556    if (dt === 'calendarevent' || dt === 'preconcalendarevent') openEventDetail(ext.calendarEventId, { updateUrl: true });
557    else routeToModule(ext);
558}
559
560async function openEventPopover(ext, anchorEl, title) {
561    const pageSignal = abortController?.signal;
562    if (!pageSignal || pageSignal.aborted) return;
563    if (!await requestClosePopover()) return;
564    if (pageSignal.aborted) return;
565    const openGen = ++popoverOpenGen;
566
567    const dt = (ext.displayType || '').toLowerCase();
568    const isCalendarEvent = dt === 'calendarevent' || dt === 'preconcalendarevent';
569    // Module project-tasks (RFI/SI/CHG/Submittal/clarification) carry the ProjectTaskID as
570    // calendarEventId, so we can fetch the unified mini-detail. ACCINVOICE carries an InvoiceId
571    // (different id space) and meeting-minutes have no task-dates → they stay the lean popover.
572    const isModuleTask = dt === 'projecttask' || dt === 'precontask';
573    // Submittals render as a module task (displayType 'projecttask') but live in a separate table
574    // (becc_pm_projectDocCtrl) — fetched via the submittal endpoint, not the project-task one.
575    const isSubmittal = isModuleTask && (ext.pageUrl || '').toLowerCase() === 'sub';
576    // Meeting-minute topics carry their MeetingID as calendarEventId + the topic id as meetingsTopicId.
577    const isMeetingMinute = dt === 'meetingminutes' || dt === 'preconmeetingminutes';
578    // Sources that get a live comment thread: module tasks, calendar events, meeting-minutes.
579    // (ACCINVOICE — displayType 'accounting' — is the only dated source with no comment thread.)
580    const hasComments = isModuleTask || isCalendarEvent || isMeetingMinute;
581    // Discriminator for the mini-detail fetch / live-refresh thread / comment endpoint.
582    const kind = isSubmittal ? 'submittal' : isMeetingMinute ? 'meeting' : isCalendarEvent ? 'event' : 'task';
583    const isCompleted = !!ext.taskCompletedDate;
584    // Status is derivable for ANY dated row (module due-dates included), not just calendar events.
585    const status = statusOf(ext.taskCompletedDate, ext.eventDateStart);
586    const moduleLabel = MODULE_LABELS[(ext.pageUrl || '').toLowerCase()] || 'item';
587
588    const vm = {
589        title: title || '(untitled)',
590        // Calendar events carry a real type (Task/Call/Meeting); module rows show their module label.
591        typeText: ext.type || (isCalendarEvent ? '' : moduleLabel),
592        priorityKey: (ext.priorityKey || '').toUpperCase(),
593        priorityText: ext.priority || '',          // data-gated: only calendar events carry priority
594        statusKey: status,
595        statusText: STATUS_TEXT[status] || '',
596        whenText: buildWhenText(ext),
597        assigned: ext.assigned || '',              // legacy showed assigned + invited as separate rows
598        invited: ext.invited || '',
599        location: ext.location || '',
600        projectName: ext.projectName || '',
601        descriptionSnippet: (ext.description || '').slice(0, 240),
602        isCalendarEvent,
603        isModuleTask,
604        hasComments,
605        // Detail-style header (hard-hat project + type label + bold title) for module tasks, calendar
606        // events, AND meeting-minutes — only ACCINVOICE stays the lean header. Same set as hasComments.
607        hasDetailHeader: hasComments,
608        isCompleted,
609        moduleLabel,
610        // Type label above the title: module → entity label; event → its Task/Call/Meeting type;
611        // meeting-minute → "Meeting Topic". (Module #-ref + project subtitle still fill on fetch.)
612        typeLabel: isModuleTask ? (MODULE_TYPE_LABELS[(ext.pageUrl || '').toLowerCase()] || moduleLabel)
613                 : isCalendarEvent ? (ext.type || 'Event')
614                 : isMeetingMinute ? 'Meeting Topic'
615                 : '',
616    };
617
618    // Render into a detached host; createPortalPopover portals firstElementChild to body.
619    // The await ensures the opening click has fully finished before we bind outside-click.
620    const host = document.createElement('div');
621    await renderTo(host, '#tpl-calendar-event-popover', vm);
622    if (openGen !== popoverOpenGen || anchorEl.isConnected === false || pageSignal.aborted) return;
623    const popEl = host.firstElementChild;
624    if (!popEl) return;
625
626    // Side placement (right/left of the chip) so the popover never covers the day's other chips —
627    // legacy parity. createPortalPopover flips side near the viewport edge + drives the arrow.
628    const cleanup = createPortalPopover(anchorEl, popEl, { offset: 12, placement: 'side' });
629    const backdropCleanup = hasComments
630        ? createMobilePopoverBackdrop(popEl, () => { void requestClosePopover(); })
631        : null;
632
633    // popEl was rendered detached; createIcons({ root }) draws it either way — this pass covers portal re-parenting (CA-620).
634    window.lucide?.createIcons({ root: popEl });
635
636    const onDocClick = (e) => {
637        if (activePopover?.el !== popEl || activePopover.dismissPending) return;
638        if (!popEl.contains(e.target) && !anchorEl.contains(e.target)) {
639            // These Calendar controls make their own awaited close request, so let the original
640            // click continue; otherwise a confirmed discard would force an unnecessary re-click.
641            if (e.target.closest('#cal-prev, #cal-next, #cal-today, #calendar-view-toggle [data-view], [data-event-key], [data-day-goto], .fc-event')) return;
642            if (popoverHasDraft(activePopover)) {
643                // The background click must not navigate or open another record while the user
644                // is deciding whether to discard the comment. They can repeat it after consent.
645                e.preventDefault();
646                e.stopPropagation();
647            }
648            void requestClosePopover();
649        }
650    };
651    const onKey = (e) => {
652        if (e.key !== 'Escape' || activePopover?.el !== popEl || activePopover.dismissPending) return;
653        e.preventDefault();
654        e.stopPropagation();
655        void requestClosePopover();
656    };
657    document.addEventListener('click', onDocClick, true);
658    document.addEventListener('keydown', onKey);
659    activePopover = { cleanup, backdropCleanup, el: popEl, anchorEl, onDocClick, onKey, dismissPending: false };
660    if (hasComments) popEl.querySelector('[data-pop-close]')?.focus({ preventScroll: true });
661
662    popEl.querySelectorAll('[data-pop-close]').forEach(b => b.addEventListener('click', () => { void requestClosePopover(); }));
663    popEl.querySelector('[data-pop-open]')?.addEventListener('click', async () => {
664        const actionSignal = abortController?.signal;
665        const id = ext.calendarEventId;
666        if (!await requestClosePopover()) return;
667        if (!actionSignal || actionSignal.aborted || actionSignal !== abortController?.signal) return;
668        openEventDetail(id, { updateUrl: true });
669    });
670    popEl.querySelector('[data-pop-goto]')?.addEventListener('click', async () => {
671        const actionSignal = abortController?.signal;
672        if (!await requestClosePopover()) return;
673        if (!actionSignal || actionSignal.aborted || actionSignal !== abortController?.signal) return;
674        routeToModule(ext, { newTab: isModuleTask });   // module "View" → new tab (keep calendar position)
675    });
676    popEl.querySelector('[data-pop-complete]')?.addEventListener('click', async (e) => {
677        const actionSignal = abortController?.signal;
678        if (!await requestClosePopover({ keepOpen: true })) return;
679        if (!actionSignal || actionSignal.aborted || actionSignal !== abortController?.signal
680            || activePopover?.el !== popEl) return;
681        const id = ext.calendarEventId;
682        runWithBusyButton(e.currentTarget, 'Completing…', async () => {
683            await projectCalendarApi.completeEvent(id, '');
684            if (actionSignal.aborted || actionSignal !== abortController?.signal || activePopover?.el !== popEl) return;
685            closePopover();
686            window.showToast?.('Event completed', 'success');
687            refresh();
688        }, { errorTitle: 'Failed to complete event' });
689    });
690
691    // Sources with a comment thread (module tasks, calendar events, meeting-minutes) → upgrade the
692    // popover to a mini-detail: fetch the thread (+ task-dates for module tasks), wire the composer
693    // (Ctrl/Cmd+Enter to post), and live-refresh while open.
694    if (hasComments) {
695        const addBtn = popEl.querySelector('[data-pop-add-comment]');
696        addBtn?.addEventListener('click', () => postPopoverComment(addBtn, popEl, ext, kind));
697        popEl.querySelector('#cal-pop-comment-input')?.addEventListener('keydown', (e) => {
698            if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); addBtn?.click(); }
699        });
700        loadPopoverDetail(ext, popEl, kind);
701    }
702}
703
704function popoverHasDraft(owner) {
705    return !!owner?.el.querySelector('#cal-pop-comment-input')?.value.trim();
706}
707
708/** Ask before abandoning authored text; the same decision owns outside, Escape, Close and actions. */
709function requestClosePopover({ keepOpen = false } = {}) {
710    const owner = activePopover;
711    if (!owner) return true;
712    if (owner.dismissPending) return false;
713    if (owner.posting) {
714        window.showToast?.('Comment is still posting', 'info');
715        return false;
716    }
717    if (!popoverHasDraft(owner)) {
718        if (!keepOpen) closePopover();
719        return true;
720    }
721
722    owner.dismissPending = true;
723    return (async () => {
724        let discard;
725        try {
726            discard = await confirm({
727                title: 'Discard comment?',
728                message: 'Your unsent comment will be lost.',
729                icon: 'message-circle',
730                confirmText: 'Discard',
731                confirmClass: 'btn-st-error',
732                signal: abortController?.signal,
733            });
734        } catch (error) {
735            if (activePopover === owner) {
736                owner.dismissPending = false;
737                window.showToast?.('Could not open discard confirmation', 'error', { description: error.message });
738            }
739            return false;
740        }
741        if (activePopover !== owner) return false;
742        owner.dismissPending = false;
743        if (!discard) {
744            owner.el.querySelector('#cal-pop-comment-input')?.focus({ preventScroll: true });
745            return false;
746        }
747        if (keepOpen) owner.el.querySelector('#cal-pop-comment-input').value = '';
748        else closePopover();
749        return true;
750    })();
751}
752
753/** Force teardown only for a completed action or page destruction; user exits use requestClosePopover. */
754function closePopover({ restoreFocus = true } = {}) {
755    popoverOpenGen++;
756    if (!activePopover) return;
757    const { cleanup, backdropCleanup, el, anchorEl, onDocClick, onKey, sub } = activePopover;
758    activePopover = null;
759    sub?.dispose();   // TaskGroupSync live-refresh subscription (module mini-detail), if any
760    document.removeEventListener('click', onDocClick, true);
761    document.removeEventListener('keydown', onKey);
762    backdropCleanup?.();
763    cleanup?.();      // removes reposition listeners (does NOT remove the node)
764    el?.remove();     // remove the portaled popover
765    if (restoreFocus && anchorEl?.isConnected) anchorEl.focus({ preventScroll: true });
766}
767
768/**
769 * Mini-detail fetchers — every popover source resolves to the SAME uniform shape
770 * ({ assignees, taskDates, comments, ... }) so renderPopoverDetail/renderPopoverComments never
771 * change. Module tasks + submittals have dedicated calendar endpoints; calendar events and
772 * meeting-minutes reuse their own module APIs, normalized to the uniform shape here.
773 */
774function fetchMiniDetail(ext, kind) {
775    const id = ext.calendarEventId;
776    switch (kind) {
777        case 'submittal': return calendarApi.getSubmittalDetail(id);
778        case 'event':     return projectCalendarApi.getEventDetail(id).then(normalizeEventDetail);
779        case 'meeting':   return projectMeetingsApi
780                              .getTopicComments(ext.projectId, id, ext.meetingsTopicId)
781                              .then(normalizeMeetingTopicDetail);
782        default:          return calendarApi.getTaskDetail(id);   // 'task'
783    }
784}
785
786/** Live-refresh thread id per source (matches the backend Notify* thread conventions). */
787function threadFor(ext, kind) {
788    switch (kind) {
789        case 'submittal': return `submittal-${ext.calendarEventId}`;
790        case 'event':     return `calagsn-${ext.calendarEventId}`;
791        case 'meeting':   return `meeting-topic-${ext.meetingsTopicId}`;
792        default:          return `asgn-${ext.calendarEventId}`;
793    }
794}
795
796/** getEventDetail() → uniform shape. Events have no task-dates; assignee shows in the lean rows. */
797function normalizeEventDetail(d) {
798    return {
799        referenceNumber: '', projectNumber: '', assignedUserName: '', assignees: [], taskDates: [],
800        comments: (d?.comments || []).map(mapEventComment),
801    };
802}
803
804/** getTopicComments() → uniform shape. Meeting-minutes have no task-dates; assignee in the lean rows. */
805function normalizeMeetingTopicDetail(resp) {
806    return {
807        referenceNumber: '', projectNumber: '', assignedUserName: '', assignees: [], taskDates: [],
808        comments: (resp?.comments || []).map(mapMeetingComment),
809    };
810}
811
812const mapEventComment = (c) => ({ createdByName: c.author || 'Unknown', comment: c.text || '', dateCreatedDisplay: c.date || '' });
813const mapMeetingComment = (c) => ({ createdByName: c.createdByName || 'Unknown', comment: c.comment || '', dateCreatedDisplay: c.dateCreated ? formatDate(c.dateCreated) : '' });
814
815function setPopoverDetailAvailability(popEl, available) {
816    const input = popEl.querySelector('#cal-pop-comment-input');
817    const addBtn = popEl.querySelector('[data-pop-add-comment]');
818    popEl.querySelectorAll('[data-pop-detail-region]').forEach(region => {
819        region.classList.toggle('hidden', !available);
820    });
821    if (input) input.disabled = !available;
822    if (addBtn) addBtn.disabled = !available;
823}
824
825async function renderPopoverDetailState(popEl, state) {
826    const statusEl = popEl.querySelector('#cal-pop-detail-status');
827    if (!statusEl) return activePopover?.el === popEl;
828
829    if (state === 'ready') {
830        statusEl.replaceChildren();
831        statusEl.classList.add('hidden');
832        setPopoverDetailAvailability(popEl, true);
833        return activePopover?.el === popEl;
834    }
835
836    setPopoverDetailAvailability(popEl, false);
837    statusEl.classList.remove('hidden');
838    await renderTo(statusEl, '#tpl-calendar-mini-detail-state', {
839        isLoading: state === 'loading',
840        isDenied: state === 'denied',
841        canRetry: state === 'error',
842        message: state === 'denied'
843            ? 'You do not have access to this item.'
844            : state === 'error'
845                ? 'Could not load these details.'
846                : 'Loading details…',
847    });
848    return activePopover?.el === popEl;
849}
850
851/**
852 * Popover → fetch the unified mini-detail, fill the skeleton regions, then subscribe to live
853 * updates. Guards against the popover being closed/replaced mid-fetch. The fetch + thread vary by
854 * source kind (task / submittal / event / meeting); the rendered shape is uniform.
855 */
856async function loadPopoverDetail(ext, popEl, kind) {
857    if (activePopover?.el !== popEl) return;
858    if (!await renderPopoverDetailState(popEl, 'loading')) return;
859
860    let detail;
861    try {
862        detail = await fetchMiniDetail(ext, kind);
863    } catch (err) {
864        if (err.name === 'AbortError') return;   // navigated away mid-fetch — popover is already gone
865        if (activePopover?.el !== popEl) return;
866
867        const state = err.status === 403 ? 'denied' : 'error';
868        if (!await renderPopoverDetailState(popEl, state)) return;
869        if (state === 'error') {
870            popEl.querySelector('[data-pop-retry-detail]')?.addEventListener('click', () => {
871                if (activePopover?.el === popEl) return loadPopoverDetail(ext, popEl, kind);
872                return undefined;
873            }, { once: true });
874        }
875        return;
876    }
877    if (activePopover?.el !== popEl) return;   // popover was closed/replaced during the fetch
878
879    renderPopoverDetail(popEl, detail);
880    await renderPopoverDetailState(popEl, 'ready');
881    if (activePopover?.el !== popEl) return;
882    activePopover.cleanup?.reposition?.();   // content grew → re-clamp to viewport
883
884    // Live comment refresh: re-fetch via the same source on a refreshTaskComments ping.
885    activePopover.sub = subscribeTaskUpdates(threadFor(ext, kind), async () => {
886        if (activePopover?.el !== popEl) return;
887        try {
888            const fresh = await fetchMiniDetail(ext, kind);
889            if (activePopover?.el === popEl) {
890                renderPopoverDetail(popEl, fresh);
891                activePopover.cleanup?.reposition?.();
892            }
893        } catch { /* ignore transient refresh errors */ }
894    });
895}
896
897/** Fill the popover's Assigned / Task-Dates / Comments regions from a getTaskDetail() payload. */
898function renderPopoverDetail(popEl, detail) {
899    // Header fields that only arrive with the fetch: reference # + project subtitle (number).
900    const refEl = popEl.querySelector('[data-ref-number]');
901    if (refEl) refEl.textContent = detail.referenceNumber || '';
902    const subEl = popEl.querySelector('[data-project-subtitle]');
903    if (subEl) {
904        const sub = detail.projectNumber ? `No. ${detail.projectNumber}` : '';
905        subEl.textContent = sub;
906        subEl.classList.toggle('hidden', !sub);
907    }
908
909    const assignedEl = popEl.querySelector('#cal-pop-assigned');
910    if (assignedEl) {
911        const names = (detail.assignees || []).map(a => a.name).filter(Boolean);
912        const text = names.length ? names.join(', ') : (detail.assignedUserName || '');
913        const target = assignedEl.querySelector('[data-assigned-names]');
914        if (text && target) {
915            target.textContent = text;
916            assignedEl.classList.remove('hidden');
917            assignedEl.classList.add('flex');
918        } else {
919            assignedEl.classList.add('hidden');
920        }
921    }
922
923    // Highlight the next-due row (first not-completed, not-N/A) — the milestone the clicked chip represents.
924    const datesEl = popEl.querySelector('#cal-pop-dates');
925    if (datesEl) {
926        const dates = detail.taskDates || [];
927        const activeIdx = dates.findIndex(d => !d.dateCompletedDisplay && !d.isNA);
928        const rows = dates.map((d, i) => ({ ...d, isActive: i === activeIdx }));
929        renderTo(datesEl, '#tpl-cal-pop-dates', { rows });
930    }
931
932    renderPopoverComments(popEl, detail.comments || []);
933}
934
935/** Render the popover comment thread (normalized to the shared comment-row shape). */
936function renderPopoverComments(popEl, comments) {
937    const el = popEl.querySelector('#cal-pop-comments');
938    if (!el) return;
939    const rows = (comments || []).map(c => ({
940        author: c.createdByName || 'Unknown', text: c.comment || '', dateText: c.dateCreatedDisplay || '',
941    }));
942    if (rows.length) renderTo(el, '#tpl-calendar-comment-row', rows);
943    else renderTo(el, '#tpl-calendar-activity-empty', { label: 'No comments yet.' });
944
945    const countEl = popEl.querySelector('[data-comment-count]');
946    if (countEl) {
947        countEl.textContent = String(rows.length);
948        countEl.classList.toggle('hidden', rows.length === 0);
949    }
950}
951
952/** Post a comment from the popover → backend notifies the assignees → re-render the thread. */
953async function postPopoverComment(btn, popEl, ext, kind) {
954    if (activePopover?.el !== popEl) return;
955    const owner = activePopover;
956    const input = popEl.querySelector('#cal-pop-comment-input');
957    const text = (input?.value || '').trim();
958    if (!text) { window.showToast?.('Enter a comment first', 'info'); return; }
959    if (btn.dataset.busy === '1') return;
960    btn.dataset.busy = '1';
961    owner.posting = true;
962    setLoading(btn, true, '', { iconOnly: true });
963    try {
964        const res = await addPopoverComment(ext, kind, text);
965        if (activePopover?.el === popEl) {
966            renderPopoverComments(popEl, res.comments || []);
967            if (input) input.value = '';
968            window.showToast?.('Comment posted', 'success');
969        }
970    } catch (err) {
971        if (err.name === 'AbortError') return;   // navigated away mid-post — finally still resets the button
972        if (activePopover?.el === popEl) {
973            window.showToast?.('Failed to post comment', 'error', { description: err.message });
974        }
975    } finally {
976        owner.posting = false;
977        if (activePopover === owner) {
978            setLoading(btn, false);
979            btn.dataset.busy = '';
980        }
981    }
982}
983
984/** Post a comment for any source kind; resolves to { comments } in the uniform shape. */
985async function addPopoverComment(ext, kind, text) {
986    const id = ext.calendarEventId;
987    switch (kind) {
988        case 'submittal': return calendarApi.addSubmittalComment(id, text);
989        case 'event': {
990            const comments = await projectCalendarApi.addEventComment(id, text);   // returns mapped [{author,text,date}]
991            return { comments: (comments || []).map(mapEventComment) };
992        }
993        case 'meeting': {
994            await projectMeetingsApi.addComment(ext.projectId, id, ext.meetingsTopicId, { comment: text });
995            const resp = await projectMeetingsApi.getTopicComments(ext.projectId, id, ext.meetingsTopicId);
996            return { comments: (resp?.comments || []).map(mapMeetingComment) };
997        }
998        default:          return calendarApi.addTaskComment(id, text);   // 'task'
999    }
1000}
1001
1002function buildWhenText(ext) {
1003    const parts = [];
1004    if (ext.eventDateStart) parts.push(formatDate(ext.eventDateStart));
1005    if (ext.eventStartTime) {
1006        let t = formatTimeDisplay(ext.eventStartTime);
1007        if (ext.eventEndTime) t += ` – ${formatTimeDisplay(ext.eventEndTime)}`;
1008        parts.push(t);
1009    }
1010    // Duration ("2 Hours") adds info only when no explicit end time already conveys the span.
1011    // List events carry durationVal/durationType (NOT durationText — that backend field is unset
1012    // on the list payload), so build the label here like the detail modal does (calendar.js:~785).
1013    const dur = durationLabel(ext);
1014    if (dur && !ext.eventEndTime) parts.push(dur);
1015    return parts.join(' · ');
1016}
1017
1018/** Human duration ("2 Hours") from a list event's durationVal/durationType; '' when none/zero. */
1019function durationLabel(ext) {
1020    const val = Number(ext.durationVal);
1021    if (!val || val <= 0) return '';
1022    return `${ext.durationVal} ${DURATION_TYPE_LABELS[ext.durationType] || ''}`.trim();
1023}
1024
1025/**
1026 * Route a module event to its owning page, deep-linking the specific RECORD where the
1027 * target sub-module's route opens it by the id the calendar carries (calendarEventId);
1028 * otherwise route to the module tab. Mapping verified 2026-06-24 — see
1029 * porting-maps/audits/calendar-audit-scorecard.md "Go-To deep-link spec".
1030 *   • Record-deep-linkable (calendarEventId IS the id the target opens): contract-admin
1031 *     rfi/si/chg/sub (ProjectTaskID), contract-admin meetings from a meeting-minute
1032 *     (MeetingID → the meeting itself), precon desclar/tenderclar (clarification item),
1033 *     accounting/invoices (InvoiceID — backend now resolves the linked invoice id).
1034 *   • Tab-only (id space doesn't match → would silently mis-route): precon meeting-minutes
1035 *     (meetings tab takes no id; 'meeting'→'meetings' slug fix). The !!recordId guard also
1036 *     routes any row with no resolvable record id (e.g. an unlinked invoice) to the tab.
1037 */
1038function routeToModule(ext, { newTab = false } = {}) {
1039    const projectId = ext.projectId;
1040    const recordId = ext.calendarEventId;
1041    const pageUrl = (ext.pageUrl || '').toLowerCase();
1042    const displayType = (ext.displayType || '').toLowerCase();
1043
1044    if (!projectId || !pageUrl) {
1045        window.showToast?.('Open the related module to view this item', 'info');
1046        return;
1047    }
1048
1049    let base = 'contract-admin';
1050    if (displayType === 'accounting') base = 'accounting';
1051    else if (displayType.startsWith('precon')) base = 'pre-construction';
1052
1053    // Legacy → new-app slug fixups (only known divergence: precon meeting-minutes emit singular 'meeting').
1054    const slug = pageUrl === 'meeting' ? 'meetings' : pageUrl;
1055
1056    const canDeepLink = !!recordId && (
1057        (base === 'contract-admin' && displayType === 'projecttask' && ['rfi', 'si', 'chg', 'sub'].includes(pageUrl)) ||
1058        (base === 'contract-admin' && displayType === 'meetingminutes' && slug === 'meetings') ||
1059        (base === 'pre-construction' && displayType === 'precontask' && ['desclar', 'tenderclar'].includes(pageUrl)) ||
1060        (base === 'accounting' && displayType === 'accounting' && pageUrl === 'invoices')
1061    );
1062
1063    const url = canDeepLink
1064        ? `/${base}/${projectId}/${slug}/${recordId}`
1065        : `/${base}/${projectId}/${slug}`;
1066    // From the popover "View" we open a new tab so the user keeps their calendar month/scroll
1067    // position (the calendar isn't URL-routed yet — see the calendar-routing follow-up).
1068    if (newTab) { window.open(url, '_blank', 'noopener'); return; }
1069    if (typeof window.page === 'function') window.page(url);
1070    else window.location.href = url;
1071}
1072
1073// ========================================
1074// FILTER / MY-TASKS
1075// ========================================
1076
1077async function loadProjects() {
1078    try {
1079        const resp = await projectsApi.getProjects();
1080        const list = Array.isArray(resp) ? resp : (resp?.projects || resp?.items || []);
1081        projectsCache = list.map(p => ({
1082            projectId: p.projectId ?? p.id,
1083            projectTitle: p.projectTitle ?? p.title ?? p.name ?? '(Untitled)',
1084            projectNumber: p.projectNumber ?? p.number ?? '',
1085        })).filter(p => p.projectId);
1086    } catch (error) {
1087        if (error.name === 'AbortError') return;
1088        projectsCache = [];
1089    }
1090    populateFilterDropdown();
1091}
1092
1093/**
1094 * Populate the searchable filter select with the project options (after the static
1095 * All Events / Personal defaults), then re-sync the enhanced dropdown. Mirrors
1096 * messages.js populateProjectDropdown. XSS-safe (textContent, not innerHTML).
1097 */
1098function populateFilterDropdown() {
1099    const select = document.getElementById('calendar-filter');
1100    if (!select) return;
1101    // Drop any previously-appended project options; keep the 'all' All / '0' Personal defaults.
1102    select.querySelectorAll('option').forEach((o) => {
1103        if (o.value !== 'all' && o.value !== '0') o.remove();
1104    });
1105    for (const p of projectsCache) {
1106        const opt = document.createElement('option');
1107        opt.value = String(p.projectId);
1108        opt.textContent = p.projectNumber ? `(${p.projectNumber}) ${p.projectTitle}` : p.projectTitle;
1109        select.appendChild(opt);
1110    }
1111    // currentFilterId '' (All) ↔ select value 'all'; '0' / {id} map directly.
1112    select.value = currentFilterId === '' ? 'all' : currentFilterId;
1113    const inst = SearchableDropdown.getInstance(select);
1114    inst?.refresh();
1115    // Tag the panel itself so the calendar-only width/truncation CSS can target it: the
1116    // component portals the panel to <body> on open (createPortalPopover), so a
1117    // #calendar-container selector never matches it. The class rides with the element.
1118    inst?.dropdown?.classList.add('calendar-filter-dropdown');
1119}
1120
1121function syncMyTasksToggle() {
1122    const toggle = document.getElementById('my-tasks-toggle');
1123    if (toggle) toggle.checked = assignedToMe;
1124}
1125
1126// ========================================
1127// CREATE / EDIT MODAL
1128// ========================================
1129
1130function isoToday() {
1131    const d = new Date();
1132    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1133}
1134
1135function isoLocal(d) {
1136    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1137}
1138
1139/** YYYY-MM-DD from an ISO/Date-ish value (for the DatePicker value on edit). */
1140function toIsoDate(value) {
1141    if (!value) return '';
1142    const d = new Date(value);
1143    if (isNaN(d.getTime())) return typeof value === 'string' ? value.slice(0, 10) : '';
1144    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1145}
1146
1147/** Coerce a stored start time ("09:00" or "9:00 AM") to the 24h "HH:mm" the editor needs. */
1148function to24hTime(value) {
1149    if (!value) return '';
1150    const m = /^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i.exec(value.trim());
1151    if (!m) return value;
1152    let hh = parseInt(m[1], 10);
1153    const mm = m[2];
1154    const mer = (m[3] || '').toUpperCase();
1155    if (mer === 'PM' && hh < 12) hh += 12;
1156    if (mer === 'AM' && hh === 12) hh = 0;
1157    return `${String(hh).padStart(2, '0')}:${mm}`;
1158}
1159
1160/**
1161 * Open the create/edit event modal.
1162 * @param {object|null} existing — normalized event (from openEventDetail) for edit; null = create.
1163 * @param {string} [defaultDate] — YYYY-MM-DD to preselect on create (from a date-cell click).
1164 */
1165async function openEventModal(existing = null, defaultDate) {
1166    const isEdit = !!existing;
1167    const projectIdForEdit = isEdit ? (existing.typeCode === 'PRO' ? existing.typeId : 0) : 0;
1168
1169    const modalData = isEdit ? {
1170        calendarEventId: existing.id,
1171        typeCode: existing.typeCode,
1172        projectId: projectIdForEdit,
1173        type: existing.taskType,
1174        priority: existing.priority,
1175        title: existing.title,
1176        eventDateStart: toIsoDate(existing.dateStart),
1177        eventStartTime: to24hTime(existing.startTime),
1178        eventDuration: (existing.duration && Number(existing.duration) > 0) ? existing.duration : '',
1179        durationType: existing.durationType || 'M',
1180        description: existing.description,
1181        location: existing.location,
1182        projects: projectsCache,
1183    } : {
1184        eventDateStart: defaultDate || isoToday(),
1185        type: 'T',
1186        priority: 'M',
1187        projects: projectsCache,
1188    };
1189
1190    // Backdrop-click closes here too — while the composer is UNTOUCHED. The calendar is an
1191    // all-click-and-explore surface, so refusing to close an empty form punishes a legitimate
1192    // "I clicked to see what it does" instinct (Dan's call). Once the user has actually entered
1193    // something, the app-wide unsaved-input guard takes over and a mis-click outside is refused;
1194    // the X asks before discarding. That is what "the guard subsumes both calendar opt-ins" means
1195    // — the opt-in stays, its blast radius does not.
1196    const modal = await showModal('#tpl-calendar-event-modal', modalData, {
1197        ...(abortController ? { signal: abortController.signal } : {}),
1198        closeOnBackdrop: true,
1199    });
1200    const el = modal.element;
1201
1202    const projectSel = el.querySelector('#calendar-event-project');
1203    const typeSel = el.querySelector('#calendar-event-type');
1204
1205    await renderPeopleFields(el, selectedProjectId(projectSel), existing);
1206
1207    projectSel?.addEventListener('change', async () => {
1208        await renderPeopleFields(el, selectedProjectId(projectSel), null);
1209    });
1210    typeSel?.addEventListener('change', () => syncTypeFields(el, typeSel));
1211
1212    el.querySelector('#calendar-event-save')
1213        ?.addEventListener('click', () => handleSave(modal, el, existing));
1214}
1215
1216function selectedProjectId(projectSel) {
1217    return parseInt(projectSel?.value || '0', 10) || 0;
1218}
1219
1220/**
1221 * Render the assign/invite/location fields. Project events (projectId > 0) get project members (Assign +
1222 * member invites) and project contacts from getTaskAddOptions. Personal events (projectId 0) can't assign
1223 * (no project members) but CAN invite the company's contacts — reuses the C
1223ontacts list endpoint
1224 * (`contactsApi.getAll`); the backend resolves each contact's email from its CompanyContactID on ICS send.
1225 */
1226async function renderPeopleFields(el, projectId, existing) {
1227    const container = el.querySelector('#calendar-event-people');
1228    if (!container) return;
1229
1230    const isProject = projectId > 0;
1231    let members = [];
1232    let contacts = [];
1233    if (isProject) {
1234        try {
1235            const opts = await projectCalendarApi.getTaskAddOptions(projectId);
1236            members = opts.members || [];
1237            contacts = opts.contacts || [];
1238        } catch (error) {
1239            if (error.name === 'AbortError') return;
1240        }
1241    } else {
1242        // Personal event → invite from the company contact list (same {id}|C token space as project contacts).
1243        try {
1244            const resp = await contactsApi.getAll();
1245            const list = resp?.results || resp?.Results || [];
1246            contacts = list.map(c => ({
1247                id: c.companyContactId,
1248                name: [c.firstName, c.lastName].filter(Boolean).join(' ').trim() || c.emailAddress || 'Unknown',
1249                companyName: c.companyName || '',
1250            })).filter(c => c.id);
1251        } catch (error) {
1252            if (error.name === 'AbortError') return;
1253        }
1254    }
1255
1256    // Precompute selected flags so options render pre-selected (reliable across the
1257    // SearchableDropdown enhancement renderTo triggers — a post-render setValue races it).
1258    const assignedSet = new Set((existing?.assignedIds || []).map(String));
1259    const invitedSet = new Set(existing?.invitedTokens || []);
1260    const memberOpts = members.map(m => ({
1261        ...m,
1262        assignSelected: assignedSet.has(String(m.id)),
1263        inviteSelected: invitedSet.has(`${m.id}|M`),
1264    }));
1265    const contactOpts = contacts.map(c => ({
1266        ...c,
1267        inviteSelected: invitedSet.has(`${c.id}|C`),
1268    }));
1269
1270    await renderTo(container, '#tpl-calendar-event-people', {
1271        members: memberOpts,
1272        contacts: contactOpts,
1273        canAssign: isProject,                                       // Assign (members) is project-only
1274        canInvite: memberOpts.length > 0 || contactOpts.length > 0, // Invite shows whenever there's anyone to invite
1275        location: existing?.location || '',
1276    });
1277
1278    syncTypeFields(el, el.querySelector('#calendar-event-type'));
1279}
1280
1281/** Show Assign for Task, Invite for Meeting/Call, Location for Meeting. */
1282function syncTypeFields(el, typeSel) {
1283    const t = typeSel?.value || 'T';
1284    el.querySelector('[data-cal-field="assign"]')?.classList.toggle('hidden', t !== 'T');
1285    el.querySelector('[data-cal-field="invite"]')?.classList.toggle('hidden', t !== 'M' && t !== 'C');
1286    el.querySelector('[data-cal-field="location"]')?.classList.toggle('hidden', t !== 'M');
1287}
1288
1289function handleSave(modal, el, existing) {
1290    const isEdit = !!existing;
1291    const titleEl = el.querySelector('#calendar-event-title');
1292    const dateEl = el.querySelector('#calendar-event-date');
1293    const priorityEl = el.querySelector('#calendar-event-priority');
1294
1295    const type = isEdit ? existing.taskType : (el.querySelector('#calendar-event-type')?.value || 'T');
1296    const projectId = isEdit ? (existing.typeCode === 'PRO' ? existing.typeId : 0)
1297        : selectedProjectId(el.querySelector('#calendar-event-project'));
1298    const isProject = projectId > 0;
1299
1300    const assignEl = el.querySelector('#calendar-event-assign');
1301    const inviteEl = el.querySelector('#calendar-event-invite');
1302    const durationEl = el.querySelector('#calendar-event-duration');
1303
1304    const rules = [
1305        { el: titleEl, label: 'Title' },
1306        { el: dateEl, label: 'Date' },
1307        { el: priorityEl, label: 'Priority' },
1308        // Joins the SAME pass rather than gating separately (MCF-0425): a missing title and a
1309        // malformed duration are reported together, in one round.
1310        { el: durationEl, label: 'Duration', numeric: CALENDAR_EVENT_DURATION, required: false },
1311    ];
1312    if (isProject && type === 'T' && assignEl) rules.push({ el: assignEl, label: 'Assign To' });
1313    else if (isProject && (type === 'M' || type === 'C') && inviteEl) rules.push({ el: inviteEl, label: 'Invite People' });
1314    if (!validateRequired(el, rules)) return;
1315
1316    // Round-trip the FULL current set for BOTH lists (backend UpdateAsync removes-all-then-re-adds).
1317    let assignedIds = isEdit ? (existing.assignedIds || []).map(v => parseInt(v, 10)).filter(v => v > 0) : [];
1318    let invitations = isEdit ? [...(existing.invitedTokens || [])] : [];
1319    if (isProject && type === 'T' && assignEl) {
1320        const raw = SearchableDropdown.getInstance(assignEl)?.getValue() ?? [];
1321        assignedIds = (Array.isArray(raw) ? raw : [raw]).map(v => parseInt(v, 10)).filter(v => v > 0);
1322    } else if ((type === 'M' || type === 'C') && inviteEl) {
1323        // Invitations apply to project AND personal Meeting/Call (personal invites company contacts).
1324        const raw = SearchableDropdown.getInstance(inviteEl)?.getValue() ?? [];
1325        invitations = (Array.isArray(raw) ? raw : [raw]).filter(
1325Boolean);
1326    }
1327
1328    // Exact `Number` of text the gate above has already proven readable. The `(duration && duration
1329    // > 0) ? duration : null` publication below is UNCHANGED and stays the contract: blank publishes
1330    // `null`, and a deliberate `0` also publishes `null` because a zero-length event has no
1331    // duration. What changed is that garbage no longer reaches it — it is refused with a sentence
1332    // instead of being silently converted to `NaN` and then thrown away as `null`.
1333    const durationRaw = durationEl?.value.trim();
1334    const duration = durationRaw ? Number(durationRaw) : null;
1335    const locationEl = el.querySelector('#calendar-event-location');
1336
1337    const body = {
1338        typeCode: isProject ? 'PRO' : 'USR',
1339        typeId: isProject ? projectId : 0,
1340        title: titleEl.value.trim(),
1341        description: el.querySelector('#calendar-event-description')?.value.trim() || '',
1342        type,
1343        priority: priorityEl.value,
1344        eventDateStart: dateEl.value,
1345        eventStartTime: el.querySelector('#calendar-event-time')?.value || '',
1346        location: (type === 'M' && locationEl) ? locationEl.value.trim() : '',
1347        eventDuration: (duration && duration > 0) ? duration : null,
1348        eventDurationType: el.querySelector('#calendar-event-duration-type')?.value || 'M',
1349    };
1350
1351    runWithBusyButton(el.querySelector('#calendar-event-save'), isEdit ? 'Saving…' : 'Creating…', async () => {
1352        if (isEdit) {
1353            body.calendarEventId = existing.id;
1354            await calendarApi.update(existing.id, body, assignedIds, invitations);
1355            window.showToast?.('Event updated', 'success');
1356        } else {
1357            await calendarApi.create(body, assignedIds, invitations);
1358            window.showToast?.('Event created', 'success');
1359        }
1360        modal.close();
1361        refresh();
1362    }, { errorTitle: isEdit ? 'Failed to update event' : 'Failed to create event' });
1363}
1364
1365// ========================================
1366// DETAIL MODAL (real calendar events)
1367// ========================================
1368
1369/**
1370 * Open the rich detail modal for a calendar event (completion + comments + history).
1371 * @param {number|string} eventId
1372 * @param {{ gotoDate?: boolean }} [opts] — gotoDate navigates the grid to the event (deep-link).
1373 */
1374async function openEventDetail(eventId, opts = {}) {
1375    if (!eventId) return;
1376    // Capture the mount this open belongs to. destroyCalendarPage nulls abortController, so a
1377    // continuation that re-reads the module variable later cannot tell whose page it is standing on.
1378    const ctrl = abortController;
1379    if (!ctrl || ctrl.signal.aborted) return;
1380    const idStr = String(eventId);
1381    if (detailEventId === idStr) return;   // already open / opening — no-op the page() re-dispatch
1382    detailEventId = idStr;                  // claim synchronously, before the await / the page() push
1383    const gen = ++detailOpenGen;            // this attempt's identity; a route-out or a newer open invalidates it
1384
1385    // User-driven open (popover "Open details") → reflect in the URL so it's shareable + Back-closable.
1386    // The page() re-dispatch this triggers re-enters here and no-ops via the guard above. Route-driven
1387    // opens (cold deep-link / Back-Forward) run under _routing and skip the push (URL already correct).
1388    if (opts.updateUrl && !_routing) window.page('/calendar/event/' + idStr);
1389
1390    let data;
1391    try {
1392        data = await projectCalendarApi.getEventDetail(eventId, { signal: ctrl.signal });
1393    } catch (error) {
1394        // Same ownership question as the success path below: a route-out can release our claim and let a
1395        // NEWER attempt for the same id claim it, so an unguarded `detailEventId = null` here would
1396        // destroy the live attempt's claim and toast an error onto a page the user is still using.
1397        const stillOurs = gen === detailOpenGen && abortController === ctrl && !ctrl.signal.aborted;
1398        if (gen === detailOpenGen) detailEventId = null;
1399        if (error.name === 'AbortError' || !stillOurs) return;
1400        window.showToast?.('Could not open the calendar event', 'error', { description: error.message });
1401        return;
1402    }
1403
1404    // Ownership re-check — belt AND braces, because the signal above is necessary but not sufficient.
1405    // Two live paths still resolve (rather than reject) after we've lost the page: a same-page route-out
1406    // aborts nothing at all (navigateCalendarPage, no teardown), and api/base.js's offline interceptor
1407    // can turn a failed request into a resolved fallback. Either way this continuation may land on a
1408    // torn-down page, a page the user already routed away from, or behind a newer open. Publishing
1409    // anyway floats an orphan modal over the next page, and dismissing that orphan pushes
1410    // buildCalendarUrl() — the exact hijack this card exists to remove.
1411    if (gen !== detailOpenGen || abortController !== ctrl || ctrl.signal.aborted) {
1412        if (gen === detailOpenGen) detailEventId = null;   // only release the claim if it is still ours
1413        return;
1414    }
1415
1416    const e = data?.event;
1417    if (!e || !e.id) {
1418        detailEventId = null;
1419        window.showToast?.('That calendar event no longer exists', 'info');
1420        return;
1421    }
1422
1423    if (opts.gotoDate && e.dateStart && calendar) {
1424        const d = new Date(e.dateStart);
1425        if (!isNaN(d.getTime())) calendar.gotoDate(d);
1426    }
1427
1428    const durationText = (e.duration && Number(e.duration) > 0)
1429        ? `${e.duration} ${DURATION_TYPE_LABELS[e.durationType] || ''}`.trim()
1430        : '';
1431
1432    detailSub?.dispose();
1433    detailSub = null;
1434
1435    const modal = await showModal('#tpl-calendar-event-detail', { ...e, durationText }, {
1436        // Plain key, not a conditional spread: the ownership check above proves ctrl is live, and a
1437        // spread that evaluates to nothing would leave modal.js with no abort wiring at all — which
1438        // is precisely what made the 'abort' arm below unreachable.
1439        signal: ctrl.signal,
1440        closeOnBackdrop: true,   // click-and-see: a mis-click outside dismisses an UNTOUCHED detail;
1441                                 // once anything is edited the unsaved-input guard refuses it
1442        onClose: (reason) => {
1443            detailSub?.dispose();
1444            detailSub = null;
1445            detailModalRef = null;
1446            detailEventId = null;
1447            // User closed the modal → drop the event id from the URL. Three closes must NOT push:
1448            //   • 'abort'  — page teardown (destroyCalendarPage's abortController.abort()); pushing here
1449            //                hijacks the route change the user just started.
1450            //   • 'navigate' — an in-app jump that owns its own destination push (openMessage below);
1451            //                pushing here sandwiches a calendar entry in front of it.
1452            //   • _routing — a route-driven close (Back); the URL already moved.
1453            if (reason !== 'abort' && reason !== 'navigate' && !_routing) 
1453window.page(buildCalendarUrl());
1454        },
1455    });
1456    detailModalRef = modal;
1457
1458    renderActivity(modal.element, data.comments || [], data.history || [], data.messages || []);
1459    bindDetailActions(modal, e, ctrl);
1460
1461    detailSub = subscribeTaskUpdates(`calagsn-${e.id}`, async () => {
1462        if (!detailModalRef) return;
1463        try {
1464            const fresh = await projectCalendarApi.getEventDetail(e.id);
1465            renderActivity(modal.element, fresh.comments || [], fresh.history || [], fresh.messages || []);
1466        } catch { /* best-effort */ }
1467    });
1468}
1469
1470function renderActivity(root, comments, history, messages) {
1471    const commentsEl = root.querySelector('#cal-detail-comments');
1472    if (commentsEl) {
1473        const rows = (comments || []).map(c => ({
1474            author: c.author || 'Unknown', text: c.text || '', dateText: formatDate(c.date),
1475        }));
1476        if (rows.length) renderTo(commentsEl, '#tpl-calendar-comment-row', rows);
1477        else renderTo(commentsEl, '#tpl-calendar-activity-empty', { label: 'No comments yet.' });
1478    }
1479    const historyEl = root.querySelector('#cal-detail-history');
1480    if (historyEl) {
1481        const rows = (history || []).map(h => ({ text: h.text || '', dateText: formatDate(h.date) }));
1482        if (rows.length) renderTo(historyEl, '#tpl-calendar-history-row', rows);
1483        else renderTo(historyEl, '#tpl-calendar-activity-empty', { label: 'No history yet.' });
1484    }
1485    const messagesEl = root.querySelector('#cal-detail-messages');
1486    if (messagesEl) {
1487        const rows = (messages || []).map(m => ({
1488            id: m.id, subject: m.subject || '(no subject)', from: m.from || '',
1489            sendTo: m.sendTo || '', dateText: m.date ? formatDate(m.date) : '',
1490        }));
1491        if (rows.length) renderTo(messagesEl, '#tpl-calendar-message-row', rows);
1492        else renderTo(messagesEl, '#tpl-calendar-activity-empty', { label: 'No linked messages.' });
1493
1494        const countEl = root.querySelector('#cal-detail-messages-count');
1495        if (countEl) {
1496            countEl.textContent = String(rows.length);
1497            countEl.classList.toggle('hidden', rows.length === 0);
1498        }
1499    }
1500}
1501
1502/** @param {AbortController} ctrl — the mount this modal belongs to; gates the destructive delete. */
1503function bindDetailActions(modal, event, ctrl) {
1504    const el = modal.element;
1505
1506    // Messages: collapse toggle (long threads can overwhelm) + blurb click → full message.
1507    const msgToggle = el.querySelector('[data-cal-messages-toggle]');
1508    const msgList = el.querySelector('#cal-detail-messages');
1509    const msgChevron = el.querySelector('[data-cal-messages-chevron]');
1510    msgToggle?.addEventListener('click', () => {
1511        const collapsed = msgList?.classList.toggle('hidden');
1512        msgToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
1513        if (msgChevron) msgChevron.style.transform = collapsed ? 'rotate(180deg)' : '';
1514    });
1515    const openMessage = (id) => {
1516        if (!id) return;
1517        // 'navigate' → onClose skips its own URL push, so this jump leaves exactly one history entry.
1518        modal.close('navigate');
1519        if (typeof window.page === 'function') window.page(`/messages/${id}`);
1520        else window.location.href = `/messages/${id}`;
1521    };
1522    // Delegated on the (persistent) list container — survives renderActivity re-renders.
1523    msgList?.addEventListener('click', (e) => {
1524        const row = e.target.closest('[data-message-id]');
1525        if (row) openMessage(row.dataset.messageId);
1526    });
1527    msgList?.addEventListener('keydown', (e) => {
1528        if (e.key !== 'Enter' && e.key !== ' ') return;
1529        const row = e.target.closest('[data-message-id]');
1530        if (row) { e.preventDefault(); openMessage(row.dataset.messageId); }
1531    });
1532
1533    el.querySelector('[data-cal-edit]')?.addEventListener('click', () => {
1534        modal.close();
1535        openEventModal(event);
1536    });
1537
1538    el.querySelector('[data-cal-complete]')?.addEventListener('click', (e) => {
1539        const comment = el.querySelector('#cal-complete-comment')?.value || '';
1540        runWithBusyButton(e.currentTarget, 'Completing…', async () => {
1541            await projectCalendarApi.completeEvent(event.id, comment);
1542            window.showToast?.('Event completed', 'success');
1543            modal.close();
1544            refresh();
1545        }, { errorTitle: 'Failed to complete event' });
1546    });
1547
1548    el.querySelector('[data-cal-add-comment]')?.addEventListener('click', (e) => {
1549        const input = el.querySelector('#cal-comment-input');
1550        const text = (input?.value || '').trim();
1551        if (!text) { window.showToast?.('Enter a comment first', 'info'); return; }
1552        runWithBusyButton(e.currentTarget, 'Posting…', async () => {
1553            await projectCalendarApi.addEventComment(event.id, text);
1554            const fresh = await projectCalendarApi.getEventDetail(event.id);
1555            renderActivity(el, fresh.comments || [], fresh.history || [], fresh.messages || []);
1556            if (input) input.value = '';
1557        }, { errorTitle: 'Failed to add comment', resetOnSuccess: true });
1558    });
1559
1560    el.querySelector('[data-cal-delete]')?.addEventListener('click', (e) => {
1561        runWithBusyButton(e.currentTarget, 'Deleting…', async () => {
1562            const confirmed = await confirm({
1563                title: 'Delete this event?',
1564                message: 'This cannot be undone.',
1565                icon: 'trash-2',
1566                confirmText: 'Delete',
1567                confirmClass: 'btn-st-error',
1568                signal: ctrl.signal,   // without this the dialog survives teardown and floats over the next page
1569            });
1570            if (!confirmed) return;
1571            // calendarApi.delete is an uncancellable mutation — a signal cannot un-commit it, so the
1572            // page must still be ours at the moment we decide to send it.
1573            if (ctrl.signal.aborted) return;
1574            await calendarApi.delete(event.id);
1575            window.showToast?.('Event deleted', 'success');
1576            modal.close();
1577            refresh();
1578        }, { errorTitle: 'Failed to delete event' });
1579    });
1580}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.