Files
duty-teller/webapp/js/hints.js
Nikolay Tatarinov a4d8d085c6 feat: update language support and enhance API functionality
- Changed the default language in `index.html` from Russian to English, updating the title and button aria-labels for improved accessibility.
- Refactored the `buildFetchOptions` function in `api.js` to include an optional external abort signal, enhancing request management.
- Updated `fetchDuties` and `fetchCalendarEvents` to support request cancellation using the new abort signal, improving error handling.
- Added unit tests for the API functions to ensure proper functionality, including handling of 403 errors and request cancellations.
- Enhanced CSS styles for duty markers to improve visual consistency.
- Removed unused code and improved the overall structure of the JavaScript files for better maintainability.
2026-03-02 12:40:49 +03:00

433 lines
15 KiB
JavaScript

/**
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl, state } from "./dom.js";
import { t } from "./i18n.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.classList.remove("calendar-event-hint--visible");
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";
});
}
requestAnimationFrame(() => {
hintEl.classList.add("calendar-event-hint--visible");
});
});
}
/**
* 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 "from"/"to" (e.g. " - " for text, "\u00a0" for HTML)
* @param {string} fromLabel - translated "from" prefix
* @param {string} toLabel - translated "to" prefix
* @returns {string}
*/
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLabel) {
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 = fromLabel + sep + startHHMM;
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (startSameDay && startHHMM) {
/* First of multiple, but starts today — show full range */
timePrefix = fromLabel + sep + startHHMM;
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
/* Continuation from previous day — only end time */
timePrefix = toLabel + sep + endHHMM;
}
} else if (idx > 0) {
if (startSameDay && startHHMM) {
timePrefix = fromLabel + sep + startHHMM;
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
/* Continuation from previous day — only end time */
timePrefix = toLabel + 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
* @param {string} fromLabel
* @param {string} toLabel
* @returns {{ timePrefix: string, fullName: string }[]}
*/
export function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
return dutyItems.map((item, idx) => {
const timePrefix = buildDutyItemTimePrefix(
item,
idx,
dutyItems.length,
hintDay,
timeSep,
fromLabel,
toLabel
);
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 lang = state.lang;
const type = marker.getAttribute("data-event-type") || "duty";
const label = t(lang, "event_type." + type);
const names = marker.getAttribute("data-names") || "";
const fromLabel = t(lang, "hint.from");
const toLabel = t(lang, "hint.to");
let body;
if (type === "duty") {
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
if (dutyItems.length >= 1 && hasTimes) {
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ", fromLabel, toLabel);
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 lang = state.lang;
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";
const fromLabel = t(lang, "hint.from");
const toLabel = t(lang, "hint.to");
let rowHtmls;
if (dutyItems.length >= 1 && hasTimes) {
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp, fromLabel, toLabel);
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">' + escapeHtml(t(lang, "hint.duty_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"));
}
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
let dutyMarkerHideTimeout = null;
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
/**
* Get or create the calendar event (info button) hint element.
* @returns {HTMLElement|null}
*/
function getOrCreateCalendarEventHint() {
let el = document.getElementById("calendarEventHint");
if (!el) {
el = document.createElement("div");
el.id = "calendarEventHint";
el.className = "calendar-event-hint";
el.setAttribute("role", "tooltip");
el.hidden = true;
document.body.appendChild(el);
}
return el;
}
/**
* Get or create the duty marker hint element.
* @returns {HTMLElement|null}
*/
function getOrCreateDutyMarkerHint() {
let el = document.getElementById("dutyMarkerHint");
if (!el) {
el = document.createElement("div");
el.id = "dutyMarkerHint";
el.className = "calendar-event-hint";
el.setAttribute("role", "tooltip");
el.hidden = true;
document.body.appendChild(el);
}
return el;
}
/**
* Set up event delegation on calendarEl for info button and duty marker tooltips.
* Call once at startup (e.g. alongside initDayDetail). No need to re-bind after render.
*/
export function initHints() {
const calendarEventHint = getOrCreateCalendarEventHint();
const dutyMarkerHint = getOrCreateDutyMarkerHint();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
if (btn) {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = t(state.lang, "hint.events") + "\n" + summary;
if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
calendarEventHint.textContent = content;
positionHint(calendarEventHint, btn.getBoundingClientRect());
calendarEventHint.dataset.active = "1";
} else {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
}
return;
}
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (marker) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
}, 150);
marker.classList.remove("calendar-marker-active");
return;
}
clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker);
if (html) {
dutyMarkerHint.innerHTML = html;
} else {
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
dutyMarkerHint.dataset.active = "1";
marker.classList.add("calendar-marker-active");
}
});
calendarEl.addEventListener("mouseover", (e) => {
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (!marker) return;
const related = e.relatedTarget instanceof Node ? e.relatedTarget : null;
if (related && marker.contains(related)) return;
if (dutyMarkerHideTimeout) {
clearTimeout(dutyMarkerHideTimeout);
dutyMarkerHideTimeout = null;
}
const html = getDutyMarkerHintHtml(marker);
if (html) {
dutyMarkerHint.innerHTML = html;
} else {
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
});
calendarEl.addEventListener("mouseout", (e) => {
const fromMarker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (!fromMarker) return;
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
if (toMarker) return;
if (dutyMarkerHint.dataset.active) return;
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
dutyMarkerHideTimeout = setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHideTimeout = null;
}, 150);
});
if (!state.calendarHintBound) {
state.calendarHintBound = true;
document.addEventListener("click", () => {
if (calendarEventHint.dataset.active) {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && calendarEventHint.dataset.active) {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
}
});
}
if (!state.dutyMarkerHintBound) {
state.dutyMarkerHintBound = true;
document.addEventListener("click", () => {
if (dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
}
});
}
}