diff --git a/webapp/index.html b/webapp/index.html index 574c557..c98e2f3 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -1,31 +1,29 @@ - + - Календарь дежурств +
- +

- +
- ПнВтСрЧтПтСбВс +
-
Загрузка…
+
- +
diff --git a/webapp/js/api.js b/webapp/js/api.js index 78f3335..89558ac 100644 --- a/webapp/js/api.js +++ b/webapp/js/api.js @@ -9,16 +9,31 @@ import { t } from "./i18n.js"; /** * Build fetch options with init data header, Accept-Language and timeout abort. + * Optional external signal (e.g. from loadMonth) aborts this request when triggered. * @param {string} initData - Telegram init data - * @returns {{ headers: object, signal: AbortSignal, timeoutId: number }} + * @param {AbortSignal} [externalSignal] - when aborted, cancels this request + * @returns {{ headers: object, signal: AbortSignal, cleanup: () => void }} */ -export function buildFetchOptions(initData) { +export function buildFetchOptions(initData, externalSignal) { const headers = {}; if (initData) headers["X-Telegram-Init-Data"] = initData; headers["Accept-Language"] = state.lang || "ru"; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - return { headers, signal: controller.signal, timeoutId }; + const onAbort = () => controller.abort(); + const cleanup = () => { + clearTimeout(timeoutId); + if (externalSignal) externalSignal.removeEventListener("abort", onAbort); + }; + if (externalSignal) { + if (externalSignal.aborted) { + cleanup(); + controller.abort(); + } else { + externalSignal.addEventListener("abort", onAbort); + } + } + return { headers, signal: controller.signal, cleanup }; } /** @@ -26,31 +41,34 @@ export function buildFetchOptions(initData) { * Caller checks res.ok, res.status, res.json(). * @param {string} path - e.g. "/api/duties" * @param {{ from?: string, to?: string }} params - query params + * @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation * @returns {Promise} - raw response */ -export async function apiGet(path, params = {}) { +export async function apiGet(path, params = {}, options = {}) { const base = window.location.origin; const query = new URLSearchParams(params).toString(); const url = query ? `${base}${path}?${query}` : `${base}${path}`; const initData = getInitData(); - const opts = buildFetchOptions(initData); + const opts = buildFetchOptions(initData, options.signal); try { const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); return res; } finally { - clearTimeout(opts.timeoutId); + opts.cleanup(); } } /** * Fetch duties for date range. Throws ACCESS_DENIED error on 403. + * AbortError is rethrown when the request is cancelled (e.g. stale loadMonth). * @param {string} from - YYYY-MM-DD * @param {string} to - YYYY-MM-DD + * @param {AbortSignal} [signal] - optional signal to cancel the request * @returns {Promise} */ -export async function fetchDuties(from, to) { +export async function fetchDuties(from, to, signal) { try { - const res = await apiGet("/api/duties", { from, to }); + const res = await apiGet("/api/duties", { from, to }, { signal }); if (res.status === 403) { let detail = t(state.lang, "access_denied"); try { @@ -71,25 +89,26 @@ export async function fetchDuties(from, to) { if (!res.ok) throw new Error(t(state.lang, "error_load_failed")); return res.json(); } catch (e) { - if (e.name === "AbortError") { - throw new Error(t(state.lang, "error_network")); - } + if (e.name === "AbortError") throw e; throw e; } } /** * Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403. + * Rethrows AbortError when the request is cancelled (e.g. stale loadMonth). * @param {string} from - YYYY-MM-DD * @param {string} to - YYYY-MM-DD + * @param {AbortSignal} [signal] - optional signal to cancel the request * @returns {Promise} */ -export async function fetchCalendarEvents(from, to) { +export async function fetchCalendarEvents(from, to, signal) { try { - const res = await apiGet("/api/calendar-events", { from, to }); + const res = await apiGet("/api/calendar-events", { from, to }, { signal }); if (!res.ok) return []; return res.json(); } catch (e) { + if (e.name === "AbortError") throw e; return []; } } diff --git a/webapp/js/api.test.js b/webapp/js/api.test.js new file mode 100644 index 0000000..8038621 --- /dev/null +++ b/webapp/js/api.test.js @@ -0,0 +1,104 @@ +/** + * Unit tests for api: buildFetchOptions, fetchDuties (403 handling, AbortError). + */ + +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; + +beforeAll(() => { + document.body.innerHTML = + '

' + + '
' + + '
' + + ''; +}); + +const mockGetInitData = vi.fn(); +vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() })); + +import { buildFetchOptions, fetchDuties } from "./api.js"; +import { state } from "./dom.js"; + +describe("buildFetchOptions", () => { + beforeEach(() => { + state.lang = "ru"; + }); + + it("sets X-Telegram-Init-Data when initData provided", () => { + const opts = buildFetchOptions("init-data-string"); + expect(opts.headers["X-Telegram-Init-Data"]).toBe("init-data-string"); + opts.cleanup(); + }); + + it("omits X-Telegram-Init-Data when initData empty", () => { + const opts = buildFetchOptions(""); + expect(opts.headers["X-Telegram-Init-Data"]).toBeUndefined(); + opts.cleanup(); + }); + + it("sets Accept-Language from state.lang", () => { + state.lang = "en"; + const opts = buildFetchOptions(""); + expect(opts.headers["Accept-Language"]).toBe("en"); + opts.cleanup(); + }); + + it("returns signal and cleanup function", () => { + const opts = buildFetchOptions(""); + expect(opts.signal).toBeDefined(); + expect(typeof opts.cleanup).toBe("function"); + opts.cleanup(); + }); + + it("cleanup clears timeout and removes external abort listener", () => { + const controller = new AbortController(); + const opts = buildFetchOptions("", controller.signal); + opts.cleanup(); + controller.abort(); + }); +}); + +describe("fetchDuties", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetInitData.mockReturnValue("test-init-data"); + state.lang = "ru"; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns JSON on 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve([{ id: 1 }]), + }); + const result = await fetchDuties("2025-02-01", "2025-02-28"); + expect(result).toEqual([{ id: 1 }]); + }); + + it("throws ACCESS_DENIED on 403 with server detail from body", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({ detail: "Custom access denied" }), + }); + await expect(fetchDuties("2025-02-01", "2025-02-28")).rejects.toMatchObject({ + message: "ACCESS_DENIED", + serverDetail: "Custom access denied", + }); + }); + + it("rethrows AbortError when request is aborted", async () => { + const aborter = new AbortController(); + const abortError = new DOMException("aborted", "AbortError"); + globalThis.fetch = vi.fn().mockImplementation(() => { + return Promise.reject(abortError); + }); + await expect( + fetchDuties("2025-02-01", "2025-02-28", aborter.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/webapp/js/auth.test.js b/webapp/js/auth.test.js new file mode 100644 index 0000000..e614e49 --- /dev/null +++ b/webapp/js/auth.test.js @@ -0,0 +1,100 @@ +/** + * Unit tests for auth: getTgWebAppDataFromHash, getInitData, isLocalhost. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + getTgWebAppDataFromHash, + getInitData, + isLocalhost, +} from "./auth.js"; + +describe("getTgWebAppDataFromHash", () => { + it("returns empty string when tgWebAppData= not present", () => { + expect(getTgWebAppDataFromHash("foo=bar")).toBe(""); + }); + + it("returns value from tgWebAppData= to next &tgWebApp or end", () => { + expect(getTgWebAppDataFromHash("tgWebAppData=encoded%3Ddata")).toBe( + "encoded=data" + ); + }); + + it("stops at &tgWebApp", () => { + const hash = "tgWebAppData=value&tgWebAppVersion=6"; + expect(getTgWebAppDataFromHash(hash)).toBe("value"); + }); + + it("decodes URI component", () => { + expect(getTgWebAppDataFromHash("tgWebAppData=hello%20world")).toBe( + "hello world" + ); + }); +}); + +describe("getInitData", () => { + const origLocation = window.location; + const origTelegram = window.Telegram; + + afterEach(() => { + window.location = origLocation; + window.Telegram = origTelegram; + }); + + it("returns initData from Telegram.WebApp when set", () => { + window.Telegram = { WebApp: { initData: "sdk-init-data" } }; + delete window.location; + window.location = { ...origLocation, hash: "", search: "" }; + expect(getInitData()).toBe("sdk-init-data"); + }); + + it("returns data from hash tgWebAppData when SDK empty", () => { + window.Telegram = { WebApp: { initData: "" } }; + delete window.location; + window.location = { + ...origLocation, + hash: "#tgWebAppData=hash%20data", + search: "", + }; + expect(getInitData()).toBe("hash data"); + }); + + it("returns empty string when no source", () => { + window.Telegram = { WebApp: { initData: "" } }; + delete window.location; + window.location = { ...origLocation, hash: "", search: "" }; + expect(getInitData()).toBe(""); + }); +}); + +describe("isLocalhost", () => { + const origLocation = window.location; + + afterEach(() => { + window.location = origLocation; + }); + + it("returns true for localhost", () => { + delete window.location; + window.location = { ...origLocation, hostname: "localhost" }; + expect(isLocalhost()).toBe(true); + }); + + it("returns true for 127.0.0.1", () => { + delete window.location; + window.location = { ...origLocation, hostname: "127.0.0.1" }; + expect(isLocalhost()).toBe(true); + }); + + it("returns true for empty hostname", () => { + delete window.location; + window.location = { ...origLocation, hostname: "" }; + expect(isLocalhost()).toBe(true); + }); + + it("returns false for other hostnames", () => { + delete window.location; + window.location = { ...origLocation, hostname: "example.com" }; + expect(isLocalhost()).toBe(false); + }); +}); diff --git a/webapp/js/calendar.js b/webapp/js/calendar.js index a9b4ee2..7597886 100644 --- a/webapp/js/calendar.js +++ b/webapp/js/calendar.js @@ -29,6 +29,9 @@ export function calendarEventsByDate(events) { return byDate; } +/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */ +const MAX_DAYS_PER_DUTY = 366; + /** * Group duties by local date (start_at/end_at are UTC). * @param {object[]} duties - Duties with start_at, end_at @@ -39,14 +42,17 @@ export function dutiesByDate(duties) { duties.forEach((d) => { const start = new Date(d.start_at); const end = new Date(d.end_at); + if (end < start) return; const endLocal = localDateString(end); - let t = new Date(start); - while (true) { - const key = localDateString(t); + let cursor = new Date(start); + let iterations = 0; + while (iterations <= MAX_DAYS_PER_DUTY) { + const key = localDateString(cursor); if (!byDate[key]) byDate[key] = []; byDate[key].push(d); if (key === endLocal) break; - t.setDate(t.getDate() + 1); + cursor.setDate(cursor.getDate() + 1); + iterations++; } }); return byDate; @@ -104,14 +110,8 @@ export function renderCalendar( start_at: x.start_at, end_at: x.end_at })); - cell.setAttribute( - "data-day-duties", - JSON.stringify(dayPayload).replace(/"/g, """) - ); - cell.setAttribute( - "data-day-events", - JSON.stringify(eventSummaries).replace(/"/g, """) - ); + cell.setAttribute("data-day-duties", JSON.stringify(dayPayload)); + cell.setAttribute("data-day-events", JSON.stringify(eventSummaries)); } const ariaParts = []; diff --git a/webapp/js/calendar.test.js b/webapp/js/calendar.test.js new file mode 100644 index 0000000..ce85a97 --- /dev/null +++ b/webapp/js/calendar.test.js @@ -0,0 +1,95 @@ +/** + * Unit tests for calendar: dutiesByDate (including edge case end_at < start_at), + * calendarEventsByDate. + */ + +import { describe, it, expect, beforeAll } from "vitest"; + +beforeAll(() => { + document.body.innerHTML = + '

' + + '
' + + '
' + + ''; +}); + +import { dutiesByDate, calendarEventsByDate } from "./calendar.js"; + +describe("dutiesByDate", () => { + it("groups duty by single local day", () => { + const duties = [ + { + full_name: "Alice", + start_at: "2025-02-25T09:00:00Z", + end_at: "2025-02-25T18:00:00Z", + }, + ]; + const byDate = dutiesByDate(duties); + expect(byDate["2025-02-25"]).toHaveLength(1); + expect(byDate["2025-02-25"][0].full_name).toBe("Alice"); + }); + + it("spans duty across multiple days", () => { + const duties = [ + { + full_name: "Bob", + start_at: "2025-02-25T00:00:00Z", + end_at: "2025-02-27T23:59:59Z", + }, + ]; + const byDate = dutiesByDate(duties); + const keys = Object.keys(byDate).sort(); + expect(keys.length).toBeGreaterThanOrEqual(2); + keys.forEach((k) => expect(byDate[k]).toHaveLength(1)); + expect(byDate[keys[0]][0].full_name).toBe("Bob"); + }); + + it("skips duty when end_at < start_at (no infinite loop)", () => { + const duties = [ + { + full_name: "Bad", + start_at: "2025-02-28T12:00:00Z", + end_at: "2025-02-25T08:00:00Z", + }, + ]; + const byDate = dutiesByDate(duties); + expect(Object.keys(byDate)).toHaveLength(0); + }); + + it("does not iterate more than MAX_DAYS_PER_DUTY", () => { + const start = "2025-01-01T00:00:00Z"; + const end = "2026-06-01T00:00:00Z"; + const duties = [{ full_name: "Long", start_at: start, end_at: end }]; + const byDate = dutiesByDate(duties); + const keys = Object.keys(byDate).sort(); + expect(keys.length).toBeLessThanOrEqual(367); + }); + + it("handles empty duties", () => { + expect(dutiesByDate([])).toEqual({}); + }); +}); + +describe("calendarEventsByDate", () => { + it("maps events to local date key by UTC date", () => { + const events = [ + { date: "2025-02-25", summary: "Holiday" }, + { date: "2025-02-25", summary: "Meeting" }, + { date: "2025-02-26", summary: "Other" }, + ]; + const byDate = calendarEventsByDate(events); + expect(byDate["2025-02-25"]).toEqual(["Holiday", "Meeting"]); + expect(byDate["2025-02-26"]).toEqual(["Other"]); + }); + + it("skips events without summary", () => { + const events = [{ date: "2025-02-25", summary: null }]; + const byDate = calendarEventsByDate(events); + expect(byDate["2025-02-25"] || []).toHaveLength(0); + }); + + it("handles null or undefined events", () => { + expect(calendarEventsByDate(null)).toEqual({}); + expect(calendarEventsByDate(undefined)).toEqual({}); + }); +}); diff --git a/webapp/js/dateUtils.js b/webapp/js/dateUtils.js index 7f8686b..a99d7fc 100644 --- a/webapp/js/dateUtils.js +++ b/webapp/js/dateUtils.js @@ -84,16 +84,6 @@ export function dateKeyToDDMM(key) { return key.slice(8, 10) + "." + key.slice(5, 7); } -/** - * Format ISO date as HH:MM in local time. - * @param {string} isoStr - ISO date string - * @returns {string} HH:MM - */ -export function formatTimeLocal(isoStr) { - const d = new Date(isoStr); - return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); -} - /** * Format ISO string as HH:MM (local). * @param {string} isoStr - ISO date string diff --git a/webapp/js/dateUtils.test.js b/webapp/js/dateUtils.test.js new file mode 100644 index 0000000..fde4c00 --- /dev/null +++ b/webapp/js/dateUtils.test.js @@ -0,0 +1,159 @@ +/** + * Unit tests for dateUtils: localDateString, dutyOverlapsLocalDay, + * dutyOverlapsLocalRange, getMonday, formatHHMM. + */ + +import { describe, it, expect } from "vitest"; +import { + localDateString, + dutyOverlapsLocalDay, + dutyOverlapsLocalRange, + getMonday, + formatHHMM, +} from "./dateUtils.js"; + +describe("localDateString", () => { + it("formats date as YYYY-MM-DD", () => { + const d = new Date(2025, 0, 15); + expect(localDateString(d)).toBe("2025-01-15"); + }); + + it("pads month and day with zero", () => { + expect(localDateString(new Date(2025, 0, 5))).toBe("2025-01-05"); + expect(localDateString(new Date(2025, 8, 9))).toBe("2025-09-09"); + }); + + it("handles December and year boundary", () => { + expect(localDateString(new Date(2024, 11, 31))).toBe("2024-12-31"); + }); +}); + +describe("dutyOverlapsLocalDay", () => { + it("returns true when duty spans the whole day", () => { + const d = { + start_at: "2025-02-25T00:00:00Z", + end_at: "2025-02-25T23:59:59Z", + }; + expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true); + }); + + it("returns true when duty overlaps part of the day", () => { + const d = { + start_at: "2025-02-25T09:00:00Z", + end_at: "2025-02-25T14:00:00Z", + }; + expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true); + }); + + it("returns true when duty continues from previous day", () => { + const d = { + start_at: "2025-02-24T22:00:00Z", + end_at: "2025-02-25T06:00:00Z", + }; + expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true); + }); + + it("returns false when duty ends before the day", () => { + const d = { + start_at: "2025-02-24T09:00:00Z", + end_at: "2025-02-24T18:00:00Z", + }; + expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false); + }); + + it("returns false when duty starts after the day", () => { + const d = { + start_at: "2025-02-26T09:00:00Z", + end_at: "2025-02-26T18:00:00Z", + }; + expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false); + }); +}); + +describe("dutyOverlapsLocalRange", () => { + it("returns true when duty overlaps the range", () => { + const d = { + start_at: "2025-02-24T12:00:00Z", + end_at: "2025-02-26T12:00:00Z", + }; + expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true); + }); + + it("returns true when duty is entirely inside the range", () => { + const d = { + start_at: "2025-02-26T09:00:00Z", + end_at: "2025-02-26T18:00:00Z", + }; + expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true); + }); + + it("returns false when duty ends before range start", () => { + const d = { + start_at: "2025-02-20T09:00:00Z", + end_at: "2025-02-22T18:00:00Z", + }; + expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false); + }); + + it("returns false when duty starts after range end", () => { + const d = { + start_at: "2025-03-01T09:00:00Z", + end_at: "2025-03-01T18:00:00Z", + }; + expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false); + }); +}); + +describe("getMonday", () => { + it("returns same day when date is Monday", () => { + const monday = new Date(2025, 0, 6); // 6 Jan 2025 is Monday + const result = getMonday(monday); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(0); + expect(result.getDate()).toBe(6); + expect(result.getDay()).toBe(1); + }); + + it("returns previous Monday for Wednesday", () => { + const wed = new Date(2025, 0, 8); + const result = getMonday(wed); + expect(result.getDay()).toBe(1); + expect(result.getDate()).toBe(6); + }); + + it("returns Monday of same week for Sunday", () => { + const sun = new Date(2025, 0, 12); + const result = getMonday(sun); + expect(result.getDay()).toBe(1); + expect(result.getDate()).toBe(6); + }); +}); + +describe("formatHHMM", () => { + it("formats ISO string as HH:MM in local time", () => { + const s = "2025-02-25T14:30:00Z"; + const result = formatHHMM(s); + expect(result).toMatch(/^\d{2}:\d{2}$/); + const d = new Date(s); + const expected = (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes(); + expect(result).toBe(expected); + }); + + it("returns empty string for null", () => { + expect(formatHHMM(null)).toBe(""); + }); + + it("returns empty string for undefined", () => { + expect(formatHHMM(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(formatHHMM("")).toBe(""); + }); + + it("pads hours and minutes with zero", () => { + const s = "2025-02-25T09:05:00Z"; + const result = formatHHMM(s); + expect(result).toMatch(/^\d{2}:\d{2}$/); + }); +}); diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js index e439407..5c68aee 100644 --- a/webapp/js/dayDetail.js +++ b/webapp/js/dayDetail.js @@ -28,8 +28,7 @@ let sheetScrollY = 0; function parseDataAttr(raw) { if (!raw) return []; try { - const s = raw.replace(/"/g, '"'); - const parsed = JSON.parse(s); + const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; diff --git a/webapp/js/dom.js b/webapp/js/dom.js index fe38b72..778ba3a 100644 --- a/webapp/js/dom.js +++ b/webapp/js/dom.js @@ -41,5 +41,13 @@ export const state = { /** @type {ReturnType|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("