(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 "