- Added `bot_username` to settings for dynamic retrieval of the bot's username. - Implemented `_resolve_bot_username` function to fetch the bot's username if not set, improving user experience in group chats. - Updated `DutyWithUser` schema to include optional `phone` and `username` fields for enhanced duty information. - Enhanced API responses to include contact details for users, ensuring better communication. - Introduced a new current duty view in the web app, displaying active duty information along with contact options. - Updated CSS styles for better presentation of contact information in duty cards. - Added unit tests to verify the inclusion of contact details in API responses and the functionality of the current duty view.
281 lines
8.5 KiB
JavaScript
281 lines
8.5 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 { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.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();
|
|
const startParam =
|
|
(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe &&
|
|
window.Telegram.WebApp.initDataUnsafe.start_param) ||
|
|
"";
|
|
if (startParam === "duty") {
|
|
state.lang = getLang();
|
|
showCurrentDutyView(() => {
|
|
hideCurrentDutyView();
|
|
loadMonth();
|
|
});
|
|
} else {
|
|
loadMonth();
|
|
}
|
|
});
|
|
});
|