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:
@@ -1,31 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<link rel="icon" href="favicon.png" type="image/png">
|
||||
<title>Календарь дежурств</title>
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="calendar-sticky" id="calendarSticky">
|
||||
<header class="header">
|
||||
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц">‹</button>
|
||||
<button type="button" class="nav" id="prevMonth" aria-label="">‹</button>
|
||||
<h1 class="title" id="monthTitle"></h1>
|
||||
<button type="button" class="nav" id="nextMonth" aria-label="Следующий месяц">›</button>
|
||||
<button type="button" class="nav" id="nextMonth" aria-label="">›</button>
|
||||
</header>
|
||||
<div class="weekdays">
|
||||
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="calendar" id="calendar"></div>
|
||||
</div>
|
||||
<div class="duty-list" id="dutyList"></div>
|
||||
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text">Загрузка…</span></div>
|
||||
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text"></span></div>
|
||||
<div class="error" id="error" hidden></div>
|
||||
<div class="access-denied" id="accessDenied" hidden>
|
||||
<p>Доступ запрещён.</p>
|
||||
</div>
|
||||
<div class="access-denied" id="accessDenied" hidden></div>
|
||||
</div>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
@@ -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<Response>} - 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<object[]>}
|
||||
*/
|
||||
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<object[]>}
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
104
webapp/js/api.test.js
Normal file
104
webapp/js/api.test.js
Normal file
@@ -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 =
|
||||
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
|
||||
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
|
||||
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
|
||||
'<button id="prevMonth"></button><button id="nextMonth"></button>';
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
100
webapp/js/auth.test.js
Normal file
100
webapp/js/auth.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
|
||||
95
webapp/js/calendar.test.js
Normal file
95
webapp/js/calendar.test.js
Normal file
@@ -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 =
|
||||
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
|
||||
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
|
||||
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
|
||||
'<button id="prevMonth"></button><button id="nextMonth"></button>';
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
159
webapp/js/dateUtils.test.js
Normal file
159
webapp/js/dateUtils.test.js
Normal file
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
|
||||
@@ -41,5 +41,13 @@ export const state = {
|
||||
/** @type {ReturnType<typeof setInterval>|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
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
'<div class="' +
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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;
|
||||
calendarEventHint.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
calendarEventHint.hidden = true;
|
||||
calendarEventHint.removeAttribute("data-active");
|
||||
}, 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");
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
if (!document._dutyMarkerHintBound) {
|
||||
document._dutyMarkerHintBound = true;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
87
webapp/js/i18n.test.js
Normal file
87
webapp/js/i18n.test.js
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
42
webapp/js/utils.test.js
Normal file
42
webapp/js/utils.test.js
Normal file
@@ -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("<script>")).toBe("<script>");
|
||||
});
|
||||
|
||||
it("escapes double quote", () => {
|
||||
expect(escapeHtml('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
it("escapes single quote", () => {
|
||||
expect(escapeHtml("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
it("escapes all special chars together", () => {
|
||||
expect(escapeHtml('&<>"\'')).toBe("&<>"'");
|
||||
});
|
||||
|
||||
it("returns unchanged string when no special chars", () => {
|
||||
expect(escapeHtml("plain text")).toBe("plain text");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(escapeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("coerces non-string to string", () => {
|
||||
expect(escapeHtml(123)).toBe("123");
|
||||
expect(escapeHtml(null)).toBe("null");
|
||||
expect(escapeHtml(undefined)).toBe("undefined");
|
||||
});
|
||||
});
|
||||
@@ -545,7 +545,7 @@ body.day-detail-sheet-open {
|
||||
|
||||
.duty-marker {
|
||||
color: var(--duty);
|
||||
background: rgba(158, 206, 106, 0.25);
|
||||
background: color-mix(in srgb, var(--duty) 25%, transparent);
|
||||
}
|
||||
|
||||
.unavailable-marker {
|
||||
@@ -767,10 +767,6 @@ body.day-detail-sheet-open {
|
||||
border-left-color: var(--vacation);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .duty-marker {
|
||||
background: color-mix(in srgb, var(--duty) 25%, transparent);
|
||||
}
|
||||
|
||||
.duty-item .duty-item-type {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
Reference in New Issue
Block a user