diff --git a/webapp/js/dateUtils.js b/webapp/js/dateUtils.js index 539410d..7f8686b 100644 --- a/webapp/js/dateUtils.js +++ b/webapp/js/dateUtils.js @@ -29,6 +29,23 @@ export function dutyOverlapsLocalDay(d, dateKey) { return end > dayStart && start < dayEnd; } +/** + * True if duty overlaps any local day in the range [firstKey, lastKey] (inclusive). + * @param {object} d - Duty with start_at, end_at + * @param {string} firstKey - YYYY-MM-DD (first day of range) + * @param {string} lastKey - YYYY-MM-DD (last day of range) + * @returns {boolean} + */ +export function dutyOverlapsLocalRange(d, firstKey, lastKey) { + const [y1, m1, day1] = firstKey.split("-").map(Number); + const [y2, m2, day2] = lastKey.split("-").map(Number); + const rangeStart = new Date(y1, m1 - 1, day1, 0, 0, 0, 0); + const rangeEnd = new Date(y2, m2 - 1, day2, 23, 59, 59, 999); + const start = new Date(d.start_at); + const end = new Date(d.end_at); + return end > rangeStart && start < rangeEnd; +} + /** @param {Date} d - Date */ export function firstDayOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js index ea0dcb4..3777d70 100644 --- a/webapp/js/dutyList.js +++ b/webapp/js/dutyList.js @@ -13,7 +13,6 @@ import { formatTimeLocal, formatDateKey } from "./dateUtils.js"; -import { dutiesByDate } from "./calendar.js"; /** * Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day. @@ -60,8 +59,6 @@ export function dutyTimelineCardHtml(d, isCurrent) { * @returns {string} */ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) { - const startDate = new Date(d.start_at); - const endDate = new Date(d.end_at); const typeLabel = typeLabelOverride != null ? typeLabelOverride @@ -70,25 +67,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) { if (extraClass) itemClass += " " + extraClass; let timeOrRange = ""; if (showUntilEnd && d.event_type === "duty") { - const end = - String(endDate.getHours()).padStart(2, "0") + - ":" + - String(endDate.getMinutes()).padStart(2, "0"); - timeOrRange = "до " + end; + timeOrRange = "до " + formatTimeLocal(d.end_at); } else if (d.event_type === "vacation" || d.event_type === "unavailable") { const startStr = formatDateKey(d.start_at); const endStr = formatDateKey(d.end_at); timeOrRange = startStr === endStr ? startStr : startStr + " – " + endStr; } else { - const start = - String(startDate.getHours()).padStart(2, "0") + - ":" + - String(startDate.getMinutes()).padStart(2, "0"); - const end = - String(endDate.getHours()).padStart(2, "0") + - ":" + - String(endDate.getMinutes()).padStart(2, "0"); - timeOrRange = start + " – " + end; + timeOrRange = + formatTimeLocal(d.start_at) + " – " + formatTimeLocal(d.end_at); } return ( '
0 && + dutyItems.some((it) => { + const start = it.start_at != null ? it.start_at : it.startAt; + const end = it.end_at != null ? it.end_at : it.endAt; + return start || end; + }); + const hintDay = marker.getAttribute("data-date") || ""; + return { dutyItems, hasTimes, hintDay }; +} + +/** + * Get fullName from duty item (supports snake_case and camelCase). + * @param {object} item + * @returns {string} + */ +function getItemFullName(item) { + return item.full_name != null ? item.full_name : item.fullName; +} + +/** + * Build timePrefix for one duty item in the list (shared logic for text and HTML). + * @param {object} item - duty item with start_at/startAt, end_at/endAt + * @param {number} idx - index in dutyItems + * @param {number} total - dutyItems.length + * @param {string} hintDay - YYYY-MM-DD + * @param {string} sep - separator after "с"/"до" (e.g. " - " for text, "\u00a0" for HTML) + * @returns {string} + */ +function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) { + const startAt = item.start_at != null ? item.start_at : item.startAt; + const endAt = item.end_at != null ? item.end_at : item.endAt; + const endHHMM = endAt ? formatHHMM(endAt) : ""; + const startHHMM = startAt ? formatHHMM(startAt) : ""; + const startSameDay = + hintDay && startAt && localDateString(new Date(startAt)) === hintDay; + const endSameDay = + hintDay && endAt && localDateString(new Date(endAt)) === hintDay; + let timePrefix = ""; + if (idx === 0) { + if (total === 1 && startSameDay && startHHMM) { + timePrefix = "с" + sep + startHHMM; + if (endSameDay && endHHMM && endHHMM !== startHHMM) { + timePrefix += " до" + sep + endHHMM; + } + } else if (endHHMM) { + timePrefix = "до" + sep + endHHMM; + } + } else if (idx > 0) { + if (startHHMM) timePrefix = "с" + sep + startHHMM; + if (endHHMM && endSameDay && endHHMM !== startHHMM) { + timePrefix += (timePrefix ? " " : "") + "до" + sep + endHHMM; + } + } + return timePrefix; +} + +/** + * Get array of { timePrefix, fullName } for duty items (single source of time rules). + * @param {object[]} dutyItems + * @param {string} hintDay + * @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML + * @returns {{ timePrefix: string, fullName: string }[]} + */ +function getDutyMarkerRows(dutyItems, hintDay, timeSep) { + return dutyItems.map((item, idx) => { + const timePrefix = buildDutyItemTimePrefix( + item, + idx, + dutyItems.length, + hintDay, + timeSep + ); + const fullName = getItemFullName(item); + return { timePrefix, fullName }; + }); +} + /** * Get plain text content for duty marker tooltip. * @param {HTMLElement} marker - .duty-marker, .unavailable-marker or .vacation-marker @@ -59,63 +153,13 @@ export function getDutyMarkerHintContent(marker) { const names = marker.getAttribute("data-names") || ""; let body; if (type === "duty") { - const dutyItemsRaw = - marker.getAttribute("data-duty-items") || - (marker.dataset && marker.dataset.dutyItems) || - ""; - let dutyItems = []; - try { - if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw); - } catch (e) { - /* ignore */ - } - const hasTimes = - dutyItems.length > 0 && - dutyItems.some((it) => { - const start = it.start_at != null ? it.start_at : it.startAt; - const end = it.end_at != null ? it.end_at : it.endAt; - return start || end; - }); + const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker); if (dutyItems.length >= 1 && hasTimes) { - const hintDay = marker.getAttribute("data-date") || ""; - body = dutyItems - .map((item, idx) => { - const startAt = item.start_at != null ? item.start_at : item.startAt; - const endAt = item.end_at != null ? item.end_at : item.endAt; - const endHHMM = endAt ? formatHHMM(endAt) : ""; - const startHHMM = startAt ? formatHHMM(startAt) : ""; - const startSameDay = - hintDay && startAt && localDateString(new Date(startAt)) === hintDay; - const endSameDay = - hintDay && endAt && localDateString(new Date(endAt)) === hintDay; - const fullName = - item.full_name != null ? item.full_name : item.fullName; - let timePrefix = ""; - if (idx === 0) { - if ( - dutyItems.length === 1 && - startSameDay && - startHHMM - ) { - timePrefix = "с - " + startHHMM; - if (endSameDay && endHHMM && endHHMM !== startHHMM) { - timePrefix += " до - " + endHHMM; - } - } else if (endHHMM) { - timePrefix = "до -" + endHHMM; - } - } else if (idx > 0) { - if (startHHMM) timePrefix = "с - " + startHHMM; - if ( - endHHMM && - endSameDay && - endHHMM !== startHHMM - ) { - timePrefix += (timePrefix ? " " : "") + "до - " + endHHMM; - } - } - return timePrefix ? timePrefix + " — " + fullName : fullName; - }) + const rows = getDutyMarkerRows(dutyItems, hintDay, " - "); + body = rows + .map((r) => + r.timePrefix ? r.timePrefix + " — " + r.fullName : r.fullName + ) .join("\n"); } else { body = names; @@ -135,65 +179,16 @@ export function getDutyMarkerHintHtml(marker) { const type = marker.getAttribute("data-event-type") || "duty"; if (type !== "duty") return null; const names = marker.getAttribute("data-names") || ""; - const dutyItemsRaw = - marker.getAttribute("data-duty-items") || - (marker.dataset && marker.dataset.dutyItems) || - ""; - let dutyItems = []; - try { - if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw); - } catch (e) { - /* ignore */ - } - const hasTimes = - dutyItems.length > 0 && - dutyItems.some((it) => { - const start = it.start_at != null ? it.start_at : it.startAt; - const end = it.end_at != null ? it.end_at : it.endAt; - return start || end; - }); - const hintDay = marker.getAttribute("data-date") || ""; + const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker); const nbsp = "\u00a0"; - let rows; + let rowHtmls; if (dutyItems.length >= 1 && hasTimes) { - rows = dutyItems.map((item, idx) => { - const startAt = item.start_at != null ? item.start_at : item.startAt; - const endAt = item.end_at != null ? item.end_at : item.endAt; - const endHHMM = endAt ? formatHHMM(endAt) : ""; - const startHHMM = startAt ? formatHHMM(startAt) : ""; - const startSameDay = - hintDay && startAt && localDateString(new Date(startAt)) === hintDay; - const endSameDay = - hintDay && endAt && localDateString(new Date(endAt)) === hintDay; - const fullName = - item.full_name != null ? item.full_name : item.fullName; - let timePrefix = ""; - if (idx === 0) { - if ( - dutyItems.length === 1 && - startSameDay && - startHHMM - ) { - timePrefix = "с" + nbsp + startHHMM; - if (endSameDay && endHHMM && endHHMM !== startHHMM) { - timePrefix += " до" + nbsp + endHHMM; - } - } else if (endHHMM) { - timePrefix = "до" + nbsp + endHHMM; - } - } else if (idx > 0) { - if (startHHMM) timePrefix = "с" + nbsp + startHHMM; - if ( - endHHMM && - endSameDay && - endHHMM !== startHHMM - ) { - timePrefix += (timePrefix ? " " : "") + "до" + nbsp + endHHMM; - } - } - const timeHtml = timePrefix ? escapeHtml(timePrefix) : ""; - const nameHtml = escapeHtml(fullName); - const namePart = timePrefix ? " — " + nameHtml : nameHtml; + const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp); + rowHtmls = rows.map((r) => { + const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : ""; + const namePart = r.timePrefix + ? " — " + escapeHtml(r.fullName) + : escapeHtml(r.fullName); return ( '' + timeHtml + @@ -203,7 +198,7 @@ export function getDutyMarkerHintHtml(marker) { ); }); } else { - rows = (names ? names.split("\n") : []).map((fullName) => { + rowHtmls = (names ? names.split("\n") : []).map((fullName) => { const nameHtml = escapeHtml(fullName.trim()); return ( '' + @@ -212,10 +207,10 @@ export function getDutyMarkerHintHtml(marker) { ); }); } - if (rows.length === 0) return null; + if (rowHtmls.length === 0) return null; return ( '
Дежурство:
' + - rows + rowHtmls .map((r) => '
' + r + "
") .join("") + "
" diff --git a/webapp/js/main.js b/webapp/js/main.js index 1272bf8..d0c6279 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -26,7 +26,8 @@ import { firstDayOfMonth, lastDayOfMonth, getMonday, - localDateString + localDateString, + dutyOverlapsLocalRange } from "./dateUtils.js"; initTheme(); @@ -113,12 +114,9 @@ async function loadMonth() { const last = lastDayOfMonth(current); const firstKey = localDateString(first); const lastKey = localDateString(last); - const dutiesInMonth = duties.filter((d) => { - const byDateLocal = dutiesByDate([d]); - return Object.keys(byDateLocal).some( - (key) => key >= firstKey && key <= lastKey - ); - }); + const dutiesInMonth = duties.filter((d) => + dutyOverlapsLocalRange(d, firstKey, lastKey) + ); state.lastDutiesForList = dutiesInMonth; renderDutyList(dutiesInMonth); if (state.todayRefreshInterval) { @@ -197,16 +195,17 @@ if (nextBtn) { (e) => { if (e.changedTouches.length === 0) return; if (accessDeniedEl && !accessDeniedEl.hidden) return; - if (prevBtn && prevBtn.disabled) return; const t = e.changedTouches[0]; const deltaX = t.clientX - startX; const deltaY = t.clientY - startY; if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; if (Math.abs(deltaY) > Math.abs(deltaX)) return; if (deltaX > SWIPE_THRESHOLD) { + if (prevBtn && prevBtn.disabled) return; state.current.setMonth(state.current.getMonth() - 1); loadMonth(); } else if (deltaX < -SWIPE_THRESHOLD) { + if (nextBtn && nextBtn.disabled) return; state.current.setMonth(state.current.getMonth() + 1); loadMonth(); } diff --git a/webapp/js/ui.js b/webapp/js/ui.js index 764d22c..0129839 100644 --- a/webapp/js/ui.js +++ b/webapp/js/ui.js @@ -16,7 +16,7 @@ import { /** * Show access-denied view and hide calendar/list/loading/error. - * @param {string} [serverDetail] - message from API 403 detail (unused; kept for callers) + * @param {string} [serverDetail] - message from API 403 detail (shown below main text when present) */ export function showAccessDenied(serverDetail) { if (headerEl) headerEl.hidden = true; @@ -25,7 +25,16 @@ export function showAccessDenied(serverDetail) { if (dutyListEl) dutyListEl.hidden = true; if (loadingEl) loadingEl.classList.add("hidden"); if (errorEl) errorEl.hidden = true; - if (accessDeniedEl) accessDeniedEl.hidden = false; + if (accessDeniedEl) { + accessDeniedEl.innerHTML = "

Доступ запрещён.

"; + if (serverDetail && serverDetail.trim()) { + const detail = document.createElement("p"); + detail.className = "access-denied-detail"; + detail.textContent = serverDetail; + accessDeniedEl.appendChild(detail); + } + accessDeniedEl.hidden = false; + } } /** diff --git a/webapp/style.css b/webapp/style.css index ffa5749..e6ea5b9 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -143,6 +143,7 @@ body { } .day { + position: relative; aspect-ratio: 1; display: flex; flex-direction: column; @@ -175,10 +176,6 @@ body { border: 1px solid color-mix(in srgb, var(--today) 35%, transparent); } -.day { - position: relative; -} - .info-btn { position: absolute; top: 0; @@ -577,6 +574,12 @@ body { font-weight: 600; } +.access-denied .access-denied-detail { + margin-top: 8px; + font-size: 0.9rem; + color: var(--muted); +} + .access-denied[hidden] { display: none !important; }