Files
duty-teller/webapp/js/hints.js
Nikolay Tatarinov c9cf86a8f6 refactor: restructure web application with modular JavaScript and remove legacy code
- Deleted the `app.js` file and migrated its functionality to a modular structure with multiple JavaScript files for better organization and maintainability.
- Updated `index.html` to reference the new `main.js` module, ensuring proper loading of the application.
- Introduced new utility modules for API requests, authentication, calendar handling, and DOM manipulation to enhance code clarity and separation of concerns.
- Enhanced CSS styles for improved layout and theming consistency across the application.
- Added comprehensive comments and documentation to new modules to facilitate future development and understanding.
2026-02-19 15:24:52 +03:00

363 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";
});
}
});
}
/**
* 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 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;
});
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;
})
.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 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 nbsp = "\u00a0";
let rows;
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;
return (
'<span class="calendar-event-hint-time">' +
timeHtml +
'</span><span class="calendar-event-hint-name">' +
namePart +
"</span>"
);
});
} else {
rows = (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 (rows.length === 0) return null;
return (
'<div class="calendar-event-hint-title">Дежурство:</div><div class="calendar-event-hint-rows">' +
rows
.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();
}
});
}
}