Files
duty-teller/webapp/js/hints.js
Nikolay Tatarinov dd14d48824 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.
2026-02-19 15:40:34 +03:00

358 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl } from "./dom.js";
import { EVENT_TYPE_LABELS } from "./constants.js";
import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js";
/**
* Position hint element near button; flip below if needed, clamp to viewport.
* @param {HTMLElement} hintEl
* @param {DOMRect} btnRect
*/
export function positionHint(hintEl, btnRect) {
const vw = document.documentElement.clientWidth;
const margin = 12;
hintEl.classList.remove("below");
hintEl.style.left = btnRect.left + "px";
hintEl.style.top = btnRect.top - 4 + "px";
hintEl.hidden = false;
requestAnimationFrame(() => {
const hintRect = hintEl.getBoundingClientRect();
let left = parseFloat(hintEl.style.left);
let top = parseFloat(hintEl.style.top);
if (hintRect.top < margin) {
hintEl.classList.add("below");
top = btnRect.bottom + 4;
}
if (hintRect.right > vw - margin) {
left = vw - hintRect.width - margin;
}
if (left < margin) left = margin;
left = Math.min(left, vw - hintRect.width - margin);
hintEl.style.left = left + "px";
hintEl.style.top = top + "px";
if (hintEl.classList.contains("below")) {
requestAnimationFrame(() => {
const hintRect2 = hintEl.getBoundingClientRect();
let left2 = parseFloat(hintEl.style.left);
if (hintRect2.right > vw - margin) {
left2 = vw - hintRect2.width - margin;
}
if (left2 < margin) left2 = margin;
hintEl.style.left = left2 + "px";
});
}
});
}
/**
* 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
* @returns {string}
*/
export function getDutyMarkerHintContent(marker) {
const type = marker.getAttribute("data-event-type") || "duty";
const label = EVENT_TYPE_LABELS[type] || type;
const names = marker.getAttribute("data-names") || "";
let body;
if (type === "duty") {
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
if (dutyItems.length >= 1 && hasTimes) {
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ");
body = rows
.map((r) =>
r.timePrefix ? r.timePrefix + " — " + r.fullName : r.fullName
)
.join("\n");
} else {
body = names;
}
} else {
body = names;
}
return body ? label + ":\n" + body : label;
}
/**
* Returns HTML for duty hint with aligned times, or null to use textContent.
* @param {HTMLElement} marker
* @returns {string|null}
*/
export function getDutyMarkerHintHtml(marker) {
const type = marker.getAttribute("data-event-type") || "duty";
if (type !== "duty") return null;
const names = marker.getAttribute("data-names") || "";
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
const nbsp = "\u00a0";
let rowHtmls;
if (dutyItems.length >= 1 && hasTimes) {
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 +
'</span><span class="calendar-event-hint-name">' +
namePart +
"</span>"
);
});
} else {
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">' +
nameHtml +
"</span>"
);
});
}
if (rowHtmls.length === 0) return null;
return (
'<div class="calendar-event-hint-title">Дежурство:</div><div class="calendar-event-hint-rows">' +
rowHtmls
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
.join("") +
"</div>"
);
}
/**
* Remove active class from all duty/unavailable/vacation markers.
*/
export function clearActiveDutyMarker() {
if (!calendarEl) return;
calendarEl
.querySelectorAll(
".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active"
)
.forEach((m) => m.classList.remove("calendar-marker-active"));
}
/**
* Bind click tooltips for .info-btn (calendar event summaries).
*/
export function bindInfoButtonTooltips() {
let hintEl = document.getElementById("calendarEventHint");
if (!hintEl) {
hintEl = document.createElement("div");
hintEl.id = "calendarEventHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
hintEl.hidden = true;
document.body.appendChild(hintEl);
}
if (!calendarEl) return;
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = "События:\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
const rect = btn.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.dataset.active = "1";
} else {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}
});
});
if (!document._calendarHintBound) {
document._calendarHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}
});
}
}
/**
* Bind hover/click tooltips for duty/unavailable/vacation markers.
*/
export function bindDutyMarkerTooltips() {
let hintEl = document.getElementById("dutyMarkerHint");
if (!hintEl) {
hintEl = document.createElement("div");
hintEl.id = "dutyMarkerHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
hintEl.hidden = true;
document.body.appendChild(hintEl);
}
if (!calendarEl) return;
let hideTimeout = null;
const selector = ".duty-marker, .unavailable-marker, .vacation-marker";
calendarEl.querySelectorAll(selector).forEach((marker) => {
marker.addEventListener("mouseenter", () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
});
marker.addEventListener("mouseleave", () => {
if (hintEl.dataset.active) return;
hideTimeout = setTimeout(() => {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", (e) => {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
marker.classList.remove("calendar-marker-active");
return;
}
clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
hintEl.dataset.active = "1";
marker.classList.add("calendar-marker-active");
});
});
if (!document._dutyMarkerHintBound) {
document._dutyMarkerHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}
});
}
}