(function () { const FETCH_TIMEOUT_MS = 15000; const RETRY_DELAY_MS = 800; const RETRY_AFTER_ACCESS_DENIED_MS = 1200; const THEME_BG = { dark: "#1a1b26", light: "#d5d6db" }; function getTheme() { if (typeof window === "undefined") return "dark"; var twa = window.Telegram?.WebApp; if (twa?.colorScheme) { return twa.colorScheme; } var cssScheme = ""; try { cssScheme = getComputedStyle(document.documentElement).getPropertyValue("--tg-color-scheme").trim(); } catch (e) {} if (cssScheme === "light" || cssScheme === "dark") { return cssScheme; } if (window.matchMedia("(prefers-color-scheme: dark)").matches) { return "dark"; } return "light"; } function applyTheme() { var scheme = getTheme(); document.documentElement.dataset.theme = scheme; var bg = THEME_BG[scheme] || THEME_BG.dark; if (window.Telegram?.WebApp?.setBackgroundColor) { window.Telegram.WebApp.setBackgroundColor(bg); } if (window.Telegram?.WebApp?.setHeaderColor) { window.Telegram.WebApp.setHeaderColor(bg); } } 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); } const MONTHS = [ "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" ]; let current = new Date(); let lastDutiesForList = []; let todayRefreshInterval = null; 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; } /** True if duty (start_at/end_at UTC) overlaps the local day YYYY-MM-DD. */ function dutyOverlapsLocalDay(d, dateKey) { const [y, m, day] = dateKey.split("-").map(Number); const dayStart = new Date(y, m - 1, day, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, day, 23, 59, 59, 999); const start = new Date(d.start_at); const end = new Date(d.end_at); return end > dayStart && start < dayEnd; } 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 dutyList = dayDuties.filter(function (x) { return x.event_type === "duty"; }); const unavailableList = dayDuties.filter(function (x) { return x.event_type === "unavailable"; }); const vacationList = dayDuties.filter(function (x) { return x.event_type === "vacation"; }); const hasAny = dayDuties.length > 0; const isToday = key === today; const eventSummaries = calendarEventsByDate[key] || []; const hasEvent = eventSummaries.length > 0; const cell = document.createElement("div"); const showMarkers = !isOther; cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (showMarkers && hasAny ? " has-duty" : "") + (showMarkers && hasEvent ? " holiday" : ""); function namesAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join("\n")) : ""; } function titleAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join(", ")) : ""; } let html = "" + d.getDate() + "
"; if (showMarkers) { if (dutyList.length) { html += "Д"; } if (unavailableList.length) { html += "Н"; } if (vacationList.length) { html += "О"; } if (hasEvent) { html += ""; } } html += "
"; cell.innerHTML = html; calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } monthTitleEl.textContent = MONTHS[month] + " " + year; bindInfoButtonTooltips(); bindDutyMarkerTooltips(); } function positionHint(hintEl, btnRect) { var vw = document.documentElement.clientWidth; var margin = 12; hintEl.classList.remove("below"); hintEl.style.left = btnRect.left + "px"; hintEl.style.top = (btnRect.top - 4) + "px"; hintEl.hidden = false; requestAnimationFrame(function () { var hintRect = hintEl.getBoundingClientRect(); var left = parseFloat(hintEl.style.left); var 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(function () { hintRect = hintEl.getBoundingClientRect(); left = parseFloat(hintEl.style.left); if (hintRect.right > vw - margin) { left = vw - hintRect.width - margin; } if (left < margin) { left = margin; } hintEl.style.left = left + "px"; }); } }); } 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; var rect = btn.getBoundingClientRect(); positionHint(hintEl, rect); 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 bindDutyMarkerTooltips() { var hintEl = document.getElementById("dutyMarkerHint"); if (!hintEl) { hintEl = document.createElement("div"); hintEl.id = "dutyMarkerHint"; hintEl.className = "calendar-event-hint"; hintEl.setAttribute("role", "tooltip"); hintEl.hidden = true; document.body.appendChild(hintEl); } var hideTimeout = null; var selector = ".duty-marker, .unavailable-marker, .vacation-marker"; calendarEl.querySelectorAll(selector).forEach(function (marker) { marker.addEventListener("mouseenter", function () { if (hideTimeout) { clearTimeout(hideTimeout); hideTimeout = null; } var names = marker.getAttribute("data-names") || ""; hintEl.textContent = names; var rect = marker.getBoundingClientRect(); positionHint(hintEl, rect); hintEl.hidden = false; }); marker.addEventListener("mouseleave", function () { hideTimeout = setTimeout(function () { hintEl.hidden = true; hideTimeout = null; }, 150); }); }); } var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" }; /** Format UTC date from ISO string as DD.MM for display. */ /** Format date as DD.MM in user's local timezone (for duty card labels). */ function formatDateKey(isoDateStr) { const d = new Date(isoDateStr); const day = String(d.getDate()).padStart(2, "0"); const month = String(d.getMonth() + 1).padStart(2, "0"); return day + "." + month; } /** Format YYYY-MM-DD as DD.MM for list header. */ function dateKeyToDDMM(key) { return key.slice(8, 10) + "." + key.slice(5, 7); } /** Format ISO date as HH:MM in local time. */ function formatTimeLocal(isoStr) { const d = new Date(isoStr); return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); } /** Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day "DD.MM HH:MM – DD.MM HH:MM". */ 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 = formatTimeLocal(d.start_at); const endTime = formatTimeLocal(d.end_at); let timeStr; if (startLocal === endLocal) { timeStr = startDDMM + ", " + startTime + " – " + endTime; } else { timeStr = startDDMM + " " + startTime + " – " + endDDMM + " " + endTime; } const typeLabel = isCurrent ? "Сейчас дежурит" : (EVENT_TYPE_LABELS[d.event_type] || "Дежурство"); const extraClass = isCurrent ? " duty-item--current" : ""; return "
" + escapeHtml(typeLabel) + " " + escapeHtml(d.full_name) + "
" + escapeHtml(timeStr) + "
"; } /** 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". */ function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) { const startDate = new Date(d.start_at); const endDate = new Date(d.end_at); const typeLabel = typeLabelOverride != null ? typeLabelOverride : (EVENT_TYPE_LABELS[d.event_type] || d.event_type); let itemClass = "duty-item duty-item--" + (d.event_type || "duty"); if (extraClass) itemClass += " " + extraClass; let timeOrRange = ""; if (showUntilEnd && d.event_type === "duty") { const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0"); timeOrRange = "до " + end; } 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 { const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0"); const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0"); timeOrRange = start + " – " + end; } return "
" + escapeHtml(typeLabel) + " " + escapeHtml(d.full_name) + "
" + timeOrRange + "
"; } function renderDutyList(duties) { duties = duties.filter(function (d) { return d.event_type === "duty"; }); if (duties.length === 0) { dutyListEl.classList.remove("duty-timeline"); dutyListEl.innerHTML = "

В этом месяце дежурств нет.

"; return; } dutyListEl.classList.add("duty-timeline"); 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(); duties.forEach(function (d) { dateSet.add(localDateString(new Date(d.start_at))); }); if (showTodayInMonth) dateSet.add(todayKey); let dates = Array.from(dateSet).sort(); const now = new Date(); let fullHtml = ""; dates.forEach(function (date) { const isToday = date === todayKey; const dayClass = "duty-timeline-day" + (isToday ? " duty-timeline-day--today" : ""); const dateLabel = isToday ? "Сегодня, " + dateKeyToDDMM(date) : dateKeyToDDMM(date); const dayDuties = duties.filter(function (d) { return localDateString(new Date(d.start_at)) === date; }).sort(function (a, b) { return new Date(a.start_at) - new Date(b.start_at); }); let dayHtml = ""; dayDuties.forEach(function (d) { const start = new Date(d.start_at); const end = new Date(d.end_at); const isCurrent = isToday && start <= now && now < end; dayHtml += "
" + escapeHtml(dateLabel) + "
" + dutyTimelineCardHtml(d, isCurrent) + "
"; }); if (dayDuties.length === 0 && isToday) { dayHtml += "
" + escapeHtml(dateLabel) + "
"; } fullHtml += "
" + dayHtml + "
"; }); dutyListEl.innerHTML = fullHtml; var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today"); if (scrollTarget) { scrollTarget.scrollIntoView({ behavior: "auto", block: "start" }); } } function escapeHtml(s) { const div = document.createElement("div"); div.textContent = s; return div.innerHTML; } /** Group duties by local date (start_at/end_at are UTC). */ function dutiesByDate(duties) { const byDate = {}; duties.forEach(function (d) { const start = new Date(d.start_at); const end = new Date(d.end_at); const endLocal = localDateString(end); let t = new Date(start); while (true) { const key = localDateString(t); if (!byDate[key]) byDate[key] = []; byDate[key].push(d); if (key === endLocal) break; t.setDate(t.getDate() + 1); } }); return byDate; } function showError(msg) { errorEl.textContent = msg; errorEl.hidden = false; loadingEl.classList.add("hidden"); } function runWhenReady(cb) { if (window.Telegram && window.Telegram.WebApp) { if (window.Telegram.WebApp.ready) { window.Telegram.WebApp.ready(); } if (window.Telegram.WebApp.expand) { window.Telegram.WebApp.expand(); } applyTheme(); if (window.Telegram.WebApp.onEvent) { window.Telegram.WebApp.onEvent("theme_changed", applyTheme); } requestAnimationFrame(function () { 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 (initData may be set asynchronously). */ function requireTelegramOrLocalhost(onAllowed) { var initData = getInitData(); var isLocal = isLocalhost(); if (initData) { onAllowed(); return; } if (isLocal) { onAllowed(); return; } if (window.Telegram && window.Telegram.WebApp) { setTimeout(function () { initData = getInitData(); if (initData) { onAllowed(); return; } showAccessDenied(); loadingEl.classList.add("hidden"); }, RETRY_DELAY_MS); return; } showAccessDenied(); loadingEl.classList.add("hidden"); } function setNavEnabled(enabled) { if (prevBtn) prevBtn.disabled = !enabled; if (nextBtn) nextBtn.disabled = !enabled; } async function loadMonth() { hideAccessDenied(); setNavEnabled(false); loadingEl.classList.remove("hidden"); errorEl.hidden = true; 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 { const dutiesPromise = fetchDuties(from, to); const eventsPromise = fetchCalendarEvents(from, to); 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(function (d) { const byDateLocal = dutiesByDate([d]); return Object.keys(byDateLocal).some(function (key) { return key >= firstKey && key <= lastKey; }); }); lastDutiesForList = dutiesInMonth; renderDutyList(dutiesInMonth); if (todayRefreshInterval) clearInterval(todayRefreshInterval); todayRefreshInterval = null; const viewedMonth = current.getFullYear() * 12 + current.getMonth(); const thisMonth = new Date().getFullYear() * 12 + new Date().getMonth(); if (viewedMonth === thisMonth) { todayRefreshInterval = setInterval(function () { if (current.getFullYear() * 12 + current.getMonth() !== new Date().getFullYear() * 12 + new Date().getMonth()) return; renderDutyList(lastDutiesForList); }, 60000); } } catch (e) { if (e.message === "ACCESS_DENIED") { showAccessDenied(e.serverDetail); setNavEnabled(true); if (window.Telegram && window.Telegram.WebApp && !window._initDataRetried) { window._initDataRetried = true; setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS); } return; } showError(e.message || "Не удалось загрузить данные."); setNavEnabled(true); return; } loadingEl.classList.add("hidden"); setNavEnabled(true); } document.getElementById("prevMonth").addEventListener("click", function () { if (!accessDeniedEl.hidden) return; current.setMonth(current.getMonth() - 1); loadMonth(); }); document.getElementById("nextMonth").addEventListener("click", function () { if (!accessDeniedEl.hidden) return; current.setMonth(current.getMonth() + 1); loadMonth(); }); (function bindSwipeMonth() { var swipeEl = document.getElementById("calendarSticky"); if (!swipeEl) return; var startX = 0; var startY = 0; var SWIPE_THRESHOLD = 50; swipeEl.addEventListener("touchstart", function (e) { if (e.changedTouches.length === 0) return; var t = e.changedTouches[0]; startX = t.clientX; startY = t.clientY; }, { passive: true }); swipeEl.addEventListener("touchend", function (e) { if (e.changedTouches.length === 0) return; if (!accessDeniedEl.hidden) return; if (prevBtn.disabled) return; var t = e.changedTouches[0]; var deltaX = t.clientX - startX; var deltaY = t.clientY - startY; if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; if (Math.abs(deltaY) > Math.abs(deltaX)) return; if (deltaX > SWIPE_THRESHOLD) { current.setMonth(current.getMonth() - 1); loadMonth(); } else if (deltaX < -SWIPE_THRESHOLD) { current.setMonth(current.getMonth() + 1); loadMonth(); } }, { passive: true }); })(); function bindStickyScrollShadow() { var stickyEl = document.getElementById("calendarSticky"); if (!stickyEl || document._stickyScrollBound) return; document._stickyScrollBound = true; function updateScrolled() { stickyEl.classList.toggle("is-scrolled", window.scrollY > 0); } window.addEventListener("scroll", updateScrolled, { passive: true }); updateScrolled(); } runWhenReady(function () { requireTelegramOrLocalhost(function () { bindStickyScrollShadow(); loadMonth(); }); }); })();