feat: update language support and enhance API functionality

- Changed the default language in `index.html` from Russian to English, updating the title and button aria-labels for improved accessibility.
- Refactored the `buildFetchOptions` function in `api.js` to include an optional external abort signal, enhancing request management.
- Updated `fetchDuties` and `fetchCalendarEvents` to support request cancellation using the new abort signal, improving error handling.
- Added unit tests for the API functions to ensure proper functionality, including handling of 403 errors and request cancellations.
- Enhanced CSS styles for duty markers to improve visual consistency.
- Removed unused code and improved the overall structure of the JavaScript files for better maintainability.
This commit is contained in:
2026-03-02 12:40:49 +03:00
parent b906bfa777
commit a4d8d085c6
17 changed files with 822 additions and 181 deletions

View File

@@ -1,31 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <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"> <link rel="icon" href="favicon.png" type="image/png">
<title>Календарь дежурств</title> <title></title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="calendar-sticky" id="calendarSticky"> <div class="calendar-sticky" id="calendarSticky">
<header class="header"> <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> <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> </header>
<div class="weekdays"> <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>
<div class="calendar" id="calendar"></div> <div class="calendar" id="calendar"></div>
</div> </div>
<div class="duty-list" id="dutyList"></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="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden> <div class="access-denied" id="accessDenied" hidden></div>
<p>Доступ запрещён.</p>
</div>
</div> </div>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script type="module" src="js/main.js"></script> <script type="module" src="js/main.js"></script>

View File

