From b60111462a3aa27fbca0bb0f421c30d98b5603db Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Thu, 19 Feb 2026 16:23:46 +0300 Subject: [PATCH] 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. --- webapp/js/calendar.js | 109 +++++++-------- webapp/js/dayDetail.js | 300 +++++++++++++++++++++++++++++++++++++++++ webapp/js/hints.js | 2 +- webapp/js/i18n.js | 6 +- webapp/js/main.js | 2 + webapp/style.css | 143 ++++++++++++++++++++ 6 files changed, 501 insertions(+), 61 deletions(-) create mode 100644 webapp/js/dayDetail.js diff --git a/webapp/js/calendar.js b/webapp/js/calendar.js index 702444c..a9b4ee2 100644 --- a/webapp/js/calendar.js +++ b/webapp/js/calendar.js @@ -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 = - '' + - d.getDate() + - '
'; - if (showMarkers) { - const lang = state.lang; - if (dutyList.length) { - html += - '"; - } - if (unavailableList.length) { - html += - ''; - } - if (vacationList.length) { - html += - ''; - } - if (hasEvent) { - html += - ''; - } + 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, """) + ); + cell.setAttribute( + "data-day-events", + JSON.stringify(eventSummaries).replace(/"/g, """) + ); } - html += "
"; - 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 = '
'; + if (dutyList.length) indicatorHtml += ''; + if (unavailableList.length) indicatorHtml += ''; + if (vacationList.length) indicatorHtml += ''; + if (hasEvent) indicatorHtml += ''; + indicatorHtml += "
"; + } + + cell.innerHTML = + '' + d.getDate() + "" + indicatorHtml; calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } monthTitleEl.textContent = monthName(state.lang, month) + " " + year; - bindInfoButtonTooltips(); - bindDutyMarkerTooltips(); } diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js new file mode 100644 index 0000000..138a509 --- /dev/null +++ b/webapp/js/dayDetail.js @@ -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 " 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 = + '

' + escapeHtml(title) + "

"; + html += '
'; + + 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 += + '
' + + '

' + + escapeHtml(t(lang, "event_type.duty")) + + "

    '; + rows.forEach((r) => { + const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : ""; + html += + "
  • " + + (timeHtml ? '' + timeHtml + "" : "") + + escapeHtml(r.fullName) + + "
  • "; + }); + html += "
"; + } + + if (unavailableList.length > 0) { + html += + '
' + + '

' + + escapeHtml(t(lang, "event_type.unavailable")) + + "

    "; + unavailableList.forEach((d) => { + html += "
  • " + escapeHtml(d.full_name || "") + "
  • "; + }); + html += "
"; + } + + if (vacationList.length > 0) { + html += + '
' + + '

' + + escapeHtml(t(lang, "event_type.vacation")) + + "

    "; + vacationList.forEach((d) => { + html += "
  • " + escapeHtml(d.full_name || "") + "
  • "; + }); + html += "
"; + } + + if (summaries.length > 0) { + html += + '
' + + '

' + + escapeHtml(t(lang, "hint.events")) + + "

    "; + summaries.forEach((s) => { + html += "
  • " + escapeHtml(String(s)) + "
  • "; + }); + html += "
"; + } + + html += "
"; + 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 = + '
'; + + 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); + }); +} diff --git a/webapp/js/hints.js b/webapp/js/hints.js index 4e45f11..64783b3 100644 --- a/webapp/js/hints.js +++ b/webapp/js/hints.js @@ -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, diff --git a/webapp/js/i18n.js b/webapp/js/i18n.js index d6f00cf..1bb75af 100644 --- a/webapp/js/i18n.js +++ b/webapp/js/i18n.js @@ -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": "Закрыть" } }; diff --git a/webapp/js/main.js b/webapp/js/main.js index d73a3ec..1e02a02 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -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(); }); }); diff --git a/webapp/style.css b/webapp/style.css index 5f3d78e..52051b0 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -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;