- Updated the error handling in `index.html` to include a retry button for failed app loads, enhancing user experience. - Added session storage management to prevent repeated reloads on errors. - Enhanced the `.gitignore` file to include log files, improving project cleanliness. - Included error logging in `main.js` to ensure better tracking of issues during app initialization.
321 lines
9.8 KiB
JavaScript
321 lines
9.8 KiB
JavaScript
/**
|
|
* 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);
|
|
|
|
try {
|
|
sessionStorage.removeItem("__dtLoadRetry");
|
|
} catch (_) {}
|
|
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();
|
|
}
|
|
});
|
|
});
|