Files
duty-teller/webapp/app.js
Nikolay Tatarinov 78a1696a69 Enhance calendar duty display and tooltip functionality
- Updated the calendar rendering to use a duty marker with improved accessibility attributes for duty names and titles.
- Introduced a new function `bindDutyMarkerTooltips` to display tooltips with duty names on hover over duty markers.
- Enhanced CSS styles for duty markers to improve visibility and user interaction.
- Ensured tooltips are dynamically positioned and hidden appropriately to enhance user experience.
2026-02-17 22:47:06 +03:00

483 lines
17 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" : "");
const dutyNamesAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join("\n")) : "";
const dutyTitleAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join(", ")) : "";
cell.innerHTML =
"<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"duty-marker\" data-duty-names=\"" + dutyNamesAttr + "\" title=\"" + dutyTitleAttr + "\" aria-label=\"Дежурные\">Д</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();
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;
calendarEl.querySelectorAll(".duty-marker").forEach(function (marker) {
marker.addEventListener("mouseenter", function () {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
var names = marker.getAttribute("data-duty-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);
});
});
}
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();
});
});
})();