feat: implement day detail panel for calendar

- Introduced a new `dayDetail.js` module to manage the day detail panel functionality, allowing users to view detailed information about duties and events for each calendar day.
- Enhanced the calendar rendering in `calendar.js` to include visual indicators for duties and events, improving user interaction and experience.
- Updated CSS in `style.css` to style the day detail panel and its components, ensuring a responsive design for both desktop and mobile views.
- Refactored `hints.js` to export the `getDutyMarkerRows` function, facilitating better integration with the new day detail features.
- Added localization support for the day detail panel in `i18n.js`, including new translations for close button and event titles.
- Enhanced the initialization process in `main.js` to set up the day detail panel on application load.
This commit is contained in:
2026-02-19 16:23:46 +03:00
parent 4c2d95e776
commit b60111462a
6 changed files with 501 additions and 61 deletions

View File

@@ -9,10 +9,9 @@ import {
localDateString,
firstDayOfMonth,
lastDayOfMonth,
getMonday
getMonday,
dateKeyToDDMM
} from "./dateUtils.js";
import { bindInfoButtonTooltips } from "./hints.js";
import { bindDutyMarkerTooltips } from "./hints.js";
/**
* Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local.
@@ -88,71 +87,65 @@ export function renderCalendar(
const eventSummaries = calendarEventsByDate[key] || [];
const hasEvent = eventSummaries.length > 0;
const showMarkers = !isOther;
const showIndicator = !isOther;
const cell = document.createElement("div");
cell.className =
"day" +
(isOther ? " other-month" : "") +
(isToday ? " today" : "") +
(showMarkers && hasAny ? " has-duty" : "") +
(showMarkers && hasEvent ? " holiday" : "");
(showIndicator && hasAny ? " has-duty" : "") +
(showIndicator && hasEvent ? " holiday" : "");
const namesAttr = (list) =>
list.length
? escapeHtml(list.map((x) => x.full_name).join("\n"))
: "";
const dutyItemsJson = dutyList.length
? JSON.stringify(
dutyList.map((x) => ({
full_name: x.full_name,
start_at: x.start_at,
end_at: x.end_at
}))
).replace(/'/g, "'")
: "";
let html =
'<span class="num">' +
d.getDate() +
'</span><div class="day-markers">';
if (showMarkers) {
const lang = state.lang;
if (dutyList.length) {
html +=
'<button type="button" class="duty-marker" data-event-type="duty" data-date="' +
escapeHtml(key) +
'" data-names="' +
namesAttr(dutyList) +
'" data-duty-items=\'' +
dutyItemsJson +
"' aria-label=\"" + escapeHtml(t(lang, "aria.duty")) + "\">Д</button>";
}
if (unavailableList.length) {
html +=
'<button type="button" class="unavailable-marker" data-event-type="unavailable" data-names="' +
namesAttr(unavailableList) +
'" aria-label="' + escapeHtml(t(lang, "aria.unavailable")) + '">Н</button>';
}
if (vacationList.length) {
html +=
'<button type="button" class="vacation-marker" data-event-type="vacation" data-names="' +
namesAttr(vacationList) +
'" aria-label="' + escapeHtml(t(lang, "aria.vacation")) + '">О</button>';
}
if (hasEvent) {
html +=
'<button type="button" class="info-btn" aria-label="' + escapeHtml(t(lang, "aria.day_info")) + '" data-summary="' +
escapeHtml(eventSummaries.join("\n")) +
'">i</button>';
}
cell.setAttribute("data-date", key);
if (showIndicator) {
const dayPayload = dayDuties.map((x) => ({
event_type: x.event_type,
full_name: x.full_name,
start_at: x.start_at,
end_at: x.end_at
}));
cell.setAttribute(
"data-day-duties",
JSON.stringify(dayPayload).replace(/"/g, "&quot;")
);
cell.setAttribute(
"data-day-events",
JSON.stringify(eventSummaries).replace(/"/g, "&quot;")
);
}
html += "</div>";
cell.innerHTML = html;
const ariaParts = [];
ariaParts.push(dateKeyToDDMM(key));
if (hasAny || hasEvent) {
const counts = [];
const lang = state.lang;
if (dutyList.length) counts.push(dutyList.length + " " + t(lang, "event_type.duty"));
if (unavailableList.length) counts.push(unavailableList.length + " " + t(lang, "event_type.unavailable"));
if (vacationList.length) counts.push(vacationList.length + " " + t(lang, "event_type.vacation"));
if (hasEvent) counts.push(t(lang, "hint.events"));
ariaParts.push(counts.join(", "));
} else {
ariaParts.push(t(state.lang, "aria.day_info"));
}
cell.setAttribute("role", "button");
cell.setAttribute("tabindex", "0");
cell.setAttribute("aria-label", ariaParts.join("; "));
let indicatorHtml = "";
if (showIndicator && (hasAny || hasEvent)) {
indicatorHtml = '<div class="day-indicator">';
if (dutyList.length) indicatorHtml += '<span class="day-indicator-dot duty"></span>';
if (unavailableList.length) indicatorHtml += '<span class="day-indicator-dot unavailable"></span>';
if (vacationList.length) indicatorHtml += '<span class="day-indicator-dot vacation"></span>';
if (hasEvent) indicatorHtml += '<span class="day-indicator-dot events"></span>';
indicatorHtml += "</div>";
}
cell.innerHTML =
'<span class="num">' + d.getDate() + "</span>" + indicatorHtml;
calendarEl.appendChild(cell);
d.setDate(d.getDate() + 1);
}
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
bindInfoButtonTooltips();
bindDutyMarkerTooltips();
}

300
webapp/js/dayDetail.js Normal file
View File

@@ -0,0 +1,300 @@
/**
* 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;
/**
* Parse JSON from data attribute (values are stored with &quot; for safety).
* @param {string} raw
* @returns {object[]|string[]}
*/
function parseDataAttr(raw) {
if (!raw) return [];
try {
const s = raw.replace(/&quot;/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) {
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\">";
unavailableList.forEach((d) => {
html += "<li>" + escapeHtml(d.full_name || "") + "</li>";
});
html += "</ul></section>";
}
if (vacationList.length > 0) {
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\">";
vacationList.forEach((d) => {
html += "<li>" + escapeHtml(d.full_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;
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 (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 || ""));
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);
});
}

View File

@@ -132,7 +132,7 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLa
* @param {string} toLabel
* @returns {{ timePrefix: string, fullName: string }[]}
*/
function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
export function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
return dutyItems.map((item, idx) => {
const timePrefix = buildDutyItemTimePrefix(
item,

View File

@@ -48,7 +48,8 @@ export const MESSAGES = {
"hint.from": "from",
"hint.to": "until",
"hint.duty_title": "Duty:",
"hint.events": "Events:"
"hint.events": "Events:",
"day_detail.close": "Close"
},
ru: {
"app.title": "Календарь дежурств",
@@ -92,7 +93,8 @@ export const MESSAGES = {
"hint.from": "с",
"hint.to": "до",
"hint.duty_title": "Дежурство:",
"hint.events": "События:"
"hint.events": "События:",
"day_detail.close": "Закрыть"
}
};

View File

@@ -23,6 +23,7 @@ import {
calendarEventsByDate,
renderCalendar
} from "./calendar.js";
import { initDayDetail } from "./dayDetail.js";
import { renderDutyList } from "./dutyList.js";
import {
firstDayOfMonth,
@@ -244,6 +245,7 @@ function bindStickyScrollShadow() {
runWhenReady(() => {
requireTelegramOrLocalhost(() => {
bindStickyScrollShadow();
initDayDetail();
loadMonth();
});
});

View File

@@ -176,6 +176,149 @@ body {
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
}
.day {
cursor: pointer;
}
.day-indicator {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px;
margin-top: 2px;
}
.day-indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.day-indicator-dot.duty {
background: var(--duty);
}
.day-indicator-dot.unavailable {
background: var(--unavailable);
}
.day-indicator-dot.vacation {
background: var(--vacation);
}
.day-indicator-dot.events {
background: var(--accent);
}
/* === Day detail panel (popover / bottom sheet) */
.day-detail-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
-webkit-tap-highlight-color: transparent;
}
.day-detail-panel {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100vw - 24px));
max-height: 70vh;
overflow: auto;
background: var(--surface);
color: var(--text);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
padding-top: 36px;
}
.day-detail-panel--sheet {
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
max-width: none;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding-top: 12px;
padding-left: 16px;
padding-right: 16px;
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
.day-detail-panel--sheet::before {
content: "";
display: block;
width: 36px;
height: 4px;
margin: 0 auto 8px;
background: var(--muted);
border-radius: 2px;
}
.day-detail-close {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 8px;
}
.day-detail-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--muted) 25%, transparent);
}
.day-detail-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
}
.day-detail-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.day-detail-section-title {
margin: 0 0 4px 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
.day-detail-section--events .day-detail-section-title { color: var(--accent); }
.day-detail-list {
margin: 0;
padding-left: 1.2em;
font-size: 0.9rem;
line-height: 1.45;
}
.day-detail-list li {
margin-bottom: 2px;
}
.day-detail-time {
color: var(--muted);
}
.info-btn {
position: absolute;
top: 0;