Files
duty-teller/webapp/js/main.js
Nikolay Tatarinov b60111462a feat: implement day detail panel for calendar
- Introduced a new `dayDetail.js` module to manage the day detail panel functionality, allowing users to view detailed information about duties and events for each calendar day.
- Enhanced the calendar rendering in `calendar.js` to include visual indicators for duties and events, improving user interaction and experience.
- Updated CSS in `style.css` to style the day detail panel and its components, ensuring a responsive design for both desktop and mobile views.
- Refactored `hints.js` to export the `getDutyMarkerRows` function, facilitating better integration with the new day detail features.
- Added localization support for the day detail panel in `i18n.js`, including new translations for close button and event titles.
- Enhanced the initialization process in `main.js` to set up the day detail panel on application load.
2026-02-19 16:23:46 +03:00

252 lines
7.3 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 } 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,
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 { 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");
if (loadingEl) loadingEl.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");
}
/**
* 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) =>
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.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 || t(state.lang, "error_generic"));
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;
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) {
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 || 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();
initDayDetail();
loadMonth();
});
});