/** * Entry point: theme init, auth gate, loadMonth, nav and swipe handlers. */ import { initTheme, applyTheme } from "./theme.js"; import { getLang, t, weekdayLabels } from "./i18n.js"; import { getInitData, isLocalhost } from "./auth.js"; import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js"; import { state, getAccessDeniedEl, getPrevBtn, getNextBtn, getLoadingEl, getErrorEl, getWeekdaysEl } from "./dom.js"; import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js"; import { fetchDuties, fetchCalendarEvents } from "./api.js"; import { logger } from "./logger.js"; import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js"; import { initDayDetail } from "./dayDetail.js"; import { initHints } from "./hints.js"; import { renderDutyList } from "./dutyList.js"; import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js"; import { firstDayOfMonth, lastDayOfMonth, getMonday, localDateString, dutyOverlapsLocalRange } from "./dateUtils.js"; initTheme(); state.lang = getLang(); /** * Apply current state.lang to document and locale-dependent UI elements (title, loading, weekdays, nav). * Call at startup and after re-evaluating lang when initData becomes available. * Exported for tests. */ export function applyLangToUi() { document.documentElement.lang = state.lang; document.title = t(state.lang, "app.title"); const loadingEl = getLoadingEl(); const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); const dayLabels = weekdayLabels(state.lang); const weekdaysEl = getWeekdaysEl(); if (weekdaysEl) { const spans = weekdaysEl.querySelectorAll("span"); spans.forEach((span, i) => { if (dayLabels[i]) span.textContent = dayLabels[i]; }); } const prevBtn = getPrevBtn(); const nextBtn = getNextBtn(); if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month")); if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")); } applyLangToUi(); logger.info("App init", "lang=" + state.lang); window.__dtReady = true; /** * 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.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(undefined); const loading = getLoadingEl(); if (loading) loading.classList.add("hidden"); }, RETRY_DELAY_MS); return; } showAccessDenied(undefined); const loading = getLoadingEl(); if (loading) loading.classList.add("hidden"); } /** AbortController for the in-flight loadMonth request; aborted when a new load starts. */ let loadMonthAbortController = null; /** * Load current month: fetch duties and events, render calendar and duty list. * Stale requests are cancelled when the user navigates to another month before they complete. */ async function loadMonth() { if (loadMonthAbortController) loadMonthAbortController.abort(); loadMonthAbortController = new AbortController(); const signal = loadMonthAbortController.signal; hideAccessDenied(); setNavEnabled(false); const loadingEl = getLoadingEl(); if (loadingEl) loadingEl.classList.remove("hidden"); const errorEl = getErrorEl(); 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 { logger.debug("Loading month", from, to); const dutiesPromise = fetchDuties(from, to, signal); const eventsPromise = fetchCalendarEvents(from, to, signal); 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) => dutyOverlapsLocalRange(d, firstKey, 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.name === "AbortError") { return; } if (e.message === "ACCESS_DENIED") { logger.warn("Access denied in loadMonth", e.serverDetail); showAccessDenied(e.serverDetail); setNavEnabled(true); if ( window.Telegram && window.Telegram.WebApp && !state.initDataRetried ) { state.initDataRetried = true; setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS); } return; } logger.error("Load month failed", e); showError(e.message || t(state.lang, "error_generic"), loadMonth); setNavEnabled(true); return; } const errorEl2 = getErrorEl(); if (errorEl2) errorEl2.hidden = true; const loading = getLoadingEl(); if (loading) loading.classList.add("hidden"); setNavEnabled(true); } const prevBtnEl = getPrevBtn(); if (prevBtnEl) { prevBtnEl.addEventListener("click", () => { if (document.body.classList.contains("day-detail-sheet-open")) return; const accessDeniedEl = getAccessDeniedEl(); if (accessDeniedEl && !accessDeniedEl.hidden) return; state.current.setMonth(state.current.getMonth() - 1); loadMonth(); }); } const nextBtnEl = getNextBtn(); if (nextBtnEl) { nextBtnEl.addEventListener("click", () => { if (document.body.classList.contains("day-detail-sheet-open")) return; const accessDeniedEl = getAccessDeniedEl(); 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 touch = e.changedTouches[0]; startX = touch.clientX; startY = touch.clientY; }, { passive: true } ); swipeEl.addEventListener( "touchend", (e) => { if (e.changedTouches.length === 0) return; if (document.body.classList.contains("day-detail-sheet-open")) return; const accessDeniedEl = getAccessDeniedEl(); if (accessDeniedEl && !accessDeniedEl.hidden) return; const touch = e.changedTouches[0]; const deltaX = touch.clientX - startX; const deltaY = touch.clientY - startY; if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; if (Math.abs(deltaY) > Math.abs(deltaX)) return; const prevBtn = getPrevBtn(); const nextBtn = getNextBtn(); if (deltaX > SWIPE_THRESHOLD) { if (prevBtn && prevBtn.disabled) return; state.current.setMonth(state.current.getMonth() - 1); loadMonth(); } else if (deltaX < -SWIPE_THRESHOLD) { if (nextBtn && nextBtn.disabled) return; state.current.setMonth(state.current.getMonth() + 1); loadMonth(); } }, { passive: true } ); })(); function bindStickyScrollShadow() { const stickyEl = document.getElementById("calendarSticky"); if (!stickyEl || state.stickyScrollBound) return; state.stickyScrollBound = true; function updateScrolled() { stickyEl.classList.toggle("is-scrolled", window.scrollY > 0); } window.addEventListener("scroll", updateScrolled, { passive: true }); updateScrolled(); } runWhenReady(() => { requireTelegramOrLocalhost(() => { const newLang = getLang(); if (newLang !== state.lang) { state.lang = newLang; applyLangToUi(); } bindStickyScrollShadow(); initDayDetail(); initHints(); const startParam = (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe && window.Telegram.WebApp.initDataUnsafe.start_param) || ""; if (startParam === "duty") { showCurrentDutyView(() => { hideCurrentDutyView(); loadMonth(); }); } else { loadMonth(); } }); });