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;
}