Files
duty-teller/webapp/js/hints.js
Nikolay Tatarinov a5f7a5a0ef style: improve calendar event hint layout and rendering
- Updated CSS for `.calendar-event-hint-rows` and `.calendar-event-hint-row` to enhance layout using table display properties for better alignment.
- Adjusted JavaScript in `hints.js` to modify the separator between time and name in duty marker hints, improving visual clarity.
- Ensured consistent styling and spacing for better readability of duty hints in the calendar.
2026-02-19 15:48:09 +03:00

359 lines
12 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 sepHtml = r.timePrefix ? '<span class="calendar-event-hint-sep">-</span>' : '<span class="calendar-event-hint-sep"></span>';
const nameHtml = escapeHtml(r.fullName);
return (
'<span class="calendar-event-hint-time">' +
timeHtml +
"</span>" +
sepHtml +
'<span class="calendar-event-hint-name">' +
nameHtml +
"</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-sep"></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();
}
});
}
}