import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n"; import { initSidebar } from "./sidebar"; import { attachEventTypeMultiSelectFilter, fetchEventTypes, eventTypeLabel, type EventType, type FilterHandle, } from "./event-types"; import { projectIndent } from "./project-indent"; // Eye-icon SVG used inside .approval-pill--icon. Kept as a string // constant rather than a separate module since only events.ts and // agenda.ts render it; the duplication is two lines, easier to read // than yet another import. const APPROVAL_PILL_EYE_SVG = ''; // EventsPage shared client (t-paliad-110). Drives /deadlines and // /appointments off the same shell — the route handler injects // `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }` // on first paint and we read it here to set the initial chip + filter // visibility. The chip toggle on the page lets the user widen to // "Beides" without a route change; the URL gets `?type=` for state. declare global { interface Window { __PALIAD_EVENTS__?: { defaultType?: EventTypeChoice; }; } } type EventTypeChoice = "deadline" | "appointment" | "all"; type EventView = "cards" | "list" | "calendar"; interface EventListItem { type: "deadline" | "appointment"; id: string; title: string; description?: string; event_date: string; project_id?: string; project_reference?: string; project_title?: string; project_type?: string; // Approval workflow (t-paliad-138). "pending" → render the warning pill. approval_status?: "approved" | "pending" | "legacy"; // deadline-only due_date?: string; status?: string; completed_at?: string; source?: string; rule_id?: string; rule_code?: string; rule_name?: string; rule_name_en?: string; event_type_ids?: string[]; // appointment-only start_at?: string; end_at?: string; location?: string; appointment_type?: string; } interface EventSummary { deadlines?: { overdue: number; today: number; this_week: number; next_week: number; later: number; completed: number; total: number; }; appointments?: { today: number; this_week: number; next_week: number; later: number; total: number; }; } interface Project { id: string; reference?: string | null; title: string; path: string; } interface Me { id: string; job_title: string | null; global_role: string; } const PERSONAL = "__personal__"; // Status options per Type. Deadlines carry the full set; appointments // only get the date-bucket subset (no pending/overdue/completed — those // are deadline-only concepts). type=all reuses the deadline set so users // keep all deadline-side filters available in Beides mode. type StatusOption = { value: string; key: string }; const STATUS_OPTIONS_DEADLINE: StatusOption[] = [ { value: "all", key: "deadlines.filter.all" }, { value: "pending", key: "deadlines.filter.pending" }, { value: "overdue", key: "deadlines.filter.overdue" }, { value: "today", key: "deadlines.filter.today" }, { value: "this_week", key: "deadlines.filter.thisweek" }, { value: "next_week", key: "deadlines.filter.nextweek" }, { value: "later", key: "deadlines.filter.later" }, { value: "completed", key: "deadlines.filter.completed" }, ]; const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [ { value: "all", key: "events.filter.status.all" }, { value: "today", key: "deadlines.filter.today" }, { value: "this_week", key: "deadlines.filter.thisweek" }, { value: "next_week", key: "deadlines.filter.nextweek" }, { value: "later", key: "deadlines.filter.later" }, ]; function statusOptionsFor(type: EventTypeChoice): StatusOption[] { if (type === "appointment") return STATUS_OPTIONS_APPOINTMENT; return STATUS_OPTIONS_DEADLINE; } function defaultStatusFor(type: EventTypeChoice): string { return type === "appointment" ? "all" : "pending"; } let currentType: EventTypeChoice = "deadline"; let currentView: EventView = "cards"; let statusFilter = "pending"; let projectFilter = ""; let appointmentTypeFilter = ""; let allItems: EventListItem[] = []; let allProjects: Project[] = []; let me: Me | null = null; let eventTypeFilter: FilterHandle | null = null; let eventTypeByID: Map = new Map(); let loadedOK = false; let calYear = 0; let calMonth = 0; function urlParams(): URLSearchParams { return new URLSearchParams(window.location.search); } function defaultType(): EventTypeChoice { const injected = window.__PALIAD_EVENTS__?.defaultType; if (injected === "deadline" || injected === "appointment" || injected === "all") { return injected; } return "all"; } function esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } function setCount(id: string, n: number) { const el = document.getElementById(id); if (el) el.textContent = String(n); } function fmtDateOnly(iso: string): string { try { const d = new Date(iso.length === 10 ? iso + "T00:00:00" : iso); return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", { year: "numeric", month: "2-digit", day: "2-digit", }); } catch { return iso; } } function fmtDateTime(iso: string): string { try { const d = new Date(iso); return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } } function fmtTime(iso: string): string { try { const d = new Date(iso); return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", { hour: "2-digit", minute: "2-digit", }); } catch { return ""; } } // formatDateCell yields the per-row date label. Deadlines render as // "31.08.2026" (date-only). Appointments render as "15.09.2026 09:00", // extended with "–11:00" when end_at is on the same day, or // "→ 16.09.2026 11:00" when end_at spills into a later day. function formatDateCell(item: EventListItem): string { if (item.type === "deadline") { return fmtDateOnly(item.due_date ?? item.event_date); } if (!item.start_at) return ""; const start = new Date(item.start_at); const startLabel = fmtDateTime(item.start_at); if (!item.end_at) return startLabel; const end = new Date(item.end_at); const sameDay = start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth() && start.getDate() === end.getDate(); if (sameDay) { return `${startLabel}–${fmtTime(item.end_at)}`; } return `${startLabel} → ${fmtDateTime(item.end_at)}`; } function urgencyClass(item: EventListItem): string { if (item.type === "deadline") { if (item.status === "completed") return "frist-urgency-done"; } const today = new Date(); today.setHours(0, 0, 0, 0); const d = new Date(item.event_date); const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000); if (diffDays < 0) return "frist-urgency-overdue"; if (diffDays <= 7) return "frist-urgency-soon"; return "frist-urgency-later"; } function ruleDisplay(item: EventListItem): string { if (item.type !== "deadline") return ""; // Prefer the saved citation (RoP.023, R.151) over the rule name — // REGEL is meant for the legal reference, not the rule's display // name (which is the title column's job). if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code); const lang = getLang(); const localized = lang === "en" ? item.rule_name_en : item.rule_name; if (localized && localized.trim()) return esc(localized); return "—"; } function eventTypeDisplay(item: EventListItem): string { if (item.type !== "deadline") return ""; const ids = item.event_type_ids ?? []; if (ids.length === 0) return "—"; const labels: string[] = []; for (const id of ids) { const et = eventTypeByID.get(id); if (et) labels.push(eventTypeLabel(et)); } if (labels.length === 0) return "—"; return labels.map((l) => `${esc(l)}`).join(" "); } function rowTypeChip(item: EventListItem): string { const label = item.type === "deadline" ? t("events.row.type.deadline") : t("events.row.type.appointment"); const cls = item.type === "deadline" ? "events-row-type-frist" : "events-row-type-termin"; return `${esc(label)}`; } function canReopen(): boolean { return !!me && me.global_role === "global_admin"; } async function loadProjects() { try { const resp = await fetch("/api/projects"); if (resp.ok) allProjects = await resp.json(); } catch { /* non-fatal */ } } async function loadMe() { try { const resp = await fetch("/api/me"); if (resp.ok) me = await resp.json(); } catch { /* non-fatal */ } } async function loadEventTypes() { try { const types = await fetchEventTypes(); eventTypeByID = new Map(types.map((et) => [et.id, et])); } catch { /* non-fatal */ } } async function loadSummary() { try { const params = new URLSearchParams(); params.set("type", currentType); if (projectFilter === PERSONAL) { // "Nur persönliche" → items the caller created. The backend // applies this uniformly to both rails (deadlines + appointments) // and ignores any project_id we'd send. See t-paliad-128. params.set("personal_only", "true"); } else if (projectFilter) { params.set("project_id", projectFilter); } const resp = await fetch(`/api/events/summary?${params.toString()}`); if (!resp.ok) return; const sum: EventSummary = await resp.json(); // 4 universal cards: pick the deadline value when present, otherwise // the appointment value. In Beides mode, sum both rails. const today = (sum.deadlines?.today ?? 0) + (sum.appointments?.today ?? 0); const thisWeek = (sum.deadlines?.this_week ?? 0) + (sum.appointments?.this_week ?? 0); const nextWeek = (sum.deadlines?.next_week ?? 0) + (sum.appointments?.next_week ?? 0); const later = (sum.deadlines?.later ?? 0) + (sum.appointments?.later ?? 0); setCount("events-sum-today", today); setCount("events-sum-week", thisWeek); setCount("events-sum-next-week", nextWeek); setCount("events-sum-later", later); // Überfällig is deadline-only and conditional. Hide on appointment view // OR when the count is zero. Alarm-style when count > 0 on a view that // includes deadlines (mirrors t-paliad-105 / t-paliad-106 behaviour). const overdue = sum.deadlines?.overdue ?? 0; setCount("events-sum-overdue", overdue); applyOverdueState(overdue); } catch { /* non-fatal */ } } // Überfällig is deadline-view-only per t-paliad-110 spec. It stays hidden // in Beides mode even when overdue > 0 — the design treats "overdue" as a // deadline-specific concept (appointments don't go overdue, they happen), // so showing the card in mixed mode would be a category error. function applyOverdueState(overdue: number) { const card = document.querySelector('.frist-summary-card[data-bucket="overdue"]'); if (!card) return; const isDeadlineView = currentType === "deadline"; const hidden = !isDeadlineView || overdue === 0; card.classList.toggle("frist-card-overdue-hidden", hidden); card.classList.toggle("frist-card-alarm", isDeadlineView && overdue > 0); } async function loadList() { const unavailable = document.getElementById("events-unavailable")!; try { const params = new URLSearchParams(); params.set("type", currentType); if (statusFilter) { // Status carries either a deadline filter (pending/overdue/completed, // which only make sense when deadlines are in scope) or a date-bucket // (today/this_week/next_week/later) which the backend applies to // both rails. EventService.bucketAppointmentWindow turns bucket // values into a start_at window; non-bucket values (all/"") are a // no-op on the appointment side. params.set("status", statusFilter); } if (projectFilter === PERSONAL) { // "Nur persönliche" → items the caller created (t-paliad-128). // Applies uniformly to both rails server-side; project_id is // intentionally not sent (the two are mutually exclusive). params.set("personal_only", "true"); } else if (projectFilter) { params.set("project_id", projectFilter); } if (currentType !== "appointment") { const eventTypeQuery = eventTypeFilter?.toQueryValue() ?? ""; if (eventTypeQuery) params.set("event_type", eventTypeQuery); } if (currentType === "appointment" && appointmentTypeFilter) { params.set("type_filter", appointmentTypeFilter); } const resp = await fetch(`/api/events?${params.toString()}`); if (resp.status === 503) { unavailable.style.display = "block"; hideTableAndCalendar(); document.getElementById("events-empty")!.style.display = "none"; return; } if (!resp.ok) { unavailable.style.display = "block"; hideTableAndCalendar(); return; } const data: EventListItem[] = await resp.json(); allItems = data; loadedOK = true; render(); } catch { unavailable.style.display = "block"; hideTableAndCalendar(); } } function hideTableAndCalendar() { const tableWrap = document.getElementById("events-table-wrap"); const calWrap = document.getElementById("events-calendar-wrap"); if (tableWrap) tableWrap.style.display = "none"; if (calWrap) calWrap.hidden = true; } function render() { if (!loadedOK) return; if (currentView === "calendar") { renderCalendar(); } else { renderTable(); } } function renderTable() { const tbody = document.getElementById("events-body")!; const empty = document.getElementById("events-empty")!; const emptyFiltered = document.getElementById("events-empty-filtered")!; const tableWrap = document.getElementById("events-table-wrap") as HTMLElement; const table = document.getElementById("events-table"); if (allItems.length === 0) { tbody.innerHTML = ""; tableWrap.style.display = "none"; if (isFilterPristine()) { empty.style.display = "block"; emptyFiltered.style.display = "none"; } else { empty.style.display = "none"; emptyFiltered.style.display = "block"; } return; } tableWrap.style.display = ""; empty.style.display = "none"; emptyFiltered.style.display = "none"; const showReopen = canReopen(); tbody.innerHTML = allItems.map((item) => renderRow(item, showReopen)).join(""); // Hide-on-uniform pattern (t-paliad-073, generalised in t-088). When all // visible rows agree on a column we drop the column entirely so empty // cells don't add noise. Each column has its own data signal so we can // toggle independently. const anyDeadline = allItems.some((x) => x.type === "deadline"); const anyAppointment = allItems.some((x) => x.type === "appointment"); const anyEventType = allItems.some((x) => (x.event_type_ids ?? []).length > 0); const anyLocation = allItems.some((x) => !!x.location); const anyAppointmentType = allItems.some((x) => !!x.appointment_type); const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_code || x.rule_name || x.rule_name_en)); const anyStatus = anyDeadline; // appointments don't carry a status column table?.classList.toggle("events-table--hide-row-type", !anyDeadline || !anyAppointment); table?.classList.toggle("events-table--hide-rule", !anyRule); table?.classList.toggle("entity-table--hide-event-type", !anyEventType); table?.classList.toggle("events-table--hide-location", !anyLocation); table?.classList.toggle("events-table--hide-appointment-type", !anyAppointmentType); table?.classList.toggle("entity-table--hide-status", !anyStatus); wireRowHandlers(tbody); } function renderRow(item: EventListItem, showReopen: boolean): string { const urgency = urgencyClass(item); const dateLabel = formatDateCell(item); const ruleLabel = item.type === "deadline" ? ruleDisplay(item) : ""; const eventTypeCell = item.type === "deadline" ? eventTypeDisplay(item) : ""; const projectCell = item.project_id ? `${esc(item.project_reference ?? "")}` + `${esc(item.project_title ?? "")}` : `${esc(t("appointments.personal"))}`; let checkCell = ""; let titleClass = ""; let statusCell = ""; if (item.type === "deadline") { const isDone = item.status === "completed"; titleClass = isDone ? "frist-title-done" : ""; const statusLabel = tDyn(`deadlines.status.${item.status ?? "pending"}`) || (item.status ?? ""); statusCell = `${esc(statusLabel)}`; const reopenLabel = esc(t("deadlines.action.reopen")); if (isDone) { checkCell = showReopen ? `` : ``; } else { checkCell = ``; } } else { const typeClass = item.appointment_type ? `termin-type-${item.appointment_type}` : ""; checkCell = ``; } const locationCell = item.location ? esc(item.location) : "—"; const appointmentTypeCell = item.appointment_type ? `${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}` : "—"; // Approval pending pill (t-paliad-138 / m's 2026-05-08 cosmetic ask). // Soft-tint the row + drop an eye-icon pill next to the title; hover // reveals the lifecycle label. Inbox surface shows the full detail. const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : ""; const pendingLabel = item.approval_status === "pending" ? t("approvals.pending_update.label") : ""; const pendingPill = item.approval_status === "pending" ? `${APPROVAL_PILL_EYE_SVG}` : ""; return ` ${checkCell} ${rowTypeChip(item)} ${esc(dateLabel)} ${esc(item.title)}${pendingPill ? " " + pendingPill : ""} ${projectCell} ${ruleLabel || "—"} ${eventTypeCell || "—"} ${locationCell} ${appointmentTypeCell} ${statusCell} `; } // itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when // plotting an event onto the calendar. Deadlines bucket on due_date; // appointments on start_at's local-date component. function itemDateISO(item: EventListItem): string { if (item.type === "deadline") { const src = item.due_date ?? item.event_date; return src.slice(0, 10); } if (!item.start_at) return item.event_date.slice(0, 10); const d = new Date(item.start_at); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function isoDate(year: number, month: number, day: number): string { return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } function fmtMonthYear(year: number, month: number): string { return `${tDyn(`cal.month.${month}`)} ${year}`; } function calDotClass(item: EventListItem): string { // Per-item dot colour. Deadlines reuse the existing urgency palette; // appointments get their own colour so they're visually distinct from // deadlines on a mixed (Beides) calendar. if (item.type === "appointment") return "events-cal-dot-appointment"; return urgencyClass(item).replace("frist-urgency-", "frist-urgency-"); } function renderCalendar() { const wrap = document.getElementById("events-calendar-wrap")!; const grid = document.getElementById("events-cal-grid")!; const empty = document.getElementById("events-cal-empty") as HTMLElement; const monthLabel = document.getElementById("events-cal-month-label")!; const tableEmpty = document.getElementById("events-empty")!; const tableEmptyFiltered = document.getElementById("events-empty-filtered")!; // Calendar always renders the visible month from allItems, regardless of // pristine vs filtered state — empty calendar is allowed (the per-month // empty hint communicates "no items in this month" without confusing it // with the table-mode "no items at all" empty state). tableEmpty.style.display = "none"; tableEmptyFiltered.style.display = "none"; wrap.hidden = false; monthLabel.textContent = fmtMonthYear(calYear, calMonth); const firstDay = new Date(calYear, calMonth, 1); const jsWeekday = firstDay.getDay(); const offset = (jsWeekday + 6) % 7; const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate(); const today = new Date(); const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate()); // Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n). const byDate = new Map(); for (const item of allItems) { const iso = itemDateISO(item); const list = byDate.get(iso); if (list) list.push(item); else byDate.set(iso, [item]); } const cells: string[] = []; for (let i = 0; i < offset; i++) { cells.push(`
`); } for (let day = 1; day <= daysInMonth; day++) { const iso = isoDate(calYear, calMonth, day); const items = byDate.get(iso) ?? []; const isToday = iso === todayISO; const dots = items .slice(0, 4) .map((it) => ``) .join(""); const more = items.length > 4 ? `+${items.length - 4}` : ""; cells.push( `
0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}"> ${day}
${dots}${more}
`, ); } grid.innerHTML = cells.join(""); grid.querySelectorAll(".frist-cal-cell-has").forEach((cell) => { cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? [])); }); const monthStart = isoDate(calYear, calMonth, 1); const monthEnd = isoDate(calYear, calMonth, daysInMonth); const hasInMonth = allItems.some((it) => { const iso = itemDateISO(it); return iso >= monthStart && iso <= monthEnd; }); empty.hidden = hasInMonth; } function openCalPopup(iso: string, items: EventListItem[]) { if (items.length === 0) return; const popup = document.getElementById("events-cal-popup") as HTMLElement; const dateEl = document.getElementById("events-cal-popup-date")!; const list = document.getElementById("events-cal-popup-list")!; const d = new Date(iso + "T00:00:00"); dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", { weekday: "long", year: "numeric", month: "long", day: "numeric", }); list.innerHTML = items .map((it) => { const cls = calDotClass(it); const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`; const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : ""; const projectLabel = it.project_reference ?? ""; const projectCell = projectHref ? `${esc(projectLabel)}` : ""; return `
  • ${rowTypeChip(it)} ${esc(it.title)} ${projectCell}
  • `; }) .join(""); popup.style.display = "flex"; } function applyView() { const tableWrap = document.getElementById("events-table-wrap") as HTMLElement; const calWrap = document.getElementById("events-calendar-wrap") as HTMLElement; const summary = document.getElementById("events-summary") as HTMLElement; // Active state on selector buttons. document.querySelectorAll("[data-event-view]").forEach((b) => { b.classList.toggle("events-view-btn-active", b.dataset.eventView === currentView); }); // Body class so CSS can hook in if needed downstream. document.body.classList.toggle("events-view-cards", currentView === "cards"); document.body.classList.toggle("events-view-list", currentView === "list"); document.body.classList.toggle("events-view-calendar", currentView === "calendar"); // Cards view = the original layout (5-card summary + table). // List view = no summary cards, table only — gives more vertical space // and matches users' mental model of a flat list. // Calendar view = month grid; cards + table both hidden. summary.style.display = currentView === "cards" ? "" : "none"; tableWrap.style.display = currentView === "calendar" ? "none" : ""; calWrap.hidden = currentView !== "calendar"; if (currentView === "calendar" && loadedOK) renderCalendar(); } function wireRowHandlers(tbody: HTMLElement) { tbody.querySelectorAll(".events-row").forEach((row) => { const id = row.dataset.id!; const type = row.dataset.type!; row.addEventListener("click", (e) => { const target = e.target as HTMLElement; if ( target.closest(".frist-complete-cb") || target.closest(".frist-reopen-btn") || target.closest("a") ) return; window.location.href = type === "deadline" ? `/deadlines/${id}` : `/appointments/${id}`; }); const cb = row.querySelector(".frist-complete-cb"); if (cb && !cb.disabled) { cb.addEventListener("change", async () => { if (!cb.checked) return; cb.disabled = true; try { const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" }); if (resp.ok) { await Promise.all([loadList(), loadSummary()]); } else { cb.checked = false; cb.disabled = false; } } catch { cb.checked = false; cb.disabled = false; } }); } const reopenBtn = row.querySelector(".frist-reopen-btn"); if (reopenBtn) { reopenBtn.addEventListener("click", async () => { reopenBtn.disabled = true; try { const resp = await fetch(`/api/deadlines/${id}/reopen`, { method: "PATCH" }); if (resp.ok) { await Promise.all([loadList(), loadSummary()]); } else { reopenBtn.disabled = false; } } catch { reopenBtn.disabled = false; } }); } }); } function isFilterPristine(): boolean { return ( statusFilter === defaultStatusFor(currentType) && !projectFilter && !appointmentTypeFilter && (eventTypeFilter?.toQueryValue() ?? "") === "" ); } function applyTypeVisibility() { // Page-level chrome — heading, subtitle, actions, type chips' active state. const heading = document.getElementById("events-heading"); const subtitle = document.getElementById("events-subtitle"); const title = document.getElementById("events-title"); const isAppointment = currentType === "appointment"; const isDeadline = currentType === "deadline"; const isAll = currentType === "all"; if (heading) heading.textContent = isAppointment ? t("appointments.list.heading") : t("deadlines.list.heading"); if (subtitle) subtitle.textContent = isAppointment ? t("appointments.list.subtitle") : t("deadlines.list.subtitle"); if (title) title.textContent = isAppointment ? t("appointments.list.title") : t("deadlines.list.title"); // Single "Neu …" CTA per type. View axis is the calendar/list/cards selector. toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex"); toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex"); // Empty-state CTAs. toggleDisplay("events-empty-cta-deadline", isDeadline || isAll, "inline-flex"); toggleDisplay("events-empty-cta-appointment", isAppointment || isAll, "inline-flex"); // Status filter applies to all types — for appointments the option set // narrows to date buckets only (Heute / Diese Woche / Nächste Woche / // Später / Alle). populateStatusFilter handles the option swap and may // reset statusFilter when switching from a deadline-only value. populateStatusFilter(); // Event-type multi-select is deadline-only (appointments have no event_types). toggleFilterPair("events-filter-event-type", !isAppointment); // The panel is a popup the trigger owns via `panel.hidden`. Never stamp // `display: block` on it from the type filter — that overrides the // `.multi-panel[hidden]` CSS rule and leaves the panel visible on larger // screens. When switching to appointment view, force-close it. const eventTypePanel = document.getElementById("events-filter-event-type-panel") as HTMLElement | null; if (eventTypePanel) { eventTypePanel.style.display = ""; if (isAppointment) eventTypePanel.hidden = true; } // Termin-Typ is appointment-only. toggleFilterPair("events-filter-appointment-type", !isDeadline); // "Nur persönliche" applies uniformly to deadlines + appointments now // (t-paliad-128) — items where the caller is the creator. Always available. // Update chip active state. document.querySelectorAll('[data-event-type]').forEach((b) => { b.classList.toggle("agenda-chip-active", b.dataset.eventType === currentType); }); // Refresh overdue card visibility on type change. applyOverdueState(parseInt(document.getElementById("events-sum-overdue")?.textContent ?? "0", 10)); // Sidebar highlight (t-paliad-115). The bare /events HTML SSRs with // currentPath="/events" so neither Fristen nor Termine sidebar entries // match — we re-highlight at hydration based on the active type so the // user always sees the correct nav entry lit. Same logic for BottomNav, // which today doesn't carry these entries but stays consistent if it // ever does. applySidebarTypeHighlight(); } function applySidebarTypeHighlight() { const items = document.querySelectorAll(".sidebar-item"); for (const a of items) { const href = a.getAttribute("href") ?? ""; if (href === "/events?type=deadline") { a.classList.toggle("active", currentType === "deadline"); } else if (href === "/events?type=appointment") { a.classList.toggle("active", currentType === "appointment"); } } } function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") { const el = document.getElementById(id); if (!el) return; el.style.display = show ? displayWhenShown : "none"; } // toggleFilterPair shows/hides the wrapping .filter-group so the label, // control, and (for the multi-select) the .multi-anchor collapse together // when a filter doesn't apply for the current type. function toggleFilterPair(controlID: string, show: boolean) { const ctrl = document.getElementById(controlID); const group = ctrl?.closest(".filter-group"); if (group) group.style.display = show ? "" : "none"; } function syncURLParams() { const url = new URL(window.location.href); url.searchParams.delete("type"); url.searchParams.delete("view"); url.searchParams.delete("status"); url.searchParams.delete("project_id"); url.searchParams.delete("personal_only"); url.searchParams.delete("event_type"); url.searchParams.delete("type_filter"); // Default type per route comes from window.__PALIAD_EVENTS__; only // surface in the URL when the user opted into something else. if (currentType !== defaultType()) url.searchParams.set("type", currentType); if (currentView !== "cards") url.searchParams.set("view", currentView); if (statusFilter && statusFilter !== defaultStatusFor(currentType)) { url.searchParams.set("status", statusFilter); } if (projectFilter) url.searchParams.set("project_id", projectFilter); if (currentType !== "appointment") { const eventQuery = eventTypeFilter?.toQueryValue() ?? ""; if (eventQuery) url.searchParams.set("event_type", eventQuery); } if (currentType === "appointment" && appointmentTypeFilter) { url.searchParams.set("type_filter", appointmentTypeFilter); } window.history.replaceState(null, "", url.toString()); } // populateStatusFilter rebuilds the Status `