diff --git a/webapp/app.js b/webapp/app.js deleted file mode 100644 index 2705393..0000000 --- a/webapp/app.js +++ /dev/null @@ -1,912 +0,0 @@ -(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, "'") - : ""; - - 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") || ""; - 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") || ""; - var nbsp = "\u00a0"; - 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 timePrefix = ""; - if (idx === 0) { - if (dutyItems.length === 1 && startSameDay && startHHMM) { - timePrefix = "с" + nbsp + startHHMM; - if (endSameDay && endHHMM && endHHMM !== startHHMM) { timePrefix += " до" + nbsp + endHHMM; } - } else if (endHHMM) { - timePrefix = "до" + nbsp + endHHMM; - } - } else if (idx > 0) { - if (startHHMM) { timePrefix = "с" + nbsp + startHHMM; } - if (endHHMM && endSameDay && endHHMM !== startHHMM) { timePrefix += (timePrefix ? " " : "") + "до" + nbsp + endHHMM; } - } - return timePrefix ? timePrefix + " — " + fullName : fullName; - }).join("\n"); - } else { - body = names; - } - } else { - body = names; - } - return body ? label + ":\n" + body : label; - } - - /** Returns HTML for duty hint with aligned times and no wrap inside name, or null to use textContent. */ - function getDutyMarkerHintHtml(marker) { - var type = marker.getAttribute("data-event-type") || "duty"; - if (type !== "duty") { return null; } - var names = marker.getAttribute("data-names") || ""; - 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; - }); - var hintDay = marker.getAttribute("data-date") || ""; - var nbsp = "\u00a0"; - var rows; - if (dutyItems.length >= 1 && hasTimes) { - rows = 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 timePrefix = ""; - if (idx === 0) { - if (dutyItems.length === 1 && startSameDay && startHHMM) { - timePrefix = "с" + nbsp + startHHMM; - if (endSameDay && endHHMM && endHHMM !== startHHMM) { timePrefix += " до" + nbsp + endHHMM; } - } else if (endHHMM) { - timePrefix = "до" + nbsp + endHHMM; - } - } else if (idx > 0) { - if (startHHMM) { timePrefix = "с" + nbsp + startHHMM; } - if (endHHMM && endSameDay && endHHMM !== startHHMM) { timePrefix += (timePrefix ? " " : "") + "до" + nbsp + endHHMM; } - } - var timeHtml = timePrefix ? escapeHtml(timePrefix) : ""; - var nameHtml = escapeHtml(fullName); - var namePart = timePrefix ? " — " + nameHtml : nameHtml; - return "" + timeHtml + "" + namePart + ""; - }); - } else { - rows = (names ? names.split("\n") : []).map(function (fullName) { - var nameHtml = escapeHtml(fullName.trim()); - return "" + nameHtml + ""; - }); - } - if (rows.length === 0) { return null; } - return "
Дежурство:
" + - rows.map(function (r) { return "
" + r + "
"; }).join("") + "
"; - } - - 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; - } - var html = getDutyMarkerHintHtml(marker); - if (html) { - hintEl.innerHTML = html; - } else { - 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(); - var html = getDutyMarkerHintHtml(marker); - if (html) { - hintEl.innerHTML = html; - } else { - 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 "
" + 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 dateCellHtml = isToday - ? "Сегодня" + escapeHtml(dateLabel) + "" - : "" + escapeHtml(dateLabel) + ""; - 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 += "
" + dateCellHtml + "
" + dutyTimelineCardHtml(d, isCurrent) + "
"; - }); - if (dayDuties.length === 0 && isToday) { - dayHtml += "
" + dateCellHtml + "
"; - } - fullHtml += "
" + dayHtml + "
"; - }); - 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(); - }); - }); -})(); diff --git a/webapp/index.html b/webapp/index.html index b55a016..95b7f1f 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -28,6 +28,6 @@ - + diff --git a/webapp/js/api.js b/webapp/js/api.js new file mode 100644 index 0000000..b97509a --- /dev/null +++ b/webapp/js/api.js @@ -0,0 +1,93 @@ +/** + * Network requests: duties and calendar events. + */ + +import { FETCH_TIMEOUT_MS } from "./constants.js"; +import { getInitData } from "./auth.js"; + +/** + * Build fetch options with init data header and timeout abort. + * @param {string} initData - Telegram init data + * @returns {{ headers: object, signal: AbortSignal, timeoutId: number }} + */ +export function buildFetchOptions(initData) { + const headers = {}; + if (initData) headers["X-Telegram-Init-Data"] = initData; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + return { headers, signal: controller.signal, timeoutId }; +} + +/** + * Fetch duties for date range. Throws ACCESS_DENIED error on 403. + * @param {string} from - YYYY-MM-DD + * @param {string} to - YYYY-MM-DD + * @returns {Promise} + */ +export 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 { + const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); + if (res.status === 403) { + let detail = "Доступ запрещён"; + try { + const 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 */ + } + const 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. + * @param {string} from - YYYY-MM-DD + * @param {string} to - YYYY-MM-DD + * @returns {Promise} + */ +export 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 { + const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); + if (!res.ok) return []; + return res.json(); + } catch (e) { + return []; + } finally { + clearTimeout(opts.timeoutId); + } +} diff --git a/webapp/js/auth.js b/webapp/js/auth.js new file mode 100644 index 0000000..9bed5fa --- /dev/null +++ b/webapp/js/auth.js @@ -0,0 +1,81 @@ +/** + * Telegram init data and access checks. + */ + +/** + * Get tgWebAppData value from hash when it contains unencoded & and =. + * Value runs from tgWebAppData= until next &tgWebApp or end. + * @param {string} hash - location.hash without # + * @returns {string} + */ +export function getTgWebAppDataFromHash(hash) { + const idx = hash.indexOf("tgWebAppData="); + if (idx === -1) return ""; + const start = idx + "tgWebAppData=".length; + let end = hash.indexOf("&tgWebApp", start); + if (end === -1) end = hash.length; + const raw = hash.substring(start, end); + try { + return decodeURIComponent(raw); + } catch (e) { + return raw; + } +} + +/** + * Get Telegram init data string (from SDK, hash or query). + * @returns {string} + */ +export function getInitData() { + const fromSdk = + (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData) || ""; + if (fromSdk) return fromSdk; + const hash = window.location.hash ? window.location.hash.slice(1) : ""; + if (hash) { + const fromHash = getTgWebAppDataFromHash(hash); + if (fromHash) return fromHash; + try { + const hashParams = new URLSearchParams(hash); + const tgFromHash = hashParams.get("tgWebAppData"); + if (tgFromHash) return decodeURIComponent(tgFromHash); + } catch (e) { + /* ignore */ + } + } + const q = window.location.search + ? new URLSearchParams(window.location.search).get("tgWebAppData") + : null; + if (q) { + try { + return decodeURIComponent(q); + } catch (e) { + return q; + } + } + return ""; +} + +/** + * @returns {boolean} True if host is localhost or 127.0.0.1 + */ +export function isLocalhost() { + const h = window.location.hostname; + return h === "localhost" || h === "127.0.0.1" || h === ""; +} + +/** + * True when hash has tg WebApp params but no init data (e.g. version without data). + * @returns {boolean} + */ +export function hasTelegramHashButNoInitData() { + const hash = window.location.hash ? window.location.hash.slice(1) : ""; + if (!hash) return false; + try { + const keys = Array.from(new URLSearchParams(hash).keys()); + const hasVersion = keys.indexOf("tgWebAppVersion") !== -1; + const hasData = keys.indexOf("tgWebAppData") !== -1 || !!getTgWebAppDataFromHash(hash); + return hasVersion && !hasData; + } catch (e) { + return false; + } +} diff --git a/webapp/js/calendar.js b/webapp/js/calendar.js new file mode 100644 index 0000000..2ac5459 --- /dev/null +++ b/webapp/js/calendar.js @@ -0,0 +1,157 @@ +/** + * Calendar grid and events-by-date mapping. + */ + +import { calendarEl, monthTitleEl } from "./dom.js"; +import { MONTHS } from "./constants.js"; +import { escapeHtml } from "./utils.js"; +import { + localDateString, + firstDayOfMonth, + lastDayOfMonth, + getMonday +} from "./dateUtils.js"; +import { bindInfoButtonTooltips } from "./hints.js"; +import { bindDutyMarkerTooltips } from "./hints.js"; + +/** + * Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local. + * @param {object[]} events - Calendar events with date, summary + * @returns {Record} + */ +export function calendarEventsByDate(events) { + const byDate = {}; + (events || []).forEach((e) => { + const utcMidnight = new Date(e.date + "T00:00:00Z"); + const key = localDateString(utcMidnight); + if (!byDate[key]) byDate[key] = []; + if (e.summary) byDate[key].push(e.summary); + }); + return byDate; +} + +/** + * Group duties by local date (start_at/end_at are UTC). + * @param {object[]} duties - Duties with start_at, end_at + * @returns {Record} + */ +export function dutiesByDate(duties) { + const byDate = {}; + duties.forEach((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; +} + +/** + * Render calendar grid for given month with duties and calendar events. + * @param {number} year + * @param {number} month - 0-based + * @param {Record} dutiesByDateMap + * @param {Record} calendarEventsByDateMap + */ +export function renderCalendar( + year, + month, + dutiesByDateMap, + calendarEventsByDateMap +) { + if (!calendarEl || !monthTitleEl) return; + 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()); + const calendarEventsByDate = calendarEventsByDateMap || {}; + + 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 = dutiesByDateMap[key] || []; + const dutyList = dayDuties.filter((x) => x.event_type === "duty"); + const unavailableList = dayDuties.filter((x) => x.event_type === "unavailable"); + const vacationList = dayDuties.filter((x) => x.event_type === "vacation"); + const hasAny = dayDuties.length > 0; + const isToday = key === today; + const eventSummaries = calendarEventsByDate[key] || []; + const hasEvent = eventSummaries.length > 0; + + const showMarkers = !isOther; + const cell = document.createElement("div"); + cell.className = + "day" + + (isOther ? " other-month" : "") + + (isToday ? " today" : "") + + (showMarkers && hasAny ? " has-duty" : "") + + (showMarkers && hasEvent ? " holiday" : ""); + + const namesAttr = (list) => + list.length + ? escapeHtml(list.map((x) => x.full_name).join("\n")) + : ""; + const dutyItemsJson = dutyList.length + ? JSON.stringify( + dutyList.map((x) => ({ + full_name: x.full_name, + start_at: x.start_at, + end_at: x.end_at + })) + ).replace(/'/g, "'") + : ""; + + 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(); +} diff --git a/webapp/js/constants.js b/webapp/js/constants.js new file mode 100644 index 0000000..80fe6e2 --- /dev/null +++ b/webapp/js/constants.js @@ -0,0 +1,20 @@ +/** + * Application constants and static labels. + */ + +export const FETCH_TIMEOUT_MS = 15000; +export const RETRY_DELAY_MS = 800; +export const RETRY_AFTER_ACCESS_DENIED_MS = 1200; + +export const THEME_BG = { dark: "#17212b", light: "#d5d6db" }; + +export const MONTHS = [ + "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" +]; + +export const EVENT_TYPE_LABELS = { + duty: "Дежурство", + unavailable: "Недоступен", + vacation: "Отпуск" +}; diff --git a/webapp/js/dateUtils.js b/webapp/js/dateUtils.js new file mode 100644 index 0000000..539410d --- /dev/null +++ b/webapp/js/dateUtils.js @@ -0,0 +1,91 @@ +/** + * Date/time helpers for calendar and duty display. + */ + +/** + * YYYY-MM-DD in local time (for calendar keys, "today", request range). + * @param {Date} d - Date + * @returns {string} + */ +export 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. + * @param {object} d - Duty with start_at, end_at + * @param {string} dateKey - YYYY-MM-DD + * @returns {boolean} + */ +export 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; +} + +/** @param {Date} d - Date */ +export function firstDayOfMonth(d) { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +/** @param {Date} d - Date */ +export function lastDayOfMonth(d) { + return new Date(d.getFullYear(), d.getMonth() + 1, 0); +} + +/** @param {Date} d - Date (returns Monday of that week) */ +export function getMonday(d) { + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + return new Date(d.getFullYear(), d.getMonth(), diff); +} + +/** + * Format UTC date from ISO string as DD.MM for display. + * @param {string} isoDateStr - ISO date string + * @returns {string} DD.MM + */ +export 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. + * @param {string} key - YYYY-MM-DD + * @returns {string} DD.MM + */ +export function dateKeyToDDMM(key) { + return key.slice(8, 10) + "." + key.slice(5, 7); +} + +/** + * Format ISO date as HH:MM in local time. + * @param {string} isoStr - ISO date string + * @returns {string} HH:MM + */ +export function formatTimeLocal(isoStr) { + const d = new Date(isoStr); + return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); +} + +/** + * Format ISO string as HH:MM (local). + * @param {string} isoStr - ISO date string + * @returns {string} HH:MM or "" + */ +export function formatHHMM(isoStr) { + if (!isoStr) return ""; + const d = new Date(isoStr); + const h = d.getHours(); + const m = d.getMinutes(); + return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m; +} diff --git a/webapp/js/dom.js b/webapp/js/dom.js new file mode 100644 index 0000000..d4369f5 --- /dev/null +++ b/webapp/js/dom.js @@ -0,0 +1,43 @@ +/** + * DOM references and shared application state. + */ + +/** @type {HTMLDivElement|null} */ +export const calendarEl = document.getElementById("calendar"); + +/** @type {HTMLElement|null} */ +export const monthTitleEl = document.getElementById("monthTitle"); + +/** @type {HTMLDivElement|null} */ +export const dutyListEl = document.getElementById("dutyList"); + +/** @type {HTMLElement|null} */ +export const loadingEl = document.getElementById("loading"); + +/** @type {HTMLElement|null} */ +export const errorEl = document.getElementById("error"); + +/** @type {HTMLElement|null} */ +export const accessDeniedEl = document.getElementById("accessDenied"); + +/** @type {HTMLElement|null} */ +export const headerEl = document.querySelector(".header"); + +/** @type {HTMLElement|null} */ +export const weekdaysEl = document.querySelector(".weekdays"); + +/** @type {HTMLButtonElement|null} */ +export const prevBtn = document.getElementById("prevMonth"); + +/** @type {HTMLButtonElement|null} */ +export const nextBtn = document.getElementById("nextMonth"); + +/** Currently viewed month (mutable). */ +export const state = { + /** @type {Date} */ + current: new Date(), + /** @type {object[]} */ + lastDutiesForList: [], + /** @type {ReturnType|null} */ + todayRefreshInterval: null +}; diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js new file mode 100644 index 0000000..ea0dcb4 --- /dev/null +++ b/webapp/js/dutyList.js @@ -0,0 +1,187 @@ +/** + * Duty list (timeline) rendering. + */ + +import { dutyListEl, state } from "./dom.js"; +import { EVENT_TYPE_LABELS } from "./constants.js"; +import { escapeHtml } from "./utils.js"; +import { + localDateString, + firstDayOfMonth, + lastDayOfMonth, + dateKeyToDDMM, + formatTimeLocal, + formatDateKey +} from "./dateUtils.js"; +import { dutiesByDate } from "./calendar.js"; + +/** + * Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day. + * @param {object} d - Duty + * @param {boolean} isCurrent - Whether this is "current" duty + * @returns {string} + */ +export 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" + * @returns {string} + */ +export 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 + + "
" + ); +} + +/** + * Render duty list (timeline) for current month; scroll to today if visible. + * @param {object[]} duties - Duties (only duty type used for timeline) + */ +export function renderDutyList(duties) { + if (!dutyListEl) return; + const filtered = duties.filter((d) => d.event_type === "duty"); + if (filtered.length === 0) { + dutyListEl.classList.remove("duty-timeline"); + dutyListEl.innerHTML = '

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