@@ -9,16 +9,31 @@ import { t } from "./i18n.js";
/** /**
* Build fetch options with init data header, Accept-Language and timeout abort. * 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 * @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 = {}; const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData; if (initData) headers["X-Telegram-Init-Data"] = initData;
headers["Accept-Language"] = state.lang || "ru"; headers["Accept-Language"] = state.lang || "ru";
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); 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(). * Caller checks res.ok, res.status, res.json().
* @param {string} path - e.g. "/api/duties" * @param {string} path - e.g. "/api/duties"
* @param {{ from?: string, to?: string }} params - query params * @param {{ from?: string, to?: string }} params - query params
* @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation
* @returns {Promise<Response>} - raw response * @returns {Promise<Response>} - raw response
*/ */
export async function apiGet(path, params = {}) { export async function apiGet(path, params = {}, options = {}) {
const base = window.location.origin; const base = window.location.origin;
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();
const url = query ? `${base}${path}?${query}` : `${base}${path}`; const url = query ? `${base}${path}?${query}` : `${base}${path}`;
const initData = getInitData(); const initData = getInitData();
const opts = buildFetchOptions(initData); const opts = buildFetchOptions(initData, options.signal);
try { try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
return res; return res;
} finally { } finally {
clearTimeout(opts.timeoutId); opts.cleanup();
} }
} }
/** /**
* Fetch duties for date range. Throws ACCESS_DENIED error on 403. * 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} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD * @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
export async function fetchDuties(from, to) { export async function fetchDuties(from, to, signal) {
try { try {
const res = await apiGet("/api/duties", { from, to }); const res = await apiGet("/api/duties", { from, to }, { signal });
if (res.status === 403) { if (res.status === 403) {
let detail = t(state.lang, "access_denied"); let detail = t(state.lang, "access_denied");
try { try {
@@ -71,25 +89,26 @@ export async function fetchDuties(from, to) {
if (!res.ok) throw new Error(t(state.lang, "error_load_failed")); if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json(); return res.json();
} catch (e) { } catch (e) {
if (e.name === "AbortError") { if (e.name === "AbortError") throw e;
throw new Error(t(state.lang, "error_network"));
}
throw e; throw e;
} }
} }
/** /**
* Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403. * 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} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD * @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
export async function fetchCalendarEvents(from, to) { export async function fetchCalendarEvents(from, to, signal) {
try { try {
const res = await apiGet("/api/calendar-events", { from, to }); const res = await apiGet("/api/calendar-events", { from, to }, { signal });
if (!res.ok) return []; if (!res.ok) return [];
return res.json(); return res.json();
} catch (e) { } catch (e) {
if (e.name === "AbortError") throw e;
return []; return [];
} }
} }

104
webapp/js/api.test.js Normal file
View 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
View 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);
});
});

View File

@@ -29,6 +29,9 @@ export function calendarEventsByDate(events) {
return byDate; 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). * Group duties by local date (start_at/end_at are UTC).
* @param {object[]} duties - Duties with start_at, end_at * @param {object[]} duties - Duties with start_at, end_at
@@ -39,14 +42,17 @@ export function dutiesByDate(duties) {
duties.forEach((d) => { duties.forEach((d) => {
const start = new Date(d.start_at); const start = new Date(d.start_at);
const end = new Date(d.end_at); const end = new Date(d.end_at);
if (end < start) return;
const endLocal = localDateString(end); const endLocal = localDateString(end);
let t = new Date(start); let cursor = new Date(start);
while (true) { let iterations = 0;
const key = localDateString(t); while (iterations <= MAX_DAYS_PER_DUTY) {
const key = localDateString(cursor);
if (!byDate[key]) byDate[key] = []; if (!byDate[key]) byDate[key] = [];
byDate[key].push(d); byDate[key].push(d);
if (key === endLocal) break; if (key === endLocal) break;
t.setDate(t.getDate() + 1); cursor.setDate(cursor.getDate() + 1);
iterations++;
} }
}); });
return byDate; return byDate;
@@ -104,14 +110,8 @@ export function renderCalendar(
start_at: x.start_at, start_at: x.start_at,
end_at: x.end_at end_at: x.end_at
})); }));
cell.setAttribute( cell.setAttribute("data-day-duties", JSON.stringify(dayPayload));
"data-day-duties", cell.setAttribute("data-day-events", JSON.stringify(eventSummaries));
JSON.stringify(dayPayload).replace(/"/g, "&quot;")
);
cell.setAttribute(
"data-day-events",
JSON.stringify(eventSummaries).replace(/"/g, "&quot;")
);
} }
const ariaParts = []; const ariaParts = [];

View 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({});
});
});

View File

@@ -84,16 +84,6 @@ export function dateKeyToDDMM(key) {
return key.slice(8, 10) + "." + key.slice(5, 7); 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). * Format ISO string as HH:MM (local).
* @param {string} isoStr - ISO date string * @param {string} isoStr - ISO date string

159
webapp/js/dateUtils.test.js Normal file
View 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}$/);
});
});

View File

@@ -28,8 +28,7 @@ let sheetScrollY = 0;
function parseDataAttr(raw) { function parseDataAttr(raw) {
if (!raw) return []; if (!raw) return [];
try { try {
const s = raw.replace(/&quot;/g, '"'); const parsed = JSON.parse(raw);
const parsed = JSON.parse(s);
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch (e) { } catch (e) {
return []; return [];

View File

@@ -41,5 +41,13 @@ export const state = {
/** @type {ReturnType<typeof setInterval>|null} */ /** @type {ReturnType<typeof setInterval>|null} */
todayRefreshInterval: null, todayRefreshInterval: null,
/** @type {'ru'|'en'} */ /** @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
}; };

View File

@@ -10,7 +10,7 @@ import {
firstDayOfMonth, firstDayOfMonth,
lastDayOfMonth, lastDayOfMonth,
dateKeyToDDMM, dateKeyToDDMM,
formatTimeLocal, formatHHMM,
formatDateKey formatDateKey
} from "./dateUtils.js"; } from "./dateUtils.js";
@@ -25,8 +25,8 @@ export function dutyTimelineCardHtml(d, isCurrent) {
const endLocal = localDateString(new Date(d.end_at)); const endLocal = localDateString(new Date(d.end_at));
const startDDMM = dateKeyToDDMM(startLocal); const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal); const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatTimeLocal(d.start_at); const startTime = formatHHMM(d.start_at);
const endTime = formatTimeLocal(d.end_at); const endTime = formatHHMM(d.end_at);
let timeStr; let timeStr;
if (startLocal === endLocal) { if (startLocal === endLocal) {
timeStr = startDDMM + ", " + startTime + " " + endTime; timeStr = startDDMM + ", " + startTime + " " + endTime;
@@ -69,14 +69,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
if (extraClass) itemClass += " " + extraClass; if (extraClass) itemClass += " " + extraClass;
let timeOrRange = ""; let timeOrRange = "";
if (showUntilEnd && d.event_type === "duty") { 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") { } else if (d.event_type === "vacation" || d.event_type === "unavailable") {
const startStr = formatDateKey(d.start_at); const startStr = formatDateKey(d.start_at);
const endStr = formatDateKey(d.end_at); const endStr = formatDateKey(d.end_at);
timeOrRange = startStr === endStr ? startStr : startStr + " " + endStr; timeOrRange = startStr === endStr ? startStr : startStr + " " + endStr;
} else { } else {
timeOrRange = timeOrRange =
formatTimeLocal(d.start_at) + " " + formatTimeLocal(d.end_at); formatHHMM(d.start_at) + " " + formatHHMM(d.end_at);
} }
return ( return (
'<div class="' + '<div class="' +

View File

@@ -258,110 +258,82 @@ export function clearActiveDutyMarker() {
.forEach((m) => m.classList.remove("calendar-marker-active")); .forEach((m) => m.classList.remove("calendar-marker-active"));
} }
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
let dutyMarkerHideTimeout = null;
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
/** /**
* 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() { function getOrCreateCalendarEventHint() {
let hintEl = document.getElementById("calendarEventHint"); let el = document.getElementById("calendarEventHint");
if (!hintEl) { if (!el) {
hintEl = document.createElement("div"); el = document.createElement("div");
hintEl.id = "calendarEventHint"; el.id = "calendarEventHint";
hintEl.className = "calendar-event-hint"; el.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip"); el.setAttribute("role", "tooltip");
hintEl.hidden = true; el.hidden = true;
document.body.appendChild(hintEl); document.body.appendChild(el);
}
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);
}
});
} }
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() { function getOrCreateDutyMarkerHint() {
let hintEl = document.getElementById("dutyMarkerHint"); let el = document.getElementById("dutyMarkerHint");
if (!hintEl) { if (!el) {
hintEl = document.createElement("div"); el = document.createElement("div");
hintEl.id = "dutyMarkerHint"; el.id = "dutyMarkerHint";
hintEl.className = "calendar-event-hint"; el.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip"); el.setAttribute("role", "tooltip");
hintEl.hidden = true; el.hidden = true;
document.body.appendChild(hintEl); 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; if (!calendarEl) return;
let hideTimeout = null;
const selector = ".duty-marker, .unavailable-marker, .vacation-marker"; calendarEl.addEventListener("click", (e) => {
calendarEl.querySelectorAll(selector).forEach((marker) => { const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
marker.addEventListener("mouseenter", () => { if (btn) {
if (hideTimeout) { e.stopPropagation();
clearTimeout(hideTimeout); const summary = btn.getAttribute("data-summary") || "";
hideTimeout = null; const content = t(state.lang, "hint.events") + "\n" + summary;
} if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
const html = getDutyMarkerHintHtml(marker); calendarEventHint.textContent = content;
if (html) { positionHint(calendarEventHint, btn.getBoundingClientRect());
hintEl.innerHTML = html; calendarEventHint.dataset.active = "1";
} else { } else {
hintEl.textContent = getDutyMarkerHintContent(marker); calendarEventHint.classList.remove("calendar-event-hint--visible");
} setTimeout(() => {
const rect = marker.getBoundingClientRect(); calendarEventHint.hidden = true;
positionHint(hintEl, rect); calendarEventHint.removeAttribute("data-active");
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); }, 150);
}); }
marker.addEventListener("click", (e) => { return;
}
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (marker) {
e.stopPropagation(); e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) { if (marker.classList.contains("calendar-marker-active")) {
hintEl.classList.remove("calendar-event-hint--visible"); dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => { setTimeout(() => {
hintEl.hidden = true; dutyMarkerHint.hidden = true;
hintEl.removeAttribute("data-active"); dutyMarkerHint.removeAttribute("data-active");
}, 150); }, 150);
marker.classList.remove("calendar-marker-active"); marker.classList.remove("calendar-marker-active");
return; return;
@@ -369,35 +341,89 @@ export function bindDutyMarkerTooltips() {
clearActiveDutyMarker(); clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker); const html = getDutyMarkerHintHtml(marker);
if (html) { if (html) {
hintEl.innerHTML = html; dutyMarkerHint.innerHTML = html;
} else { } else {
hintEl.textContent = getDutyMarkerHintContent(marker); dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
} }
const rect = marker.getBoundingClientRect(); positionHint(dutyMarkerHint, marker.getBoundingClientRect());
positionHint(hintEl, rect); dutyMarkerHint.hidden = false;
hintEl.hidden = false; dutyMarkerHint.dataset.active = "1";
hintEl.dataset.active = "1";
marker.classList.add("calendar-marker-active"); 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", () => { document.addEventListener("click", () => {
if (hintEl.dataset.active) { if (calendarEventHint.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible"); calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => { setTimeout(() => {
hintEl.hidden = true; calendarEventHint.hidden = true;
hintEl.removeAttribute("data-active"); 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(); clearActiveDutyMarker();
}, 150); }, 150);
} }
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) { if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible"); dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => { setTimeout(() => {
hintEl.hidden = true; dutyMarkerHint.hidden = true;
hintEl.removeAttribute("data-active"); dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker(); clearActiveDutyMarker();
}, 150); }, 150);
} }

87
webapp/js/i18n.test.js Normal file
View 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("");
});
});

View File

@@ -4,8 +4,7 @@
import { initTheme, applyTheme } from "./theme.js"; import { initTheme, applyTheme } from "./theme.js";
import { getLang, t, weekdayLabels } from "./i18n.js"; import { getLang, t, weekdayLabels } from "./i18n.js";
import { getInitData } from "./auth.js"; import { getInitData, isLocalhost } from "./auth.js";
import { isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js"; import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import { import {
state, state,
@@ -24,6 +23,7 @@ import {
renderCalendar renderCalendar
} from "./calendar.js"; } from "./calendar.js";
import { initDayDetail } from "./dayDetail.js"; import { initDayDetail } from "./dayDetail.js";
import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js"; import { renderDutyList } from "./dutyList.js";
import { import {
firstDayOfMonth, firstDayOfMonth,
@@ -106,10 +106,18 @@ function requireTelegramOrLocalhost(onAllowed) {
if (loadingEl) loadingEl.classList.add("hidden"); 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. * 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() { async function loadMonth() {
if (loadMonthAbortController) loadMonthAbortController.abort();
loadMonthAbortController = new AbortController();
const signal = loadMonthAbortController.signal;
hideAccessDenied(); hideAccessDenied();
setNavEnabled(false); setNavEnabled(false);
if (loadingEl) loadingEl.classList.remove("hidden"); if (loadingEl) loadingEl.classList.remove("hidden");
@@ -122,8 +130,8 @@ async function loadMonth() {
const from = localDateString(start); const from = localDateString(start);
const to = localDateString(gridEnd); const to = localDateString(gridEnd);
try { try {
const dutiesPromise = fetchDuties(from, to); const dutiesPromise = fetchDuties(from, to, signal);
const eventsPromise = fetchCalendarEvents(from, to); const eventsPromise = fetchCalendarEvents(from, to, signal);
const duties = await dutiesPromise; const duties = await dutiesPromise;
const events = await eventsPromise; const events = await eventsPromise;
const byDate = dutiesByDate(duties); const byDate = dutiesByDate(duties);
@@ -156,15 +164,18 @@ async function loadMonth() {
}, 60000); }, 60000);
} }
} catch (e) { } catch (e) {
if (e.name === "AbortError") {
return;
}
if (e.message === "ACCESS_DENIED") { if (e.message === "ACCESS_DENIED") {
showAccessDenied(e.serverDetail); showAccessDenied(e.serverDetail);
setNavEnabled(true); setNavEnabled(true);
if ( if (
window.Telegram && window.Telegram &&
window.Telegram.WebApp && window.Telegram.WebApp &&
!window._initDataRetried !state.initDataRetried
) { ) {
window._initDataRetried = true; state.initDataRetried = true;
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS); setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
} }
return; return;
@@ -204,9 +215,9 @@ if (nextBtn) {
"touchstart", "touchstart",
(e) => { (e) => {
if (e.changedTouches.length === 0) return; if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0]; const touch = e.changedTouches[0];
startX = t.clientX; startX = touch.clientX;
startY = t.clientY; startY = touch.clientY;
}, },
{ passive: true } { passive: true }
); );
@@ -216,9 +227,9 @@ if (nextBtn) {
if (e.changedTouches.length === 0) return; if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return; if (document.body.classList.contains("day-detail-sheet-open")) return;
if (accessDeniedEl && !accessDeniedEl.hidden) return; if (accessDeniedEl && !accessDeniedEl.hidden) return;
const t = e.changedTouches[0]; const touch = e.changedTouches[0];
const deltaX = t.clientX - startX; const deltaX = touch.clientX - startX;
const deltaY = t.clientY - startY; const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return; if (Math.abs(deltaY) > Math.abs(deltaX)) return;
if (deltaX > SWIPE_THRESHOLD) { if (deltaX > SWIPE_THRESHOLD) {
@@ -237,8 +248,8 @@ if (nextBtn) {
function bindStickyScrollShadow() { function bindStickyScrollShadow() {
const stickyEl = document.getElementById("calendarSticky"); const stickyEl = document.getElementById("calendarSticky");
if (!stickyEl || document._stickyScrollBound) return; if (!stickyEl || state.stickyScrollBound) return;
document._stickyScrollBound = true; state.stickyScrollBound = true;
function updateScrolled() { function updateScrolled() {
stickyEl.classList.toggle("is-scrolled", window.scrollY > 0); stickyEl.classList.toggle("is-scrolled", window.scrollY > 0);
} }
@@ -250,6 +261,7 @@ runWhenReady(() => {
requireTelegramOrLocalhost(() => { requireTelegramOrLocalhost(() => {
bindStickyScrollShadow(); bindStickyScrollShadow();
initDayDetail(); initDayDetail();
initHints();
loadMonth(); loadMonth();
}); });
}); });

View File

@@ -2,13 +2,19 @@
* Common utilities. * Common utilities.
*/ */
const ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
/** /**
* Escape string for safe use in HTML (text content / attributes). * Escape string for safe use in HTML (text content / attributes).
* @param {string} s - Raw string * @param {string} s - Raw string
* @returns {string} HTML-escaped string * @returns {string} HTML-escaped string
*/ */
export function escapeHtml(s) { export function escapeHtml(s) {
const div = document.createElement("div"); return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
div.textContent = s;
return div.innerHTML;
} }

42
webapp/js/utils.test.js Normal file
View 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 &amp; b");
});
it("escapes less-than and greater-than", () => {
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
});
it("escapes double quote", () => {
expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
});
it("escapes single quote", () => {
expect(escapeHtml("it's")).toBe("it&#39;s");
});
it("escapes all special chars together", () => {
expect(escapeHtml('&<>"\'')).toBe("&amp;&lt;&gt;&quot;&#39;");
});
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");
});
});

View File

@@ -545,7 +545,7 @@ body.day-detail-sheet-open {
.duty-marker { .duty-marker {
color: var(--duty); color: var(--duty);
background: rgba(158, 206, 106, 0.25); background: color-mix(in srgb, var(--duty) 25%, transparent);
} }
.unavailable-marker { .unavailable-marker {
@@ -767,10 +767,6 @@ body.day-detail-sheet-open {
border-left-color: var(--vacation); border-left-color: var(--vacation);
} }
[data-theme="dark"] .duty-marker {
background: color-mix(in srgb, var(--duty) 25%, transparent);
}
.duty-item .duty-item-type { .duty-item .duty-item-type {
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;