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;