'; + return; + } + dutyListEl.classList.add("duty-timeline"); + const current = state.current; + 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(); + filtered.forEach((d) => { + dateSet.add(localDateString(new Date(d.start_at))); + }); + if (showTodayInMonth) dateSet.add(todayKey); + const dates = Array.from(dateSet).sort(); + const now = new Date(); + let fullHtml = ""; + dates.forEach((date) => { + const isToday = date === todayKey; + const dayClass = + "duty-timeline-day" + (isToday ? " duty-timeline-day--today" : ""); + const dateLabel = dateKeyToDDMM(date); + const dateCellHtml = isToday + ? 'Сегодня' + + escapeHtml(dateLabel) + + '' + : '' + escapeHtml(dateLabel) + ""; + const dayDuties = filtered + .filter((d) => localDateString(new Date(d.start_at)) === date) + .sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); + let dayHtml = ""; + dayDuties.forEach((d) => { + const start = new Date(d.start_at); + const end = new Date(d.end_at); + const isCurrent = isToday && start <= now && now < end; + dayHtml += + '
' + + dateCellHtml + + '
' + + dutyTimelineCardHtml(d, isCurrent) + + "
"; + }); + if (dayDuties.length === 0 && isToday) { + dayHtml += + '
' + + dateCellHtml + + '
'; + } + fullHtml += + '
' + + dayHtml + + "
"; + }); + dutyListEl.innerHTML = fullHtml; + const scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today"); + if (scrollTarget) { + const calendarSticky = document.getElementById("calendarSticky"); + if (calendarSticky) { + requestAnimationFrame(() => { + const calendarHeight = calendarSticky.offsetHeight; + const todayTop = scrollTarget.getBoundingClientRect().top + window.scrollY; + const scrollTop = Math.max(0, todayTop - calendarHeight); + window.scrollTo({ top: scrollTop, behavior: "auto" }); + }); + } else { + scrollTarget.scrollIntoView({ behavior: "auto", block: "start" }); + } + } +} diff --git a/webapp/js/hints.js b/webapp/js/hints.js new file mode 100644 index 0000000..9c4632d --- /dev/null +++ b/webapp/js/hints.js @@ -0,0 +1,362 @@ +/** + * Tooltips for calendar info buttons and duty markers. + */ + +import { calendarEl } from "./dom.js"; +import { EVENT_TYPE_LABELS } from "./constants.js"; +import { escapeHtml } from "./utils.js"; +import { localDateString, formatHHMM } from "./dateUtils.js"; + +/** + * Position hint element near button; flip below if needed, clamp to viewport. + * @param {HTMLElement} hintEl + * @param {DOMRect} btnRect + */ +export function positionHint(hintEl, btnRect) { + const vw = document.documentElement.clientWidth; + const margin = 12; + hintEl.classList.remove("below"); + hintEl.style.left = btnRect.left + "px"; + hintEl.style.top = btnRect.top - 4 + "px"; + hintEl.hidden = false; + requestAnimationFrame(() => { + const hintRect = hintEl.getBoundingClientRect(); + let left = parseFloat(hintEl.style.left); + let 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(() => { + const hintRect2 = hintEl.getBoundingClientRect(); + let left2 = parseFloat(hintEl.style.left); + if (hintRect2.right > vw - margin) { + left2 = vw - hintRect2.width - margin; + } + if (left2 < margin) left2 = margin; + hintEl.style.left = left2 + "px"; + }); + } + }); +} + +/** + * Get plain text content for duty marker tooltip. + * @param {HTMLElement} marker - .duty-marker, .unavailable-marker or .vacation-marker + * @returns {string} + */ +export function getDutyMarkerHintContent(marker) { + const type = marker.getAttribute("data-event-type") || "duty"; + const label = EVENT_TYPE_LABELS[type] || type; + const names = marker.getAttribute("data-names") || ""; + let body; + if (type === "duty") { + const dutyItemsRaw = + marker.getAttribute("data-duty-items") || + (marker.dataset && marker.dataset.dutyItems) || + ""; + let dutyItems = []; + try { + if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw); + } catch (e) { + /* ignore */ + } + const hasTimes = + dutyItems.length > 0 && + dutyItems.some((it) => { + const start = it.start_at != null ? it.start_at : it.startAt; + const end = it.end_at != null ? it.end_at : it.endAt; + return start || end; + }); + if (dutyItems.length >= 1 && hasTimes) { + const hintDay = marker.getAttribute("data-date") || ""; + body = dutyItems + .map((item, idx) => { + const startAt = item.start_at != null ? item.start_at : item.startAt; + const endAt = item.end_at != null ? item.end_at : item.endAt; + const endHHMM = endAt ? formatHHMM(endAt) : ""; + const startHHMM = startAt ? formatHHMM(startAt) : ""; + const startSameDay = + hintDay && startAt && localDateString(new Date(startAt)) === hintDay; + const endSameDay = + hintDay && endAt && localDateString(new Date(endAt)) === hintDay; + const fullName = + item.full_name != null ? item.full_name : item.fullName; + let timePrefix = ""; + if (idx === 0) { + if ( + dutyItems.length === 1 && + startSameDay && + startHHMM + ) { + timePrefix = "с - " + startHHMM; + if (endSameDay && endHHMM && endHHMM !== startHHMM) { + timePrefix += " до - " + endHHMM; + } + } else if (endHHMM) { + timePrefix = "до -" + endHHMM; + } + } else if (idx > 0) { + if (startHHMM) timePrefix = "с - " + startHHMM; + if ( + endHHMM && + endSameDay && + endHHMM !== startHHMM + ) { + timePrefix += (timePrefix ? " " : "") + "до - " + endHHMM; + } + } + return timePrefix ? timePrefix + " — " + fullName : fullName; + }) + .join("\n"); + } else { + body = names; + } + } else { + body = names; + } + return body ? label + ":\n" + body : label; +} + +/** + * Returns HTML for duty hint with aligned times, or null to use textContent. + * @param {HTMLElement} marker + * @returns {string|null} + */ +export function getDutyMarkerHintHtml(marker) { + const type = marker.getAttribute("data-event-type") || "duty"; + if (type !== "duty") return null; + const names = marker.getAttribute("data-names") || ""; + const dutyItemsRaw = + marker.getAttribute("data-duty-items") || + (marker.dataset && marker.dataset.dutyItems) || + ""; + let dutyItems = []; + try { + if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw); + } catch (e) { + /* ignore */ + } + const hasTimes = + dutyItems.length > 0 && + dutyItems.some((it) => { + const start = it.start_at != null ? it.start_at : it.startAt; + const end = it.end_at != null ? it.end_at : it.endAt; + return start || end; + }); + const hintDay = marker.getAttribute("data-date") || ""; + const nbsp = "\u00a0"; + let rows; + if (dutyItems.length >= 1 && hasTimes) { + rows = dutyItems.map((item, idx) => { + const startAt = item.start_at != null ? item.start_at : item.startAt; + const endAt = item.end_at != null ? item.end_at : item.endAt; + const endHHMM = endAt ? formatHHMM(endAt) : ""; + const startHHMM = startAt ? formatHHMM(startAt) : ""; + const startSameDay = + hintDay && startAt && localDateString(new Date(startAt)) === hintDay; + const endSameDay = + hintDay && endAt && localDateString(new Date(endAt)) === hintDay; + const fullName = + item.full_name != null ? item.full_name : item.fullName; + let timePrefix = ""; + if (idx === 0) { + if ( + dutyItems.length === 1 && + startSameDay && + startHHMM + ) { + timePrefix = "с" + nbsp + startHHMM; + if (endSameDay && endHHMM && endHHMM !== startHHMM) { + timePrefix += " до" + nbsp + endHHMM; + } + } else if (endHHMM) { + timePrefix = "до" + nbsp + endHHMM; + } + } else if (idx > 0) { + if (startHHMM) timePrefix = "с" + nbsp + startHHMM; + if ( + endHHMM && + endSameDay && + endHHMM !== startHHMM + ) { + timePrefix += (timePrefix ? " " : "") + "до" + nbsp + endHHMM; + } + } + const timeHtml = timePrefix ? escapeHtml(timePrefix) : ""; + const nameHtml = escapeHtml(fullName); + const namePart = timePrefix ? " — " + nameHtml : nameHtml; + return ( + '' + + timeHtml + + '' + + namePart + + "" + ); + }); + } else { + rows = (names ? names.split("\n") : []).map((fullName) => { + const nameHtml = escapeHtml(fullName.trim()); + return ( + '' + + nameHtml + + "" + ); + }); + } + if (rows.length === 0) return null; + return ( + '
Дежурство:
' + + rows + .map((r) => '
' + r + "
") + .join("") + + "
" + ); +} + +/** + * Remove active class from all duty/unavailable/vacation markers. + */ +export function clearActiveDutyMarker() { + if (!calendarEl) return; + calendarEl + .querySelectorAll( + ".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active" + ) + .forEach((m) => m.classList.remove("calendar-marker-active")); +} + +/** + * Bind click tooltips for .info-btn (calendar event summaries). + */ +export function bindInfoButtonTooltips() { + let 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); + } + if (!calendarEl) return; + calendarEl.querySelectorAll(".info-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const summary = btn.getAttribute("data-summary") || ""; + const content = "События:\n" + summary; + if (hintEl.hidden || hintEl.textContent !== content) { + hintEl.textContent = content; + const 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", () => { + if (hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + } + }); + } +} + +/** + * Bind hover/click tooltips for duty/unavailable/vacation markers. + */ +export function bindDutyMarkerTooltips() { + let 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); + } + if (!calendarEl) return; + let hideTimeout = null; + const selector = ".duty-marker, .unavailable-marker, .vacation-marker"; + calendarEl.querySelectorAll(selector).forEach((marker) => { + marker.addEventListener("mouseenter", () => { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + const html = getDutyMarkerHintHtml(marker); + if (html) { + hintEl.innerHTML = html; + } else { + hintEl.textContent = getDutyMarkerHintContent(marker); + } + const rect = marker.getBoundingClientRect(); + positionHint(hintEl, rect); + hintEl.hidden = false; + }); + marker.addEventListener("mouseleave", () => { + if (hintEl.dataset.active) return; + hideTimeout = setTimeout(() => { + hintEl.hidden = true; + hideTimeout = null; + }, 150); + }); + marker.addEventListener("click", (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(); + const html = getDutyMarkerHintHtml(marker); + if (html) { + hintEl.innerHTML = html; + } else { + hintEl.textContent = getDutyMarkerHintContent(marker); + } + const 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", () => { + if (hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + clearActiveDutyMarker(); + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + clearActiveDutyMarker(); + } + }); + } +} diff --git a/webapp/js/main.js b/webapp/js/main.js new file mode 100644 index 0000000..1272bf8 --- /dev/null +++ b/webapp/js/main.js @@ -0,0 +1,234 @@ +/** + * Entry point: theme init, auth gate, loadMonth, nav and swipe handlers. + */ + +import { initTheme, applyTheme } from "./theme.js"; +import { getInitData } from "./auth.js"; +import { isLocalhost } from "./auth.js"; +import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js"; +import { + state, + accessDeniedEl, + prevBtn, + nextBtn, + loadingEl, + errorEl +} from "./dom.js"; +import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js"; +import { fetchDuties, fetchCalendarEvents } from "./api.js"; +import { + dutiesByDate, + calendarEventsByDate, + renderCalendar +} from "./calendar.js"; +import { renderDutyList } from "./dutyList.js"; +import { + firstDayOfMonth, + lastDayOfMonth, + getMonday, + localDateString +} from "./dateUtils.js"; + +initTheme(); + +/** + * Run callback when Telegram WebApp is ready (or immediately outside Telegram). + * Expands and applies theme when in TWA. + * @param {() => void} cb + */ +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(() => 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. + * @param {() => void} onAllowed + */ +function requireTelegramOrLocalhost(onAllowed) { + let initData = getInitData(); + const isLocal = isLocalhost(); + if (initData) { + onAllowed(); + return; + } + if (isLocal) { + onAllowed(); + return; + } + if (window.Telegram && window.Telegram.WebApp) { + setTimeout(() => { + initData = getInitData(); + if (initData) { + onAllowed(); + return; + } + showAccessDenied(); + if (loadingEl) loadingEl.classList.add("hidden"); + }, RETRY_DELAY_MS); + return; + } + showAccessDenied(); + if (loadingEl) loadingEl.classList.add("hidden"); +} + +/** + * Load current month: fetch duties and events, render calendar and duty list. + */ +async function loadMonth() { + hideAccessDenied(); + setNavEnabled(false); + if (loadingEl) loadingEl.classList.remove("hidden"); + if (errorEl) errorEl.hidden = true; + const current = state.current; + 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((d) => { + const byDateLocal = dutiesByDate([d]); + return Object.keys(byDateLocal).some( + (key) => key >= firstKey && key <= lastKey + ); + }); + state.lastDutiesForList = dutiesInMonth; + renderDutyList(dutiesInMonth); + if (state.todayRefreshInterval) { + clearInterval(state.todayRefreshInterval); + } + state.todayRefreshInterval = null; + const viewedMonth = current.getFullYear() * 12 + current.getMonth(); + const thisMonth = + new Date().getFullYear() * 12 + new Date().getMonth(); + if (viewedMonth === thisMonth) { + state.todayRefreshInterval = setInterval(() => { + if ( + current.getFullYear() * 12 + current.getMonth() !== + new Date().getFullYear() * 12 + new Date().getMonth() + ) { + return; + } + renderDutyList(state.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; + } + if (loadingEl) loadingEl.classList.add("hidden"); + setNavEnabled(true); +} + +if (prevBtn) { + prevBtn.addEventListener("click", () => { + if (accessDeniedEl && !accessDeniedEl.hidden) return; + state.current.setMonth(state.current.getMonth() - 1); + loadMonth(); + }); +} +if (nextBtn) { + nextBtn.addEventListener("click", () => { + if (accessDeniedEl && !accessDeniedEl.hidden) return; + state.current.setMonth(state.current.getMonth() + 1); + loadMonth(); + }); +} + +(function bindSwipeMonth() { + const swipeEl = document.getElementById("calendarSticky"); + if (!swipeEl) return; + let startX = 0; + let startY = 0; + const SWIPE_THRESHOLD = 50; + swipeEl.addEventListener( + "touchstart", + (e) => { + if (e.changedTouches.length === 0) return; + const t = e.changedTouches[0]; + startX = t.clientX; + startY = t.clientY; + }, + { passive: true } + ); + swipeEl.addEventListener( + "touchend", + (e) => { + if (e.changedTouches.length === 0) return; + if (accessDeniedEl && !accessDeniedEl.hidden) return; + if (prevBtn && prevBtn.disabled) return; + const t = e.changedTouches[0]; + const deltaX = t.clientX - startX; + const deltaY = t.clientY - startY; + if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; + if (Math.abs(deltaY) > Math.abs(deltaX)) return; + if (deltaX > SWIPE_THRESHOLD) { + state.current.setMonth(state.current.getMonth() - 1); + loadMonth(); + } else if (deltaX < -SWIPE_THRESHOLD) { + state.current.setMonth(state.current.getMonth() + 1); + loadMonth(); + } + }, + { passive: true } + ); +})(); + +function bindStickyScrollShadow() { + const 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(() => { + requireTelegramOrLocalhost(() => { + bindStickyScrollShadow(); + loadMonth(); + }); +}); diff --git a/webapp/js/theme.js b/webapp/js/theme.js new file mode 100644 index 0000000..3c68a59 --- /dev/null +++ b/webapp/js/theme.js @@ -0,0 +1,77 @@ +/** + * Telegram and system theme detection and application. + */ + +/** + * Resolve current color scheme (dark/light). + * @returns {"dark"|"light"} + */ +export function getTheme() { + if (typeof window === "undefined") return "dark"; + const twa = window.Telegram?.WebApp; + if (twa?.colorScheme) return twa.colorScheme; + let cssScheme = ""; + try { + cssScheme = getComputedStyle(document.documentElement) + .getPropertyValue("--tg-color-scheme") + .trim(); + } catch (e) { + /* ignore */ + } + 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. + */ +export function applyThemeParamsToCss() { + const twa = window.Telegram?.WebApp; + const params = twa?.themeParams; + if (!params) return; + const root = document.documentElement; + const 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(([key, cssKey]) => { + const value = params[key]; + if (value) root.style.setProperty("--tg-theme-" + cssKey, value); + }); +} + +/** + * Apply current theme: set data-theme and Telegram background/header when in TWA. + */ +export function applyTheme() { + const scheme = getTheme(); + document.documentElement.dataset.theme = scheme; + const twa = window.Telegram?.WebApp; + if (twa) { + if (twa.setBackgroundColor) twa.setBackgroundColor("bg_color"); + if (twa.setHeaderColor) twa.setHeaderColor("bg_color"); + applyThemeParamsToCss(); + } +} + +/** + * Run initial theme apply and subscribe to theme changes (TWA or prefers-color-scheme). + * Call once at load. + */ +export function initTheme() { + 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); + } +} diff --git a/webapp/js/ui.js b/webapp/js/ui.js new file mode 100644 index 0000000..764d22c --- /dev/null +++ b/webapp/js/ui.js @@ -0,0 +1,61 @@ +/** + * Screen states: access denied, error, nav enabled. + */ + +import { + calendarEl, + dutyListEl, + loadingEl, + errorEl, + accessDeniedEl, + headerEl, + weekdaysEl, + prevBtn, + nextBtn +} from "./dom.js"; + +/** + * Show access-denied view and hide calendar/list/loading/error. + * @param {string} [serverDetail] - message from API 403 detail (unused; kept for callers) + */ +export function showAccessDenied(serverDetail) { + if (headerEl) headerEl.hidden = true; + if (weekdaysEl) weekdaysEl.hidden = true; + if (calendarEl) calendarEl.hidden = true; + if (dutyListEl) dutyListEl.hidden = true; + if (loadingEl) loadingEl.classList.add("hidden"); + if (errorEl) errorEl.hidden = true; + if (accessDeniedEl) accessDeniedEl.hidden = false; +} + +/** + * Hide access-denied and show calendar/list/header/weekdays. + */ +export function hideAccessDenied() { + if (accessDeniedEl) accessDeniedEl.hidden = true; + if (headerEl) headerEl.hidden = false; + if (weekdaysEl) weekdaysEl.hidden = false; + if (calendarEl) calendarEl.hidden = false; + if (dutyListEl) dutyListEl.hidden = false; +} + +/** + * Show error message and hide loading. + * @param {string} msg - Error text + */ +export function showError(msg) { + if (errorEl) { + errorEl.textContent = msg; + errorEl.hidden = false; + } + if (loadingEl) loadingEl.classList.add("hidden"); +} + +/** + * Enable or disable prev/next month buttons. + * @param {boolean} enabled + */ +export function setNavEnabled(enabled) { + if (prevBtn) prevBtn.disabled = !enabled; + if (nextBtn) nextBtn.disabled = !enabled; +} diff --git a/webapp/js/utils.js b/webapp/js/utils.js new file mode 100644 index 0000000..618cf87 --- /dev/null +++ b/webapp/js/utils.js @@ -0,0 +1,14 @@ +/** + * Common utilities. + */ + +/** + * Escape string for safe use in HTML (text content / attributes). + * @param {string} s - Raw string + * @returns {string} HTML-escaped string + */ +export function escapeHtml(s) { + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; +} diff --git a/webapp/style.css b/webapp/style.css index d5bd9f0..ffa5749 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -1,3 +1,4 @@ +/* === Variables & themes */ :root { --bg: #1a1b26; --surface: #24283b; @@ -41,6 +42,7 @@ --error: #e06c75; } +/* === Layout & base */ html { scrollbar-gutter: stable; scrollbar-width: none; @@ -132,7 +134,7 @@ body { touch-action: pan-y; } - +/* === Calendar grid & day cells */ .calendar { display: grid; grid-template-columns: repeat(7, 1fr); @@ -214,11 +216,12 @@ body { min-width: 0; } +/* === Hints (tooltips) */ .calendar-event-hint { position: fixed; z-index: 1000; width: max-content; - max-width: min(90vw, 600px); + max-width: min(98vw, 900px); padding: 8px 12px; background: var(--surface); color: var(--text); @@ -231,10 +234,6 @@ body { transform: translateY(-100%); } -.calendar-event-hint .calendar-event-hint-rows { - white-space: normal; -} - .calendar-event-hint.below { transform: none; } @@ -253,13 +252,15 @@ body { .calendar-event-hint-row { display: table; width: max-content; + min-width: max-content; white-space: nowrap; } .calendar-event-hint-row .calendar-event-hint-time { display: table-cell; white-space: nowrap; - min-width: 8.5em; + width: 7.5em; + min-width: 7.5em; vertical-align: top; padding-right: 0.35em; } @@ -269,7 +270,7 @@ body { white-space: nowrap !important; } -/* Маркеры: компактный размер 11px, кнопки для доступности и тапа */ +/* === Markers (duty / unavailable / vacation) */ .duty-marker, .unavailable-marker, .vacation-marker { @@ -314,6 +315,7 @@ body { box-shadow: 0 0 0 2px var(--vacation); } +/* === Duty list & timeline */ .duty-list { font-size: 0.9rem; } @@ -545,6 +547,7 @@ body { background: color-mix(in srgb, var(--today) 12%, var(--surface)); } +/* === Loading / error / access denied */ .loading, .error { text-align: center; padding: 12px;