diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index c4c209b..f2ef0f2 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -1,93 +1,53 @@ --- -description: Rules for working with the Telegram Mini App frontend (webapp/) +description: Rules for working with the Telegram Mini App frontend (webapp-next/) globs: - - webapp/** + - webapp-next/** --- -# Frontend — Telegram Mini App +# Frontend — Telegram Mini App (Next.js) -## Module structure +The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`. -All source lives in `webapp/js/`. Each module has a single responsibility: +## Stack -| Module | Responsibility | -|--------|---------------| -| `main.js` | Entry point: theme init, auth gate, `loadMonth`, navigation, swipe gestures, sticky scroll | -| `dom.js` | Lazy DOM getters (`getCalendarEl()`, `getDutyListEl()`, etc.) and shared mutable `state` | -| `i18n.js` | `MESSAGES` dictionary (en/ru), `getLang()`, `t()`, `monthName()`, `weekdayLabels()` | -| `auth.js` | Telegram `initData` extraction (`getInitData`), `isLocalhost`, hash/query fallback | -| `theme.js` | Theme detection (`getTheme`), CSS variable injection (`applyThemeParamsToCss`), `initTheme` | -| `api.js` | `apiGet`, `fetchDuties`, `fetchCalendarEvents`; timeout, auth header, `Accept-Language` | -| `calendar.js` | `dutiesByDate`, `calendarEventsByDate`, `renderCalendar` (6-week grid with indicators) | -| `dutyList.js` | `dutyTimelineCardHtml`, `dutyItemHtml`, `renderDutyList` (monthly duty timeline cards) | -| `dayDetail.js` | Day detail panel — popover (desktop) or bottom sheet (mobile), `buildDayDetailContent` | -| `hints.js` | Tooltip positioning, duty marker hint content, info-button tooltips | -| `dateUtils.js` | Date helpers: `localDateString`, `dutyOverlapsLocalDay/Range`, `getMonday`, `formatHHMM`, etc. | -| `utils.js` | `escapeHtml` utility | -| `constants.js` | `FETCH_TIMEOUT_MS`, `RETRY_DELAY_MS`, `RETRY_AFTER_ACCESS_DENIED_MS` | +- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`) +- **TypeScript** +- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.) +- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge +- **Zustand** — app store (month, lang, duties, calendar events, loading, view state) +- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton -## State management +## Structure -A single mutable `state` object is exported from `dom.js`: +| Area | Location | +|------|----------| +| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` | +| Providers | `src/components/providers/TelegramProvider.tsx` | +| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators | +| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem | +| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent | +| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` | +| Contact links | `src/components/contact/ContactLinks.tsx` | +| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied | +| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh | +| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils | +| i18n | `src/i18n/` — messages.ts, use-translation.ts | +| Store | `src/store/app-store.ts` | +| Types | `src/types/index.ts` | -```js -export const state = { - current: new Date(), // currently displayed month - lastDutiesForList: [], // duties array for the duty list - todayRefreshInterval: null, // interval handle - lang: "en" // 'ru' | 'en' -}; -``` +## Conventions -- **No store / pub-sub / reactivity.** `main.js` mutates `state`, then calls - render functions (`renderCalendar`, `renderDutyList`) imperatively. -- Other modules read `state` but should not mutate it directly. +- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client). +- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on ``. +- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost. +- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend). +- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED. -## HTML rendering +## Testing -- Rendering functions build HTML strings (concatenation + `escapeHtml`) or use - `createElement` + `setAttribute`. -- Always escape user-controlled text with `escapeHtml()` before inserting via `innerHTML`. -- `setAttribute()` handles attribute quoting automatically — do not manually escape - quotes for data attributes. +- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library. +- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`. +- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`. +- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states. -## i18n - -```js -t(lang, key, params?) // → translated string -``` - -- `lang` is `'ru'` or `'en'`, stored in `state.lang`. -- `getLang()` reads **backend config** only: `window.__DT_LANG` (set by `/app/config.js` from `DEFAULT_LANGUAGE`). If missing or invalid, falls back to `"en"`. No Telegram or navigator language detection. -- Params use named placeholders: `t(lang, "duty.until", { time: "14:00" })`. -- Fallback chain: `MESSAGES[lang][key]` → `MESSAGES.en[key]` → raw key string. -- All user-visible text must go through `t()` — never hardcode Russian strings in JS. - -## Telegram WebApp SDK - -- Loaded via ` - - - - - - - diff --git a/webapp/js/api.js b/webapp/js/api.js deleted file mode 100644 index 9b1156f..0000000 --- a/webapp/js/api.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Network requests: duties and calendar events. - */ - -import { FETCH_TIMEOUT_MS } from "./constants.js"; -import { getInitData } from "./auth.js"; -import { state } from "./dom.js"; -import { t } from "./i18n.js"; -import { logger } from "./logger.js"; - -/** - * Build fetch options with init data header, Accept-Language and timeout abort. - * Optional external signal (e.g. from loadMonth) aborts this request when triggered. - * @param {string} initData - Telegram init data - * @param {AbortSignal} [externalSignal] - when aborted, cancels this request - * @returns {{ headers: object, signal: AbortSignal, cleanup: () => void }} - */ -export function buildFetchOptions(initData, externalSignal) { - const headers = {}; - if (initData) headers["X-Telegram-Init-Data"] = initData; - headers["Accept-Language"] = state.lang || "en"; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - const onAbort = () => controller.abort(); - const cleanup = () => { - clearTimeout(timeoutId); - if (externalSignal) externalSignal.removeEventListener("abort", onAbort); - }; - if (externalSignal) { - if (externalSignal.aborted) { - cleanup(); - controller.abort(); - } else { - externalSignal.addEventListener("abort", onAbort); - } - } - return { headers, signal: controller.signal, cleanup }; -} - -/** - * GET request to API with init data, Accept-Language, timeout. - * Caller checks res.ok, res.status, res.json(). - * @param {string} path - e.g. "/api/duties" - * @param {{ from?: string, to?: string }} params - query params - * @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation - * @returns {Promise} - raw response - */ -export async function apiGet(path, params = {}, options = {}) { - const base = window.location.origin; - const query = new URLSearchParams(params).toString(); - const url = query ? `${base}${path}?${query}` : `${base}${path}`; - const initData = getInitData(); - logger.debug("API request", path, params); - const opts = buildFetchOptions(initData, options.signal); - try { - const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); - if (!res.ok) { - logger.warn("API non-OK response", path, res.status); - } - return res; - } catch (e) { - logger.error("API request failed", path, e); - throw e; - } finally { - opts.cleanup(); - } -} - -/** - * Fetch duties for date range. Throws ACCESS_DENIED error on 403. - * AbortError is rethrown when the request is cancelled (e.g. stale loadMonth). - * @param {string} from - YYYY-MM-DD - * @param {string} to - YYYY-MM-DD - * @param {AbortSignal} [signal] - optional signal to cancel the request - * @returns {Promise} - */ -export async function fetchDuties(from, to, signal) { - const res = await apiGet("/api/duties", { from, to }, { signal }); - if (res.status === 403) { - logger.warn("Access denied", from, to); - 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 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(); -} - -/** - * Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403. - * Rethrows AbortError when the request is cancelled (e.g. stale loadMonth). - * @param {string} from - YYYY-MM-DD - * @param {string} to - YYYY-MM-DD - * @param {AbortSignal} [signal] - optional signal to cancel the request - * @returns {Promise} - */ -export async function fetchCalendarEvents(from, to, signal) { - try { - const res = await apiGet("/api/calendar-events", { from, to }, { signal }); - if (!res.ok) return []; - return res.json(); - } catch (e) { - if (e.name === "AbortError") throw e; - return []; - } -} diff --git a/webapp/js/api.test.js b/webapp/js/api.test.js deleted file mode 100644 index 618429f..0000000 --- a/webapp/js/api.test.js +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Unit tests for api: buildFetchOptions, fetchDuties (403 handling, AbortError). - */ - -import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest"; - -beforeAll(() => { - document.body.innerHTML = - '

' + - '
' + - '
' + - ''; -}); - -const mockGetInitData = vi.fn(); -vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() })); - -import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js"; -import { state } from "./dom.js"; - -describe("buildFetchOptions", () => { - beforeEach(() => { - state.lang = "ru"; - }); - - it("sets X-Telegram-Init-Data when initData provided", () => { - const opts = buildFetchOptions("init-data-string"); - expect(opts.headers["X-Telegram-Init-Data"]).toBe("init-data-string"); - opts.cleanup(); - }); - - it("omits X-Telegram-Init-Data when initData empty", () => { - const opts = buildFetchOptions(""); - expect(opts.headers["X-Telegram-Init-Data"]).toBeUndefined(); - opts.cleanup(); - }); - - it("sets Accept-Language from state.lang", () => { - state.lang = "en"; - const opts = buildFetchOptions(""); - expect(opts.headers["Accept-Language"]).toBe("en"); - opts.cleanup(); - }); - - it("returns signal and cleanup function", () => { - const opts = buildFetchOptions(""); - expect(opts.signal).toBeDefined(); - expect(typeof opts.cleanup).toBe("function"); - opts.cleanup(); - }); - - it("cleanup clears timeout and removes external abort listener", () => { - const controller = new AbortController(); - const opts = buildFetchOptions("", controller.signal); - opts.cleanup(); - controller.abort(); - }); -}); - -describe("fetchDuties", () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - mockGetInitData.mockReturnValue("test-init-data"); - state.lang = "ru"; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("returns JSON on 200", async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve([{ id: 1 }]), - }); - const result = await fetchDuties("2025-02-01", "2025-02-28"); - expect(result).toEqual([{ id: 1 }]); - }); - - it("throws ACCESS_DENIED on 403 with server detail from body", async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 403, - json: () => Promise.resolve({ detail: "Custom access denied" }), - }); - await expect(fetchDuties("2025-02-01", "2025-02-28")).rejects.toMatchObject({ - message: "ACCESS_DENIED", - serverDetail: "Custom access denied", - }); - }); - - it("rethrows AbortError when request is aborted", async () => { - const aborter = new AbortController(); - const abortError = new DOMException("aborted", "AbortError"); - globalThis.fetch = vi.fn().mockImplementation(() => { - return Promise.reject(abortError); - }); - await expect( - fetchDuties("2025-02-01", "2025-02-28", aborter.signal) - ).rejects.toMatchObject({ name: "AbortError" }); - }); -}); - -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" }); - }); -}); diff --git a/webapp/js/auth.js b/webapp/js/auth.js deleted file mode 100644 index 595095f..0000000 --- a/webapp/js/auth.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Telegram init data and access checks. - */ - -import { logger } from "./logger.js"; - -/** - * Get tgWebAppData value from hash when it contains unencoded & and =. - * Value runs from tgWebAppData= until next &tgWebApp or end. - * @param {string} hash - location.hash without # - * @returns {string} - */ -export function getTgWebAppDataFromHash(hash) { - const idx = hash.indexOf("tgWebAppData="); - if (idx === -1) return ""; - const start = idx + "tgWebAppData=".length; - let end = hash.indexOf("&tgWebApp", start); - if (end === -1) end = hash.length; - const raw = hash.substring(start, end); - try { - return decodeURIComponent(raw); - } catch (e) { - return raw; - } -} - -/** - * Get Telegram init data string (from SDK, hash or query). - * @returns {string} - */ -export function getInitData() { - const fromSdk = - (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; - if (fromSdk) return fromSdk; - const hash = window.location.hash ? window.location.hash.slice(1) : ""; - if (hash) { - const fromHash = getTgWebAppDataFromHash(hash); - if (fromHash) { - logger.debug("initData from hash"); - return fromHash; - } - const hashParams = new URLSearchParams(hash); - const tgFromHash = hashParams.get("tgWebAppData"); - if (tgFromHash) return tgFromHash; - } - const q = window.location.search - ? new URLSearchParams(window.location.search).get("tgWebAppData") - : null; - if (q) { - logger.debug("initData from query"); - return q; - } - return ""; -} - -/** - * @returns {boolean} True if host is localhost or 127.0.0.1 - */ -export function isLocalhost() { - const h = window.location.hostname; - return h === "localhost" || h === "127.0.0.1" || h === ""; -} - -/** - * True when hash has tg WebApp params but no init data (e.g. version without data). - * @returns {boolean} - */ -export function hasTelegramHashButNoInitData() { - const hash = window.location.hash ? window.location.hash.slice(1) : ""; - if (!hash) return false; - try { - const keys = Array.from(new URLSearchParams(hash).keys()); - const hasVersion = keys.indexOf("tgWebAppVersion") !== -1; - const hasData = keys.indexOf("tgWebAppData") !== -1 || !!getTgWebAppDataFromHash(hash); - return hasVersion && !hasData; - } catch (e) { - return false; - } -} diff --git a/webapp/js/auth.test.js b/webapp/js/auth.test.js deleted file mode 100644 index 364fd31..0000000 --- a/webapp/js/auth.test.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Unit tests for auth: getTgWebAppDataFromHash, getInitData, isLocalhost. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - getTgWebAppDataFromHash, - getInitData, - isLocalhost, - hasTelegramHashButNoInitData, -} from "./auth.js"; - -describe("getTgWebAppDataFromHash", () => { - it("returns empty string when tgWebAppData= not present", () => { - expect(getTgWebAppDataFromHash("foo=bar")).toBe(""); - }); - - it("returns value from tgWebAppData= to next &tgWebApp or end", () => { - expect(getTgWebAppDataFromHash("tgWebAppData=encoded%3Ddata")).toBe( - "encoded=data" - ); - }); - - it("stops at &tgWebApp", () => { - const hash = "tgWebAppData=value&tgWebAppVersion=6"; - expect(getTgWebAppDataFromHash(hash)).toBe("value"); - }); - - it("decodes URI component", () => { - expect(getTgWebAppDataFromHash("tgWebAppData=hello%20world")).toBe( - "hello world" - ); - }); -}); - -describe("getInitData", () => { - const origLocation = window.location; - const origTelegram = window.Telegram; - - afterEach(() => { - window.location = origLocation; - window.Telegram = origTelegram; - }); - - it("returns initData from Telegram.WebApp when set", () => { - window.Telegram = { WebApp: { initData: "sdk-init-data" } }; - delete window.location; - window.location = { ...origLocation, hash: "", search: "" }; - expect(getInitData()).toBe("sdk-init-data"); - }); - - it("returns data from hash tgWebAppData when SDK empty", () => { - window.Telegram = { WebApp: { initData: "" } }; - delete window.location; - window.location = { - ...origLocation, - hash: "#tgWebAppData=hash%20data", - search: "", - }; - expect(getInitData()).toBe("hash data"); - }); - - it("returns empty string when no source", () => { - window.Telegram = { WebApp: { initData: "" } }; - delete window.location; - window.location = { ...origLocation, hash: "", search: "" }; - expect(getInitData()).toBe(""); - }); - - it("returns data from hash query param without double-decoding", () => { - window.Telegram = { WebApp: { initData: "" } }; - delete window.location; - window.location = { - ...origLocation, - hash: "#tgWebAppVersion=6&tgWebAppData=user%3Dname%26hash%3Dabc", - search: "", - }; - expect(getInitData()).toBe("user=name&hash=abc"); - }); - - it("returns data from search query param without double-decoding", () => { - window.Telegram = { WebApp: { initData: "" } }; - delete window.location; - window.location = { - ...origLocation, - hash: "", - search: "?tgWebAppData=user%3Dname%26hash%3Dabc", - }; - expect(getInitData()).toBe("user=name&hash=abc"); - }); -}); - -describe("isLocalhost", () => { - const origLocation = window.location; - - afterEach(() => { - window.location = origLocation; - }); - - it("returns true for localhost", () => { - delete window.location; - window.location = { ...origLocation, hostname: "localhost" }; - expect(isLocalhost()).toBe(true); - }); - - it("returns true for 127.0.0.1", () => { - delete window.location; - window.location = { ...origLocation, hostname: "127.0.0.1" }; - expect(isLocalhost()).toBe(true); - }); - - it("returns true for empty hostname", () => { - delete window.location; - window.location = { ...origLocation, hostname: "" }; - expect(isLocalhost()).toBe(true); - }); - - it("returns false for other hostnames", () => { - delete window.location; - window.location = { ...origLocation, hostname: "example.com" }; - expect(isLocalhost()).toBe(false); - }); -}); - -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); - }); -}); diff --git a/webapp/js/calendar.js b/webapp/js/calendar.js deleted file mode 100644 index e220644..0000000 --- a/webapp/js/calendar.js +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Calendar grid and events-by-date mapping. - */ - -import { getCalendarEl, getMonthTitleEl, state } from "./dom.js"; -import { monthName, t } from "./i18n.js"; -import { escapeHtml } from "./utils.js"; -import { - localDateString, - firstDayOfMonth, - lastDayOfMonth, - getMonday, - dateKeyToDDMM -} from "./dateUtils.js"; - -/** - * Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local. - * @param {object[]} events - Calendar events with date, summary - * @returns {Record} - */ -export function calendarEventsByDate(events) { - const byDate = {}; - (events || []).forEach((e) => { - const utcMidnight = new Date(e.date + "T00:00:00Z"); - const key = localDateString(utcMidnight); - if (!byDate[key]) byDate[key] = []; - if (e.summary) byDate[key].push(e.summary); - }); - return byDate; -} - -/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */ -const MAX_DAYS_PER_DUTY = 366; - -/** - * Group duties by local date (start_at/end_at are UTC). - * @param {object[]} duties - Duties with start_at, end_at - * @returns {Record} - */ -export function dutiesByDate(duties) { - const byDate = {}; - duties.forEach((d) => { - const start = new Date(d.start_at); - const end = new Date(d.end_at); - if (end < start) return; - const endLocal = localDateString(end); - let cursor = new Date(start); - let iterations = 0; - while (iterations <= MAX_DAYS_PER_DUTY) { - const key = localDateString(cursor); - if (!byDate[key]) byDate[key] = []; - byDate[key].push(d); - if (key === endLocal) break; - cursor.setDate(cursor.getDate() + 1); - iterations++; - } - }); - return byDate; -} - -/** - * Render calendar grid for given month with duties and calendar events. - * @param {number} year - * @param {number} month - 0-based - * @param {Record} dutiesByDateMap - * @param {Record} calendarEventsByDateMap - */ -export function renderCalendar( - year, - month, - 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)); - const start = getMonday(first); - const today = localDateString(new Date()); - const calendarEventsByDate = calendarEventsByDateMap || {}; - - calendarEl.innerHTML = ""; - let d = new Date(start); - const cells = 42; - for (let i = 0; i < cells; i++) { - const key = localDateString(d); - const isOther = d.getMonth() !== month; - const dayDuties = dutiesByDateMap[key] || []; - const dutyList = dayDuties.filter((x) => x.event_type === "duty"); - const unavailableList = dayDuties.filter((x) => x.event_type === "unavailable"); - const vacationList = dayDuties.filter((x) => x.event_type === "vacation"); - const hasAny = dayDuties.length > 0; - const isToday = key === today; - const eventSummaries = calendarEventsByDate[key] || []; - const hasEvent = eventSummaries.length > 0; - - const showIndicator = !isOther; - const cell = document.createElement("div"); - cell.className = - "day" + - (isOther ? " other-month" : "") + - (isToday ? " today" : "") + - (showIndicator && hasAny ? " has-duty" : "") + - (showIndicator && hasEvent ? " holiday" : ""); - - cell.setAttribute("data-date", key); - if (showIndicator) { - const dayPayload = dayDuties.map((x) => ({ - event_type: x.event_type, - full_name: x.full_name, - start_at: x.start_at, - end_at: x.end_at - })); - cell.setAttribute("data-day-duties", JSON.stringify(dayPayload)); - cell.setAttribute("data-day-events", JSON.stringify(eventSummaries)); - } - - const ariaParts = []; - ariaParts.push(dateKeyToDDMM(key)); - if (hasAny || hasEvent) { - const counts = []; - const lang = state.lang; - if (dutyList.length) counts.push(dutyList.length + " " + t(lang, "event_type.duty")); - if (unavailableList.length) counts.push(unavailableList.length + " " + t(lang, "event_type.unavailable")); - if (vacationList.length) counts.push(vacationList.length + " " + t(lang, "event_type.vacation")); - if (hasEvent) counts.push(t(lang, "hint.events")); - ariaParts.push(counts.join(", ")); - } else { - ariaParts.push(t(state.lang, "aria.day_info")); - } - cell.setAttribute("role", "button"); - cell.setAttribute("tabindex", "0"); - cell.setAttribute("aria-label", ariaParts.join("; ")); - - let indicatorHtml = ""; - if (showIndicator && (hasAny || hasEvent)) { - indicatorHtml = '
'; - if (dutyList.length) indicatorHtml += ''; - if (unavailableList.length) indicatorHtml += ''; - if (vacationList.length) indicatorHtml += ''; - if (hasEvent) indicatorHtml += ''; - indicatorHtml += "
"; - } - - cell.innerHTML = - '' + d.getDate() + "" + indicatorHtml; - calendarEl.appendChild(cell); - d.setDate(d.getDate() + 1); - } - - monthTitleEl.textContent = monthName(state.lang, month) + " " + year; -} diff --git a/webapp/js/calendar.test.js b/webapp/js/calendar.test.js deleted file mode 100644 index a1a99bf..0000000 --- a/webapp/js/calendar.test.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Unit tests for calendar: dutiesByDate (including edge case end_at < start_at), - * calendarEventsByDate. - */ - -import { describe, it, expect, beforeAll, beforeEach } from "vitest"; - -beforeAll(() => { - document.body.innerHTML = - '

' + - '
' + - '
' + - ''; -}); - -import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js"; -import { state } from "./dom.js"; - -describe("dutiesByDate", () => { - it("groups duty by single local day", () => { - const duties = [ - { - full_name: "Alice", - start_at: "2025-02-25T09:00:00Z", - end_at: "2025-02-25T18:00:00Z", - }, - ]; - const byDate = dutiesByDate(duties); - expect(byDate["2025-02-25"]).toHaveLength(1); - expect(byDate["2025-02-25"][0].full_name).toBe("Alice"); - }); - - it("spans duty across multiple days", () => { - const duties = [ - { - full_name: "Bob", - start_at: "2025-02-25T00:00:00Z", - end_at: "2025-02-27T23:59:59Z", - }, - ]; - const byDate = dutiesByDate(duties); - const keys = Object.keys(byDate).sort(); - expect(keys.length).toBeGreaterThanOrEqual(2); - keys.forEach((k) => expect(byDate[k]).toHaveLength(1)); - expect(byDate[keys[0]][0].full_name).toBe("Bob"); - }); - - it("skips duty when end_at < start_at (no infinite loop)", () => { - const duties = [ - { - full_name: "Bad", - start_at: "2025-02-28T12:00:00Z", - end_at: "2025-02-25T08:00:00Z", - }, - ]; - const byDate = dutiesByDate(duties); - expect(Object.keys(byDate)).toHaveLength(0); - }); - - it("does not iterate more than MAX_DAYS_PER_DUTY", () => { - const start = "2025-01-01T00:00:00Z"; - const end = "2026-06-01T00:00:00Z"; - const duties = [{ full_name: "Long", start_at: start, end_at: end }]; - const byDate = dutiesByDate(duties); - const keys = Object.keys(byDate).sort(); - expect(keys.length).toBeLessThanOrEqual(367); - }); - - it("handles empty duties", () => { - expect(dutiesByDate([])).toEqual({}); - }); -}); - -describe("calendarEventsByDate", () => { - it("maps events to local date key by UTC date", () => { - const events = [ - { date: "2025-02-25", summary: "Holiday" }, - { date: "2025-02-25", summary: "Meeting" }, - { date: "2025-02-26", summary: "Other" }, - ]; - const byDate = calendarEventsByDate(events); - expect(byDate["2025-02-25"]).toEqual(["Holiday", "Meeting"]); - expect(byDate["2025-02-26"]).toEqual(["Other"]); - }); - - it("skips events without summary", () => { - const events = [{ date: "2025-02-25", summary: null }]; - const byDate = calendarEventsByDate(events); - expect(byDate["2025-02-25"] || []).toHaveLength(0); - }); - - it("handles null or undefined events", () => { - expect(calendarEventsByDate(null)).toEqual({}); - expect(calendarEventsByDate(undefined)).toEqual({}); - }); -}); - -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"); - }); -}); diff --git a/webapp/js/contactHtml.js b/webapp/js/contactHtml.js deleted file mode 100644 index 9534417..0000000 --- a/webapp/js/contactHtml.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * 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"; - -/** Phone icon SVG for block layout (inline, same style as dutyList). */ -const ICON_PHONE = - ''; - -/** Telegram / send icon SVG for block layout. */ -const ICON_TELEGRAM = - ''; - -/** - * Format Russian phone number for display: 79146522209 -> +7 914 652-22-09. - * Accepts 10 digits (9XXXXXXXXX), 11 digits (79XXXXXXXXX or 89XXXXXXXXX). - * Other lengths are returned as-is (digits only). - * - * @param {string} phone - Raw phone string (digits, optional leading 7/8) - * @returns {string} Formatted display string, e.g. "+7 914 652-22-09" - */ -export function formatPhoneDisplay(phone) { - if (phone == null || String(phone).trim() === "") return ""; - const digits = String(phone).replace(/\D/g, ""); - if (digits.length === 10) { - return "+7 " + digits.slice(0, 3) + " " + digits.slice(3, 6) + "-" + digits.slice(6, 8) + "-" + digits.slice(8); - } - if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) { - const rest = digits.slice(1); - return "+7 " + rest.slice(0, 3) + " " + rest.slice(3, 6) + "-" + rest.slice(6, 8) + "-" + rest.slice(8); - } - return digits; -} - -/** - * 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 (ignored when layout is "block") - * @param {string} [options.separator=' '] - Separator between contact parts (e.g. " ", " · ") - * @param {'inline'|'block'} [options.layout='inline'] - "block" = full-width button blocks with SVG icons (for current duty card) - * @returns {string} HTML string or "" if no valid contact - */ -export function buildContactLinksHtml(lang, phone, username, options) { - const { classPrefix, showLabels = true, separator = " ", layout = "inline" } = options || {}; - const parts = []; - - if (phone && String(phone).trim()) { - const p = String(phone).trim(); - const safeHref = - "tel:" + - p.replace(/&/g, "&").replace(/"/g, """).replace(/' + - ICON_PHONE + - "" + - escapeHtml(displayPhone) + - "" - ); - } else { - const linkHtml = - '' + - escapeHtml(displayPhone) + - ""; - if (showLabels) { - const label = t(lang, "contact.phone"); - parts.push( - '' + - escapeHtml(label) + - ": " + - linkHtml + - "" - ); - } 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); - if (layout === "block") { - const blockClass = classPrefix + "-block " + classPrefix + "-block--telegram"; - parts.push( - '' + - ICON_TELEGRAM + - "" + - escapeHtml(display) + - "" - ); - } else { - const linkHtml = - '' + - escapeHtml(display) + - ""; - if (showLabels) { - const label = t(lang, "contact.telegram"); - parts.push( - '' + - escapeHtml(label) + - ": " + - linkHtml + - "" - ); - } else { - parts.push(linkHtml); - } - } - } - } - - if (parts.length === 0) return ""; - const rowClass = classPrefix + "-row" + (layout === "block" ? " " + classPrefix + "-row--blocks" : ""); - const inner = layout === "block" ? parts.join("") : parts.join(separator); - return '
' + inner + "
"; -} diff --git a/webapp/js/contactHtml.test.js b/webapp/js/contactHtml.test.js deleted file mode 100644 index ad0dfc8..0000000 --- a/webapp/js/contactHtml.test.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Unit tests for contactHtml (formatPhoneDisplay, buildContactLinksHtml). - */ - -import { describe, it, expect } from "vitest"; -import { formatPhoneDisplay, buildContactLinksHtml } from "./contactHtml.js"; - -describe("formatPhoneDisplay", () => { - it("formats 11-digit number starting with 7", () => { - expect(formatPhoneDisplay("79146522209")).toBe("+7 914 652-22-09"); - expect(formatPhoneDisplay("+79146522209")).toBe("+7 914 652-22-09"); - }); - - it("formats 11-digit number starting with 8", () => { - expect(formatPhoneDisplay("89146522209")).toBe("+7 914 652-22-09"); - }); - - it("formats 10-digit number as Russian", () => { - expect(formatPhoneDisplay("9146522209")).toBe("+7 914 652-22-09"); - }); - - it("returns empty string for null or empty", () => { - expect(formatPhoneDisplay(null)).toBe(""); - expect(formatPhoneDisplay("")).toBe(""); - expect(formatPhoneDisplay(" ")).toBe(""); - }); - - it("strips non-digits before formatting", () => { - expect(formatPhoneDisplay("+7 (914) 652-22-09")).toBe("+7 914 652-22-09"); - }); - - it("returns digits as-is for non-10/11 length", () => { - expect(formatPhoneDisplay("123")).toBe("123"); - expect(formatPhoneDisplay("12345678901")).toBe("12345678901"); - }); -}); - -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("displays phone formatted for Russian numbers", () => { - const html = buildContactLinksHtml("en", "79146522209", null, baseOptions); - expect(html).toContain("+7 914 652-22-09"); - expect(html).toContain('href="tel:79146522209"'); - }); - - 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("+7 900 111-22-33"); - 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 href; display uses formatted digits only", () => { - const html = buildContactLinksHtml("en", '+7 999 "1" <2>', null, baseOptions); - expect(html).toContain("""); - expect(html).toContain("<"); - expect(html).toContain("tel:"); - expect(html).toContain("799912"); - 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(" · "); - }); - - describe("layout: block", () => { - it("renders phone as block with icon and formatted number", () => { - const html = buildContactLinksHtml("en", "79146522209", null, { - classPrefix: "current-duty-contact", - layout: "block", - }); - expect(html).toContain("current-duty-contact-row--blocks"); - expect(html).toContain("current-duty-contact-block"); - expect(html).toContain("current-duty-contact-block--phone"); - expect(html).toContain("+7 914 652-22-09"); - expect(html).toContain("tel:"); - expect(html).toContain(" { - const html = buildContactLinksHtml("en", null, "alice_dev", { - classPrefix: "current-duty-contact", - layout: "block", - }); - expect(html).toContain("current-duty-contact-block--telegram"); - expect(html).toContain("https://t.me/"); - expect(html).toContain("@alice_dev"); - expect(html).toContain(" { - const html = buildContactLinksHtml("en", "+79001112233", "bob", { - classPrefix: "current-duty-contact", - layout: "block", - }); - expect(html).toContain("current-duty-contact-block--phone"); - expect(html).toContain("current-duty-contact-block--telegram"); - expect(html).toContain("+7 900 111-22-33"); - expect(html).toContain("@bob"); - }); - }); -}); diff --git a/webapp/js/currentDuty.js b/webapp/js/currentDuty.js deleted file mode 100644 index 1ae1bca..0000000 --- a/webapp/js/currentDuty.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Current duty view: full-screen card when opened via Mini App deep link (startapp=duty). - * Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts. - */ - -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, - dateKeyToDDMM, - formatHHMM -} from "./dateUtils.js"; - -/** Empty calendar icon for "no duty" state (outline, stroke). */ -const ICON_NO_DUTY = - ''; - -/** @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; - -/** - * Compute remaining time until end of shift. Call only when now < end (active duty). - * @param {string|Date} endAt - ISO end time of the shift - * @returns {{ hours: number, minutes: number }} - */ -export function getRemainingTime(endAt) { - const end = new Date(endAt).getTime(); - const now = Date.now(); - const ms = Math.max(0, end - now); - const hours = Math.floor(ms / (1000 * 60 * 60)); - const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); - return { hours, minutes }; -} - -/** - * 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 - * @returns {object|null} - */ -export function findCurrentDuty(duties) { - const now = Date.now(); - const dutyType = (duties || []).filter((d) => d.event_type === "duty"); - const candidates = dutyType.length ? dutyType : duties || []; - for (const d of candidates) { - const start = new Date(d.start_at).getTime(); - const end = new Date(d.end_at).getTime(); - if (start <= now && now < end) return d; - } - return null; -} - -/** - * Render the current duty view content (card with duty or no-duty message). - * @param {object|null} duty - Active duty or null - * @param {string} lang - * @returns {string} - */ -export function renderCurrentDutyContent(duty, lang) { - const backLabel = t(lang, "current_duty.back"); - const title = t(lang, "current_duty.title"); - - if (!duty) { - const noDuty = t(lang, "current_duty.no_duty"); - return ( - '
' + - '

' + - escapeHtml(title) + - "

" + - '
' + - '' + - ICON_NO_DUTY + - "" + - '

' + - escapeHtml(noDuty) + - "

" + - "
" + - '" + - "
" - ); - } - - const startLocal = localDateString(new Date(duty.start_at)); - const endLocal = localDateString(new Date(duty.end_at)); - const startDDMM = dateKeyToDDMM(startLocal); - const endDDMM = dateKeyToDDMM(endLocal); - const startTime = formatHHMM(duty.start_at); - const endTime = formatHHMM(duty.end_at); - const shiftStr = - startDDMM + - " " + - startTime + - " — " + - endDDMM + - " " + - endTime; - const shiftLabel = t(lang, "current_duty.shift"); - const { hours: remHours, minutes: remMinutes } = getRemainingTime(duty.end_at); - const remainingStr = t(lang, "current_duty.remaining", { - hours: String(remHours), - minutes: String(remMinutes) - }); - const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, { - classPrefix: "current-duty-contact", - showLabels: true, - separator: " ", - layout: "block" - }); - - return ( - '
' + - '

' + - ' ' + - escapeHtml(title) + - "

" + - '

' + - escapeHtml(duty.full_name) + - "

" + - '
' + - escapeHtml(shiftLabel) + - ": " + - escapeHtml(shiftStr) + - "
" + - '
' + - escapeHtml(remainingStr) + - "
" + - contactHtml + - '" + - "
" - ); -} - -/** - * Show the current duty view: fetch today's duties, render card or no-duty, show back button. - * Hides calendar/duty list and shows #currentDutyView. Optionally shows Telegram BackButton. - * @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; - - 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; - currentDutyViewEl.innerHTML = - '
' + - escapeHtml(t(lang, "loading")) + - "
"; - - if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) { - window.Telegram.WebApp.BackButton.show(); - const handler = () => { - if (onBackCallback) onBackCallback(); - }; - backButtonHandler = handler; - window.Telegram.WebApp.BackButton.onClick(handler); - } - - const today = new Date(); - const from = localDateString(today); - const to = from; - try { - const duties = await fetchDuties(from, to); - const duty = findCurrentDuty(duties); - currentDutyViewEl.innerHTML = renderCurrentDutyContent(duty, lang); - } catch (e) { - currentDutyViewEl.innerHTML = - '
' + - '

' + - escapeHtml(e.message || t(lang, "error_generic")) + - "

" + - '" + - "
"; - } - - currentDutyViewEl.addEventListener("click", handleCurrentDutyClick); -} - -/** - * Delegate click for back button. - * @param {MouseEvent} e - */ -function handleCurrentDutyClick(e) { - const btn = e.target && e.target.closest("[data-action='back']"); - if (!btn) return; - if (onBackCallback) onBackCallback(); -} - -/** - * Hide the current duty view and show calendar/duty list again. - * 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"); - - if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) { - if (backButtonHandler) { - window.Telegram.WebApp.BackButton.offClick(backButtonHandler); - } - window.Telegram.WebApp.BackButton.hide(); - } - if (currentDutyViewEl) { - currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick); - 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/currentDuty.test.js b/webapp/js/currentDuty.test.js deleted file mode 100644 index 42425ba..0000000 --- a/webapp/js/currentDuty.test.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Unit tests for currentDuty (findCurrentDuty, renderCurrentDutyContent, showCurrentDutyView). - */ - -import { describe, it, expect, beforeAll, vi } from "vitest"; - -vi.mock("./api.js", () => ({ - fetchDuties: vi.fn().mockResolvedValue([]) -})); - -import { - findCurrentDuty, - getRemainingTime, - renderCurrentDutyContent -} from "./currentDuty.js"; - -describe("currentDuty", () => { - beforeAll(() => { - document.body.innerHTML = - '
' + - '
' + - '
' + - '
' + - '' + - "
"; - }); - - describe("getRemainingTime", () => { - it("returns hours and minutes until end from now", () => { - const endAt = "2025-03-02T17:30:00.000Z"; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z")); - const { hours, minutes } = getRemainingTime(endAt); - vi.useRealTimers(); - expect(hours).toBe(5); - expect(minutes).toBe(30); - }); - - it("returns 0 when end is in the past", () => { - const endAt = "2025-03-02T09:00:00.000Z"; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z")); - const { hours, minutes } = getRemainingTime(endAt); - vi.useRealTimers(); - expect(hours).toBe(0); - expect(minutes).toBe(0); - }); - }); - - describe("findCurrentDuty", () => { - it("returns duty when now is between start_at and end_at", () => { - const now = new Date(); - const start = new Date(now); - start.setHours(start.getHours() - 1, 0, 0, 0); - const end = new Date(now); - end.setHours(end.getHours() + 1, 0, 0, 0); - const duties = [ - { - event_type: "duty", - full_name: "Иванов", - start_at: start.toISOString(), - end_at: end.toISOString() - } - ]; - const duty = findCurrentDuty(duties); - expect(duty).not.toBeNull(); - expect(duty.full_name).toBe("Иванов"); - }); - - it("returns null when no duty overlaps current time", () => { - const duties = [ - { - event_type: "duty", - full_name: "Past", - start_at: "2020-01-01T09:00:00Z", - end_at: "2020-01-01T17:00:00Z" - }, - { - event_type: "duty", - full_name: "Future", - start_at: "2030-01-01T09:00:00Z", - end_at: "2030-01-01T17:00:00Z" - } - ]; - expect(findCurrentDuty(duties)).toBeNull(); - }); - }); - - describe("renderCurrentDutyContent", () => { - it("renders no-duty message and back button when duty is null", () => { - const html = renderCurrentDutyContent(null, "en"); - expect(html).toContain("current-duty-card"); - expect(html).toContain("current-duty-card--no-duty"); - expect(html).toContain("Current Duty"); - expect(html).toContain("current-duty-no-duty-wrap"); - expect(html).toContain("current-duty-no-duty-icon"); - expect(html).toContain("current-duty-no-duty"); - expect(html).toContain("No one is on duty right now"); - expect(html).toContain("Back to calendar"); - expect(html).toContain('data-action="back"'); - expect(html).not.toContain("current-duty-live-dot"); - }); - - it("renders duty card with name, shift, remaining time, and back button when duty has no contacts", () => { - const duty = { - event_type: "duty", - full_name: "Иванов Иван", - start_at: "2025-03-02T06:00:00.000Z", - end_at: "2025-03-03T06:00:00.000Z" - }; - const html = renderCurrentDutyContent(duty, "ru"); - expect(html).toContain("current-duty-live-dot"); - expect(html).toContain("Сейчас дежурит"); - expect(html).toContain("Иванов Иван"); - expect(html).toContain("Смена"); - expect(html).toContain("current-duty-remaining"); - expect(html).toMatch(/Осталось:\s*\d+ч\s*\d+мин/); - expect(html).toContain("Назад к календарю"); - expect(html).toContain('data-action="back"'); - }); - - it("renders duty card with phone and Telegram links when present", () => { - const duty = { - event_type: "duty", - full_name: "Alice", - start_at: "2025-03-02T09:00:00", - end_at: "2025-03-02T17:00:00", - phone: "+7 900 123-45-67", - username: "alice_dev" - }; - const html = renderCurrentDutyContent(duty, "en"); - expect(html).toContain("Alice"); - expect(html).toContain("current-duty-remaining"); - expect(html).toMatch(/Remaining:\s*\d+h\s*\d+min/); - expect(html).toContain("current-duty-contact-row"); - expect(html).toContain("current-duty-contact-row--blocks"); - expect(html).toContain("current-duty-contact-block"); - expect(html).toContain('href="tel:'); - expect(html).toContain("+7 900 123-45-67"); - expect(html).toContain("https://t.me/"); - expect(html).toContain("alice_dev"); - expect(html).toContain("Back to calendar"); - }); - }); - - describe("showCurrentDutyView", () => { - it("hides the global loading element when called", async () => { - vi.resetModules(); - const { showCurrentDutyView } = await import("./currentDuty.js"); - await showCurrentDutyView(() => {}); - const loading = document.getElementById("loading"); - expect(loading).not.toBeNull(); - expect(loading.classList.contains("hidden")).toBe(true); - }); - }); -}); diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js deleted file mode 100644 index d090add..0000000 --- a/webapp/js/dayDetail.js +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap. - */ - -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"; - -const BOTTOM_SHEET_BREAKPOINT_PX = 640; -const POPOVER_MARGIN = 12; - -/** @type {HTMLElement|null} */ -let panelEl = null; -/** @type {HTMLElement|null} */ -let overlayEl = null; -/** @type {((e: MouseEvent) => void)|null} */ -let popoverCloseHandler = null; -/** Scroll position saved when sheet opens (for restore on close). */ -let sheetScrollY = 0; - -/** - * Parse JSON from data attribute (values are stored with " for safety). - * @param {string} raw - * @returns {object[]|string[]} - */ -function parseDataAttr(raw) { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch (e) { - return []; - } -} - -/** - * Build HTML content for the day detail panel. - * @param {string} dateKey - YYYY-MM-DD - * @param {object[]} duties - Array of { event_type, full_name, start_at?, end_at? } - * @param {string[]} eventSummaries - Calendar event summary strings - * @returns {string} - */ -export function buildDayDetailContent(dateKey, duties, eventSummaries) { - const lang = state.lang; - const todayKey = localDateString(new Date()); - const ddmm = dateKeyToDDMM(dateKey); - const title = - dateKey === todayKey - ? t(lang, "duty.today") + ", " + ddmm - : ddmm; - - const dutyList = (duties || []) - .filter((d) => d.event_type === "duty") - .sort((a, b) => new Date(a.start_at || 0) - new Date(b.start_at || 0)); - const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable"); - const vacationList = (duties || []).filter((d) => d.event_type === "vacation"); - const summaries = eventSummaries || []; - - const fromLabel = t(lang, "hint.from"); - const toLabel = t(lang, "hint.to"); - const nbsp = "\u00a0"; - - let html = - '

' + escapeHtml(title) + "

"; - html += '
'; - - if (dutyList.length > 0) { - const hasTimes = dutyList.some( - (it) => (it.start_at || it.end_at) - ); - const rows = hasTimes - ? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel) - : dutyList.map((it) => ({ - timePrefix: "", - fullName: it.full_name || "", - phone: it.phone, - username: it.username - })); - - html += - '
' + - '

' + - escapeHtml(t(lang, "event_type.duty")) + - "

    '; - rows.forEach((r, i) => { - const duty = hasTimes ? dutyList[i] : null; - 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 = buildContactLinksHtml(lang, phone, username, { - classPrefix: "day-detail-contact", - showLabels: true, - separator: " " - }); - html += - "
  • " + - (timeHtml ? '' + timeHtml + "" : "") + - escapeHtml(r.fullName) + - (contactHtml ? contactHtml : "") + - "
  • "; - }); - html += "
"; - } - - if (unavailableList.length > 0) { - const uniqueUnavailable = [...new Set(unavailableList.map((d) => d.full_name || "").filter(Boolean))]; - html += - '
' + - '

' + - escapeHtml(t(lang, "event_type.unavailable")) + - "

    "; - uniqueUnavailable.forEach((name) => { - html += "
  • " + escapeHtml(name) + "
  • "; - }); - html += "
"; - } - - if (vacationList.length > 0) { - const uniqueVacation = [...new Set(vacationList.map((d) => d.full_name || "").filter(Boolean))]; - html += - '
' + - '

' + - escapeHtml(t(lang, "event_type.vacation")) + - "

    "; - uniqueVacation.forEach((name) => { - html += "
  • " + escapeHtml(name) + "
  • "; - }); - html += "
"; - } - - if (summaries.length > 0) { - html += - '
' + - '

' + - escapeHtml(t(lang, "hint.events")) + - "

    "; - summaries.forEach((s) => { - html += "
  • " + escapeHtml(String(s)) + "
  • "; - }); - html += "
"; - } - - html += "
"; - return html; -} - -/** - * Position panel as popover near cell rect; keep inside viewport. - * @param {HTMLElement} panel - * @param {DOMRect} cellRect - */ -function positionPopover(panel, cellRect) { - const vw = document.documentElement.clientWidth; - const vh = document.documentElement.clientHeight; - const margin = POPOVER_MARGIN; - panel.classList.remove("day-detail-panel--below"); - panel.style.left = ""; - panel.style.top = ""; - panel.style.right = ""; - panel.style.bottom = ""; - panel.hidden = false; - requestAnimationFrame(() => { - 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"); - } else { - panel.classList.remove("day-detail-panel--below"); - } - if (left < margin) left = margin; - if (left + panelRect.width > vw - margin) left = vw - panelRect.width - margin; - panel.style.left = left + "px"; - panel.style.top = top + "px"; - }); -} - -/** - * Show panel as bottom sheet (fixed at bottom). - * Renders panel closed (translateY(100%)), then on next frame adds open/visible classes for animation. - */ -function showAsBottomSheet() { - if (!panelEl || !overlayEl) return; - overlayEl.classList.remove("day-detail-overlay--visible"); - panelEl.classList.remove("day-detail-panel--open"); - overlayEl.hidden = false; - panelEl.classList.add("day-detail-panel--sheet"); - panelEl.style.left = ""; - panelEl.style.top = ""; - panelEl.style.right = ""; - panelEl.style.bottom = "0"; - panelEl.hidden = false; - - sheetScrollY = window.scrollY; - document.body.classList.add("day-detail-sheet-open"); - document.body.style.top = "-" + sheetScrollY + "px"; - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - overlayEl.classList.add("day-detail-overlay--visible"); - panelEl.classList.add("day-detail-panel--open"); - const closeBtn = panelEl.querySelector(".day-detail-close"); - if (closeBtn) closeBtn.focus(); - }); - }); -} - -/** - * Show panel as popover at cell position. - * @param {DOMRect} cellRect - */ -function showAsPopover(cellRect) { - if (!panelEl || !overlayEl) return; - overlayEl.hidden = true; - panelEl.classList.remove("day-detail-panel--sheet"); - positionPopover(panelEl, cellRect); - if (popoverCloseHandler) document.removeEventListener("click", popoverCloseHandler); - popoverCloseHandler = (e) => { - 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(); - }; - setTimeout(() => document.addEventListener("click", popoverCloseHandler), 0); -} - -/** Timeout fallback (ms) if transitionend does not fire (e.g. prefers-reduced-motion). */ -const SHEET_CLOSE_TIMEOUT_MS = 400; - -/** - * Hide panel and overlay. For bottom sheet: remove open classes, wait for transitionend (or timeout), then cleanup. - */ -export function hideDayDetail() { - if (popoverCloseHandler) { - document.removeEventListener("click", popoverCloseHandler); - popoverCloseHandler = null; - } - const isSheet = - panelEl && - overlayEl && - panelEl.classList.contains("day-detail-panel--sheet") && - document.body.classList.contains("day-detail-sheet-open"); - - if (isSheet) { - panelEl.classList.remove("day-detail-panel--open"); - overlayEl.classList.remove("day-detail-overlay--visible"); - - const finishClose = () => { - if (closeTimeoutId != null) clearTimeout(closeTimeoutId); - panelEl.removeEventListener("transitionend", onTransitionEnd); - document.body.classList.remove("day-detail-sheet-open"); - document.body.style.top = ""; - window.scrollTo(0, sheetScrollY); - panelEl.classList.remove("day-detail-panel--sheet"); - panelEl.hidden = true; - overlayEl.hidden = true; - }; - - let closeTimeoutId = setTimeout(finishClose, SHEET_CLOSE_TIMEOUT_MS); - - const onTransitionEnd = (e) => { - if (e.target !== panelEl || e.propertyName !== "transform") return; - finishClose(); - }; - panelEl.addEventListener("transitionend", onTransitionEnd); - return; - } - - if (document.body.classList.contains("day-detail-sheet-open")) { - document.body.classList.remove("day-detail-sheet-open"); - document.body.style.top = ""; - window.scrollTo(0, sheetScrollY); - } - if (panelEl) panelEl.hidden = true; - if (overlayEl) overlayEl.hidden = true; -} - -/** - * Open day detail for the given cell (reads data from data-* attributes). - * @param {HTMLElement} cell - .day element - */ -function openDayDetail(cell) { - const dateKey = cell.getAttribute("data-date"); - if (!dateKey) return; - - const dutiesRaw = cell.getAttribute("data-day-duties"); - const eventsRaw = cell.getAttribute("data-day-events"); - const duties = /** @type {object[]} */ (parseDataAttr(dutiesRaw || "")); - const eventSummaries = /** @type {string[]} */ (parseDataAttr(eventsRaw || "")); - - if (duties.length === 0 && eventSummaries.length === 0) return; - - ensurePanelInDom(); - if (!panelEl) return; - - const contentHtml = buildDayDetailContent(dateKey, duties, eventSummaries); - const body = panelEl.querySelector(".day-detail-body"); - if (body) body.innerHTML = contentHtml; - - const isNarrow = window.matchMedia("(max-width: " + BOTTOM_SHEET_BREAKPOINT_PX + "px)").matches; - if (isNarrow) { - showAsBottomSheet(); - } else { - const rect = cell.getBoundingClientRect(); - showAsPopover(rect); - } -} - -/** - * Ensure #dayDetailPanel and overlay exist in DOM. - */ -function ensurePanelInDom() { - if (panelEl) return; - overlayEl = document.getElementById("dayDetailOverlay"); - panelEl = document.getElementById("dayDetailPanel"); - if (!overlayEl) { - overlayEl = document.createElement("div"); - overlayEl.id = "dayDetailOverlay"; - overlayEl.className = "day-detail-overlay"; - overlayEl.setAttribute("aria-hidden", "true"); - overlayEl.hidden = true; - document.body.appendChild(overlayEl); - } - if (!panelEl) { - panelEl = document.createElement("div"); - panelEl.id = "dayDetailPanel"; - panelEl.className = "day-detail-panel"; - panelEl.setAttribute("role", "dialog"); - panelEl.setAttribute("aria-modal", "true"); - panelEl.setAttribute("aria-labelledby", "dayDetailTitle"); - panelEl.hidden = true; - - const closeLabel = t(state.lang, "day_detail.close"); - const closeIcon = - ''; - panelEl.innerHTML = - '
'; - - const closeBtn = panelEl.querySelector(".day-detail-close"); - if (closeBtn) { - closeBtn.addEventListener("click", hideDayDetail); - } - panelEl.addEventListener("keydown", (e) => { - if (e.key === "Escape") hideDayDetail(); - }); - overlayEl.addEventListener("click", hideDayDetail); - document.body.appendChild(panelEl); - } -} - -/** - * 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); - if (!cell) return; - e.preventDefault(); - openDayDetail(cell); - }); - calendarEl.addEventListener("keydown", (e) => { - if (e.key !== "Enter" && e.key !== " ") return; - const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null); - if (!cell) return; - e.preventDefault(); - openDayDetail(cell); - }); -} diff --git a/webapp/js/dayDetail.test.js b/webapp/js/dayDetail.test.js deleted file mode 100644 index 1278771..0000000 --- a/webapp/js/dayDetail.test.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Unit tests for buildDayDetailContent. - * Verifies dutyList is sorted by start_at before display. - */ - -import { describe, it, expect, beforeAll } from "vitest"; -import { buildDayDetailContent } from "./dayDetail.js"; - -describe("buildDayDetailContent", () => { - beforeAll(() => { - document.body.innerHTML = - '
' + - '
' + - '
' + - ''; - }); - - it("sorts duty list by start_at when input order is wrong", () => { - const dateKey = "2025-02-25"; - const duties = [ - { - event_type: "duty", - full_name: "Петров", - start_at: "2025-02-25T14:00:00", - end_at: "2025-02-25T18:00:00", - }, - { - event_type: "duty", - full_name: "Иванов", - start_at: "2025-02-25T09:00:00", - end_at: "2025-02-25T14:00:00", - }, - ]; - const html = buildDayDetailContent(dateKey, duties, []); - expect(html).toContain("Иванов"); - expect(html).toContain("Петров"); - const ivanovPos = html.indexOf("Иванов"); - const petrovPos = html.indexOf("Петров"); - expect(ivanovPos).toBeLessThan(petrovPos); - }); - - it("includes contact info (phone, username) for duty entries when present", () => { - const dateKey = "2025-03-01"; - const duties = [ - { - event_type: "duty", - full_name: "Alice", - start_at: "2025-03-01T09:00:00", - end_at: "2025-03-01T17:00:00", - phone: "+79991234567", - username: "alice_dev", - }, - ]; - const html = buildDayDetailContent(dateKey, duties, []); - expect(html).toContain("Alice"); - expect(html).toContain("day-detail-contact-row"); - expect(html).toContain('href="tel:'); - expect(html).toContain("+79991234567"); - expect(html).toContain("https://t.me/"); - expect(html).toContain("alice_dev"); - }); -}); diff --git a/webapp/js/dom.js b/webapp/js/dom.js deleted file mode 100644 index fc2d857..0000000 --- a/webapp/js/dom.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * DOM references and shared application state. - * Element refs are resolved lazily via getters so modules can be imported before DOM is ready. - */ - -/** @returns {HTMLDivElement|null} */ -export function getCalendarEl() { - return document.getElementById("calendar"); -} - -/** @returns {HTMLElement|null} */ -export function getMonthTitleEl() { - return document.getElementById("monthTitle"); -} - -/** @returns {HTMLDivElement|null} */ -export function getDutyListEl() { - return document.getElementById("dutyList"); -} - -/** @returns {HTMLElement|null} */ -export function getLoadingEl() { - return document.getElementById("loading"); -} - -/** @returns {HTMLElement|null} */ -export function getErrorEl() { - return document.getElementById("error"); -} - -/** @returns {HTMLElement|null} */ -export function getAccessDeniedEl() { - return document.getElementById("accessDenied"); -} - -/** @returns {HTMLElement|null} */ -export function getHeaderEl() { - return document.querySelector(".header"); -} - -/** @returns {HTMLElement|null} */ -export function getWeekdaysEl() { - return document.querySelector(".weekdays"); -} - -/** @returns {HTMLButtonElement|null} */ -export function getPrevBtn() { - return document.getElementById("prevMonth"); -} - -/** @returns {HTMLButtonElement|null} */ -export function getNextBtn() { - return document.getElementById("nextMonth"); -} - -/** @returns {HTMLDivElement|null} */ -export function getCurrentDutyViewEl() { - return document.getElementById("currentDutyView"); -} - -/** Currently viewed month (mutable). */ -export const state = { - /** @type {Date} */ - current: new Date(), - /** @type {object[]} */ - lastDutiesForList: [], - /** @type {ReturnType|null} */ - todayRefreshInterval: null, - /** @type {'ru'|'en'} */ - lang: "en", - /** One-time bind flag for sticky scroll shadow listener. */ - stickyScrollBound: false, - /** One-time bind flag for calendar (info button) hint document listeners. */ - calendarHintBound: false, - /** One-time bind flag for duty marker hint document listeners. */ - dutyMarkerHintBound: false, - /** Whether initData retry after ACCESS_DENIED has been attempted. */ - initDataRetried: false -}; diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js deleted file mode 100644 index bab6e71..0000000 --- a/webapp/js/dutyList.js +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Duty list (timeline) rendering. - */ - -import { getDutyListEl, state } from "./dom.js"; -import { t } from "./i18n.js"; -import { escapeHtml } from "./utils.js"; -import { buildContactLinksHtml } from "./contactHtml.js"; -import { - localDateString, - firstDayOfMonth, - lastDayOfMonth, - dateKeyToDDMM, - formatHHMM, - formatDateKey -} from "./dateUtils.js"; - -/** Phone icon SVG for flip button (show contacts). */ -const ICON_PHONE = - ''; - -/** Back/arrow icon SVG for flip button (back to card). */ -const ICON_BACK = - ''; - -/** - * Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day. - * When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts). - * Otherwise returns a plain card without flip wrapper. - * @param {object} d - Duty - * @param {boolean} isCurrent - Whether this is "current" duty - * @returns {string} - */ -export function dutyTimelineCardHtml(d, isCurrent) { - const startLocal = localDateString(new Date(d.start_at)); - const endLocal = localDateString(new Date(d.end_at)); - const startDDMM = dateKeyToDDMM(startLocal); - const endDDMM = dateKeyToDDMM(endLocal); - const startTime = formatHHMM(d.start_at); - const endTime = formatHHMM(d.end_at); - let timeStr; - if (startLocal === endLocal) { - timeStr = startDDMM + ", " + startTime + " – " + endTime; - } else { - timeStr = startDDMM + " " + startTime + " – " + endDDMM + " " + endTime; - } - const lang = state.lang; - const typeLabel = isCurrent - ? t(lang, "duty.now_on_duty") - : (t(lang, "event_type." + (d.event_type || "duty"))); - const extraClass = isCurrent ? " duty-item--current" : ""; - 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()) - ); - - if (!hasContacts) { - return ( - '
' + - escapeHtml(typeLabel) + - ' ' + - escapeHtml(d.full_name) + - '
' + - escapeHtml(timeStr) + - "
" - ); - } - - const showLabel = t(lang, "contact.show"); - const backLabel = t(lang, "contact.back"); - return ( - '
' + - '
' + - '
' + - '' + - escapeHtml(typeLabel) + - ' ' + - escapeHtml(d.full_name) + - '
' + - escapeHtml(timeStr) + - '
' + - '" + - "
" + - '
' + - '' + - escapeHtml(d.full_name) + - "" + - contactHtml + - '" + - "
" + - "
" - ); -} - -/** - * Build HTML for one duty card. - * @param {object} d - Duty - * @param {string} [typeLabelOverride] - e.g. "Сейчас дежурит" - * @param {boolean} [showUntilEnd] - Show "до HH:MM" instead of range - * @param {string} [extraClass] - e.g. "duty-item--current" - * @returns {string} - */ -export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) { - const lang = state.lang; - const typeLabel = - typeLabelOverride != null - ? typeLabelOverride - : (t(lang, "event_type." + (d.event_type || "duty"))); - let itemClass = "duty-item duty-item--" + (d.event_type || "duty"); - if (extraClass) itemClass += " " + extraClass; - let timeOrRange = ""; - if (showUntilEnd && d.event_type === "duty") { - timeOrRange = t(lang, "duty.until", { time: formatHHMM(d.end_at) }); - } else if (d.event_type === "vacation" || d.event_type === "unavailable") { - const startStr = formatDateKey(d.start_at); - const endStr = formatDateKey(d.end_at); - timeOrRange = startStr === endStr ? startStr : startStr + " – " + endStr; - } else { - timeOrRange = - formatHHMM(d.start_at) + " – " + formatHHMM(d.end_at); - } - return ( - '
' + - escapeHtml(typeLabel) + - ' ' + - escapeHtml(d.full_name) + - '
' + - escapeHtml(timeOrRange) + - "
" - ); -} - -/** Whether the delegated flip-button click listener has been attached to duty list element. */ -let flipListenerAttached = false; - -/** - * Render duty list (timeline) for current month; scroll to today if visible. - * @param {object[]} duties - Duties (only duty type used for timeline) - */ -export function renderDutyList(duties) { - const dutyListEl = getDutyListEl(); - if (!dutyListEl) return; - - if (!flipListenerAttached) { - flipListenerAttached = true; - dutyListEl.addEventListener("click", (e) => { - const btn = e.target.closest(".duty-flip-btn"); - if (!btn) return; - const card = btn.closest(".duty-flip-card"); - if (!card) return; - const flipped = card.getAttribute("data-flipped") === "true"; - card.setAttribute("data-flipped", String(!flipped)); - }); - } - const filtered = duties.filter((d) => d.event_type === "duty"); - if (filtered.length === 0) { - dutyListEl.classList.remove("duty-timeline"); - dutyListEl.innerHTML = '

' + t(state.lang, "duty.none_this_month") + "

"; - return; - } - dutyListEl.classList.add("duty-timeline"); - const current = state.current; - const todayKey = localDateString(new Date()); - const firstKey = localDateString(firstDayOfMonth(current)); - const lastKey = localDateString(lastDayOfMonth(current)); - const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey; - const dateSet = new Set(); - filtered.forEach((d) => { - dateSet.add(localDateString(new Date(d.start_at))); - }); - if (showTodayInMonth) dateSet.add(todayKey); - const dates = Array.from(dateSet).sort(); - const now = new Date(); - let fullHtml = ""; - dates.forEach((date) => { - const isToday = date === todayKey; - const dayClass = - "duty-timeline-day" + (isToday ? " duty-timeline-day--today" : ""); - const dateLabel = dateKeyToDDMM(date); - const dateCellHtml = isToday - ? '' + - escapeHtml(t(state.lang, "duty.today")) + - '' + - escapeHtml(dateLabel) + - '' - : '' + escapeHtml(dateLabel) + ""; - const dayDuties = filtered - .filter((d) => localDateString(new Date(d.start_at)) === date) - .sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); - let dayHtml = ""; - dayDuties.forEach((d) => { - const start = new Date(d.start_at); - const end = new Date(d.end_at); - const isCurrent = start <= now && now < end; - dayHtml += - '
' + - dateCellHtml + - '
' + - dutyTimelineCardHtml(d, isCurrent) + - "
"; - }); - if (dayDuties.length === 0 && isToday) { - dayHtml += - '
' + - dateCellHtml + - '
'; - } - fullHtml += - '
' + - dayHtml + - "
"; - }); - dutyListEl.innerHTML = fullHtml; - const calendarSticky = document.getElementById("calendarSticky"); - const scrollToEl = (el) => { - if (!el) return; - if (calendarSticky) { - requestAnimationFrame(() => { - const calendarHeight = calendarSticky.offsetHeight; - const top = el.getBoundingClientRect().top + window.scrollY; - const scrollTop = Math.max(0, top - calendarHeight); - window.scrollTo({ top: scrollTop, behavior: "smooth" }); - }); - } else { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }; - 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) { - scrollToEl(todayBlock); - } -} diff --git a/webapp/js/dutyList.test.js b/webapp/js/dutyList.test.js deleted file mode 100644 index 424e8d8..0000000 --- a/webapp/js/dutyList.test.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Unit tests for dutyList (dutyTimelineCardHtml, dutyItemHtml, contact rendering). - */ - -import { describe, it, expect, beforeAll, vi, afterEach } from "vitest"; -import * as dateUtils from "./dateUtils.js"; -import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js"; - -describe("dutyList", () => { - beforeAll(() => { - document.body.innerHTML = - '
' + - '
' + - '
' + - ''; - }); - - describe("dutyTimelineCardHtml", () => { - it("renders duty with full_name and time range (no flip when no contacts)", () => { - const d = { - event_type: "duty", - full_name: "Иванов", - start_at: "2025-02-25T09:00:00", - end_at: "2025-02-25T18:00:00", - }; - const html = dutyTimelineCardHtml(d, false); - expect(html).toContain("Иванов"); - expect(html).toContain("duty-item"); - expect(html).toContain("duty-timeline-card"); - expect(html).not.toContain("duty-flip-card"); - expect(html).not.toContain("duty-flip-btn"); - }); - - it("uses flip-card wrapper with front and back when phone or username present", () => { - const d = { - event_type: "duty", - full_name: "Alice", - start_at: "2025-03-01T09:00:00", - end_at: "2025-03-01T17:00:00", - phone: "+79991234567", - username: "alice_dev", - }; - const html = dutyTimelineCardHtml(d, false); - expect(html).toContain("Alice"); - expect(html).toContain("duty-flip-card"); - expect(html).toContain("duty-flip-inner"); - expect(html).toContain("duty-flip-front"); - expect(html).toContain("duty-flip-back"); - expect(html).toContain("duty-flip-btn"); - expect(html).toContain('data-flipped="false"'); - expect(html).toContain("duty-contact-row"); - expect(html).toContain('href="tel:'); - expect(html).toContain("+79991234567"); - expect(html).toContain("https://t.me/"); - expect(html).toContain("alice_dev"); - }); - - it("front face contains name and time; back face contains contact links", () => { - const d = { - event_type: "duty", - full_name: "Bob", - start_at: "2025-03-02T08:00:00", - end_at: "2025-03-02T16:00:00", - phone: "+79001112233", - }; - const html = dutyTimelineCardHtml(d, false); - const frontStart = html.indexOf("duty-flip-front"); - const backStart = html.indexOf("duty-flip-back"); - const frontSection = html.slice(frontStart, backStart); - const backSection = html.slice(backStart); - expect(frontSection).toContain("Bob"); - expect(frontSection).toContain("time"); - expect(frontSection).not.toContain("duty-contact-row"); - expect(backSection).toContain("Bob"); - expect(backSection).toContain("duty-contact-row"); - expect(backSection).toContain("tel:"); - }); - - it("omits flip wrapper and button when phone and username are missing", () => { - const d = { - event_type: "duty", - full_name: "Bob", - start_at: "2025-03-02T08:00:00", - end_at: "2025-03-02T16:00:00", - }; - const html = dutyTimelineCardHtml(d, false); - expect(html).toContain("Bob"); - expect(html).not.toContain("duty-flip-card"); - expect(html).not.toContain("duty-flip-btn"); - 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 deleted file mode 100644 index 5814b0d..0000000 --- a/webapp/js/hints.js +++ /dev/null @@ -1,427 +0,0 @@ -/** - * Tooltips for calendar info buttons and duty markers. - */ - -import { getCalendarEl, state } from "./dom.js"; -import { t } from "./i18n.js"; -import { escapeHtml } from "./utils.js"; -import { localDateString, formatHHMM } from "./dateUtils.js"; - -/** - * Position hint element near button; flip below if needed, clamp to viewport. - * @param {HTMLElement} hintEl - * @param {DOMRect} btnRect - */ -export function positionHint(hintEl, btnRect) { - const vw = document.documentElement.clientWidth; - const margin = 12; - hintEl.classList.remove("below"); - hintEl.classList.remove("calendar-event-hint--visible"); - hintEl.style.left = btnRect.left + "px"; - hintEl.style.top = btnRect.top - 4 + "px"; - hintEl.hidden = false; - requestAnimationFrame(() => { - const hintRect = hintEl.getBoundingClientRect(); - let left = parseFloat(hintEl.style.left); - let top = parseFloat(hintEl.style.top); - if (hintRect.top < margin) { - hintEl.classList.add("below"); - top = btnRect.bottom + 4; - } - if (hintRect.right > vw - margin) { - left = vw - hintRect.width - margin; - } - if (left < margin) left = margin; - left = Math.min(left, vw - hintRect.width - margin); - hintEl.style.left = left + "px"; - hintEl.style.top = top + "px"; - if (hintEl.classList.contains("below")) { - requestAnimationFrame(() => { - const hintRect2 = hintEl.getBoundingClientRect(); - let left2 = parseFloat(hintEl.style.left); - if (hintRect2.right > vw - margin) { - left2 = vw - hintRect2.width - margin; - } - if (left2 < margin) left2 = margin; - hintEl.style.left = left2 + "px"; - }); - } - requestAnimationFrame(() => { - hintEl.classList.add("calendar-event-hint--visible"); - }); - }); -} - -/** - * Parse data-duty-items and data-date from marker; detect if any item has times. - * @param {HTMLElement} marker - * @returns {{ dutyItems: object[], hasTimes: boolean, hintDay: string }} - */ -function parseDutyMarkerData(marker) { - const dutyItemsRaw = - marker.getAttribute("data-duty-items") || - (marker.dataset && marker.dataset.dutyItems) || - ""; - let dutyItems = []; - try { - if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw); - } catch (e) { - /* ignore */ - } - const hasTimes = - dutyItems.length > 0 && - dutyItems.some((it) => { - const start = it.start_at != null ? it.start_at : it.startAt; - const end = it.end_at != null ? it.end_at : it.endAt; - return start || end; - }); - const hintDay = marker.getAttribute("data-date") || ""; - return { dutyItems, hasTimes, hintDay }; -} - -/** - * Get fullName from duty item (supports snake_case and camelCase). - * @param {object} item - * @returns {string} - */ -function getItemFullName(item) { - return item.full_name != null ? item.full_name : item.fullName; -} - -/** - * Build timePrefix for one duty item in the list (shared logic for text and HTML). - * @param {object} item - duty item with start_at/startAt, end_at/endAt - * @param {number} idx - index in dutyItems - * @param {number} total - dutyItems.length - * @param {string} hintDay - YYYY-MM-DD - * @param {string} sep - separator after "from"/"to" (e.g. " - " for text, "\u00a0" for HTML) - * @param {string} fromLabel - translated "from" prefix - * @param {string} toLabel - translated "to" prefix - * @returns {string} - */ -function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLabel) { - const startAt = item.start_at != null ? item.start_at : item.startAt; - const endAt = item.end_at != null ? item.end_at : item.endAt; - const endHHMM = endAt ? formatHHMM(endAt) : ""; - const startHHMM = startAt ? formatHHMM(startAt) : ""; - const startSameDay = - hintDay && startAt && localDateString(new Date(startAt)) === hintDay; - const endSameDay = - hintDay && endAt && localDateString(new Date(endAt)) === hintDay; - let timePrefix = ""; - if (idx === 0) { - if (total === 1 && startSameDay && startHHMM) { - timePrefix = fromLabel + sep + startHHMM; - if (endSameDay && endHHMM && endHHMM !== startHHMM) { - timePrefix += " " + toLabel + sep + endHHMM; - } - } else if (startSameDay && startHHMM) { - /* First of multiple, but starts today — show full range */ - timePrefix = fromLabel + sep + startHHMM; - if (endSameDay && endHHMM && endHHMM !== startHHMM) { - timePrefix += " " + toLabel + sep + endHHMM; - } - } else if (endHHMM) { - /* Continuation from previous day — only end time */ - timePrefix = toLabel + sep + endHHMM; - } - } else if (idx > 0) { - if (startSameDay && startHHMM) { - timePrefix = fromLabel + sep + startHHMM; - if (endHHMM && endSameDay && endHHMM !== startHHMM) { - timePrefix += " " + toLabel + sep + endHHMM; - } - } else if (endHHMM) { - /* Continuation from previous day — only end time */ - timePrefix = toLabel + sep + endHHMM; - } - } - return timePrefix; -} - -/** - * Get array of { timePrefix, fullName } for duty items (single source of time rules). - * @param {object[]} dutyItems - * @param {string} hintDay - * @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML - * @param {string} fromLabel - * @param {string} toLabel - * @returns {{ timePrefix: string, fullName: string }[]} - */ -export function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) { - return dutyItems.map((item, idx) => { - const timePrefix = buildDutyItemTimePrefix( - item, - idx, - dutyItems.length, - hintDay, - timeSep, - fromLabel, - toLabel - ); - const fullName = getItemFullName(item); - return { timePrefix, fullName }; - }); -} - -/** - * Get plain text content for duty marker tooltip. - * @param {HTMLElement} marker - .duty-marker, .unavailable-marker or .vacation-marker - * @returns {string} - */ -export function getDutyMarkerHintContent(marker) { - const lang = state.lang; - const type = marker.getAttribute("data-event-type") || "duty"; - const label = t(lang, "event_type." + type); - const names = marker.getAttribute("data-names") || ""; - const fromLabel = t(lang, "hint.from"); - const toLabel = t(lang, "hint.to"); - let body; - if (type === "duty") { - const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker); - if (dutyItems.length >= 1 && hasTimes) { - const rows = getDutyMarkerRows(dutyItems, hintDay, " - ", fromLabel, toLabel); - body = rows - .map((r) => - r.timePrefix ? r.timePrefix + " - " + r.fullName : r.fullName - ) - .join("\n"); - } else { - body = names; - } - } else { - body = names; - } - return body ? label + ":\n" + body : label; -} - -/** - * Returns HTML for duty hint with aligned times, or null to use textContent. - * @param {HTMLElement} marker - * @returns {string|null} - */ -export function getDutyMarkerHintHtml(marker) { - const lang = state.lang; - const type = marker.getAttribute("data-event-type") || "duty"; - if (type !== "duty") return null; - const names = marker.getAttribute("data-names") || ""; - const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker); - const nbsp = "\u00a0"; - const fromLabel = t(lang, "hint.from"); - const toLabel = t(lang, "hint.to"); - let rowHtmls; - if (dutyItems.length >= 1 && hasTimes) { - const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp, fromLabel, toLabel); - rowHtmls = rows.map((r) => { - const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : ""; - const sepHtml = r.timePrefix ? '-' : ''; - const nameHtml = escapeHtml(r.fullName); - return ( - '' + - timeHtml + - "" + - sepHtml + - '' + - nameHtml + - "" - ); - }); - } else { - rowHtmls = (names ? names.split("\n") : []).map((fullName) => { - const nameHtml = escapeHtml(fullName.trim()); - return ( - '' + - nameHtml + - "" - ); - }); - } - if (rowHtmls.length === 0) return null; - return ( - '
' + escapeHtml(t(lang, "hint.duty_title")) + '
' + - rowHtmls - .map((r) => '
' + r + "
") - .join("") + - "
" - ); -} - -/** - * Remove active class from all duty/unavailable/vacation markers. - */ -export function clearActiveDutyMarker() { - const calendarEl = getCalendarEl(); - if (!calendarEl) return; - calendarEl - .querySelectorAll( - ".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active" - ) - .forEach((m) => m.classList.remove("calendar-marker-active")); -} - -/** 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"; - -/** - * Get or create the calendar event (info button) hint element. - * @returns {HTMLElement|null} - */ -function getOrCreateCalendarEventHint() { - let el = document.getElementById("calendarEventHint"); - if (!el) { - el = document.createElement("div"); - el.id = "calendarEventHint"; - el.className = "calendar-event-hint"; - el.setAttribute("role", "tooltip"); - el.hidden = true; - document.body.appendChild(el); - } - return el; -} - -/** - * Get or create the duty marker hint element. - * @returns {HTMLElement|null} - */ -function getOrCreateDutyMarkerHint() { - let el = document.getElementById("dutyMarkerHint"); - if (!el) { - el = document.createElement("div"); - el.id = "dutyMarkerHint"; - el.className = "calendar-event-hint"; - el.setAttribute("role", "tooltip"); - el.hidden = true; - document.body.appendChild(el); - } - return el; -} - -/** - * Set up event delegation on calendarEl for info button and duty marker tooltips. - * Call once at startup (e.g. alongside initDayDetail). No need to re-bind after render. - */ -export function initHints() { - const calendarEventHint = getOrCreateCalendarEventHint(); - const dutyMarkerHint = getOrCreateDutyMarkerHint(); - const calendarEl = getCalendarEl(); - if (!calendarEl) return; - - calendarEl.addEventListener("click", (e) => { - const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null; - if (btn) { - e.stopPropagation(); - const summary = btn.getAttribute("data-summary") || ""; - const content = t(state.lang, "hint.events") + "\n" + summary; - if (calendarEventHint.hidden || calendarEventHint.textContent !== content) { - calendarEventHint.textContent = content; - positionHint(calendarEventHint, btn.getBoundingClientRect()); - calendarEventHint.dataset.active = "1"; - } else { - dismissHint(calendarEventHint); - } - return; - } - - const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null; - if (marker) { - e.stopPropagation(); - if (marker.classList.contains("calendar-marker-active")) { - dismissHint(dutyMarkerHint); - marker.classList.remove("calendar-marker-active"); - return; - } - clearActiveDutyMarker(); - const html = getDutyMarkerHintHtml(marker); - if (html) { - dutyMarkerHint.innerHTML = html; - } else { - dutyMarkerHint.textContent = getDutyMarkerHintContent(marker); - } - positionHint(dutyMarkerHint, marker.getBoundingClientRect()); - dutyMarkerHint.hidden = false; - dutyMarkerHint.dataset.active = "1"; - marker.classList.add("calendar-marker-active"); - } - }); - - calendarEl.addEventListener("mouseover", (e) => { - const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null; - if (!marker) return; - const related = e.relatedTarget instanceof Node ? e.relatedTarget : null; - if (related && marker.contains(related)) return; - if (dutyMarkerHideTimeout) { - clearTimeout(dutyMarkerHideTimeout); - dutyMarkerHideTimeout = null; - } - const html = getDutyMarkerHintHtml(marker); - if (html) { - dutyMarkerHint.innerHTML = html; - } else { - dutyMarkerHint.textContent = getDutyMarkerHintContent(marker); - } - positionHint(dutyMarkerHint, marker.getBoundingClientRect()); - dutyMarkerHint.hidden = false; - }); - - calendarEl.addEventListener("mouseout", (e) => { - const fromMarker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null; - if (!fromMarker) return; - const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null; - if (toMarker) return; - if (dutyMarkerHint.dataset.active) return; - dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, { - afterHide: () => { - dutyMarkerHideTimeout = null; - }, - }); - }); - - if (!state.calendarHintBound) { - state.calendarHintBound = true; - document.addEventListener("click", () => { - if (calendarEventHint.dataset.active) { - dismissHint(calendarEventHint); - } - }); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && calendarEventHint.dataset.active) { - dismissHint(calendarEventHint); - } - }); - } - - if (!state.dutyMarkerHintBound) { - state.dutyMarkerHintBound = true; - document.addEventListener("click", () => { - if (dutyMarkerHint.dataset.active) { - dismissHint(dutyMarkerHint, { clearActive: true }); - } - }); - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && dutyMarkerHint.dataset.active) { - dismissHint(dutyMarkerHint, { clearActive: true }); - } - }); - } -} diff --git a/webapp/js/hints.test.js b/webapp/js/hints.test.js deleted file mode 100644 index 20ad3a8..0000000 --- a/webapp/js/hints.test.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * 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, beforeEach, afterEach, vi } from "vitest"; -import { getDutyMarkerRows, dismissHint } from "./hints.js"; - -const FROM = "from"; -const TO = "until"; -const SEP = "\u00a0"; - -describe("getDutyMarkerRows", () => { - beforeAll(() => { - document.body.innerHTML = '
'; - }); - - it("preserves input order (caller must sort by start_at before passing)", () => { - const hintDay = "2025-02-25"; - const duties = [ - { - full_name: "Иванов", - start_at: "2025-02-25T14:00:00", - end_at: "2025-02-25T18:00:00", - }, - { - full_name: "Петров", - start_at: "2025-02-25T09:00:00", - end_at: "2025-02-25T14:00:00", - }, - ]; - const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); - expect(rows).toHaveLength(2); - expect(rows[0].fullName).toBe("Иванов"); - expect(rows[1].fullName).toBe("Петров"); - }); - - it("first of multiple with startSameDay shows full range (from HH:MM to HH:MM)", () => { - const hintDay = "2025-02-25"; - const duties = [ - { - full_name: "Иванов", - start_at: "2025-02-25T09:00:00", - end_at: "2025-02-25T14:00:00", - }, - { - full_name: "Петров", - start_at: "2025-02-25T14:00:00", - end_at: "2025-02-25T18:00:00", - }, - ].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); - - const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); - expect(rows).toHaveLength(2); - expect(rows[0].fullName).toBe("Иванов"); - expect(rows[0].timePrefix).toContain("09:00"); - expect(rows[0].timePrefix).toContain("14:00"); - expect(rows[0].timePrefix).toContain(FROM); - expect(rows[0].timePrefix).toContain(TO); - }); - - it("first of multiple continuation from previous day shows only end time", () => { - const hintDay = "2025-02-25"; - const duties = [ - { - full_name: "Иванов", - start_at: "2025-02-24T22:00:00", - end_at: "2025-02-25T06:00:00", - }, - { - full_name: "Петров", - start_at: "2025-02-25T09:00:00", - end_at: "2025-02-25T14:00:00", - }, - ].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); - - const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); - expect(rows).toHaveLength(2); - expect(rows[0].fullName).toBe("Иванов"); - expect(rows[0].timePrefix).not.toContain(FROM); - expect(rows[0].timePrefix).toContain(TO); - expect(rows[0].timePrefix).toContain("06:00"); - }); - - it("second duty continuation from previous day shows only end time (to HH:MM)", () => { - const hintDay = "2025-02-23"; - const duties = [ - { - full_name: "A", - start_at: "2025-02-23T00:00:00", - end_at: "2025-02-23T09:00:00", - }, - { - full_name: "B", - start_at: "2025-02-22T09:00:00", - end_at: "2025-02-23T09:00:00", - }, - ]; - const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); - expect(rows).toHaveLength(2); - expect(rows[0].fullName).toBe("A"); - expect(rows[0].timePrefix).toContain(FROM); - expect(rows[0].timePrefix).toContain("00:00"); - expect(rows[0].timePrefix).toContain(TO); - expect(rows[0].timePrefix).toContain("09:00"); - expect(rows[1].fullName).toBe("B"); - expect(rows[1].timePrefix).not.toContain(FROM); - expect(rows[1].timePrefix).toContain(TO); - expect(rows[1].timePrefix).toContain("09:00"); - }); - - it("multiple duties in one day — correct order when input is pre-sorted", () => { - const hintDay = "2025-02-25"; - const duties = [ - { full_name: "A", start_at: "2025-02-25T09:00:00", end_at: "2025-02-25T12:00:00" }, - { full_name: "B", start_at: "2025-02-25T12:00:00", end_at: "2025-02-25T15:00:00" }, - { full_name: "C", start_at: "2025-02-25T15:00:00", end_at: "2025-02-25T18:00:00" }, - ].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); - - const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); - expect(rows.map((r) => r.fullName)).toEqual(["A", "B", "C"]); - expect(rows[0].timePrefix).toContain("09:00"); - expect(rows[1].timePrefix).toContain("12:00"); - 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/logger.js b/webapp/js/logger.js deleted file mode 100644 index 2d73595..0000000 --- a/webapp/js/logger.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Frontend logger with configurable level (window.__DT_LOG_LEVEL). - * Only messages at or above the configured level are forwarded to console. - * Prefix [DutyTeller][level] for DevTools filtering. - */ - -const LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 }; - -/** - * Resolve current log level from window.__DT_LOG_LEVEL. Default: info. - * @returns {string} One of "debug", "info", "warn", "error" - */ -function getLogLevel() { - const raw = - (typeof window !== "undefined" && window.__DT_LOG_LEVEL) || "info"; - const level = String(raw).toLowerCase(); - return LEVEL_ORDER.hasOwnProperty(level) ? level : "info"; -} - -/** - * Return true if message at level should be emitted (level >= configured). - * @param {string} messageLevel - "debug" | "info" | "warn" | "error" - * @returns {boolean} - */ -function shouldLog(messageLevel) { - const configured = getLogLevel(); - const configuredNum = LEVEL_ORDER[configured] ?? 1; - const messageNum = LEVEL_ORDER[messageLevel] ?? 1; - return messageNum >= configuredNum; -} - -const PREFIX = "[DutyTeller]"; - -function logAt(level, args) { - if (!shouldLog(level)) return; - const consoleMethod = - level === "debug" - ? console.debug - : level === "info" - ? console.info - : level === "warn" - ? console.warn - : console.error; - const prefix = `${PREFIX}[${level}]`; - if (args.length === 0) { - consoleMethod(prefix); - } else if (args.length === 1) { - consoleMethod(prefix, args[0]); - } else { - consoleMethod(prefix, ...args); - } -} - -/** - * Logger object with debug, info, warn, error (signature like console). - * Example: logger.info("Loaded", { count: 5 }); - */ -export const logger = { - debug(msg, ...args) { - logAt("debug", [msg, ...args]); - }, - info(msg, ...args) { - logAt("info", [msg, ...args]); - }, - warn(msg, ...args) { - logAt("warn", [msg, ...args]); - }, - error(msg, ...args) { - logAt("error", [msg, ...args]); - }, -}; diff --git a/webapp/js/main.js b/webapp/js/main.js deleted file mode 100644 index f942ea6..0000000 --- a/webapp/js/main.js +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Entry point: theme init, auth gate, loadMonth, nav and swipe handlers. - */ - -import { initTheme, applyTheme } from "./theme.js"; -import { getLang, t, weekdayLabels } from "./i18n.js"; -import { getInitData, isLocalhost } from "./auth.js"; -import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js"; -import { - state, - getAccessDeniedEl, - getPrevBtn, - getNextBtn, - getLoadingEl, - getErrorEl, - getWeekdaysEl -} from "./dom.js"; -import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js"; -import { fetchDuties, fetchCalendarEvents } from "./api.js"; -import { logger } from "./logger.js"; -import { - dutiesByDate, - calendarEventsByDate, - renderCalendar -} from "./calendar.js"; -import { initDayDetail } from "./dayDetail.js"; -import { initHints } from "./hints.js"; -import { renderDutyList } from "./dutyList.js"; -import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js"; -import { - firstDayOfMonth, - lastDayOfMonth, - getMonday, - localDateString, - dutyOverlapsLocalRange -} from "./dateUtils.js"; - -initTheme(); - -state.lang = getLang(); - -/** - * Apply current state.lang to document and locale-dependent UI elements (title, loading, weekdays, nav). - * Call at startup and after re-evaluating lang when initData becomes available. - * Exported for tests. - */ -export function applyLangToUi() { - 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")); -} - -applyLangToUi(); - -logger.info("App init", "lang=" + state.lang); - -try { - sessionStorage.removeItem("__dtLoadRetry"); -} catch (_) {} -window.__dtReady = true; - -/** - * Run callback when Telegram WebApp is ready (or immediately outside Telegram). - * Expands and applies theme when in TWA. - * @param {() => void} cb - */ -function runWhenReady(cb) { - if (window.Telegram && window.Telegram.WebApp) { - if (window.Telegram.WebApp.expand) { - window.Telegram.WebApp.expand(); - } - applyTheme(); - if (window.Telegram.WebApp.onEvent) { - window.Telegram.WebApp.onEvent("theme_changed", applyTheme); - } - requestAnimationFrame(() => applyTheme()); - setTimeout(cb, 0); - } else { - cb(); - } -} - -/** - * If allowed (initData or localhost), call onAllowed(); otherwise show access denied. - * When inside Telegram WebApp but initData is empty, retry once after a short delay. - * @param {() => void} onAllowed - */ -function requireTelegramOrLocalhost(onAllowed) { - let initData = getInitData(); - const isLocal = isLocalhost(); - if (initData) { - onAllowed(); - return; - } - if (isLocal) { - onAllowed(); - return; - } - if (window.Telegram && window.Telegram.WebApp) { - setTimeout(() => { - initData = getInitData(); - if (initData) { - onAllowed(); - return; - } - showAccessDenied(undefined); - const loading = getLoadingEl(); - if (loading) loading.classList.add("hidden"); - }, RETRY_DELAY_MS); - return; - } - showAccessDenied(undefined); - const loading = getLoadingEl(); - if (loading) loading.classList.add("hidden"); -} - -/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */ -let loadMonthAbortController = null; - -/** - * Load current month: fetch duties and events, render calendar and duty list. - * Stale requests are cancelled when the user navigates to another month before they complete. - */ -async function loadMonth() { - if (loadMonthAbortController) loadMonthAbortController.abort(); - loadMonthAbortController = new AbortController(); - const signal = loadMonthAbortController.signal; - - hideAccessDenied(); - setNavEnabled(false); - 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); - const start = getMonday(first); - const gridEnd = new Date(start); - gridEnd.setDate(gridEnd.getDate() + 41); - const from = localDateString(start); - const to = localDateString(gridEnd); - try { - logger.debug("Loading month", from, to); - const dutiesPromise = fetchDuties(from, to, signal); - const eventsPromise = fetchCalendarEvents(from, to, signal); - const duties = await dutiesPromise; - const events = await eventsPromise; - const byDate = dutiesByDate(duties); - const calendarByDate = calendarEventsByDate(events); - renderCalendar(current.getFullYear(), current.getMonth(), byDate, calendarByDate); - const last = lastDayOfMonth(current); - const firstKey = localDateString(first); - const lastKey = localDateString(last); - const dutiesInMonth = duties.filter((d) => - dutyOverlapsLocalRange(d, firstKey, lastKey) - ); - state.lastDutiesForList = dutiesInMonth; - renderDutyList(dutiesInMonth); - if (state.todayRefreshInterval) { - clearInterval(state.todayRefreshInterval); - } - state.todayRefreshInterval = null; - const viewedMonth = current.getFullYear() * 12 + current.getMonth(); - const thisMonth = - new Date().getFullYear() * 12 + new Date().getMonth(); - if (viewedMonth === thisMonth) { - state.todayRefreshInterval = setInterval(() => { - if ( - current.getFullYear() * 12 + current.getMonth() !== - new Date().getFullYear() * 12 + new Date().getMonth() - ) { - return; - } - renderDutyList(state.lastDutiesForList); - }, 60000); - } - } catch (e) { - if (e.name === "AbortError") { - return; - } - if (e.message === "ACCESS_DENIED") { - logger.warn("Access denied in loadMonth", e.serverDetail); - showAccessDenied(e.serverDetail); - setNavEnabled(true); - if ( - window.Telegram && - window.Telegram.WebApp && - !state.initDataRetried - ) { - state.initDataRetried = true; - setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS); - } - return; - } - logger.error("Load month failed", e); - showError(e.message || t(state.lang, "error_generic"), loadMonth); - setNavEnabled(true); - return; - } - const errorEl2 = getErrorEl(); - if (errorEl2) errorEl2.hidden = true; - const loading = getLoadingEl(); - if (loading) loading.classList.add("hidden"); - setNavEnabled(true); -} - -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(); - }); -} -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(); - }); -} - -(function bindSwipeMonth() { - const swipeEl = document.getElementById("calendarSticky"); - if (!swipeEl) return; - let startX = 0; - let startY = 0; - const SWIPE_THRESHOLD = 50; - swipeEl.addEventListener( - "touchstart", - (e) => { - if (e.changedTouches.length === 0) return; - const touch = e.changedTouches[0]; - startX = touch.clientX; - startY = touch.clientY; - }, - { passive: true } - ); - swipeEl.addEventListener( - "touchend", - (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); - loadMonth(); - } else if (deltaX < -SWIPE_THRESHOLD) { - if (nextBtn && nextBtn.disabled) return; - state.current.setMonth(state.current.getMonth() + 1); - loadMonth(); - } - }, - { passive: true } - ); -})(); - -function bindStickyScrollShadow() { - const stickyEl = document.getElementById("calendarSticky"); - if (!stickyEl || state.stickyScrollBound) return; - state.stickyScrollBound = true; - function updateScrolled() { - stickyEl.classList.toggle("is-scrolled", window.scrollY > 0); - } - window.addEventListener("scroll", updateScrolled, { passive: true }); - updateScrolled(); -} - -runWhenReady(() => { - requireTelegramOrLocalhost(() => { - const newLang = getLang(); - if (newLang !== state.lang) { - state.lang = newLang; - applyLangToUi(); - } - bindStickyScrollShadow(); - initDayDetail(); - initHints(); - const startParam = - (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe && - window.Telegram.WebApp.initDataUnsafe.start_param) || - ""; - if (startParam === "duty") { - showCurrentDutyView(() => { - hideCurrentDutyView(); - loadMonth(); - }); - } else { - loadMonth(); - } - }); -}); diff --git a/webapp/js/main.test.js b/webapp/js/main.test.js deleted file mode 100644 index 52cec7c..0000000 --- a/webapp/js/main.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Unit tests for main.js: applyLangToUi (locale-to-DOM) and lang re-evaluation behaviour. - */ - -import { describe, it, expect, beforeAll } from "vitest"; - -beforeAll(() => { - document.body.innerHTML = - '
' + - '

' + - '
' + - '' + - "
" + - '
' + - '
' + - '
'; -}); - -import { applyLangToUi } from "./main.js"; -import { state } from "./dom.js"; - -describe("applyLangToUi", () => { - it("state.lang is set from getLang() at startup (getLang reads window.__DT_LANG; default en)", () => { - expect(state.lang).toBe("en"); - }); - - it("sets document title and loading text for en", () => { - state.lang = "en"; - applyLangToUi(); - expect(document.title).toBe("Duty Calendar"); - const loadingText = document.querySelector(".loading__text"); - expect(loadingText && loadingText.textContent).toBe("Loading…"); - }); - - it("sets document title and loading text for ru", () => { - state.lang = "ru"; - applyLangToUi(); - expect(document.title).toBe("Календарь дежурств"); - const loadingText = document.querySelector(".loading__text"); - expect(loadingText && loadingText.textContent).toBe("Загрузка…"); - }); - - it("sets documentElement.lang to state.lang", () => { - state.lang = "en"; - applyLangToUi(); - expect(document.documentElement.lang).toBe("en"); - state.lang = "ru"; - applyLangToUi(); - expect(document.documentElement.lang).toBe("ru"); - }); - - it("sets weekday labels and nav aria-labels for current lang", () => { - state.lang = "en"; - applyLangToUi(); - const weekdays = document.querySelectorAll(".weekdays span"); - expect(weekdays.length).toBe(7); - expect(weekdays[0].textContent).toBe("Mon"); - const prevBtn = document.getElementById("prevMonth"); - const nextBtn = document.getElementById("nextMonth"); - expect(prevBtn && prevBtn.getAttribute("aria-label")).toBe("Previous month"); - expect(nextBtn && nextBtn.getAttribute("aria-label")).toBe("Next month"); - - state.lang = "ru"; - applyLangToUi(); - expect(weekdays[0].textContent).toBe("Пн"); - expect(prevBtn && prevBtn.getAttribute("aria-label")).toContain("Пред"); - expect(nextBtn && nextBtn.getAttribute("aria-label")).toContain("След"); - }); -}); diff --git a/webapp/js/theme.js b/webapp/js/theme.js deleted file mode 100644 index 3c68a59..0000000 --- a/webapp/js/theme.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Telegram and system theme detection and application. - */ - -/** - * Resolve current color scheme (dark/light). - * @returns {"dark"|"light"} - */ -export function getTheme() { - if (typeof window === "undefined") return "dark"; - const twa = window.Telegram?.WebApp; - if (twa?.colorScheme) return twa.colorScheme; - let cssScheme = ""; - try { - cssScheme = getComputedStyle(document.documentElement) - .getPropertyValue("--tg-color-scheme") - .trim(); - } catch (e) { - /* ignore */ - } - if (cssScheme === "light" || cssScheme === "dark") return cssScheme; - if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark"; - return "light"; -} - -/** - * Map Telegram themeParams (snake_case) to CSS variables (--tg-theme-*). - * Only set when Telegram WebApp and themeParams are available. - */ -export function applyThemeParamsToCss() { - const twa = window.Telegram?.WebApp; - const params = twa?.themeParams; - if (!params) return; - const root = document.documentElement; - const map = [ - ["bg_color", "bg-color"], - ["secondary_bg_color", "secondary-bg-color"], - ["text_color", "text-color"], - ["hint_color", "hint-color"], - ["link_color", "link-color"], - ["button_color", "button-color"], - ["header_bg_color", "header-bg-color"], - ["accent_text_color", "accent-text-color"] - ]; - map.forEach(([key, cssKey]) => { - const value = params[key]; - if (value) root.style.setProperty("--tg-theme-" + cssKey, value); - }); -} - -/** - * Apply current theme: set data-theme and Telegram background/header when in TWA. - */ -export function applyTheme() { - const scheme = getTheme(); - document.documentElement.dataset.theme = scheme; - const twa = window.Telegram?.WebApp; - if (twa) { - if (twa.setBackgroundColor) twa.setBackgroundColor("bg_color"); - if (twa.setHeaderColor) twa.setHeaderColor("bg_color"); - applyThemeParamsToCss(); - } -} - -/** - * Run initial theme apply and subscribe to theme changes (TWA or prefers-color-scheme). - * Call once at load. - */ -export function initTheme() { - applyTheme(); - if (typeof window !== "undefined" && window.Telegram?.WebApp) { - setTimeout(applyTheme, 0); - setTimeout(applyTheme, 100); - } else if (window.matchMedia) { - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme); - } -} diff --git a/webapp/js/theme.test.js b/webapp/js/theme.test.js deleted file mode 100644 index d57f9a6..0000000 --- a/webapp/js/theme.test.js +++ /dev/null @@ -1,152 +0,0 @@ -/** - * 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 deleted file mode 100644 index d5c4371..0000000 --- a/webapp/js/ui.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Screen states: access denied, error, nav enabled. - */ - -import { - state, - getCalendarEl, - getDutyListEl, - getLoadingEl, - getErrorEl, - getAccessDeniedEl, - getHeaderEl, - getWeekdaysEl, - getPrevBtn, - getNextBtn -} from "./dom.js"; -import { t } from "./i18n.js"; -import { escapeHtml } from "./utils.js"; - -/** - * Show access-denied view and hide calendar/list/loading/error. - * @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; - if (dutyListEl) dutyListEl.hidden = true; - if (loadingEl) loadingEl.classList.add("hidden"); - if (errorEl) errorEl.hidden = true; - if (accessDeniedEl) { - const msg = t(state.lang, "access_denied"); - accessDeniedEl.innerHTML = "

" + msg + "

"; - if (serverDetail && serverDetail.trim()) { - const detail = document.createElement("p"); - detail.className = "access-denied-detail"; - detail.textContent = serverDetail; - accessDeniedEl.appendChild(detail); - } - accessDeniedEl.hidden = false; - } -} - -/** - * 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; - if (calendarEl) calendarEl.hidden = false; - if (dutyListEl) dutyListEl.hidden = false; -} - -/** Warning icon SVG for error state (24×24). */ -const ERROR_ICON_SVG = - ''; - -/** - * Show error message and hide loading. - * @param {string} msg - Error text - * @param {(() => void)|null} [onRetry] - Optional callback for Retry button - */ -export function showError(msg, onRetry) { - const errorEl = getErrorEl(); - const loadingEl = getLoadingEl(); - if (errorEl) { - const retryLabel = t(state.lang, "error.retry"); - const safeMsg = typeof msg === "string" ? msg : t(state.lang, "error_generic"); - let html = - ERROR_ICON_SVG + - '

' + - escapeHtml(safeMsg) + - "

"; - if (typeof onRetry === "function") { - html += '"; - } - errorEl.innerHTML = html; - errorEl.hidden = false; - const retryBtn = errorEl.querySelector(".error-retry"); - if (retryBtn && typeof onRetry === "function") { - retryBtn.addEventListener("click", () => { - onRetry(); - }); - } - } - if (loadingEl) loadingEl.classList.add("hidden"); -} - -/** - * Enable or disable prev/next month buttons. - * @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 deleted file mode 100644 index 5b5d2ce..0000000 --- a/webapp/js/ui.test.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 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"); - const textEl = errorEl?.querySelector(".error-text"); - expect(textEl?.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/js/utils.js b/webapp/js/utils.js deleted file mode 100644 index 2a6b1ef..0000000 --- a/webapp/js/utils.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Common utilities. - */ - -const ESCAPE_MAP = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", -}; - -/** - * Escape string for safe use in HTML (text content / attributes). - * @param {string} s - Raw string - * @returns {string} HTML-escaped string - */ -export function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]); -} diff --git a/webapp/js/utils.test.js b/webapp/js/utils.test.js deleted file mode 100644 index c4003c6..0000000 --- a/webapp/js/utils.test.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Unit tests for escapeHtml edge cases. - */ - -import { describe, it, expect } from "vitest"; -import { escapeHtml } from "./utils.js"; - -describe("escapeHtml", () => { - it("escapes ampersand", () => { - expect(escapeHtml("a & b")).toBe("a & b"); - }); - - it("escapes less-than and greater-than", () => { - expect(escapeHtml("