feat: enhance CI workflow and update webapp styles
Some checks failed
CI / lint-and-test (push) Failing after 45s

- Added Node.js setup and webapp testing steps to the CI workflow for improved integration.
- Updated HTML to link multiple CSS files for better modularity and organization of styles.
- Removed deprecated `style.css` and introduced new CSS files for base styles, calendar, day detail, hints, markers, states, and duty list to enhance maintainability and readability.
- Implemented new styles for improved presentation of duty information and user interactions.
- Added unit tests for new API functions and contact link rendering to ensure functionality and reliability.
This commit is contained in:
2026-03-02 17:20:33 +03:00
parent e3240d0981
commit 2fb553567f
29 changed files with 2212 additions and 1375 deletions

View File

@@ -67,31 +67,26 @@ export async function apiGet(path, params = {}, options = {}) {
* @returns {Promise<object[]>}
*/
export async function fetchDuties(from, to, signal) {
try {
const res = await apiGet("/api/duties", { from, to }, { signal });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
detail =
typeof body.detail === "string"
? body.detail
: (body.detail.msg || JSON.stringify(body.detail));
}
} catch (parseErr) {
/* ignore */
const res = await apiGet("/api/duties", { from, to }, { signal });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
detail =
typeof body.detail === "string"
? body.detail
: (body.detail.msg || JSON.stringify(body.detail));
}
const err = new Error("ACCESS_DENIED");
err.serverDetail = detail;
throw err;
} catch (parseErr) {
/* ignore */
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
} catch (e) {
if (e.name === "AbortError") throw e;
throw e;
const err = new Error("ACCESS_DENIED");
err.serverDetail = detail;
throw err;
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
}
/**

View File

@@ -15,7 +15,7 @@ beforeAll(() => {
const mockGetInitData = vi.fn();
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { buildFetchOptions, fetchDuties } from "./api.js";
import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js";
import { state } from "./dom.js";
describe("buildFetchOptions", () => {
@@ -102,3 +102,111 @@ describe("fetchDuties", () => {
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("apiGet", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "en";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("builds URL with path and query params and returns response", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true, status: 200 });
});
await apiGet("/api/duties", { from: "2025-02-01", to: "2025-02-28" });
expect(capturedUrl).toContain("/api/duties");
expect(capturedUrl).toContain("from=2025-02-01");
expect(capturedUrl).toContain("to=2025-02-28");
});
it("omits query string when params empty", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true });
});
await apiGet("/api/health", {});
expect(capturedUrl).toBe(window.location.origin + "/api/health");
});
it("passes X-Telegram-Init-Data and Accept-Language headers", async () => {
let capturedOpts = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedOpts = opts;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", { from: "2025-01-01", to: "2025-01-31" });
expect(capturedOpts?.headers["X-Telegram-Init-Data"]).toBe("init-data");
expect(capturedOpts?.headers["Accept-Language"]).toBe("en");
});
it("passes an abort signal to fetch when options.signal provided", async () => {
const controller = new AbortController();
let capturedSignal = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedSignal = opts.signal;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", {}, { signal: controller.signal });
expect(capturedSignal).toBeDefined();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
});
describe("fetchCalendarEvents", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "ru";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns JSON array on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]),
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]);
});
it("returns empty array on non-OK response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("returns empty array on 403 (does not throw)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
globalThis.fetch = vi.fn().mockImplementation(() => Promise.reject(abortError));
await expect(
fetchCalendarEvents("2025-02-01", "2025-02-28", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});

View File

@@ -7,6 +7,7 @@ import {
getTgWebAppDataFromHash,
getInitData,
isLocalhost,
hasTelegramHashButNoInitData,
} from "./auth.js";
describe("getTgWebAppDataFromHash", () => {
@@ -98,3 +99,57 @@ describe("isLocalhost", () => {
expect(isLocalhost()).toBe(false);
});
});
describe("hasTelegramHashButNoInitData", () => {
const origLocation = window.location;
afterEach(() => {
window.location = origLocation;
});
it("returns false when hash is empty", () => {
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns true when hash has tgWebAppVersion but no tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(true);
});
it("returns false when hash has both tgWebAppVersion and tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6&tgWebAppData=some%3Ddata",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has tgWebAppData in unencoded form (with & and =)", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppData=value&tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has no Telegram params", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#other=param",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
});

View File

@@ -2,7 +2,7 @@
* Calendar grid and events-by-date mapping.
*/
import { calendarEl, monthTitleEl, state } from "./dom.js";
import { getCalendarEl, getMonthTitleEl, state } from "./dom.js";
import { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
@@ -71,6 +71,8 @@ export function renderCalendar(
dutiesByDateMap,
calendarEventsByDateMap
) {
const calendarEl = getCalendarEl();
const monthTitleEl = getMonthTitleEl();
if (!calendarEl || !monthTitleEl) return;
const first = firstDayOfMonth(new Date(year, month, 1));
const last = lastDayOfMonth(new Date(year, month, 1));

View File

@@ -3,7 +3,7 @@
* calendarEventsByDate.
*/
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
@@ -13,7 +13,8 @@ beforeAll(() => {
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import { dutiesByDate, calendarEventsByDate } from "./calendar.js";
import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js";
import { state } from "./dom.js";
describe("dutiesByDate", () => {
it("groups duty by single local day", () => {
@@ -93,3 +94,46 @@ describe("calendarEventsByDate", () => {
expect(calendarEventsByDate(undefined)).toEqual({});
});
});
describe("renderCalendar", () => {
beforeEach(() => {
state.lang = "en";
});
it("renders 42 cells (6 weeks)", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = calendarEl?.querySelectorAll(".day") ?? [];
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = Array.from(calendarEl?.querySelectorAll(".day") ?? []);
const dates = cells.map((c) => c.getAttribute("data-date"));
expect(dates.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d ?? ""))).toBe(true);
});
it("adds today class to cell matching today", () => {
const today = new Date();
renderCalendar(today.getFullYear(), today.getMonth(), {}, {});
const calendarEl = document.getElementById("calendar");
const todayKey =
today.getFullYear() +
"-" +
String(today.getMonth() + 1).padStart(2, "0") +
"-" +
String(today.getDate()).padStart(2, "0");
const todayCell = calendarEl?.querySelector('.day.today[data-date="' + todayKey + '"]');
expect(todayCell).toBeTruthy();
});
it("sets month title from state.lang and given year/month", () => {
state.lang = "en";
renderCalendar(2025, 1, {}, {});
const titleEl = document.getElementById("monthTitle");
expect(titleEl?.textContent).toContain("2025");
expect(titleEl?.textContent).toContain("February");
});
});

88
webapp/js/contactHtml.js Normal file
View File

@@ -0,0 +1,88 @@
/**
* Shared HTML builder for contact links (phone, Telegram) used by day detail,
* current duty, and duty list.
*/
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
/**
* Build HTML for contact links (phone, Telegram username).
* Validates phone/username, builds tel: and t.me hrefs, wraps in spans/links.
*
* @param {'ru'|'en'} lang - UI language for labels (when showLabels is true)
* @param {string|null|undefined} phone - Phone number
* @param {string|null|undefined} username - Telegram username with or without leading @
* @param {object} options - Rendering options
* @param {string} options.classPrefix - CSS class prefix (e.g. "day-detail-contact", "duty-contact")
* @param {boolean} [options.showLabels=true] - Whether to show "Phone:" / "Telegram:" labels
* @param {string} [options.separator=' '] - Separator between contact parts (e.g. " ", " · ")
* @returns {string} HTML string or "" if no valid contact
*/
export function buildContactLinksHtml(lang, phone, username, options) {
const { classPrefix, showLabels = true, separator = " " } = options || {};
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const safeHref =
"tel:" +
p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const linkHtml =
'<a href="' +
safeHref +
'" class="' +
escapeHtml(classPrefix + "-link " + classPrefix + "-phone") +
'">' +
escapeHtml(p) +
"</a>";
if (showLabels) {
const label = t(lang, "contact.phone");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} else {
parts.push(linkHtml);
}
}
if (username && String(username).trim()) {
const u = String(username).trim().replace(/^@+/, "");
if (u) {
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
const linkHtml =
'<a href="' +
escapeHtml(href) +
'" class="' +
escapeHtml(classPrefix + "-link " + classPrefix + "-username") +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) +
"</a>";
if (showLabels) {
const label = t(lang, "contact.telegram");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} else {
parts.push(linkHtml);
}
}
}
if (parts.length === 0) return "";
const rowClass = classPrefix + "-row";
return '<div class="' + escapeHtml(rowClass) + '">' + parts.join(separator) + "</div>";
}

View File

@@ -0,0 +1,100 @@
/**
* Unit tests for buildContactLinksHtml (contact links HTML builder).
*/
import { describe, it, expect } from "vitest";
import { buildContactLinksHtml } from "./contactHtml.js";
describe("buildContactLinksHtml", () => {
const baseOptions = { classPrefix: "test-contact", showLabels: true, separator: " " };
it("returns empty string when phone and username are missing", () => {
expect(buildContactLinksHtml("en", null, null, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", undefined, undefined, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", "", "", baseOptions)).toBe("");
expect(buildContactLinksHtml("en", " ", " ", baseOptions)).toBe("");
});
it("renders phone only with label and tel: link", () => {
const html = buildContactLinksHtml("en", "+79991234567", null, baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("Phone");
expect(html).not.toContain("t.me");
});
it("renders username only with label and t.me link", () => {
const html = buildContactLinksHtml("en", null, "alice_dev", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("@alice_dev");
expect(html).toContain("Telegram");
expect(html).not.toContain("tel:");
});
it("renders both phone and username with labels", () => {
const html = buildContactLinksHtml("en", "+79001112233", "bob", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("tel:");
expect(html).toContain("+79001112233");
expect(html).toContain("t.me");
expect(html).toContain("@bob");
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
});
it("strips leading @ from username and displays with @", () => {
const html = buildContactLinksHtml("en", null, "@alice", baseOptions);
expect(html).toContain("https://t.me/alice");
expect(html).toContain("@alice");
expect(html).not.toContain("@@");
});
it("handles multiple leading @ in username", () => {
const html = buildContactLinksHtml("en", null, "@@@user", baseOptions);
expect(html).toContain("https://t.me/user");
expect(html).toContain("@user");
});
it("escapes special characters in phone in href and text", () => {
const html = buildContactLinksHtml("en", '+7 999 "1" <2>', null, baseOptions);
expect(html).toContain("&quot;");
expect(html).toContain("&lt;");
expect(html).toContain("&gt;");
expect(html).toContain("tel:");
expect(html).not.toContain("<2>");
expect(html).not.toContain('"1"');
});
it("uses custom separator when showLabels is false", () => {
const html = buildContactLinksHtml("en", "+7999", "u1", {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
expect(html).toContain(" · ");
expect(html).not.toContain("Phone");
expect(html).not.toContain("Telegram");
expect(html).toContain("duty-contact-row");
expect(html).toContain("duty-contact-link");
});
it("uses Russian labels when lang is ru", () => {
const html = buildContactLinksHtml("ru", "+7999", null, baseOptions);
expect(html).toContain("Телефон");
const htmlTg = buildContactLinksHtml("ru", null, "u", baseOptions);
expect(htmlTg).toContain("Telegram");
});
it("uses default showLabels true and separator space when options omit them", () => {
const html = buildContactLinksHtml("en", "+7999", "u", {
classPrefix: "minimal",
});
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
expect(html).toContain("minimal-row");
expect(html).not.toContain(" · ");
});
});

View File

@@ -3,9 +3,10 @@
* Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts.
*/
import { currentDutyViewEl, state, loadingEl } from "./dom.js";
import { getCurrentDutyViewEl, state, getLoadingEl } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { fetchDuties } from "./api.js";
import {
localDateString,
@@ -13,6 +14,11 @@ import {
formatHHMM
} from "./dateUtils.js";
/** @type {(() => void)|null} Callback when user taps "Back to calendar". */
let onBackCallback = null;
/** @type {(() => void)|null} Handler registered with Telegram BackButton.onClick. */
let backButtonHandler = null;
/**
* Find the duty that is currently active (start <= now < end). Prefer event_type === "duty".
* @param {object[]} duties - List of duties with start_at, end_at, event_type
@@ -30,54 +36,6 @@ export function findCurrentDuty(duties) {
return null;
}
/**
* Build contact HTML (phone + Telegram) for current duty card, styled like day-detail.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function buildContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const label = t(lang, "contact.phone");
const safeHref =
"tel:" +
p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="current-duty-contact">' +
escapeHtml(label) +
": " +
'<a href="' +
safeHref +
'" class="current-duty-contact-link current-duty-contact-phone">' +
escapeHtml(p) +
"</a></span>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const label = t(lang, "contact.telegram");
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<span class="current-duty-contact">' +
escapeHtml(label) +
": " +
'<a href="' +
escapeHtml(href) +
'" class="current-duty-contact-link current-duty-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) +
"</a></span>"
);
}
}
return parts.length
? '<div class="current-duty-contact-row">' + parts.join(" ") + "</div>"
: "";
}
/**
* Render the current duty view content (card with duty or no-duty message).
* @param {object|null} duty - Active duty or null
@@ -120,7 +78,11 @@ export function renderCurrentDutyContent(duty, lang) {
" " +
endTime;
const shiftLabel = t(lang, "current_duty.shift");
const contactHtml = buildContactHtml(lang, duty);
const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, {
classPrefix: "current-duty-contact",
showLabels: true,
separator: " "
});
return (
'<div class="current-duty-card">' +
@@ -149,16 +111,18 @@ export function renderCurrentDutyContent(duty, lang) {
* @param {() => void} onBack - Callback when user taps "Back to calendar"
*/
export async function showCurrentDutyView(onBack) {
const currentDutyViewEl = getCurrentDutyViewEl();
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
if (!currentDutyViewEl) return;
currentDutyViewEl._onBack = onBack;
onBackCallback = onBack;
currentDutyViewEl.classList.remove("hidden");
if (container) container.setAttribute("data-view", "currentDuty");
if (calendarSticky) calendarSticky.hidden = true;
if (dutyList) dutyList.hidden = true;
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.add("hidden");
const lang = state.lang;
@@ -170,9 +134,9 @@ export async function showCurrentDutyView(onBack) {
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
window.Telegram.WebApp.BackButton.show();
const handler = () => {
if (currentDutyViewEl._onBack) currentDutyViewEl._onBack();
if (onBackCallback) onBackCallback();
};
currentDutyViewEl._backButtonHandler = handler;
backButtonHandler = handler;
window.Telegram.WebApp.BackButton.onClick(handler);
}
@@ -205,9 +169,7 @@ export async function showCurrentDutyView(onBack) {
function handleCurrentDutyClick(e) {
const btn = e.target && e.target.closest("[data-action='back']");
if (!btn) return;
if (currentDutyViewEl && currentDutyViewEl._onBack) {
currentDutyViewEl._onBack();
}
if (onBackCallback) onBackCallback();
}
/**
@@ -215,24 +177,24 @@ function handleCurrentDutyClick(e) {
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
*/
export function hideCurrentDutyView() {
const currentDutyViewEl = getCurrentDutyViewEl();
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
const backHandler = currentDutyViewEl && currentDutyViewEl._backButtonHandler;
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
if (backHandler) {
window.Telegram.WebApp.BackButton.offClick(backHandler);
if (backButtonHandler) {
window.Telegram.WebApp.BackButton.offClick(backButtonHandler);
}
window.Telegram.WebApp.BackButton.hide();
}
if (currentDutyViewEl) {
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
currentDutyViewEl._onBack = null;
currentDutyViewEl._backButtonHandler = null;
currentDutyViewEl.classList.add("hidden");
currentDutyViewEl.innerHTML = "";
}
onBackCallback = null;
backButtonHandler = null;
if (container) container.removeAttribute("data-view");
if (calendarSticky) calendarSticky.hidden = false;
if (dutyList) dutyList.hidden = false;

View File

@@ -10,6 +10,10 @@ import {
dutyOverlapsLocalRange,
getMonday,
formatHHMM,
firstDayOfMonth,
lastDayOfMonth,
formatDateKey,
dateKeyToDDMM,
} from "./dateUtils.js";
describe("localDateString", () => {
@@ -157,3 +161,70 @@ describe("formatHHMM", () => {
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("firstDayOfMonth", () => {
it("returns first day of month", () => {
const d = new Date(2025, 5, 15);
const result = firstDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(5);
expect(result.getDate()).toBe(1);
});
it("handles January", () => {
const d = new Date(2025, 0, 31);
const result = firstDayOfMonth(d);
expect(result.getDate()).toBe(1);
expect(result.getMonth()).toBe(0);
});
});
describe("lastDayOfMonth", () => {
it("returns last day of month", () => {
const d = new Date(2025, 0, 15);
const result = lastDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(0);
expect(result.getDate()).toBe(31);
});
it("returns 28 for non-leap February", () => {
const d = new Date(2023, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(28);
expect(result.getMonth()).toBe(1);
});
it("returns 29 for leap February", () => {
const d = new Date(2024, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(29);
});
});
describe("formatDateKey", () => {
it("formats ISO date string as DD.MM (local time)", () => {
const result = formatDateKey("2025-02-25T00:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
const [day, month] = result.split(".");
expect(Number(day)).toBeGreaterThanOrEqual(1);
expect(Number(day)).toBeLessThanOrEqual(31);
expect(Number(month)).toBeGreaterThanOrEqual(1);
expect(Number(month)).toBeLessThanOrEqual(12);
});
it("returns DD.MM format with zero-padding", () => {
const result = formatDateKey("2025-01-05T12:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
});
});
describe("dateKeyToDDMM", () => {
it("converts YYYY-MM-DD to DD.MM", () => {
expect(dateKeyToDDMM("2025-02-25")).toBe("25.02");
});
it("handles single-digit day and month", () => {
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
});
});

View File

@@ -2,9 +2,10 @@
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
import { getDutyMarkerRows } from "./hints.js";
@@ -35,43 +36,6 @@ function parseDataAttr(raw) {
}
}
/**
* Build HTML for contact info (phone link, Telegram username link) for a duty entry.
* @param {'ru'|'en'} lang
* @param {string|null|undefined} phone
* @param {string|null|undefined} username - Telegram username with or without leading @
* @returns {string}
*/
function buildContactHtml(lang, phone, username) {
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const label = t(lang, "contact.phone");
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + safeHref + '" class="day-detail-contact-link day-detail-contact-phone">' +
escapeHtml(p) + "</a></span>"
);
}
if (username && String(username).trim()) {
const u = String(username).trim().replace(/^@+/, "");
if (u) {
const label = t(lang, "contact.telegram");
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + escapeHtml(href) + '" class="day-detail-contact-link day-detail-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) + "</a></span>"
);
}
}
return parts.length ? '<div class="day-detail-contact-row">' + parts.join(" ") + "</div>" : "";
}
/**
* Build HTML content for the day detail panel.
* @param {string} dateKey - YYYY-MM-DD
@@ -127,7 +91,11 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
const phone = r.phone != null ? r.phone : (duty && duty.phone);
const username = r.username != null ? r.username : (duty && duty.username);
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactHtml(lang, phone, username);
const contactHtml = buildContactLinksHtml(lang, phone, username, {
classPrefix: "day-detail-contact",
showLabels: true,
separator: " "
});
html +=
"<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
@@ -199,6 +167,7 @@ function positionPopover(panel, cellRect) {
const panelRect = panel.getBoundingClientRect();
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
let top = cellRect.bottom + 8;
/* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */
if (top + panelRect.height > vh - margin) {
top = cellRect.top - panelRect.height - 8;
panel.classList.add("day-detail-panel--below");
@@ -256,6 +225,7 @@ function showAsPopover(cellRect) {
const target = e.target instanceof Node ? e.target : null;
if (!target || !panelEl) return;
if (panelEl.contains(target)) return;
const calendarEl = getCalendarEl();
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
hideDayDetail();
};
@@ -390,6 +360,7 @@ function ensurePanelInDom() {
* Bind delegated click/keydown on calendar for .day cells.
*/
export function initDayDetail() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);

View File

@@ -1,39 +1,62 @@
/**
* DOM references and shared application state.
* Element refs are resolved lazily via getters so modules can be imported before DOM is ready.
*/
/** @type {HTMLDivElement|null} */
export const calendarEl = document.getElementById("calendar");
/** @returns {HTMLDivElement|null} */
export function getCalendarEl() {
return document.getElementById("calendar");
}
/** @type {HTMLElement|null} */
export const monthTitleEl = document.getElementById("monthTitle");
/** @returns {HTMLElement|null} */
export function getMonthTitleEl() {
return document.getElementById("monthTitle");
}
/** @type {HTMLDivElement|null} */
export const dutyListEl = document.getElementById("dutyList");
/** @returns {HTMLDivElement|null} */
export function getDutyListEl() {
return document.getElementById("dutyList");
}
/** @type {HTMLElement|null} */
export const loadingEl = document.getElementById("loading");
/** @returns {HTMLElement|null} */
export function getLoadingEl() {
return document.getElementById("loading");
}
/** @type {HTMLElement|null} */
export const errorEl = document.getElementById("error");
/** @returns {HTMLElement|null} */
export function getErrorEl() {
return document.getElementById("error");
}
/** @type {HTMLElement|null} */
export const accessDeniedEl = document.getElementById("accessDenied");
/** @returns {HTMLElement|null} */
export function getAccessDeniedEl() {
return document.getElementById("accessDenied");
}
/** @type {HTMLElement|null} */
export const headerEl = document.querySelector(".header");
/** @returns {HTMLElement|null} */
export function getHeaderEl() {
return document.querySelector(".header");
}
/** @type {HTMLElement|null} */
export const weekdaysEl = document.querySelector(".weekdays");
/** @returns {HTMLElement|null} */
export function getWeekdaysEl() {
return document.querySelector(".weekdays");
}
/** @type {HTMLButtonElement|null} */
export const prevBtn = document.getElementById("prevMonth");
/** @returns {HTMLButtonElement|null} */
export function getPrevBtn() {
return document.getElementById("prevMonth");
}
/** @type {HTMLButtonElement|null} */
export const nextBtn = document.getElementById("nextMonth");
/** @returns {HTMLButtonElement|null} */
export function getNextBtn() {
return document.getElementById("nextMonth");
}
/** @type {HTMLDivElement|null} */
export const currentDutyViewEl = document.getElementById("currentDutyView");
/** @returns {HTMLDivElement|null} */
export function getCurrentDutyViewEl() {
return document.getElementById("currentDutyView");
}
/** Currently viewed month (mutable). */
export const state = {

View File

@@ -2,9 +2,10 @@
* Duty list (timeline) rendering.
*/
import { dutyListEl, state } from "./dom.js";
import { getDutyListEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import {
localDateString,
firstDayOfMonth,
@@ -14,37 +15,6 @@ import {
formatDateKey
} from "./dateUtils.js";
/**
* Build HTML for contact links (phone, Telegram) for a duty. Returns empty string if none.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function dutyCardContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<a href="' + safeHref + '" class="duty-contact-link duty-contact-phone">' +
escapeHtml(p) + "</a>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<a href="' + href.replace(/"/g, "&quot;") + '" class="duty-contact-link duty-contact-username" target="_blank" rel="noopener noreferrer">@' +
escapeHtml(u) + "</a>"
);
}
}
return parts.length
? '<div class="duty-contact-row">' + parts.join(" · ") + "</div>"
: "";
}
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
@@ -79,7 +49,11 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : "";
const contactHtml = dutyCardContactHtml(lang, d);
const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
const hasContacts = Boolean(
(d.phone && String(d.phone).trim()) ||
(d.username && String(d.username).trim())
@@ -174,12 +148,12 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
timeOrRange +
escapeHtml(timeOrRange) +
"</div></div>"
);
}
/** Whether the delegated flip-button click listener has been attached to dutyListEl. */
/** Whether the delegated flip-button click listener has been attached to duty list element. */
let flipListenerAttached = false;
/**
@@ -187,6 +161,7 @@ let flipListenerAttached = false;
* @param {object[]} duties - Duties (only duty type used for timeline)
*/
export function renderDutyList(duties) {
const dutyListEl = getDutyListEl();
if (!dutyListEl) return;
if (!flipListenerAttached) {
@@ -277,8 +252,9 @@ export function renderDutyList(duties) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
const listEl = getDutyListEl();
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
if (currentDutyCard) {
scrollToEl(currentDutyCard);
} else if (todayBlock) {

View File

@@ -1,9 +1,10 @@
/**
* Unit tests for dutyList (dutyTimelineCardHtml, contact rendering).
* Unit tests for dutyList (dutyTimelineCardHtml, dutyItemHtml, contact rendering).
*/
import { describe, it, expect, beforeAll } from "vitest";
import { dutyTimelineCardHtml } from "./dutyList.js";
import { describe, it, expect, beforeAll, vi, afterEach } from "vitest";
import * as dateUtils from "./dateUtils.js";
import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js";
describe("dutyList", () => {
beforeAll(() => {
@@ -89,4 +90,75 @@ describe("dutyList", () => {
expect(html).not.toContain("duty-contact-row");
});
});
describe("dutyItemHtml", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("escapes timeOrRange so HTML special chars are not rendered raw", () => {
vi.spyOn(dateUtils, "formatHHMM").mockReturnValue("12:00 & 13:00");
vi.spyOn(dateUtils, "formatDateKey").mockReturnValue("01.02.2025");
const d = {
event_type: "duty",
full_name: "Test",
start_at: "2025-03-01T12:00:00",
end_at: "2025-03-01T13:00:00",
};
const html = dutyItemHtml(d, null, false);
expect(html).toContain("&amp;");
expect(html).not.toContain('<div class="time">12:00 & 13:00');
});
it("uses typeLabelOverride when provided", () => {
const d = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, "On duty now", false);
expect(html).toContain("On duty now");
expect(html).toContain("Alice");
});
it("shows duty.until when showUntilEnd is true for duty", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, true);
expect(html).toMatch(/until|до/);
expect(html).toMatch(/\d{2}:\d{2}/);
});
it("renders vacation with date range", () => {
vi.spyOn(dateUtils, "formatDateKey")
.mockReturnValueOnce("01.03")
.mockReturnValueOnce("05.03");
const d = {
event_type: "vacation",
full_name: "Charlie",
start_at: "2025-03-01T00:00:00",
end_at: "2025-03-05T23:59:59",
};
const html = dutyItemHtml(d);
expect(html).toContain("01.03 05.03");
expect(html).toContain("duty-item--vacation");
});
it("applies extraClass to container", () => {
const d = {
event_type: "duty",
full_name: "Dana",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, false, "duty-item--current");
expect(html).toContain("duty-item--current");
expect(html).toContain("Dana");
});
});
});

View File

@@ -2,7 +2,7 @@
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js";
@@ -250,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
* Remove active class from all duty/unavailable/vacation markers.
*/
export function clearActiveDutyMarker() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl
.querySelectorAll(
@@ -261,6 +262,25 @@ export function clearActiveDutyMarker() {
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
let dutyMarkerHideTimeout = null;
const HINT_FADE_MS = 150;
/**
* Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active.
* @param {HTMLElement} hintEl - The hint element to dismiss
* @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide
* @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout)
*/
export function dismissHint(hintEl, opts = {}) {
hintEl.classList.remove("calendar-event-hint--visible");
const id = setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
if (opts.clearActive) clearActiveDutyMarker();
if (typeof opts.afterHide === "function") opts.afterHide();
}, HINT_FADE_MS);
return id;
}
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
/**
@@ -304,6 +324,7 @@ function getOrCreateDutyMarkerHint() {
export function initHints() {
const calendarEventHint = getOrCreateCalendarEventHint();
const dutyMarkerHint = getOrCreateDutyMarkerHint();
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
@@ -317,11 +338,7 @@ export function initHints() {
positionHint(calendarEventHint, btn.getBoundingClientRect());
calendarEventHint.dataset.active = "1";
} else {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
dismissHint(calendarEventHint);
}
return;
}
@@ -330,11 +347,7 @@ export function initHints() {
if (marker) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
}, 150);
dismissHint(dutyMarkerHint);
marker.classList.remove("calendar-marker-active");
return;
}
@@ -377,31 +390,23 @@ export function initHints() {
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);
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
afterHide: () => {
dutyMarkerHideTimeout = null;
},
});
});
if (!state.calendarHintBound) {
state.calendarHintBound = true;
document.addEventListener("click", () => {
if (calendarEventHint.dataset.active) {
calendarEventHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
calendarEventHint.hidden = true;
calendarEventHint.removeAttribute("data-active");
}, 150);
dismissHint(calendarEventHint);
}
});
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);
dismissHint(calendarEventHint);
}
});
}
@@ -410,22 +415,12 @@ export function initHints() {
state.dutyMarkerHintBound = true;
document.addEventListener("click", () => {
if (dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
dutyMarkerHint.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
dutyMarkerHint.hidden = true;
dutyMarkerHint.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
}

View File

@@ -1,10 +1,11 @@
/**
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
* Also tests dismissHint helper.
*/
import { describe, it, expect, beforeAll } from "vitest";
import { getDutyMarkerRows } from "./hints.js";
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { getDutyMarkerRows, dismissHint } from "./hints.js";
const FROM = "from";
const TO = "until";
@@ -124,3 +125,52 @@ describe("getDutyMarkerRows", () => {
expect(rows[2].timePrefix).toContain("15:00");
});
});
describe("dismissHint", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("removes visible class immediately and hides element after delay", () => {
const el = document.createElement("div");
el.classList.add("calendar-event-hint--visible");
el.hidden = false;
el.setAttribute("data-active", "1");
dismissHint(el);
expect(el.classList.contains("calendar-event-hint--visible")).toBe(false);
expect(el.hidden).toBe(false);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(true);
expect(el.hasAttribute("data-active")).toBe(false);
});
it("returns timeout id usable with clearTimeout", () => {
const el = document.createElement("div");
const id = dismissHint(el);
expect(id).toBeDefined();
clearTimeout(id);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(false);
});
it("calls afterHide callback after delay when provided", () => {
const el = document.createElement("div");
let called = false;
dismissHint(el, {
afterHide: () => {
called = true;
},
});
expect(called).toBe(false);
vi.advanceTimersByTime(150);
expect(called).toBe(true);
});
});

View File

@@ -8,12 +8,12 @@ import { getInitData, isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import {
state,
accessDeniedEl,
prevBtn,
nextBtn,
loadingEl,
errorEl,
weekdaysEl
getAccessDeniedEl,
getPrevBtn,
getNextBtn,
getLoadingEl,
getErrorEl,
getWeekdaysEl
} from "./dom.js";
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
import { fetchDuties, fetchCalendarEvents } from "./api.js";
@@ -39,15 +39,19 @@ initTheme();
state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title");
const loadingEl = getLoadingEl();
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
const weekdaysEl = getWeekdaysEl();
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
@@ -99,12 +103,14 @@ function requireTelegramOrLocalhost(onAllowed) {
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}
/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
@@ -121,7 +127,9 @@ async function loadMonth() {
hideAccessDenied();
setNavEnabled(false);
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.remove("hidden");
const errorEl = getErrorEl();
if (errorEl) errorEl.hidden = true;
const current = state.current;
const first = firstDayOfMonth(current);
@@ -185,21 +193,26 @@ async function loadMonth() {
setNavEnabled(true);
return;
}
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
setNavEnabled(true);
}
if (prevBtn) {
prevBtn.addEventListener("click", () => {
const prevBtnEl = getPrevBtn();
if (prevBtnEl) {
prevBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() - 1);
loadMonth();
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
const nextBtnEl = getNextBtn();
if (nextBtnEl) {
nextBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() + 1);
loadMonth();
@@ -227,12 +240,15 @@ if (nextBtn) {
(e) => {
if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (deltaX > SWIPE_THRESHOLD) {
if (prevBtn && prevBtn.disabled) return;
state.current.setMonth(state.current.getMonth() - 1);

152
webapp/js/theme.test.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Unit tests for theme: getTheme, applyThemeParamsToCss, applyTheme, initTheme.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("theme", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("getTheme", () => {
it("returns Telegram.WebApp.colorScheme when set", async () => {
globalThis.window.Telegram = { WebApp: { colorScheme: "light" } };
vi.spyOn(document.documentElement.style, "getPropertyValue").mockReturnValue("");
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to --tg-color-scheme CSS when TWA has no colorScheme", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue("dark"),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("falls back to matchMedia prefers-color-scheme dark", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("returns light when matchMedia prefers light", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to matchMedia when getComputedStyle throws", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(() => {
throw new Error("getComputedStyle not available");
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
});
describe("applyThemeParamsToCss", () => {
it("does nothing when Telegram.WebApp or themeParams missing", async () => {
globalThis.window.Telegram = undefined;
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).not.toHaveBeenCalled();
});
it("sets --tg-theme-* CSS variables from themeParams", async () => {
globalThis.window.Telegram = {
WebApp: {
themeParams: {
bg_color: "#ffffff",
text_color: "#000000",
hint_color: "#888888",
},
},
};
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).toHaveBeenCalledWith("--tg-theme-bg-color", "#ffffff");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-text-color", "#000000");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-hint-color", "#888888");
});
});
describe("applyTheme", () => {
beforeEach(() => {
document.documentElement.dataset.theme = "";
});
it("sets data-theme on documentElement from getTheme", async () => {
const theme = await import("./theme.js");
vi.spyOn(theme, "getTheme").mockReturnValue("light");
theme.applyTheme();
expect(document.documentElement.dataset.theme).toBe("light");
});
it("calls setBackgroundColor and setHeaderColor when TWA present", async () => {
const setBackgroundColor = vi.fn();
const setHeaderColor = vi.fn();
globalThis.window.Telegram = {
WebApp: {
setBackgroundColor: setBackgroundColor,
setHeaderColor: setHeaderColor,
themeParams: null,
},
};
const { applyTheme } = await import("./theme.js");
applyTheme();
expect(setBackgroundColor).toHaveBeenCalledWith("bg_color");
expect(setHeaderColor).toHaveBeenCalledWith("bg_color");
});
});
describe("initTheme", () => {
it("runs without throwing when TWA present", async () => {
globalThis.window.Telegram = { WebApp: {} };
const { initTheme } = await import("./theme.js");
expect(() => initTheme()).not.toThrow();
});
it("adds matchMedia change listener when no TWA", async () => {
globalThis.window.Telegram = undefined;
const addEventListener = vi.fn();
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener,
removeEventListener: vi.fn(),
});
const { initTheme } = await import("./theme.js");
initTheme();
expect(addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
});
});

View File

@@ -4,15 +4,15 @@
import {
state,
calendarEl,
dutyListEl,
loadingEl,
errorEl,
accessDeniedEl,
headerEl,
weekdaysEl,
prevBtn,
nextBtn
getCalendarEl,
getDutyListEl,
getLoadingEl,
getErrorEl,
getAccessDeniedEl,
getHeaderEl,
getWeekdaysEl,
getPrevBtn,
getNextBtn
} from "./dom.js";
import { t } from "./i18n.js";
@@ -21,6 +21,13 @@ import { t } from "./i18n.js";
* @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
*/
export function showAccessDenied(serverDetail) {
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
const loadingEl = getLoadingEl();
const errorEl = getErrorEl();
const accessDeniedEl = getAccessDeniedEl();
if (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true;
if (calendarEl) calendarEl.hidden = true;
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
* Hide access-denied and show calendar/list/header/weekdays.
*/
export function hideAccessDenied() {
const accessDeniedEl = getAccessDeniedEl();
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
if (accessDeniedEl) accessDeniedEl.hidden = true;
if (headerEl) headerEl.hidden = false;
if (weekdaysEl) weekdaysEl.hidden = false;
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
* @param {string} msg - Error text
*/
export function showError(msg) {
const errorEl = getErrorEl();
const loadingEl = getLoadingEl();
if (errorEl) {
errorEl.textContent = msg;
errorEl.hidden = false;
@@ -68,6 +82,8 @@ export function showError(msg) {
* @param {boolean} enabled
*/
export function setNavEnabled(enabled) {
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled;
}

122
webapp/js/ui.test.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Unit tests for ui: showAccessDenied, hideAccessDenied, showError, setNavEnabled.
*/
import { describe, it, expect, beforeAll, beforeEach } 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 {
showAccessDenied,
hideAccessDenied,
showError,
setNavEnabled,
} from "./ui.js";
import { state } from "./dom.js";
describe("ui", () => {
beforeEach(() => {
state.lang = "ru";
const calendar = document.getElementById("calendar");
const dutyList = document.getElementById("dutyList");
const loading = document.getElementById("loading");
const error = document.getElementById("error");
const accessDenied = document.getElementById("accessDenied");
const header = document.querySelector(".header");
const weekdays = document.querySelector(".weekdays");
const prevBtn = document.getElementById("prevMonth");
const nextBtn = document.getElementById("nextMonth");
if (header) header.hidden = false;
if (weekdays) weekdays.hidden = false;
if (calendar) calendar.hidden = false;
if (dutyList) dutyList.hidden = false;
if (loading) loading.classList.remove("hidden");
if (error) error.hidden = true;
if (accessDenied) accessDenied.hidden = true;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
});
describe("showAccessDenied", () => {
it("hides header, weekdays, calendar, dutyList, loading, error and shows accessDenied", () => {
showAccessDenied();
expect(document.querySelector(".header")?.hidden).toBe(true);
expect(document.querySelector(".weekdays")?.hidden).toBe(true);
expect(document.getElementById("calendar")?.hidden).toBe(true);
expect(document.getElementById("dutyList")?.hidden).toBe(true);
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
expect(document.getElementById("error")?.hidden).toBe(true);
expect(document.getElementById("accessDenied")?.hidden).toBe(false);
});
it("sets accessDenied innerHTML with translated message", () => {
showAccessDenied();
const el = document.getElementById("accessDenied");
expect(el?.innerHTML).toContain("Доступ запрещён");
});
it("appends serverDetail in .access-denied-detail when provided", () => {
showAccessDenied("Custom 403 message");
const el = document.getElementById("accessDenied");
const detail = el?.querySelector(".access-denied-detail");
expect(detail?.textContent).toBe("Custom 403 message");
});
it("does not append detail element when serverDetail is empty string", () => {
showAccessDenied("");
const el = document.getElementById("accessDenied");
expect(el?.querySelector(".access-denied-detail")).toBeNull();
});
});
describe("hideAccessDenied", () => {
it("hides accessDenied and shows header, weekdays, calendar, dutyList", () => {
document.getElementById("accessDenied").hidden = false;
document.querySelector(".header").hidden = true;
document.getElementById("calendar").hidden = true;
hideAccessDenied();
expect(document.getElementById("accessDenied")?.hidden).toBe(true);
expect(document.querySelector(".header")?.hidden).toBe(false);
expect(document.querySelector(".weekdays")?.hidden).toBe(false);
expect(document.getElementById("calendar")?.hidden).toBe(false);
expect(document.getElementById("dutyList")?.hidden).toBe(false);
});
});
describe("showError", () => {
it("sets error text and shows error element", () => {
showError("Network error");
const errorEl = document.getElementById("error");
expect(errorEl?.textContent).toBe("Network error");
expect(errorEl?.hidden).toBe(false);
});
it("adds hidden class to loading element", () => {
document.getElementById("loading").classList.remove("hidden");
showError("Fail");
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
});
});
describe("setNavEnabled", () => {
it("disables prev and next buttons when enabled is false", () => {
setNavEnabled(false);
expect(document.getElementById("prevMonth")?.disabled).toBe(true);
expect(document.getElementById("nextMonth")?.disabled).toBe(true);
});
it("enables prev and next buttons when enabled is true", () => {
document.getElementById("prevMonth").disabled = true;
document.getElementById("nextMonth").disabled = true;
setNavEnabled(true);
expect(document.getElementById("prevMonth")?.disabled).toBe(false);
expect(document.getElementById("nextMonth")?.disabled).toBe(false);
});
});
});