(function () { const FETCH_TIMEOUT_MS = 15000; const RETRY_DELAY_MS = 800; const RETRY_AFTER_ACCESS_DENIED_MS = 1200; const MONTHS = [ "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" ]; let current = new Date(); const calendarEl = document.getElementById("calendar"); const monthTitleEl = document.getElementById("monthTitle"); const dutyListEl = document.getElementById("dutyList"); const loadingEl = document.getElementById("loading"); const errorEl = document.getElementById("error"); const accessDeniedEl = document.getElementById("accessDenied"); const headerEl = document.querySelector(".header"); const weekdaysEl = document.querySelector(".weekdays"); const prevBtn = document.getElementById("prevMonth"); const nextBtn = document.getElementById("nextMonth"); /** YYYY-MM-DD in local time (for calendar keys, "today", request range). */ function localDateString(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return y + "-" + m + "-" + day; } function firstDayOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); } function lastDayOfMonth(d) { return new Date(d.getFullYear(), d.getMonth() + 1, 0); } function getMonday(d) { const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1); return new Date(d.getFullYear(), d.getMonth(), diff); } /** Get tgWebAppData value from hash when it contains unencoded & and = (URLSearchParams would split it). Value runs from tgWebAppData= until next &tgWebApp or end. */ function getTgWebAppDataFromHash(hash) { var idx = hash.indexOf("tgWebAppData="); if (idx === -1) return ""; var start = idx + "tgWebAppData=".length; var end = hash.indexOf("&tgWebApp", start); if (end === -1) end = hash.length; var raw = hash.substring(start, end); try { return decodeURIComponent(raw); } catch (e) { return raw; } } function getInitData() { var fromSdk = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; if (fromSdk) return fromSdk; var hash = window.location.hash ? window.location.hash.slice(1) : ""; if (hash) { var fromHash = getTgWebAppDataFromHash(hash); if (fromHash) return fromHash; try { var hashParams = new URLSearchParams(hash); var tgFromHash = hashParams.get("tgWebAppData"); if (tgFromHash) return decodeURIComponent(tgFromHash); } catch (e) { /* ignore */ } } var q = window.location.search ? new URLSearchParams(window.location.search).get("tgWebAppData") : null; if (q) { try { return decodeURIComponent(q); } catch (e) { return q; } } return ""; } function isLocalhost() { var h = window.location.hostname; return h === "localhost" || h === "127.0.0.1" || h === ""; } function hasTelegramHashButNoInitData() { var hash = window.location.hash ? window.location.hash.slice(1) : ""; if (!hash) return false; try { var keys = Array.from(new URLSearchParams(hash).keys()); var hasVersion = keys.indexOf("tgWebAppVersion") !== -1; var hasData = keys.indexOf("tgWebAppData") !== -1 || getTgWebAppDataFromHash(hash); return hasVersion && !hasData; } catch (e) { return false; } } /** @param {string} [serverDetail] - message from API 403 detail (unused; kept for callers) */ function showAccessDenied(serverDetail) { if (headerEl) headerEl.hidden = true; if (weekdaysEl) weekdaysEl.hidden = true; calendarEl.hidden = true; dutyListEl.hidden = true; loadingEl.classList.add("hidden"); errorEl.hidden = true; accessDeniedEl.hidden = false; } function hideAccessDenied() { accessDeniedEl.hidden = true; if (headerEl) headerEl.hidden = false; if (weekdaysEl) weekdaysEl.hidden = false; calendarEl.hidden = false; dutyListEl.hidden = false; } function buildFetchOptions(initData) { var headers = {}; if (initData) headers["X-Telegram-Init-Data"] = initData; var controller = new AbortController(); var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS); return { headers: headers, signal: controller.signal, timeoutId: timeoutId }; } async function fetchDuties(from, to) { const base = window.location.origin; const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); const initData = getInitData(); const opts = buildFetchOptions(initData); try { var res = await fetch(url, { headers: opts.headers, signal: opts.signal }); if (res.status === 403) { var detail = "Доступ запрещён"; try { var 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 */ } var err = new Error("ACCESS_DENIED"); err.serverDetail = detail; throw err; } if (!res.ok) throw new Error("Ошибка загрузки"); return res.json(); } catch (e) { if (e.name === "AbortError") { throw new Error("Не удалось загрузить данные. Проверьте интернет."); } throw e; } finally { clearTimeout(opts.timeoutId); } } /** Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403 (caller uses duties 403). */ async function fetchCalendarEvents(from, to) { const base = window.location.origin; const url = base + "/api/calendar-events?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); const initData = getInitData(); const opts = buildFetchOptions(initData); try { var res = await fetch(url, { headers: opts.headers, signal: opts.signal }); if (!res.ok) return []; return res.json(); } catch (e) { return []; } finally { clearTimeout(opts.timeoutId); } } /** Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local date for calendar grid. */ function calendarEventsByDate(events) { const byDate = {}; (events || []).forEach(function (e) { var utcMidnight = new Date(e.date + "T00:00:00Z"); var key = localDateString(utcMidnight); if (!byDate[key]) byDate[key] = []; if (e.summary) byDate[key].push(e.summary); }); return byDate; } function renderCalendar(year, month, dutiesByDate, calendarEventsByDate) { 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()); calendarEventsByDate = calendarEventsByDate || {}; 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 = dutiesByDate[key] || []; const isToday = key === today; const eventSummaries = calendarEventsByDate[key] || []; const hasEvent = eventSummaries.length > 0; const cell = document.createElement("div"); cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : ""); cell.innerHTML = "" + d.getDate() + "" + (dayDuties.length ? "" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "" : "") + (hasEvent ? "" : ""); calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } monthTitleEl.textContent = MONTHS[month] + " " + year; bindInfoButtonTooltips(); } function bindInfoButtonTooltips() { var hintEl = document.getElementById("calendarEventHint"); if (!hintEl) { hintEl = document.createElement("div"); hintEl.id = "calendarEventHint"; hintEl.className = "calendar-event-hint"; hintEl.setAttribute("role", "tooltip"); hintEl.hidden = true; document.body.appendChild(hintEl); } calendarEl.querySelectorAll(".info-btn").forEach(function (btn) { btn.addEventListener("click", function (e) { e.stopPropagation(); var summary = btn.getAttribute("data-summary") || ""; if (hintEl.hidden || hintEl.textContent !== summary) { hintEl.textContent = summary; hintEl.hidden = false; var rect = btn.getBoundingClientRect(); hintEl.style.left = (rect.left + window.scrollX) + "px"; hintEl.style.top = (rect.top + window.scrollY - 4) + "px"; hintEl.dataset.active = "1"; } else { hintEl.hidden = true; hintEl.removeAttribute("data-active"); } }); }); if (!document._calendarHintBound) { document._calendarHintBound = true; document.addEventListener("click", function () { if (hintEl.dataset.active) { hintEl.hidden = true; hintEl.removeAttribute("data-active"); } }); document.addEventListener("keydown", function (e) { if (e.key === "Escape" && hintEl.dataset.active) { hintEl.hidden = true; hintEl.removeAttribute("data-active"); } }); } } function renderDutyList(duties) { if (duties.length === 0) { dutyListEl.innerHTML = "
В этом месяце дежурств нет.
"; return; } const grouped = {}; duties.forEach(function (d) { const date = localDateString(new Date(d.start_at)); if (!grouped[date]) grouped[date] = []; grouped[date].push(d); }); const dates = Object.keys(grouped).sort(); let html = ""; dates.forEach(function (date) { const list = grouped[date]; html += "