Files
duty-teller/webapp/app.js
Nikolay Tatarinov bf9fc59a3f Implement external calendar integration and enhance API functionality
- Added support for fetching and parsing external ICS calendars, allowing events to be displayed on the duty grid.
- Introduced a new API endpoint `/api/calendar-events` to retrieve calendar events within a specified date range.
- Updated configuration to include `EXTERNAL_CALENDAR_ICS_URL` for specifying the ICS calendar URL.
- Enhanced the web application to visually indicate days with events and provide event summaries on hover.
- Improved documentation in the README to include details about the new calendar integration and configuration options.
- Updated tests to cover the new calendar functionality and ensure proper integration.
2026-02-17 20:58:59 +03:00

410 lines
14 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 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 =
"<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "") +
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
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 = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
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 += "<h2>" + date + "</h2>";
list.forEach(function (d) {
const startDate = new Date(d.start_at);
const endDate = new Date(d.end_at);
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");
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
});
});
dutyListEl.innerHTML = html;
}
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();
}
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 from = localDateString(firstDayOfMonth(current));
const to = localDateString(lastDayOfMonth(current));
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);
renderDutyList(duties);
} 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();
});
runWhenReady(function () {
requireTelegramOrLocalhost(function () {
loadMonth();
});
});
})();