' +
@@ -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;
diff --git a/webapp/js/dateUtils.test.js b/webapp/js/dateUtils.test.js
index fde4c00..1ea7d4f 100644
--- a/webapp/js/dateUtils.test.js
+++ b/webapp/js/dateUtils.test.js
@@ -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");
+ });
+});
diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js
index 821c2f2..d0c61ec 100644
--- a/webapp/js/dayDetail.js
+++ b/webapp/js/dayDetail.js
@@ -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, "&").replace(/"/g, """).replace(/' +
- escapeHtml(label) + ": " +
- '
' +
- escapeHtml(p) + ""
- );
- }
- 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(
- '
' +
- escapeHtml(label) + ": " +
- '' +
- escapeHtml(display) + ""
- );
- }
- }
- return parts.length ? '
' + parts.join(" ") + "
" : "";
-}
-
/**
* 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 +=
"
" +
(timeHtml ? '' + timeHtml + "" : "") +
@@ -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);
diff --git a/webapp/js/dom.js b/webapp/js/dom.js
index 4ca66ed..6970843 100644
--- a/webapp/js/dom.js
+++ b/webapp/js/dom.js
@@ -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 = {
diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js
index a43c69c..bab6e71 100644
--- a/webapp/js/dutyList.js
+++ b/webapp/js/dutyList.js
@@ -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, "&").replace(/"/g, """).replace(/' +
- escapeHtml(p) + ""
- );
- }
- 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(
- '@' +
- escapeHtml(u) + ""
- );
- }
- }
- return parts.length
- ? '' + parts.join(" · ") + "
"
- : "";
-}
-
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'';
@@ -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) {
' ' +
escapeHtml(d.full_name) +
'' +
- timeOrRange +
+ escapeHtml(timeOrRange) +
"
"
);
}
-/** 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) {
diff --git a/webapp/js/dutyList.test.js b/webapp/js/dutyList.test.js
index 2ee5ba5..424e8d8 100644
--- a/webapp/js/dutyList.test.js
+++ b/webapp/js/dutyList.test.js
@@ -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("&");
+ expect(html).not.toContain('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");
+ });
+ });
});
diff --git a/webapp/js/hints.js b/webapp/js/hints.js
index 7280ad3..5814b0d 100644
--- a/webapp/js/hints.js
+++ b/webapp/js/hints.js
@@ -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 });
}
});
}
diff --git a/webapp/js/hints.test.js b/webapp/js/hints.test.js
index e09ad0f..20ad3a8 100644
--- a/webapp/js/hints.test.js
+++ b/webapp/js/hints.test.js
@@ -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);
+ });
+});
diff --git a/webapp/js/main.js b/webapp/js/main.js
index 03c7f54..d7c068a 100644
--- a/webapp/js/main.js
+++ b/webapp/js/main.js
@@ -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);
diff --git a/webapp/js/theme.test.js b/webapp/js/theme.test.js
new file mode 100644
index 0000000..d57f9a6
--- /dev/null
+++ b/webapp/js/theme.test.js
@@ -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));
+ });
+ });
+});
diff --git a/webapp/js/ui.js b/webapp/js/ui.js
index e735dcf..9520f25 100644
--- a/webapp/js/ui.js
+++ b/webapp/js/ui.js
@@ -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;
}
diff --git a/webapp/js/ui.test.js b/webapp/js/ui.test.js
new file mode 100644
index 0000000..8cb0a1c
--- /dev/null
+++ b/webapp/js/ui.test.js
@@ -0,0 +1,122 @@
+/**
+ * Unit tests for ui: showAccessDenied, hideAccessDenied, showError, setNavEnabled.
+ */
+
+import { describe, it, expect, beforeAll, beforeEach } from "vitest";
+
+beforeAll(() => {
+ document.body.innerHTML =
+ '
' +
+ '
' +
+ '
' +
+ '
';
+});
+
+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);
+ });
+ });
+});
diff --git a/webapp/style.css b/webapp/style.css
deleted file mode 100644
index f64a6d5..0000000
--- a/webapp/style.css
+++ /dev/null
@@ -1,1124 +0,0 @@
-/* === Variables & themes */
-:root {
- --bg: #1a1b26;
- --surface: #24283b;
- --text: #c0caf5;
- --muted: #565f89;
- --accent: #7aa2f7;
- --duty: #9ece6a;
- --today: #bb9af7;
- --unavailable: #e0af68;
- --vacation: #7dcfff;
- --error: #f7768e;
- --timeline-date-width: 3.6em;
- --timeline-track-width: 10px;
- --transition-fast: 0.15s;
- --transition-normal: 0.25s;
- --ease-out: cubic-bezier(0.32, 0.72, 0, 1);
-}
-
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- }
-}
-
-/* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */
-[data-theme="light"] {
- --bg: var(--tg-theme-bg-color, #f0f1f3);
- --surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
- --text: var(--tg-theme-text-color, #343b58);
- --muted: var(--tg-theme-hint-color, #6b7089);
- --accent: var(--tg-theme-link-color, #2e7de0);
- --duty: #587d0a;
- --today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #2481cc));
- --unavailable: #b8860b;
- --vacation: #0d6b9e;
- --error: #c43b3b;
-}
-
-/* Dark theme: prefer Telegram themeParams, fallback to Telegram dark palette */
-[data-theme="dark"] {
- --bg: var(--tg-theme-bg-color, #17212b);
- --surface: var(--tg-theme-secondary-bg-color, #232e3c);
- --text: var(--tg-theme-text-color, #f5f5f5);
- --muted: var(--tg-theme-hint-color, #708499);
- --accent: var(--tg-theme-link-color, #6ab3f3);
- --today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #6ab2f2));
- --duty: #5c9b4a;
- --unavailable: #b8860b;
- --vacation: #5a9bb8;
- --error: #e06c75;
-}
-
-/* === Layout & base */
-html {
- scrollbar-gutter: stable;
- scrollbar-width: none;
- -ms-overflow-style: none;
- overscroll-behavior: none;
-}
-
-html::-webkit-scrollbar {
- display: none;
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- padding: 0;
- font-family: system-ui, -apple-system, sans-serif;
- background: var(--bg);
- color: var(--text);
- min-height: 100vh;
- -webkit-tap-highlight-color: transparent;
-}
-
-.container {
- max-width: 420px;
- margin: 0 auto;
- padding: 12px;
- padding-top: 0px;
- padding-bottom: env(safe-area-inset-bottom, 12px);
-}
-
-[data-theme="light"] .container {
- border-radius: 12px;
-}
-
-.header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
-}
-
-.header[hidden],
-.weekdays[hidden] {
- display: none !important;
-}
-
-.nav {
- width: 40px;
- height: 40px;
- border: none;
- border-radius: 10px;
- background: var(--surface);
- color: var(--accent);
- font-size: 24px;
- line-height: 1;
- cursor: pointer;
- transition: opacity var(--transition-fast), transform var(--transition-fast);
-}
-
-.nav:focus {
- outline: none;
-}
-
-.nav:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.nav:active {
- transform: scale(0.95);
-}
-
-.nav:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.title {
- margin: 0;
- font-size: 1.1rem;
- font-weight: 600;
-}
-
-.weekdays {
- display: grid;
- grid-template-columns: repeat(7, 1fr);
- gap: 2px;
- margin-bottom: 6px;
- font-size: 0.75rem;
- color: var(--muted);
- text-align: center;
-}
-
-.calendar-sticky {
- position: sticky;
- top: 0;
- z-index: 10;
- background: var(--bg);
- padding-bottom: 12px;
- margin-bottom: 4px;
- touch-action: pan-y;
- transition: box-shadow var(--transition-fast) ease-out;
-}
-
-/* === Calendar grid & day cells */
-.calendar {
- display: grid;
- grid-template-columns: repeat(7, 1fr);
- gap: 4px;
- margin-bottom: 16px;
-}
-
-.day {
- position: relative;
- aspect-ratio: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-start;
- padding: 4px;
- border-radius: 8px;
- font-size: 0.85rem;
- background: var(--surface);
- min-width: 0;
- min-height: 0;
- overflow: hidden;
- transition: background-color var(--transition-fast), transform var(--transition-fast);
-}
-
-.day.other-month {
- opacity: 0.4;
-}
-
-.day.today {
- background: var(--today);
- color: var(--bg);
-}
-
-.day.has-duty .num {
- font-weight: 700;
-}
-
-.day.holiday {
- background: linear-gradient(135deg, var(--surface) 0%, color-mix(in srgb, var(--today) 15%, transparent) 100%);
- border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
-}
-
-/* Today + external calendar: same solid "today" look as weekday, plus a border to show it has external events */
-.day.today.holiday {
- background: var(--today);
- color: var(--bg);
- border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
-}
-
-.day {
- cursor: pointer;
-}
-
-.day:focus {
- outline: none;
-}
-
-.day:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.day:active {
- transform: scale(0.98);
-}
-
-.day-indicator {
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
- gap: 2px;
- margin-top: 6px;
-}
-
-.day-indicator-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.day-indicator-dot.duty {
- background: var(--duty);
-}
-
-.day-indicator-dot.unavailable {
- background: var(--unavailable);
-}
-
-.day-indicator-dot.vacation {
- background: var(--vacation);
-}
-
-.day-indicator-dot.events {
- background: var(--accent);
-}
-
-/* On "today" cell: dots darkened for contrast on --today background */
-.day.today .day-indicator-dot.duty {
- background: color-mix(in srgb, var(--duty) 65%, var(--bg));
-}
-.day.today .day-indicator-dot.unavailable {
- background: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
-}
-.day.today .day-indicator-dot.vacation {
- background: color-mix(in srgb, var(--vacation) 65%, var(--bg));
-}
-.day.today .day-indicator-dot.events {
- background: color-mix(in srgb, var(--accent) 65%, var(--bg));
-}
-
-/* === Day detail panel (popover / bottom sheet) */
-/* Блокировка фона при открытом bottom sheet: прокрутка и свайпы отключены */
-body.day-detail-sheet-open {
- position: fixed;
- left: 0;
- right: 0;
- overflow: hidden;
-}
-
-.day-detail-overlay {
- position: fixed;
- inset: 0;
- z-index: 999;
- background: rgba(0, 0, 0, 0.4);
- -webkit-tap-highlight-color: transparent;
- opacity: 0;
- pointer-events: none;
- transition: opacity var(--transition-normal) ease-out;
-}
-
-.day-detail-overlay.day-detail-overlay--visible {
- opacity: 1;
- pointer-events: auto;
-}
-
-.day-detail-panel {
- position: fixed;
- z-index: 1000;
- max-width: min(360px, calc(100vw - 24px));
- max-height: 70vh;
- overflow: auto;
- background: var(--surface);
- color: var(--text);
- border-radius: 12px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
- padding: 12px 16px;
- padding-top: 36px;
-}
-
-.day-detail-panel--sheet {
- left: 0;
- right: 0;
- bottom: 0;
- top: auto;
- width: 100%;
- max-width: none;
- max-height: 70vh;
- border-radius: 16px 16px 0 0;
- padding-top: 12px;
- padding-left: 16px;
- padding-right: 16px;
- /* Комфортный отступ снизу: safe area + дополнительное поле */
- padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
- transform: translateY(100%);
- transition: transform var(--transition-normal) var(--ease-out);
-}
-
-.day-detail-panel--sheet.day-detail-panel--open {
- transform: translateY(0);
-}
-
-.day-detail-panel--sheet::before {
- content: "";
- display: block;
- width: 36px;
- height: 4px;
- margin: 0 auto 8px;
- background: var(--muted);
- border-radius: 2px;
-}
-
-.day-detail-close {
- position: absolute;
- top: 8px;
- right: 8px;
- width: 32px;
- height: 32px;
- padding: 0;
- border: none;
- background: transparent;
- color: var(--muted);
- font-size: 1.5rem;
- line-height: 1;
- cursor: pointer;
- border-radius: 8px;
- transition: opacity var(--transition-fast), background-color var(--transition-fast);
-}
-
-.day-detail-close:focus {
- outline: none;
-}
-
-.day-detail-close:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.day-detail-close:hover {
- color: var(--text);
- background: color-mix(in srgb, var(--muted) 25%, transparent);
-}
-
-.day-detail-title {
- margin: 0 0 12px 0;
- font-size: 1.1rem;
- font-weight: 600;
-}
-
-.day-detail-sections {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.day-detail-section-title {
- margin: 0 0 4px 0;
- font-size: 0.8rem;
- font-weight: 600;
- color: var(--muted);
-}
-
-.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
-.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
-.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
-.day-detail-section--events .day-detail-section-title { color: var(--accent); }
-
-.day-detail-list {
- margin: 0;
- padding-left: 1.2em;
- font-size: 0.9rem;
- line-height: 1.45;
-}
-
-.day-detail-list li {
- margin-bottom: 2px;
-}
-
-.day-detail-time {
- color: var(--muted);
-}
-
-/* Contact info: phone (tel:) and Telegram username links in day detail */
-.day-detail-contact-row {
- margin-top: 4px;
- font-size: 0.85rem;
- color: var(--muted);
-}
-
-.day-detail-contact {
- display: inline-block;
- margin-right: 0.75em;
-}
-
-.day-detail-contact:last-child {
- margin-right: 0;
-}
-
-.day-detail-contact-link,
-.day-detail-contact-phone,
-.day-detail-contact-username {
- color: var(--accent);
- text-decoration: none;
-}
-
-.day-detail-contact-link:hover,
-.day-detail-contact-phone:hover,
-.day-detail-contact-username:hover {
- text-decoration: underline;
-}
-
-.day-detail-contact-link:focus,
-.day-detail-contact-phone:focus,
-.day-detail-contact-username:focus {
- outline: none;
-}
-
-.day-detail-contact-link:focus-visible,
-.day-detail-contact-phone:focus-visible,
-.day-detail-contact-username:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.info-btn {
- position: absolute;
- top: 0;
- right: 0;
- width: 22px;
- height: 22px;
- padding: 0;
- border: none;
- background: var(--accent);
- color: var(--bg);
- font-size: 0.7rem;
- font-weight: 700;
- line-height: 1;
- cursor: pointer;
- display: inline-flex;
- align-items: flex-start;
- justify-content: flex-end;
- flex-shrink: 0;
- clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
- padding: 2px 3px 0 0;
-}
-
-.info-btn:active {
- opacity: 0.9;
-}
-
-.day-markers {
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- justify-content: center;
- gap: 2px;
- align-items: center;
- margin-top: 2px;
- min-width: 0;
-}
-
-/* === Hints (tooltips) */
-.calendar-event-hint {
- position: fixed;
- z-index: 1000;
- width: max-content;
- max-width: min(98vw, 900px);
- min-width: 0;
- padding: 8px 12px;
- background: var(--surface);
- color: var(--text);
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
- font-size: 0.85rem;
- line-height: 1.4;
- white-space: pre;
- overflow: visible;
- transform: translateY(-100%);
- transition: opacity 0.15s ease-out, transform 0.15s ease-out;
-}
-
-.calendar-event-hint:not(.calendar-event-hint--visible) {
- opacity: 0;
-}
-
-.calendar-event-hint.calendar-event-hint--visible {
- opacity: 1;
-}
-
-.calendar-event-hint.below {
- transform: none;
-}
-
-.calendar-event-hint-title {
- margin-bottom: 4px;
- font-weight: 600;
-}
-
-.calendar-event-hint-rows {
- display: table;
- width: min-content;
- table-layout: auto;
- border-collapse: separate;
- border-spacing: 0 2px;
-}
-
-.calendar-event-hint-row {
- display: table-row;
- white-space: nowrap;
-}
-
-.calendar-event-hint-row .calendar-event-hint-time {
- display: table-cell;
- white-space: nowrap;
- width: 1%;
- vertical-align: top;
- text-align: right;
- padding-right: 0.15em;
-}
-
-.calendar-event-hint-row .calendar-event-hint-sep {
- display: table-cell;
- width: 1em;
- vertical-align: top;
- padding-right: 0.1em;
-}
-
-.calendar-event-hint-row .calendar-event-hint-name {
- display: table-cell;
- white-space: nowrap !important;
-}
-
-/* === Markers (duty / unavailable / vacation) */
-.duty-marker,
-.unavailable-marker,
-.vacation-marker {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 11px;
- height: 11px;
- padding: 0;
- border: none;
- font-size: 0.55rem;
- font-weight: 700;
- border-radius: 50%;
- flex-shrink: 0;
- cursor: pointer;
- transition: box-shadow var(--transition-fast) ease-out;
-}
-
-.duty-marker {
- color: var(--duty);
- background: color-mix(in srgb, var(--duty) 25%, transparent);
-}
-
-.unavailable-marker {
- color: var(--unavailable);
- background: color-mix(in srgb, var(--unavailable) 25%, transparent);
-}
-
-.vacation-marker {
- color: var(--vacation);
- background: color-mix(in srgb, var(--vacation) 25%, transparent);
-}
-
-.duty-marker.calendar-marker-active {
- box-shadow: 0 0 0 2px var(--duty);
-}
-
-.unavailable-marker.calendar-marker-active {
- box-shadow: 0 0 0 2px var(--unavailable);
-}
-
-.vacation-marker.calendar-marker-active {
- box-shadow: 0 0 0 2px var(--vacation);
-}
-
-/* === Duty list & timeline */
-.duty-list {
- font-size: 0.9rem;
-}
-
-.duty-list h2 {
- font-size: 0.85rem;
- color: var(--muted);
- margin: 0 0 8px 0;
-}
-
-.duty-list-day {
- margin-bottom: 16px;
-}
-
-.duty-list-day--today .duty-list-day-title {
- color: var(--today);
- font-weight: 700;
-}
-
-.duty-list-day--today .duty-list-day-title::before {
- content: "";
- display: inline-block;
- width: 4px;
- height: 1em;
- background: var(--today);
- border-radius: 2px;
- margin-right: 8px;
- vertical-align: middle;
-}
-
-/* Timeline: dates | track (line + dot) | cards */
-.duty-list.duty-timeline {
- position: relative;
-}
-
-.duty-list.duty-timeline::before {
- content: "";
- position: absolute;
- left: calc(var(--timeline-date-width) + var(--timeline-track-width) / 2 - 1px);
- top: 0;
- bottom: 0;
- width: 2px;
- background: var(--muted);
- pointer-events: none;
-}
-
-.duty-timeline-day {
- margin-bottom: 0;
-}
-
-.duty-timeline-day--today {
- scroll-margin-top: 200px;
-}
-
-.duty-timeline-row {
- display: grid;
- grid-template-columns: var(--timeline-date-width) var(--timeline-track-width) 1fr;
- gap: 0 4px;
- align-items: start;
- margin-bottom: 8px;
- min-height: 1px;
-}
-
-.duty-timeline-date {
- position: relative;
- font-size: 0.8rem;
- color: var(--muted);
- padding-top: 10px;
- padding-bottom: 10px;
- flex-shrink: 0;
- overflow: visible;
-}
-
-.duty-timeline-date::before {
- content: "";
- position: absolute;
- left: 0;
- bottom: 4px;
- width: calc(100% + var(--timeline-track-width) / 2);
- height: 2px;
- background: linear-gradient(
- to right,
- color-mix(in srgb, var(--muted) 40%, transparent) 0%,
- color-mix(in srgb, var(--muted) 40%, transparent) 50%,
- var(--muted) 70%,
- var(--muted) 100%
- );
-}
-
-.duty-timeline-date::after {
- content: "";
- position: absolute;
- left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
- bottom: 2px;
- width: 2px;
- height: 6px;
- background: var(--muted);
-}
-
-.duty-timeline-day--today .duty-timeline-date {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- padding-top: 4px;
- color: var(--today);
- font-weight: 600;
-}
-
-.duty-timeline-day--today .duty-timeline-date::before,
-.duty-timeline-day--today .duty-timeline-date::after {
- display: none;
-}
-
-.duty-timeline-date-label,
-.duty-timeline-date-day {
- display: block;
- line-height: 1.25;
-}
-
-.duty-timeline-date-day {
- align-self: flex-start;
- text-align: left;
- padding-left: 0;
- margin-left: 0;
-}
-
-.duty-timeline-date-dot {
- display: block;
- width: 100%;
- height: 8px;
- min-height: 8px;
- position: relative;
- flex-shrink: 0;
-}
-
-.duty-timeline-date-dot::before {
- content: "";
- position: absolute;
- left: 0;
- top: 50%;
- margin-top: -1px;
- width: calc(100% + var(--timeline-track-width) / 2);
- height: 1px;
- background: color-mix(in srgb, var(--today) 45%, transparent);
-}
-
-.duty-timeline-date-dot::after {
- content: "";
- position: absolute;
- left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
- top: 50%;
- margin-top: -3px;
- width: 2px;
- height: 6px;
- background: var(--today);
-}
-
-.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-label {
- color: var(--today);
-}
-
-.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-day {
- color: var(--muted);
- font-weight: 400;
- font-size: 0.75rem;
-}
-
-.duty-timeline-track {
- min-width: 0;
-}
-
-.duty-timeline-card-wrap {
- min-width: 0;
-}
-
-/* Flip-card: front = duty info + button, back = contacts */
-.duty-flip-card {
- perspective: 600px;
- position: relative;
- min-height: 0;
-}
-
-.duty-flip-inner {
- transition: transform 0.4s;
- transform-style: preserve-3d;
- position: relative;
- min-height: 0;
-}
-
-.duty-flip-card[data-flipped="true"] .duty-flip-inner {
- transform: rotateY(180deg);
-}
-
-.duty-flip-front {
- position: relative;
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
-}
-
-.duty-flip-back {
- backface-visibility: hidden;
- -webkit-backface-visibility: hidden;
- position: absolute;
- inset: 0;
- transform: rotateY(180deg);
-}
-
-.duty-flip-btn {
- position: absolute;
- right: 8px;
- top: 50%;
- transform: translateY(-50%);
- width: 36px;
- height: 36px;
- padding: 0;
- border: none;
- border-radius: 50%;
- background: var(--surface);
- color: var(--accent);
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- transition: background var(--transition-fast), color var(--transition-fast);
-}
-
-.duty-flip-btn:hover {
- background: color-mix(in srgb, var(--accent) 20%, var(--surface));
-}
-
-.duty-flip-btn:focus {
- outline: none;
-}
-
-.duty-flip-btn:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.duty-timeline-card.duty-item,
-.duty-list .duty-item {
- display: grid;
- grid-template-columns: 1fr;
- gap: 2px 0;
- align-items: baseline;
- padding: 8px 10px;
- margin-bottom: 0;
- border-radius: 8px;
- background: var(--surface);
- border-left: 3px solid var(--duty);
-}
-
-.duty-item--unavailable {
- border-left-color: var(--unavailable);
-}
-
-.duty-item--vacation {
- border-left-color: var(--vacation);
-}
-
-.duty-item .duty-item-type {
- grid-column: 1;
- grid-row: 1;
- font-size: 0.75rem;
- color: var(--muted);
-}
-
-.duty-item .name {
- grid-column: 2;
- grid-row: 1 / -1;
- min-width: 0;
- font-weight: 600;
-}
-
-.duty-item .time {
- grid-column: 1;
- grid-row: 2;
- align-self: start;
- font-size: 0.8rem;
- color: var(--muted);
-}
-
-.duty-timeline-card .duty-item-type { grid-column: 1; grid-row: 1; }
-.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
-.duty-timeline-card .time { grid-column: 1; grid-row: 3; }
-
-/* Contact info: phone and Telegram username links in duty timeline cards */
-.duty-contact-row {
- grid-column: 1;
- grid-row: 4;
- font-size: 0.8rem;
- color: var(--muted);
- margin-top: 2px;
-}
-
-.duty-contact-link,
-.duty-contact-phone,
-.duty-contact-username {
- color: var(--accent);
- text-decoration: none;
-}
-
-.duty-contact-link:hover,
-.duty-contact-phone:hover,
-.duty-contact-username:hover {
- text-decoration: underline;
-}
-
-.duty-contact-link:focus,
-.duty-contact-phone:focus,
-.duty-contact-username:focus {
- outline: none;
-}
-
-.duty-contact-link:focus-visible,
-.duty-contact-phone:focus-visible,
-.duty-contact-username:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.duty-item--current {
- border-left-color: var(--today);
- background: color-mix(in srgb, var(--today) 12%, var(--surface));
-}
-
-/* === Loading / error / access denied */
-.loading {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 10px;
- padding: 12px;
- color: var(--muted);
- text-align: center;
-}
-
-.loading__spinner {
- display: block;
- width: 20px;
- height: 20px;
- border: 2px solid transparent;
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: loading-spin 0.8s linear infinite;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .loading__spinner {
- animation: none;
- border-top-color: var(--accent);
- border-right-color: color-mix(in srgb, var(--accent) 50%, transparent);
- }
-}
-
-@keyframes loading-spin {
- to {
- transform: rotate(360deg);
- }
-}
-
-.loading, .error {
- text-align: center;
- padding: 12px;
- color: var(--muted);
-}
-
-.error,
-.access-denied {
- transition: opacity 0.2s ease-out;
-}
-
-.error {
- color: var(--error);
-}
-
-.error[hidden], .loading.hidden,
-.current-duty-view.hidden {
- display: none !important;
-}
-
-/* Current duty view (Mini App deep link startapp=duty) */
-[data-view="currentDuty"] .calendar-sticky,
-[data-view="currentDuty"] .duty-list {
- display: none !important;
-}
-
-.current-duty-view {
- padding: 24px 16px;
- min-height: 60vh;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.current-duty-card {
- background: var(--surface);
- border-radius: 12px;
- padding: 24px;
- max-width: 360px;
- width: 100%;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
-}
-
-.current-duty-title {
- margin: 0 0 16px 0;
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--text);
-}
-
-.current-duty-name {
- margin: 0 0 8px 0;
- font-size: 1.5rem;
- font-weight: 600;
- color: var(--duty);
-}
-
-.current-duty-shift {
- margin: 0 0 12px 0;
- font-size: 0.95rem;
- color: var(--muted);
-}
-
-.current-duty-no-duty,
-.current-duty-error {
- margin: 0 0 16px 0;
- color: var(--muted);
-}
-
-.current-duty-error {
- color: var(--error);
-}
-
-.current-duty-contact-row {
- margin: 12px 0 20px 0;
-}
-
-.current-duty-contact {
- display: inline-block;
- margin-right: 12px;
- font-size: 0.95rem;
-}
-
-.current-duty-contact-link,
-.current-duty-contact-phone,
-.current-duty-contact-username {
- color: var(--accent);
- text-decoration: none;
-}
-
-.current-duty-contact-link:hover,
-.current-duty-contact-phone:hover,
-.current-duty-contact-username:hover {
- text-decoration: underline;
-}
-
-.current-duty-back-btn {
- display: block;
- width: 100%;
- padding: 12px 16px;
- margin-top: 8px;
- font-size: 1rem;
- font-weight: 500;
- color: var(--bg);
- background: var(--accent);
- border: none;
- border-radius: 8px;
- cursor: pointer;
-}
-
-.current-duty-back-btn:hover {
- opacity: 0.9;
-}
-
-.current-duty-back-btn:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
-}
-
-.current-duty-loading {
- text-align: center;
- color: var(--muted);
-}
-
-.access-denied {
- text-align: center;
- padding: 24px 12px;
- color: var(--muted);
-}
-
-.access-denied p {
- margin: 0 0 8px 0;
-}
-
-.access-denied p:first-child {
- color: var(--error);
- font-weight: 600;
-}
-
-.access-denied .access-denied-detail {
- margin-top: 8px;
- font-size: 0.9rem;
- color: var(--muted);
-}
-
-.access-denied[hidden] {
- display: none !important;
-}