From d0f732d0ec2297917c7ad2bee3136384212781a7 Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 20 May 2026 15:12:05 +0200 Subject: [PATCH] =?UTF-8?q?refactor(events):=20t-paliad-224=20=E2=80=94=20?= =?UTF-8?q?fold=20Kalender=20tab=20into=20mountCalendar()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /events Kalender view now mounts the canonical mountCalendar() module from frontend/src/client/calendar/ — same renderer Custom Views uses for shape=calendar. Drops the events-page-specific month-grid + popup code path entirely. What replaces what - renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear / isoDate / itemDateISO and the calYear/calMonth module state → one mountCalendar() handle (lazy, urlState=true). - events-cal-prev / events-cal-next / events-cal-today buttons → toolbar in mountCalendar (includes its own 'Heute' button). - modal popup on cell click → drill-down to day view (matches /views; head decision §11 Q2). - @media min-height shrink on .frist-cal-cell → views-calendar-* responsive surface (CSS unchanged from /views). Behavioural deltas vs pre-refactor - /events Kalender now persists view+anchor in ?cal_view + ?cal_date (head decision §11 Q3) — refresh / share-link safe. - Pills are kind-coded (deadline / appointment) rather than urgency- coded; matches /views (head decision §11 Q4 — drop subtype dot colouring, file as follow-up). - Empty-month message gone; the per-day no-entries state from the day-view replaces it (head decision §11 Q8 — drop dead i18n). Adapter: toCalendarItem() preserves the pre-refactor bucketing rule — deadlines bucket on due_date, appointments on start_at, both fall back to event_date. events.tsx: 31-line calendar subtree (toolbar + grid + modal + empty hint) reduces to a single host div. mountCalendar fills it when the user picks Kalender. --- frontend/src/client/events.ts | 209 ++++++++++------------------------ frontend/src/events.tsx | 35 +----- 2 files changed, 61 insertions(+), 183 deletions(-) diff --git a/frontend/src/client/events.ts b/frontend/src/client/events.ts index a78a7eb..d74d3a0 100644 --- a/frontend/src/client/events.ts +++ b/frontend/src/client/events.ts @@ -8,6 +8,7 @@ import { type FilterHandle, } from "./event-types"; import { projectIndent } from "./project-indent"; +import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar"; // Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow- // up: "two eyes instead of the one." Emoji rather than SVG keeps the @@ -157,8 +158,10 @@ let me: Me | null = null; let eventTypeFilter: FilterHandle | null = null; let eventTypeByID: Map = new Map(); let loadedOK = false; -let calYear = 0; -let calMonth = 0; +// Calendar handle is created lazily when /events first switches into the +// Kalender view (t-paliad-224). The handle owns its own month/week/day +// state + ?cal_view / ?cal_date URL contract via mountCalendar. +let calendar: CalendarHandle | null = null; function urlParams(): URLSearchParams { return new URLSearchParams(window.location.search); @@ -429,12 +432,13 @@ function hideTableAndCalendar() { const calWrap = document.getElementById("events-calendar-wrap"); if (tableWrap) tableWrap.style.display = "none"; if (calWrap) calWrap.hidden = true; + teardownCalendar(); } function render() { if (!loadedOK) return; if (currentView === "calendar") { - renderCalendar(); + renderCalendarView(); } else { renderTable(); } @@ -557,135 +561,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string { `; } -// 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 { +// toCalendarItem adapts an EventListItem to the canonical CalendarItem +// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches +// the pre-refactor behaviour: deadlines bucket on due_date (fallback to +// event_date); appointments bucket on start_at (fallback to event_date). +function toCalendarItem(item: EventListItem): CalendarItem { + let bucketDate: string; if (item.type === "deadline") { - const src = item.due_date ?? item.event_date; - return src.slice(0, 10); + bucketDate = item.due_date ?? item.event_date; + } else if (item.start_at) { + bucketDate = item.start_at; + } else { + bucketDate = item.event_date; } - 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")}`; + return { + kind: item.type, + id: item.id, + title: item.title, + event_date: bucketDate, + project_id: item.project_id, + project_title: item.project_title, + project_reference: item.project_reference, + }; } -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")!; +function renderCalendarView() { + const host = document.getElementById("events-calendar-wrap"); + if (!host) return; 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; + (host as HTMLElement).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 items = allItems.map(toCalendarItem); + if (calendar) { + calendar.update(items); + return; } - - 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!) ?? [])); + // urlState=true: the Kalender tab persists its month/week/day + anchor + // in ?cal_view + ?cal_date so a refresh / shared link lands on the same + // calendar state (per t-paliad-224 §11 Q3 head decision). + calendar = mountCalendar(host as HTMLElement, items, { + urlState: true, + defaultView: "month", }); - - 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 teardownCalendar() { + if (!calendar) return; + calendar.destroy(); + calendar = null; } function applyView() { @@ -706,12 +632,18 @@ function applyView() { // 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. + // Calendar view = mountCalendar() canon (month/week/day); cards + table + // both hidden. The handle is torn down when the user leaves Kalender + // so its URL state isn't reapplied to other shapes. summary.style.display = currentView === "cards" ? "" : "none"; tableWrap.style.display = currentView === "calendar" ? "none" : ""; calWrap.hidden = currentView !== "calendar"; - if (currentView === "calendar" && loadedOK) renderCalendar(); + if (currentView === "calendar") { + if (loadedOK) renderCalendarView(); + } else { + teardownCalendar(); + } } function wireRowHandlers(tbody: HTMLElement) { @@ -1013,12 +945,10 @@ function initFilters() { } function initView() { - // Calendar always opens on the current month — month navigation is - // local to the view (cheap pagination, doesn't refetch). - const now = new Date(); - calYear = now.getFullYear(); - calMonth = now.getMonth(); - + // Kalender state (view + anchor) lives inside mountCalendar; no + // events-page-level wiring needed. The view chips below switch + // between Karten / Liste / Kalender; applyView() handles the + // mount + teardown. document.querySelectorAll("[data-event-view]").forEach((btn) => { btn.addEventListener("click", () => { const next = btn.dataset.eventView as EventView; @@ -1028,31 +958,6 @@ function initView() { syncURLParams(); }); }); - - document.getElementById("events-cal-prev")?.addEventListener("click", () => { - calMonth -= 1; - if (calMonth < 0) { calMonth = 11; calYear -= 1; } - renderCalendar(); - }); - document.getElementById("events-cal-next")?.addEventListener("click", () => { - calMonth += 1; - if (calMonth > 11) { calMonth = 0; calYear += 1; } - renderCalendar(); - }); - document.getElementById("events-cal-today")?.addEventListener("click", () => { - const t = new Date(); - calYear = t.getFullYear(); - calMonth = t.getMonth(); - renderCalendar(); - }); - - const popup = document.getElementById("events-cal-popup") as HTMLElement; - document.getElementById("events-cal-popup-close")?.addEventListener("click", () => { - popup.style.display = "none"; - }); - popup?.addEventListener("click", (e) => { - if (e.target === popup) popup.style.display = "none"; - }); } function initSummaryCards() { diff --git a/frontend/src/events.tsx b/frontend/src/events.tsx index 20c2f0f..03659ae 100644 --- a/frontend/src/events.tsx +++ b/frontend/src/events.tsx @@ -236,37 +236,10 @@ export function renderEvents(): string { -