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.
This commit is contained in:
2026-03-02 12:40:49 +03:00
parent b906bfa777
commit a4d8d085c6
17 changed files with 822 additions and 181 deletions

View File

@@ -258,110 +258,82 @@ export function clearActiveDutyMarker() {
.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";
/**
* Bind click tooltips for .info-btn (calendar event summaries).
* Get or create the calendar event (info button) hint element.
* @returns {HTMLElement|null}
*/
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;
const lang = state.lang;
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = t(lang, "hint.events") + "\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
const rect = btn.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.dataset.active = "1";
} else {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
});
if (!document._calendarHintBound) {
document._calendarHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
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;
}
/**
* Bind hover/click tooltips for duty/unavailable/vacation markers.
* Get or create the duty marker hint element.
* @returns {HTMLElement|null}
*/
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);
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;
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;
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 {
hintEl.textContent = getDutyMarkerHintContent(marker);
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
});
marker.addEventListener("mouseleave", () => {
if (hintEl.dataset.active) return;
hintEl.classList.remove("calendar-event-hint--visible");
hideTimeout = setTimeout(() => {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", (e) => {
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")) {
hintEl.classList.remove("calendar-event-hint--visible");
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
}, 150);
marker.classList.remove("calendar-marker-active");
return;
@@ -369,35 +341,89 @@ export function bindDutyMarkerTooltips() {
clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
dutyMarkerHint.innerHTML = html;
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
hintEl.dataset.active = "1";
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
dutyMarkerHint.dataset.active = "1";
marker.classList.add("calendar-marker-active");
});
}
});
if (!document._dutyMarkerHintBound) {
document._dutyMarkerHintBound = true;
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 (hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
if (calendarEventHint.dataset.active) {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
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" && hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
}