- Updated HTML structure for navigation buttons in the calendar, adding SVG icons for improved visual clarity. - Introduced a new muted text style in CSS for better presentation of empty duty list messages. - Enhanced calendar CSS for navigation buttons and day indicators, improving layout and responsiveness. - Improved error handling in the UI by adding retry functionality to the error display, allowing users to retry actions directly from the error message. - Updated internationalization messages to include a retry option for error handling. - Added unit tests to verify the new error handling behavior and UI updates.
383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
/**
|
|
* 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 =
|
|
'<h2 id="dayDetailTitle" class="day-detail-title">' + escapeHtml(title) + "</h2>";
|
|
html += '<div class="day-detail-sections">';
|
|
|
|
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 +=
|
|
'<section class="day-detail-section day-detail-section--duty">' +
|
|
'<h3 class="day-detail-section-title">' +
|
|
escapeHtml(t(lang, "event_type.duty")) +
|
|
"</h3><ul class=" +
|
|
'"day-detail-list">';
|
|
rows.forEach((r, i) => {
|
|
const duty = hasTimes ? dutyList[i] : null;
|
|
const phone = r.phone != null ? r.phone : (duty && duty.phone);
|
|
const username = r.username != null ? r.username : (duty && duty.username);
|
|
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
|
|
const contactHtml = buildContactLinksHtml(lang, phone, username, {
|
|
classPrefix: "day-detail-contact",
|
|
showLabels: true,
|
|
separator: " "
|
|
});
|
|
html +=
|
|
"<li>" +
|
|
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
|
|
escapeHtml(r.fullName) +
|
|
(contactHtml ? contactHtml : "") +
|
|
"</li>";
|
|
});
|
|
html += "</ul></section>";
|
|
}
|
|
|
|
if (unavailableList.length > 0) {
|
|
const uniqueUnavailable = [...new Set(unavailableList.map((d) => d.full_name || "").filter(Boolean))];
|
|
html +=
|
|
'<section class="day-detail-section day-detail-section--unavailable">' +
|
|
'<h3 class="day-detail-section-title">' +
|
|
escapeHtml(t(lang, "event_type.unavailable")) +
|
|
"</h3><ul class=\"day-detail-list\">";
|
|
uniqueUnavailable.forEach((name) => {
|
|
html += "<li>" + escapeHtml(name) + "</li>";
|
|
});
|
|
html += "</ul></section>";
|
|
}
|
|
|
|
if (vacationList.length > 0) {
|
|
const uniqueVacation = [...new Set(vacationList.map((d) => d.full_name || "").filter(Boolean))];
|
|
html +=
|
|
'<section class="day-detail-section day-detail-section--vacation">' +
|
|
'<h3 class="day-detail-section-title">' +
|
|
escapeHtml(t(lang, "event_type.vacation")) +
|
|
"</h3><ul class=\"day-detail-list\">";
|
|
uniqueVacation.forEach((name) => {
|
|
html += "<li>" + escapeHtml(name) + "</li>";
|
|
});
|
|
html += "</ul></section>";
|
|
}
|
|
|
|
if (summaries.length > 0) {
|
|
html +=
|
|
'<section class="day-detail-section day-detail-section--events">' +
|
|
'<h3 class="day-detail-section-title">' +
|
|
escapeHtml(t(lang, "hint.events")) +
|
|
"</h3><ul class=\"day-detail-list\">";
|
|
summaries.forEach((s) => {
|
|
html += "<li>" + escapeHtml(String(s)) + "</li>";
|
|
});
|
|
html += "</ul></section>";
|
|
}
|
|
|
|
html += "</div>";
|
|
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");
|
|
const closeIcon =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
panelEl.innerHTML =
|
|
'<button type="button" class="day-detail-close" aria-label="' +
|
|
escapeHtml(closeLabel) +
|
|
'">' +
|
|
closeIcon +
|
|
'</button><div class="day-detail-body"></div>';
|
|
|
|
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);
|
|
});
|
|
}
|