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>
|
<!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>
|
||||||
|
|||||||
@@ -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
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;
|
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, """)
|
|
||||||
);
|
|
||||||
cell.setAttribute(
|
|
||||||
"data-day-events",
|
|
||||||
JSON.stringify(eventSummaries).replace(/"/g, """)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ariaParts = [];
|
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);
|
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
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) {
|
function parseDataAttr(raw) {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
const s = raw.replace(/"/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 [];
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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="' +
|
||||||
|
|||||||
@@ -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
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 { 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
* Common utilities.
|
* Common utilities.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const ESCAPE_MAP = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
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 {
|
.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user