Files
duty-teller/webapp/js/dayDetail.js
Nikolay Tatarinov a4d8d085c6 feat: update language support and enhance API functionality
- Changed the default language in `index.html` from Russian to English, updating the title and button aria-labels for improved accessibility.
- Refactored the `buildFetchOptions` function in `api.js` to include an optional external abort signal, enhancing request management.
- Updated `fetchDuties` and `fetchCalendarEvents` to support request cancellation using the new abort signal, improving error handling.
- Added unit tests for the API functions to ensure proper functionality, including handling of 403 errors and request cancellations.
- Enhanced CSS styles for duty markers to improve visual consistency.
- Removed unused code and improved the overall structure of the JavaScript files for better maintainability.
2026-03-02 12:40:49 +03:00

361 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 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 || "" }));
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(() => {
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);
});
}