style: enhance layout and functionality of duty markers and calendar
- Updated CSS for `.day` and `.access-denied` to improve layout and visual consistency. - Introduced a new function `dutyOverlapsLocalRange` in `dateUtils.js` to check duty overlaps within a specified date range. - Refactored `dutyItemHtml` in `dutyList.js` to utilize `formatTimeLocal` for time formatting, enhancing readability. - Added utility functions in `hints.js` for parsing duty marker data and building time prefixes, streamlining hint rendering logic. - Improved the `showAccessDenied` function in `ui.js` to display detailed server messages when access is denied.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
'<div class="' +
|
||||
|
||||
@@ -48,6 +48,100 @@ export function positionHint(hintEl, btnRect) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse data-duty-items and data-date from marker; detect if any item has times.
|
||||
* @param {HTMLElement} marker
|
||||
* @returns {{ dutyItems: object[], hasTimes: boolean, hintDay: string }}
|
||||
*/
|
||||
function parseDutyMarkerData(marker) {
|
||||
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") || "";
|
||||
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 (
|
||||
'<span class="calendar-event-hint-time">' +
|
||||
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 (
|
||||
'<span class="calendar-event-hint-time"></span><span class="calendar-event-hint-name">' +
|
||||
@@ -212,10 +207,10 @@ export function getDutyMarkerHintHtml(marker) {
|
||||
);
|
||||
});
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
if (rowHtmls.length === 0) return null;
|
||||
return (
|
||||
'<div class="calendar-event-hint-title">Дежурство:</div><div class="calendar-event-hint-rows">' +
|
||||
rows
|
||||
rowHtmls
|
||||
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
|
||||
.join("") +
|
||||
"</div>"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = "<p>Доступ запрещён.</p>";
|
||||
if (serverDetail && serverDetail.trim()) {
|
||||
const detail = document.createElement("p");
|
||||
detail.className = "access-denied-detail";
|
||||
detail.textContent = serverDetail;
|
||||
accessDeniedEl.appendChild(detail);
|
||||
}
|
||||
accessDeniedEl.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user