Files
duty-teller/webapp/js/dayDetail.js
Nikolay Tatarinov 80e209e3d4
Some checks failed
CI / lint-and-test (push) Failing after 3s
refactor: clean up CSS styles and enhance JavaScript functionality
- Removed outdated CSS styles related to the calendar sticky behavior and duty item animations to streamline the stylesheet.
- Added a reflow trigger in the JavaScript function to ensure proper rendering of the day detail panel when opened, improving user experience.
- Enhanced overall code clarity by eliminating unnecessary styles and improving the animation handling logic.
2026-02-21 20:33:12 +03:00

362 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
*/
import { calendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.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 s = raw.replace(/"/g, '"');
const parsed = JSON.parse(s);
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");
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 || "" }));
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) => {
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
html +=
"<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
escapeHtml(r.fullName) +
"</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;
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(() => {
// Force reflow so WebView paints the "closed" state before we add --open
void panelEl.offsetHeight;
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;
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 =
'<button type="button" class="day-detail-close" aria-label="' +
escapeHtml(closeLabel) +
'">×</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() {
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);
});
}