Implement external calendar integration and enhance API functionality
- Added support for fetching and parsing external ICS calendars, allowing events to be displayed on the duty grid. - Introduced a new API endpoint `/api/calendar-events` to retrieve calendar events within a specified date range. - Updated configuration to include `EXTERNAL_CALENDAR_ICS_URL` for specifying the ICS calendar URL. - Enhanced the web application to visually indicate days with events and provide event summaries on hover. - Improved documentation in the README to include details about the new calendar integration and configuration options. - Updated tests to cover the new calendar functionality and ensure proper integration.
This commit is contained in:
142
webapp/app.js
142
webapp/app.js
@@ -20,8 +20,12 @@
|
||||
const prevBtn = document.getElementById("prevMonth");
|
||||
const nextBtn = document.getElementById("nextMonth");
|
||||
|
||||
function isoDate(d) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
/** YYYY-MM-DD in local time (for calendar keys, "today", request range). */
|
||||
function localDateString(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return y + "-" + m + "-" + day;
|
||||
}
|
||||
|
||||
function firstDayOfMonth(d) {
|
||||
@@ -112,16 +116,21 @@
|
||||
dutyListEl.hidden = false;
|
||||
}
|
||||
|
||||
function buildFetchOptions(initData) {
|
||||
var headers = {};
|
||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS);
|
||||
return { headers: headers, signal: controller.signal, timeoutId: timeoutId };
|
||||
}
|
||||
|
||||
async function fetchDuties(from, to) {
|
||||
const base = window.location.origin;
|
||||
const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||
const initData = getInitData();
|
||||
const headers = {};
|
||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS);
|
||||
const opts = buildFetchOptions(initData);
|
||||
try {
|
||||
var res = await fetch(url, { headers: headers, signal: controller.signal });
|
||||
var res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
var detail = "Доступ запрещён";
|
||||
try {
|
||||
@@ -142,35 +151,113 @@
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(opts.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendar(year, month, dutiesByDate) {
|
||||
/** Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403 (caller uses duties 403). */
|
||||
async function fetchCalendarEvents(from, to) {
|
||||
const base = window.location.origin;
|
||||
const url = base + "/api/calendar-events?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to);
|
||||
const initData = getInitData();
|
||||
const opts = buildFetchOptions(initData);
|
||||
try {
|
||||
var res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(opts.timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local date for calendar grid. */
|
||||
function calendarEventsByDate(events) {
|
||||
const byDate = {};
|
||||
(events || []).forEach(function (e) {
|
||||
var utcMidnight = new Date(e.date + "T00:00:00Z");
|
||||
var key = localDateString(utcMidnight);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
if (e.summary) byDate[key].push(e.summary);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
function renderCalendar(year, month, dutiesByDate, calendarEventsByDate) {
|
||||
const first = firstDayOfMonth(new Date(year, month, 1));
|
||||
const last = lastDayOfMonth(new Date(year, month, 1));
|
||||
const start = getMonday(first);
|
||||
const today = isoDate(new Date());
|
||||
const today = localDateString(new Date());
|
||||
calendarEventsByDate = calendarEventsByDate || {};
|
||||
|
||||
calendarEl.innerHTML = "";
|
||||
let d = new Date(start);
|
||||
const cells = 42;
|
||||
for (let i = 0; i < cells; i++) {
|
||||
const key = isoDate(d);
|
||||
const key = localDateString(d);
|
||||
const isOther = d.getMonth() !== month;
|
||||
const dayDuties = dutiesByDate[key] || [];
|
||||
const isToday = key === today;
|
||||
const eventSummaries = calendarEventsByDate[key] || [];
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "");
|
||||
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : "");
|
||||
cell.innerHTML =
|
||||
"<span class=\"num\">" + d.getDate() + "</span>" +
|
||||
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "");
|
||||
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return escapeHtml(x.full_name); }).join(", ") + "</span>" : "") +
|
||||
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
|
||||
calendarEl.appendChild(cell);
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
monthTitleEl.textContent = MONTHS[month] + " " + year;
|
||||
bindInfoButtonTooltips();
|
||||
}
|
||||
|
||||
function bindInfoButtonTooltips() {
|
||||
var hintEl = document.getElementById("calendarEventHint");
|
||||
if (!hintEl) {
|
||||
hintEl = document.createElement("div");
|
||||
hintEl.id = "calendarEventHint";
|
||||
hintEl.className = "calendar-event-hint";
|
||||
hintEl.setAttribute("role", "tooltip");
|
||||
hintEl.hidden = true;
|
||||
document.body.appendChild(hintEl);
|
||||
}
|
||||
calendarEl.querySelectorAll(".info-btn").forEach(function (btn) {
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
var summary = btn.getAttribute("data-summary") || "";
|
||||
if (hintEl.hidden || hintEl.textContent !== summary) {
|
||||
hintEl.textContent = summary;
|
||||
hintEl.hidden = false;
|
||||
var rect = btn.getBoundingClientRect();
|
||||
hintEl.style.left = (rect.left + window.scrollX) + "px";
|
||||
hintEl.style.top = (rect.top + window.scrollY - 4) + "px";
|
||||
hintEl.dataset.active = "1";
|
||||
} else {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!document._calendarHintBound) {
|
||||
document._calendarHintBound = true;
|
||||
document.addEventListener("click", function () {
|
||||
if (hintEl.dataset.active) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderDutyList(duties) {
|
||||
@@ -180,7 +267,7 @@
|
||||
}
|
||||
const grouped = {};
|
||||
duties.forEach(function (d) {
|
||||
const date = d.start_at.slice(0, 10);
|
||||
const date = localDateString(new Date(d.start_at));
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(d);
|
||||
});
|
||||
@@ -190,8 +277,10 @@
|
||||
const list = grouped[date];
|
||||
html += "<h2>" + date + "</h2>";
|
||||
list.forEach(function (d) {
|
||||
const start = d.start_at.slice(11, 16);
|
||||
const end = d.end_at.slice(11, 16);
|
||||
const startDate = new Date(d.start_at);
|
||||
const endDate = new Date(d.end_at);
|
||||
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
|
||||
const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0");
|
||||
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " – " + end + "</div></div>";
|
||||
});
|
||||
});
|
||||
@@ -204,15 +293,20 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** Group duties by local date (start_at/end_at are UTC). */
|
||||
function dutiesByDate(duties) {
|
||||
const byDate = {};
|
||||
duties.forEach(function (d) {
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) {
|
||||
const key = isoDate(t);
|
||||
const endLocal = localDateString(end);
|
||||
let t = new Date(start);
|
||||
while (true) {
|
||||
const key = localDateString(t);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
byDate[key].push(d);
|
||||
if (key === endLocal) break;
|
||||
t.setDate(t.getDate() + 1);
|
||||
}
|
||||
});
|
||||
return byDate;
|
||||
@@ -267,12 +361,16 @@
|
||||
setNavEnabled(false);
|
||||
loadingEl.classList.remove("hidden");
|
||||
errorEl.hidden = true;
|
||||
const from = isoDate(firstDayOfMonth(current));
|
||||
const to = isoDate(lastDayOfMonth(current));
|
||||
const from = localDateString(firstDayOfMonth(current));
|
||||
const to = localDateString(lastDayOfMonth(current));
|
||||
try {
|
||||
const duties = await fetchDuties(from, to);
|
||||
const dutiesPromise = fetchDuties(from, to);
|
||||
const eventsPromise = fetchCalendarEvents(from, to);
|
||||
const duties = await dutiesPromise;
|
||||
const events = await eventsPromise;
|
||||
const byDate = dutiesByDate(duties);
|
||||
renderCalendar(current.getFullYear(), current.getMonth(), byDate);
|
||||
const calendarByDate = calendarEventsByDate(events);
|
||||
renderCalendar(current.getFullYear(), current.getMonth(), byDate, calendarByDate);
|
||||
renderDutyList(duties);
|
||||
} catch (e) {
|
||||
if (e.message === "ACCESS_DENIED") {
|
||||
|
||||
@@ -105,6 +105,57 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day.holiday {
|
||||
background: linear-gradient(135deg, var(--surface) 0%, rgba(187, 154, 247, 0.15) 100%);
|
||||
border: 1px solid rgba(187, 154, 247, 0.35);
|
||||
}
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
|
||||
padding: 2px 3px 0 0;
|
||||
}
|
||||
|
||||
.info-btn:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.calendar-event-hint {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: 280px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.day-duties {
|
||||
font-size: 0.6rem;
|
||||
color: var(--duty);
|
||||
|
||||
Reference in New Issue
Block a user