Files
duty-teller/webapp/js/dayDetail.js
Nikolay Tatarinov dc116270b7 feat: add calendar subscription token functionality and ICS generation
- Introduced a new database model for calendar subscription tokens, allowing users to generate unique tokens for accessing their personal calendar.
- Implemented API endpoint to return ICS files containing only the subscribing user's duties, enhancing user experience with personalized calendar access.
- Added utility functions for generating ICS files from user duties, ensuring proper formatting and timezone handling.
- Updated command handlers to support the new calendar link feature, providing users with easy access to their personal calendar subscriptions.
- Included unit tests for the new functionality, ensuring reliability and correctness of token generation and ICS file creation.
2026-02-19 17:04:22 +03:00

317 lines
10 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).
*/
function showAsBottomSheet() {
if (!panelEl || !overlayEl) return;
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";
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);
}
/**
* Hide panel and overlay.
*/
export function hideDayDetail() {
if (popoverCloseHandler) {
document.removeEventListener("click", popoverCloseHandler);
popoverCloseHandler = null;
}
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);
});
}