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:
@@ -9,10 +9,9 @@ import {
|
|||||||
localDateString,
|
localDateString,
|
||||||
firstDayOfMonth,
|
firstDayOfMonth,
|
||||||
lastDayOfMonth,
|
lastDayOfMonth,
|
||||||
getMonday
|
getMonday,
|
||||||
|
dateKeyToDDMM
|
||||||
} from "./dateUtils.js";
|
} 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.
|
* 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 eventSummaries = calendarEventsByDate[key] || [];
|
||||||
const hasEvent = eventSummaries.length > 0;
|
const hasEvent = eventSummaries.length > 0;
|
||||||
|
|
||||||
const showMarkers = !isOther;
|
const showIndicator = !isOther;
|
||||||
const cell = document.createElement("div");
|
const cell = document.createElement("div");
|
||||||
cell.className =
|
cell.className =
|
||||||
"day" +
|
"day" +
|
||||||
(isOther ? " other-month" : "") +
|
(isOther ? " other-month" : "") +
|
||||||
(isToday ? " today" : "") +
|
(isToday ? " today" : "") +
|
||||||
(showMarkers && hasAny ? " has-duty" : "") +
|
(showIndicator && hasAny ? " has-duty" : "") +
|
||||||
(showMarkers && hasEvent ? " holiday" : "");
|
(showIndicator && hasEvent ? " holiday" : "");
|
||||||
|
|
||||||
const namesAttr = (list) =>
|
cell.setAttribute("data-date", key);
|
||||||
list.length
|
if (showIndicator) {
|
||||||
? escapeHtml(list.map((x) => x.full_name).join("\n"))
|
const dayPayload = dayDuties.map((x) => ({
|
||||||
: "";
|
event_type: x.event_type,
|
||||||
const dutyItemsJson = dutyList.length
|
|
||||||
? JSON.stringify(
|
|
||||||
dutyList.map((x) => ({
|
|
||||||
full_name: x.full_name,
|
full_name: x.full_name,
|
||||||
start_at: x.start_at,
|
start_at: x.start_at,
|
||||||
end_at: x.end_at
|
end_at: x.end_at
|
||||||
}))
|
}));
|
||||||
).replace(/'/g, "'")
|
cell.setAttribute(
|
||||||
: "";
|
"data-day-duties",
|
||||||
|
JSON.stringify(dayPayload).replace(/"/g, """)
|
||||||
|
);
|
||||||
|
cell.setAttribute(
|
||||||
|
"data-day-events",
|
||||||
|
JSON.stringify(eventSummaries).replace(/"/g, """)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let html =
|
const ariaParts = [];
|
||||||
'<span class="num">' +
|
ariaParts.push(dateKeyToDDMM(key));
|
||||||
d.getDate() +
|
if (hasAny || hasEvent) {
|
||||||
'</span><div class="day-markers">';
|
const counts = [];
|
||||||
if (showMarkers) {
|
|
||||||
const lang = state.lang;
|
const lang = state.lang;
|
||||||
if (dutyList.length) {
|
if (dutyList.length) counts.push(dutyList.length + " " + t(lang, "event_type.duty"));
|
||||||
html +=
|
if (unavailableList.length) counts.push(unavailableList.length + " " + t(lang, "event_type.unavailable"));
|
||||||
'<button type="button" class="duty-marker" data-event-type="duty" data-date="' +
|
if (vacationList.length) counts.push(vacationList.length + " " + t(lang, "event_type.vacation"));
|
||||||
escapeHtml(key) +
|
if (hasEvent) counts.push(t(lang, "hint.events"));
|
||||||
'" data-names="' +
|
ariaParts.push(counts.join(", "));
|
||||||
namesAttr(dutyList) +
|
} else {
|
||||||
'" data-duty-items=\'' +
|
ariaParts.push(t(state.lang, "aria.day_info"));
|
||||||
dutyItemsJson +
|
|
||||||
"' aria-label=\"" + escapeHtml(t(lang, "aria.duty")) + "\">Д</button>";
|
|
||||||
}
|
}
|
||||||
if (unavailableList.length) {
|
cell.setAttribute("role", "button");
|
||||||
html +=
|
cell.setAttribute("tabindex", "0");
|
||||||
'<button type="button" class="unavailable-marker" data-event-type="unavailable" data-names="' +
|
cell.setAttribute("aria-label", ariaParts.join("; "));
|
||||||
namesAttr(unavailableList) +
|
|
||||||
'" aria-label="' + escapeHtml(t(lang, "aria.unavailable")) + '">Н</button>';
|
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>";
|
||||||
}
|
}
|
||||||
if (vacationList.length) {
|
|
||||||
html +=
|
cell.innerHTML =
|
||||||
'<button type="button" class="vacation-marker" data-event-type="vacation" data-names="' +
|
'<span class="num">' + d.getDate() + "</span>" + indicatorHtml;
|
||||||
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>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
html += "</div>";
|
|
||||||
cell.innerHTML = html;
|
|
||||||
calendarEl.appendChild(cell);
|
calendarEl.appendChild(cell);
|
||||||
d.setDate(d.getDate() + 1);
|
d.setDate(d.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
|
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
|
||||||
bindInfoButtonTooltips();
|
|
||||||
bindDutyMarkerTooltips();
|
|
||||||
}
|
}
|
||||||
|
|||||||
300
webapp/js/dayDetail.js
Normal file
300
webapp/js/dayDetail.js
Normal 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 " 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) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLa
|
|||||||
* @param {string} toLabel
|
* @param {string} toLabel
|
||||||
* @returns {{ timePrefix: string, fullName: string }[]}
|
* @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) => {
|
return dutyItems.map((item, idx) => {
|
||||||
const timePrefix = buildDutyItemTimePrefix(
|
const timePrefix = buildDutyItemTimePrefix(
|
||||||
item,
|
item,
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const MESSAGES = {
|
|||||||
"hint.from": "from",
|
"hint.from": "from",
|
||||||
"hint.to": "until",
|
"hint.to": "until",
|
||||||
"hint.duty_title": "Duty:",
|
"hint.duty_title": "Duty:",
|
||||||
"hint.events": "Events:"
|
"hint.events": "Events:",
|
||||||
|
"day_detail.close": "Close"
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -92,7 +93,8 @@ export const MESSAGES = {
|
|||||||
"hint.from": "с",
|
"hint.from": "с",
|
||||||
"hint.to": "до",
|
"hint.to": "до",
|
||||||
"hint.duty_title": "Дежурство:",
|
"hint.duty_title": "Дежурство:",
|
||||||
"hint.events": "События:"
|
"hint.events": "События:",
|
||||||
|
"day_detail.close": "Закрыть"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
calendarEventsByDate,
|
calendarEventsByDate,
|
||||||
renderCalendar
|
renderCalendar
|
||||||
} from "./calendar.js";
|
} from "./calendar.js";
|
||||||
|
import { initDayDetail } from "./dayDetail.js";
|
||||||
import { renderDutyList } from "./dutyList.js";
|
import { renderDutyList } from "./dutyList.js";
|
||||||
import {
|
import {
|
||||||
firstDayOfMonth,
|
firstDayOfMonth,
|
||||||
@@ -244,6 +245,7 @@ function bindStickyScrollShadow() {
|
|||||||
runWhenReady(() => {
|
runWhenReady(() => {
|
||||||
requireTelegramOrLocalhost(() => {
|
requireTelegramOrLocalhost(() => {
|
||||||
bindStickyScrollShadow();
|
bindStickyScrollShadow();
|
||||||
|
initDayDetail();
|
||||||
loadMonth();
|
loadMonth();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
143
webapp/style.css
143
webapp/style.css
@@ -176,6 +176,149 @@ body {
|
|||||||
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
|
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 {
|
.info-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user