/** * Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap. */ import { getCalendarEl, state } from "./dom.js"; import { t } from "./i18n.js"; import { escapeHtml } from "./utils.js"; import { buildContactLinksHtml } from "./contactHtml.js"; import { localDateString, dateKeyToDDMM } from "./dateUtils.js"; import { getDutyMarkerRows } from "./hints.js"; const BOTTOM_SHEET_BREAKPOINT_PX = 640; const POPOVER_MARGIN = 12; /** @type {HTMLElement|null} */ let panelEl = null; /** @type {HTMLElement|null} */ let overlayEl = null; /** @type {((e: MouseEvent) => void)|null} */ let popoverCloseHandler = null; /** Scroll position saved when sheet opens (for restore on close). */ let sheetScrollY = 0; /** * Parse JSON from data attribute (values are stored with " for safety). * @param {string} raw * @returns {object[]|string[]} */ function parseDataAttr(raw) { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } } /** * Build HTML content for the day detail panel. * @param {string} dateKey - YYYY-MM-DD * @param {object[]} duties - Array of { event_type, full_name, start_at?, end_at? } * @param {string[]} eventSummaries - Calendar event summary strings * @returns {string} */ export function buildDayDetailContent(dateKey, duties, eventSummaries) { const lang = state.lang; const todayKey = localDateString(new Date()); const ddmm = dateKeyToDDMM(dateKey); const title = dateKey === todayKey ? t(lang, "duty.today") + ", " + ddmm : ddmm; const dutyList = (duties || []) .filter((d) => d.event_type === "duty") .sort((a, b) => new Date(a.start_at || 0) - new Date(b.start_at || 0)); const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable"); const vacationList = (duties || []).filter((d) => d.event_type === "vacation"); const summaries = eventSummaries || []; const fromLabel = t(lang, "hint.from"); const toLabel = t(lang, "hint.to"); const nbsp = "\u00a0"; let html = '

' + escapeHtml(title) + "

"; html += '
'; if (dutyList.length > 0) { const hasTimes = dutyList.some( (it) => (it.start_at || it.end_at) ); const rows = hasTimes ? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel) : dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "", phone: it.phone, username: it.username })); html += '
' + '

' + escapeHtml(t(lang, "event_type.duty")) + "

"; } if (unavailableList.length > 0) { const uniqueUnavailable = [...new Set(unavailableList.map((d) => d.full_name || "").filter(Boolean))]; html += '
' + '

' + escapeHtml(t(lang, "event_type.unavailable")) + "

"; } if (vacationList.length > 0) { const uniqueVacation = [...new Set(vacationList.map((d) => d.full_name || "").filter(Boolean))]; html += '
' + '

' + escapeHtml(t(lang, "event_type.vacation")) + "

"; } if (summaries.length > 0) { html += '
' + '

' + escapeHtml(t(lang, "hint.events")) + "

"; } html += "
"; return html; } /** * Position panel as popover near cell rect; keep inside viewport. * @param {HTMLElement} panel * @param {DOMRect} cellRect */ function positionPopover(panel, cellRect) { const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const margin = POPOVER_MARGIN; panel.classList.remove("day-detail-panel--below"); panel.style.left = ""; panel.style.top = ""; panel.style.right = ""; panel.style.bottom = ""; panel.hidden = false; requestAnimationFrame(() => { const panelRect = panel.getBoundingClientRect(); let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2; let top = cellRect.bottom + 8; /* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */ if (top + panelRect.height > vh - margin) { top = cellRect.top - panelRect.height - 8; panel.classList.add("day-detail-panel--below"); } else { panel.classList.remove("day-detail-panel--below"); } if (left < margin) left = margin; if (left + panelRect.width > vw - margin) left = vw - panelRect.width - margin; panel.style.left = left + "px"; panel.style.top = top + "px"; }); } /** * Show panel as bottom sheet (fixed at bottom). * Renders panel closed (translateY(100%)), then on next frame adds open/visible classes for animation. */ function showAsBottomSheet() { if (!panelEl || !overlayEl) return; overlayEl.classList.remove("day-detail-overlay--visible"); panelEl.classList.remove("day-detail-panel--open"); overlayEl.hidden = false; panelEl.classList.add("day-detail-panel--sheet"); panelEl.style.left = ""; panelEl.style.top = ""; panelEl.style.right = ""; panelEl.style.bottom = "0"; panelEl.hidden = false; sheetScrollY = window.scrollY; document.body.classList.add("day-detail-sheet-open"); document.body.style.top = "-" + sheetScrollY + "px"; requestAnimationFrame(() => { requestAnimationFrame(() => { overlayEl.classList.add("day-detail-overlay--visible"); panelEl.classList.add("day-detail-panel--open"); const closeBtn = panelEl.querySelector(".day-detail-close"); if (closeBtn) closeBtn.focus(); }); }); } /** * Show panel as popover at cell position. * @param {DOMRect} cellRect */ function showAsPopover(cellRect) { if (!panelEl || !overlayEl) return; overlayEl.hidden = true; panelEl.classList.remove("day-detail-panel--sheet"); positionPopover(panelEl, cellRect); if (popoverCloseHandler) document.removeEventListener("click", popoverCloseHandler); popoverCloseHandler = (e) => { const target = e.target instanceof Node ? e.target : null; if (!target || !panelEl) return; if (panelEl.contains(target)) return; const calendarEl = getCalendarEl(); if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return; hideDayDetail(); }; setTimeout(() => document.addEventListener("click", popoverCloseHandler), 0); } /** Timeout fallback (ms) if transitionend does not fire (e.g. prefers-reduced-motion). */ const SHEET_CLOSE_TIMEOUT_MS = 400; /** * Hide panel and overlay. For bottom sheet: remove open classes, wait for transitionend (or timeout), then cleanup. */ export function hideDayDetail() { if (popoverCloseHandler) { document.removeEventListener("click", popoverCloseHandler); popoverCloseHandler = null; } const isSheet = panelEl && overlayEl && panelEl.classList.contains("day-detail-panel--sheet") && document.body.classList.contains("day-detail-sheet-open"); if (isSheet) { panelEl.classList.remove("day-detail-panel--open"); overlayEl.classList.remove("day-detail-overlay--visible"); const finishClose = () => { if (closeTimeoutId != null) clearTimeout(closeTimeoutId); panelEl.removeEventListener("transitionend", onTransitionEnd); document.body.classList.remove("day-detail-sheet-open"); document.body.style.top = ""; window.scrollTo(0, sheetScrollY); panelEl.classList.remove("day-detail-panel--sheet"); panelEl.hidden = true; overlayEl.hidden = true; }; let closeTimeoutId = setTimeout(finishClose, SHEET_CLOSE_TIMEOUT_MS); const onTransitionEnd = (e) => { if (e.target !== panelEl || e.propertyName !== "transform") return; finishClose(); }; panelEl.addEventListener("transitionend", onTransitionEnd); return; } if (document.body.classList.contains("day-detail-sheet-open")) { document.body.classList.remove("day-detail-sheet-open"); document.body.style.top = ""; window.scrollTo(0, sheetScrollY); } if (panelEl) panelEl.hidden = true; if (overlayEl) overlayEl.hidden = true; } /** * Open day detail for the given cell (reads data from data-* attributes). * @param {HTMLElement} cell - .day element */ function openDayDetail(cell) { const dateKey = cell.getAttribute("data-date"); if (!dateKey) return; const dutiesRaw = cell.getAttribute("data-day-duties"); const eventsRaw = cell.getAttribute("data-day-events"); const duties = /** @type {object[]} */ (parseDataAttr(dutiesRaw || "")); const eventSummaries = /** @type {string[]} */ (parseDataAttr(eventsRaw || "")); if (duties.length === 0 && eventSummaries.length === 0) return; ensurePanelInDom(); if (!panelEl) return; const contentHtml = buildDayDetailContent(dateKey, duties, eventSummaries); const body = panelEl.querySelector(".day-detail-body"); if (body) body.innerHTML = contentHtml; const isNarrow = window.matchMedia("(max-width: " + BOTTOM_SHEET_BREAKPOINT_PX + "px)").matches; if (isNarrow) { showAsBottomSheet(); } else { const rect = cell.getBoundingClientRect(); showAsPopover(rect); } } /** * Ensure #dayDetailPanel and overlay exist in DOM. */ function ensurePanelInDom() { if (panelEl) return; overlayEl = document.getElementById("dayDetailOverlay"); panelEl = document.getElementById("dayDetailPanel"); if (!overlayEl) { overlayEl = document.createElement("div"); overlayEl.id = "dayDetailOverlay"; overlayEl.className = "day-detail-overlay"; overlayEl.setAttribute("aria-hidden", "true"); overlayEl.hidden = true; document.body.appendChild(overlayEl); } if (!panelEl) { panelEl = document.createElement("div"); panelEl.id = "dayDetailPanel"; panelEl.className = "day-detail-panel"; panelEl.setAttribute("role", "dialog"); panelEl.setAttribute("aria-modal", "true"); panelEl.setAttribute("aria-labelledby", "dayDetailTitle"); panelEl.hidden = true; const closeLabel = t(state.lang, "day_detail.close"); panelEl.innerHTML = '
'; const closeBtn = panelEl.querySelector(".day-detail-close"); if (closeBtn) { closeBtn.addEventListener("click", hideDayDetail); } panelEl.addEventListener("keydown", (e) => { if (e.key === "Escape") hideDayDetail(); }); overlayEl.addEventListener("click", hideDayDetail); document.body.appendChild(panelEl); } } /** * Bind delegated click/keydown on calendar for .day cells. */ export function initDayDetail() { const calendarEl = getCalendarEl(); if (!calendarEl) return; calendarEl.addEventListener("click", (e) => { const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null); if (!cell) return; e.preventDefault(); openDayDetail(cell); }); calendarEl.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null); if (!cell) return; e.preventDefault(); openDayDetail(cell); }); }