Files
duty-teller/webapp/js/main.js
Nikolay Tatarinov a4d8d085c6 feat: update language support and enhance API functionality
- Changed the default language in `index.html` from Russian to English, updating the title and button aria-labels for improved accessibility.
- Refactored the `buildFetchOptions` function in `api.js` to include an optional external abort signal, enhancing request management.
- Updated `fetchDuties` and `fetchCalendarEvents` to support request cancellation using the new abort signal, improving error handling.
- Added unit tests for the API functions to ensure proper functionality, including handling of 403 errors and request cancellations.
- Enhanced CSS styles for duty markers to improve visual consistency.
- Removed unused code and improved the overall structure of the JavaScript files for better maintainability.
2026-03-02 12:40:49 +03:00

268 lines
8.1 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,
accessDeniedEl,
prevBtn,
nextBtn,
loadingEl,
errorEl,
weekdaysEl
} 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 { initDayDetail } from "./dayDetail.js";
import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js";
import {
firstDayOfMonth,
lastDayOfMonth,
getMonday,
localDateString,
dutyOverlapsLocalRange
} from "./dateUtils.js";
initTheme();
state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title");
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
/**
* 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(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.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);
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, 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") {
showAccessDenied(e.serverDetail);
setNavEnabled(true);
if (
window.Telegram &&
window.Telegram.WebApp &&
!state.initDataRetried
) {
state.initDataRetried = true;
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
}
return;
}
showError(e.message || t(state.lang, "error_generic"));
setNavEnabled(true);
return;
}
if (loadingEl) loadingEl.classList.add("hidden");
setNavEnabled(true);
}
if (prevBtn) {
prevBtn.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() - 1);
loadMonth();
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
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;
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;
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(() => {
bindStickyScrollShadow();
initDayDetail();
initHints();
loadMonth();
});
});