|null} */
todayRefreshInterval: null,
/** @type {'ru'|'en'} */
- lang: "ru"
+ lang: "ru",
+ /** One-time bind flag for sticky scroll shadow listener. */
+ stickyScrollBound: false,
+ /** One-time bind flag for calendar (info button) hint document listeners. */
+ calendarHintBound: false,
+ /** One-time bind flag for duty marker hint document listeners. */
+ dutyMarkerHintBound: false,
+ /** Whether initData retry after ACCESS_DENIED has been attempted. */
+ initDataRetried: false
};
diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js
index 66c2dbb..f35b13e 100644
--- a/webapp/js/dutyList.js
+++ b/webapp/js/dutyList.js
@@ -10,7 +10,7 @@ import {
firstDayOfMonth,
lastDayOfMonth,
dateKeyToDDMM,
- formatTimeLocal,
+ formatHHMM,
formatDateKey
} from "./dateUtils.js";
@@ -25,8 +25,8 @@ export function dutyTimelineCardHtml(d, isCurrent) {
const endLocal = localDateString(new Date(d.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
- const startTime = formatTimeLocal(d.start_at);
- const endTime = formatTimeLocal(d.end_at);
+ const startTime = formatHHMM(d.start_at);
+ const endTime = formatHHMM(d.end_at);
let timeStr;
if (startLocal === endLocal) {
timeStr = startDDMM + ", " + startTime + " – " + endTime;
@@ -69,14 +69,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
if (extraClass) itemClass += " " + extraClass;
let timeOrRange = "";
if (showUntilEnd && d.event_type === "duty") {
- timeOrRange = t(lang, "duty.until", { time: formatTimeLocal(d.end_at) });
+ timeOrRange = t(lang, "duty.until", { time: formatHHMM(d.end_at) });
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
const startStr = formatDateKey(d.start_at);
const endStr = formatDateKey(d.end_at);
timeOrRange = startStr === endStr ? startStr : startStr + " – " + endStr;
} else {
timeOrRange =
- formatTimeLocal(d.start_at) + " – " + formatTimeLocal(d.end_at);
+ formatHHMM(d.start_at) + " – " + formatHHMM(d.end_at);
}
return (
' {
- 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);
}
diff --git a/webapp/js/i18n.test.js b/webapp/js/i18n.test.js
new file mode 100644
index 0000000..36380d6
--- /dev/null
+++ b/webapp/js/i18n.test.js
@@ -0,0 +1,87 @@
+/**
+ * Unit tests for i18n: getLang, t (fallback, params), monthName.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mockGetInitData = vi.fn();
+vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
+
+import { getLang, t, monthName, MESSAGES } from "./i18n.js";
+
+describe("getLang", () => {
+ const origNavigator = globalThis.navigator;
+
+ beforeEach(() => {
+ mockGetInitData.mockReset();
+ });
+
+ it("returns lang from initData user when present", () => {
+ mockGetInitData.mockReturnValue(
+ "user=" + encodeURIComponent(JSON.stringify({ language_code: "en" }))
+ );
+ expect(getLang()).toBe("en");
+ });
+
+ it("normalizes ru from initData", () => {
+ mockGetInitData.mockReturnValue(
+ "user=" + encodeURIComponent(JSON.stringify({ language_code: "ru" }))
+ );
+ expect(getLang()).toBe("ru");
+ });
+
+ it("falls back to navigator.language when initData empty", () => {
+ mockGetInitData.mockReturnValue("");
+ Object.defineProperty(globalThis, "navigator", {
+ value: { ...origNavigator, language: "en-US", languages: ["en-US", "en"] },
+ configurable: true,
+ });
+ expect(getLang()).toBe("en");
+ });
+
+ it("normalizes to en for unknown language code", () => {
+ mockGetInitData.mockReturnValue(
+ "user=" + encodeURIComponent(JSON.stringify({ language_code: "uk" }))
+ );
+ expect(getLang()).toBe("en");
+ });
+});
+
+describe("t", () => {
+ it("returns translation for existing key", () => {
+ expect(t("en", "app.title")).toBe("Duty Calendar");
+ expect(t("ru", "app.title")).toBe("Календарь дежурств");
+ });
+
+ it("falls back to en when key missing in lang", () => {
+ expect(t("ru", "app.title")).toBe("Календарь дежурств");
+ expect(t("en", "loading")).toBe("Loading…");
+ });
+
+ it("returns key when key missing in both", () => {
+ expect(t("en", "missing.key")).toBe("missing.key");
+ expect(t("ru", "unknown")).toBe("unknown");
+ });
+
+ it("replaces params placeholder", () => {
+ expect(t("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
+ expect(t("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
+ });
+
+ it("handles empty params", () => {
+ expect(t("en", "loading", {})).toBe("Loading…");
+ });
+});
+
+describe("monthName", () => {
+ it("returns month name for 0-based index", () => {
+ expect(monthName("en", 0)).toBe("January");
+ expect(monthName("en", 11)).toBe("December");
+ expect(monthName("ru", 0)).toBe("Январь");
+ });
+
+ it("returns empty string for out-of-range", () => {
+ expect(monthName("en", 12)).toBe("");
+ expect(monthName("en", -1)).toBe("");
+ });
+});
diff --git a/webapp/js/main.js b/webapp/js/main.js
index ba3805f..24dfd4a 100644
--- a/webapp/js/main.js
+++ b/webapp/js/main.js
@@ -4,8 +4,7 @@
import { initTheme, applyTheme } from "./theme.js";
import { getLang, t, weekdayLabels } from "./i18n.js";
-import { getInitData } from "./auth.js";
-import { isLocalhost } from "./auth.js";
+import { getInitData, isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import {
state,
@@ -24,6 +23,7 @@ import {
renderCalendar
} from "./calendar.js";
import { initDayDetail } from "./dayDetail.js";
+import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js";
import {
firstDayOfMonth,
@@ -106,10 +106,18 @@ function requireTelegramOrLocalhost(onAllowed) {
if (loadingEl) loadingEl.classList.add("hidden");
}
+/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
+let loadMonthAbortController = null;
+
/**
* Load current month: fetch duties and events, render calendar and duty list.
+ * Stale requests are cancelled when the user navigates to another month before they complete.
*/
async function loadMonth() {
+ if (loadMonthAbortController) loadMonthAbortController.abort();
+ loadMonthAbortController = new AbortController();
+ const signal = loadMonthAbortController.signal;
+
hideAccessDenied();
setNavEnabled(false);
if (loadingEl) loadingEl.classList.remove("hidden");
@@ -122,8 +130,8 @@ async function loadMonth() {
const from = localDateString(start);
const to = localDateString(gridEnd);
try {
- const dutiesPromise = fetchDuties(from, to);
- const eventsPromise = fetchCalendarEvents(from, to);
+ const dutiesPromise = fetchDuties(from, to, signal);
+ const eventsPromise = fetchCalendarEvents(from, to, signal);
const duties = await dutiesPromise;
const events = await eventsPromise;
const byDate = dutiesByDate(duties);
@@ -156,15 +164,18 @@ async function loadMonth() {
}, 60000);
}
} catch (e) {
+ if (e.name === "AbortError") {
+ return;
+ }
if (e.message === "ACCESS_DENIED") {
showAccessDenied(e.serverDetail);
setNavEnabled(true);
if (
window.Telegram &&
window.Telegram.WebApp &&
- !window._initDataRetried
+ !state.initDataRetried
) {
- window._initDataRetried = true;
+ state.initDataRetried = true;
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
}
return;
@@ -204,9 +215,9 @@ if (nextBtn) {
"touchstart",
(e) => {
if (e.changedTouches.length === 0) return;
- const t = e.changedTouches[0];
- startX = t.clientX;
- startY = t.clientY;
+ const touch = e.changedTouches[0];
+ startX = touch.clientX;
+ startY = touch.clientY;
},
{ passive: true }
);
@@ -216,9 +227,9 @@ if (nextBtn) {
if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return;
if (accessDeniedEl && !accessDeniedEl.hidden) return;
- const t = e.changedTouches[0];
- const deltaX = t.clientX - startX;
- const deltaY = t.clientY - startY;
+ const touch = e.changedTouches[0];
+ const deltaX = touch.clientX - startX;
+ const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
if (deltaX > SWIPE_THRESHOLD) {
@@ -237,8 +248,8 @@ if (nextBtn) {
function bindStickyScrollShadow() {
const stickyEl = document.getElementById("calendarSticky");
- if (!stickyEl || document._stickyScrollBound) return;
- document._stickyScrollBound = true;
+ if (!stickyEl || state.stickyScrollBound) return;
+ state.stickyScrollBound = true;
function updateScrolled() {
stickyEl.classList.toggle("is-scrolled", window.scrollY > 0);
}
@@ -250,6 +261,7 @@ runWhenReady(() => {
requireTelegramOrLocalhost(() => {
bindStickyScrollShadow();
initDayDetail();
+ initHints();
loadMonth();
});
});
diff --git a/webapp/js/utils.js b/webapp/js/utils.js
index 618cf87..2a6b1ef 100644
--- a/webapp/js/utils.js
+++ b/webapp/js/utils.js
@@ -2,13 +2,19 @@
* Common utilities.
*/
+const ESCAPE_MAP = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+};
+
/**
* Escape string for safe use in HTML (text content / attributes).
* @param {string} s - Raw string
* @returns {string} HTML-escaped string
*/
export function escapeHtml(s) {
- const div = document.createElement("div");
- div.textContent = s;
- return div.innerHTML;
+ return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}
diff --git a/webapp/js/utils.test.js b/webapp/js/utils.test.js
new file mode 100644
index 0000000..c4003c6
--- /dev/null
+++ b/webapp/js/utils.test.js
@@ -0,0 +1,42 @@
+/**
+ * Unit tests for escapeHtml edge cases.
+ */
+
+import { describe, it, expect } from "vitest";
+import { escapeHtml } from "./utils.js";
+
+describe("escapeHtml", () => {
+ it("escapes ampersand", () => {
+ expect(escapeHtml("a & b")).toBe("a & b");
+ });
+
+ it("escapes less-than and greater-than", () => {
+ expect(escapeHtml("