Files
duty-teller/webapp/app.js
Nikolay Tatarinov 7e09da2b9e chore: update Docker build and release workflow
- Refactored the `docker-build.yml` workflow to improve clarity and maintainability.
- Ensured the workflow correctly handles Docker image building and pushing to the Gitea Container Registry upon version tag pushes.
- Enhanced the release step to generate release notes based on previous tags, improving release documentation.
2026-02-19 14:38:51 +03:00

847 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const FETCH_TIMEOUT_MS = 15000;
const RETRY_DELAY_MS = 800;
const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
const THEME_BG = { dark: "#17212b", 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";
}
/**
* Map Telegram themeParams (snake_case) to CSS variables (--tg-theme-*).
* Only set when Telegram WebApp and themeParams are available.
*/
function applyThemeParamsToCss() {
var twa = window.Telegram?.WebApp;
var params = twa?.themeParams;
if (!params) return;
var root = document.documentElement;
var 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(function (pair) {
var value = params[pair[0]];
if (value) root.style.setProperty("--tg-theme-" + pair[1], value);
});
}
function applyTheme() {
var scheme = getTheme();
document.documentElement.dataset.theme = scheme;
var twa = window.Telegram?.WebApp;
if (twa) {
if (twa.setBackgroundColor) twa.setBackgroundColor("bg_color");
if (twa.setHeaderColor) twa.setHeaderColor("bg_color");
applyThemeParamsToCss();
} else {
/* Outside Telegram: only data-theme is set; no setBackgroundColor/setHeaderColor. */
}
}
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")) : ""; }
var dutyItemsJson = dutyList.length
? JSON.stringify(dutyList.map(function (x) { return { full_name: x.full_name, start_at: x.start_at, end_at: x.end_at }; })).replace(/'/g, "&#39;")
: "";
let html = "<span class=\"num\">" + d.getDate() + "</span><div class=\"day-markers\">";
if (showMarkers) {
if (dutyList.length) {
html += "<button type=\"button\" class=\"duty-marker\" data-event-type=\"duty\" data-date=\"" + escapeHtml(key) + "\" data-names=\"" + namesAttr(dutyList) + "\" data-duty-items='" + dutyItemsJson + "' aria-label=\"Дежурные\">Д</button>";
}
if (unavailableList.length) {
html += "<button type=\"button\" class=\"unavailable-marker\" data-event-type=\"unavailable\" data-names=\"" + namesAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</button>";
}
if (vacationList.length) {
html += "<button type=\"button\" class=\"vacation-marker\" data-event-type=\"vacation\" data-names=\"" + namesAttr(vacationList) + "\" aria-label=\"Отпуск\">О</button>";
}
if (hasEvent) {
html += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>";
}
}
html += "</div>";
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") || "";
var content = "События:\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
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");
}
});
}
}
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
function formatHHMM(isoStr) {
if (!isoStr) { return ""; }
var d = new Date(isoStr);
var h = d.getHours();
var m = d.getMinutes();
return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m;
}
function getDutyMarkerHintContent(marker) {
var type = marker.getAttribute("data-event-type") || "duty";
var label = EVENT_TYPE_LABELS[type] || type;
var names = marker.getAttribute("data-names") || "";
var body;
if (type === "duty") {
var dutyItemsRaw = marker.getAttribute("data-duty-items") || (marker.dataset && marker.dataset.dutyItems) || "";
var dutyItems = [];
try {
if (dutyItemsRaw) { dutyItems = JSON.parse(dutyItemsRaw); }
} catch (e) { /* ignore */ }
var hasTimes = dutyItems.length > 0 && dutyItems.some(function (it) {
var start = it.start_at != null ? it.start_at : it.startAt;
var end = it.end_at != null ? it.end_at : it.endAt;
return start || end;
});
if (dutyItems.length >= 1 && hasTimes) {
var hintDay = marker.getAttribute("data-date") || "";
body = dutyItems.map(function (item, idx) {
var startAt = item.start_at != null ? item.start_at : item.startAt;
var endAt = item.end_at != null ? item.end_at : item.endAt;
var endHHMM = endAt ? formatHHMM(endAt) : "";
var startHHMM = startAt ? formatHHMM(startAt) : "";
var startSameDay = hintDay && startAt && localDateString(new Date(startAt)) === hintDay;
var endSameDay = hintDay && endAt && localDateString(new Date(endAt)) === hintDay;
var fullName = item.full_name != null ? item.full_name : item.fullName;
var parts = [fullName];
if (idx === 0) {
if (startSameDay && startHHMM) {
parts.push("с " + startHHMM);
if (endSameDay && endHHMM && endHHMM !== startHHMM) { parts.push("до " + endHHMM); }
} else if (endHHMM) {
parts.push("до " + endHHMM);
}
} else if (idx > 0) {
if (startHHMM) { parts.push("с " + startHHMM); }
if (endHHMM && endSameDay && endHHMM !== startHHMM) { parts.push("до " + endHHMM); }
}
return parts.join(", ");
}).join("\n");
} else {
body = names;
}
} else {
body = names;
}
return body ? label + ":\n" + body : label;
}
function clearActiveDutyMarker() {
calendarEl.querySelectorAll(".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active")
.forEach(function (m) { m.classList.remove("calendar-marker-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;
}
hintEl.textContent = getDutyMarkerHintContent(marker);
var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
});
marker.addEventListener("mouseleave", function () {
if (hintEl.dataset.active) { return; }
hideTimeout = setTimeout(function () {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", function (e) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
marker.classList.remove("calendar-marker-active");
return;
}
clearActiveDutyMarker();
hintEl.textContent = getDutyMarkerHintContent(marker);
var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
hintEl.dataset.active = "1";
marker.classList.add("calendar-marker-active");
});
});
if (!document._dutyMarkerHintBound) {
document._dutyMarkerHintBound = true;
document.addEventListener("click", function () {
if (hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}
});
}
}
/** 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 "<div class=\"duty-item duty-item--duty duty-timeline-card" + extraClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + escapeHtml(timeStr) + "</div></div>";
}
/** 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 "<div class=\"" + itemClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + timeOrRange + "</div></div>";
}
function renderDutyList(duties) {
duties = duties.filter(function (d) { return d.event_type === "duty"; });
if (duties.length === 0) {
dutyListEl.classList.remove("duty-timeline");
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
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 dateCellHtml = isToday
? "<span class=\"duty-timeline-date\"><span class=\"duty-timeline-date-label\">Сегодня</span><span class=\"duty-timeline-date-day\">" + escapeHtml(dateLabel) + "</span><span class=\"duty-timeline-date-dot\" aria-hidden=\"true\"></span></span>"
: "<span class=\"duty-timeline-date\">" + escapeHtml(dateLabel) + "</span>";
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 += "<div class=\"duty-timeline-row\">" + dateCellHtml + "<span class=\"duty-timeline-track\" aria-hidden=\"true\"></span><div class=\"duty-timeline-card-wrap\">" + dutyTimelineCardHtml(d, isCurrent) + "</div></div>";
});
if (dayDuties.length === 0 && isToday) {
dayHtml += "<div class=\"duty-timeline-row duty-timeline-row--empty\">" + dateCellHtml + "<span class=\"duty-timeline-track\" aria-hidden=\"true\"></span><div class=\"duty-timeline-card-wrap\"></div></div>";
}
fullHtml += "<div class=\"" + dayClass + "\" data-date=\"" + escapeHtml(date) + "\">" + dayHtml + "</div>";
});
dutyListEl.innerHTML = fullHtml;
var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today");
if (scrollTarget) {
var calendarSticky = document.getElementById("calendarSticky");
if (calendarSticky) {
requestAnimationFrame(function () {
var calendarHeight = calendarSticky.offsetHeight;
var todayTop = scrollTarget.getBoundingClientRect().top + window.scrollY;
var scrollTop = Math.max(0, todayTop - calendarHeight);
window.scrollTo({ top: scrollTop, behavior: "auto" });
});
} else {
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();
});
});
})();