Files
duty-teller/webapp/js/calendar.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

152 lines
5.2 KiB
JavaScript

/**
* Calendar grid and events-by-date mapping.
*/
import { calendarEl, monthTitleEl, state } from "./dom.js";
import { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
localDateString,
firstDayOfMonth,
lastDayOfMonth,
getMonday,
dateKeyToDDMM
} from "./dateUtils.js";
/**
* Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local.
* @param {object[]} events - Calendar events with date, summary
* @returns {Record<string, string[]>}
*/
export function calendarEventsByDate(events) {
const byDate = {};
(events || []).forEach((e) => {
const utcMidnight = new Date(e.date + "T00:00:00Z");
const key = localDateString(utcMidnight);
if (!byDate[key]) byDate[key] = [];
if (e.summary) byDate[key].push(e.summary);
});
return byDate;
}
/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */
const MAX_DAYS_PER_DUTY = 366;
/**
* Group duties by local date (start_at/end_at are UTC).
* @param {object[]} duties - Duties with start_at, end_at
* @returns {Record<string, object[]>}
*/
export function dutiesByDate(duties) {
const byDate = {};
duties.forEach((d) => {
const start = new Date(d.start_at);
const end = new Date(d.end_at);
if (end < start) return;
const endLocal = localDateString(end);
let cursor = new Date(start);
let iterations = 0;
while (iterations <= MAX_DAYS_PER_DUTY) {
const key = localDateString(cursor);
if (!byDate[key]) byDate[key] = [];
byDate[key].push(d);
if (key === endLocal) break;
cursor.setDate(cursor.getDate() + 1);
iterations++;
}
});
return byDate;
}
/**
* Render calendar grid for given month with duties and calendar events.
* @param {number} year
* @param {number} month - 0-based
* @param {Record<string, object[]>} dutiesByDateMap
* @param {Record<string, string[]>} calendarEventsByDateMap
*/
export function renderCalendar(
year,
month,
dutiesByDateMap,
calendarEventsByDateMap
) {
if (!calendarEl || !monthTitleEl) return;
const first = firstDayOfMonth(new Date(year, month, 1));
const last = lastDayOfMonth(new Date(year, month, 1));
const start = getMonday(first);
const today = localDateString(new Date());
const calendarEventsByDate = calendarEventsByDateMap || {};
calendarEl.innerHTML = "";
let d = new Date(start);
const cells = 42;
for (let i = 0; i < cells; i++) {
const key = localDateString(d);
const isOther = d.getMonth() !== month;
const dayDuties = dutiesByDateMap[key] || [];
const dutyList = dayDuties.filter((x) => x.event_type === "duty");
const unavailableList = dayDuties.filter((x) => x.event_type === "unavailable");
const vacationList = dayDuties.filter((x) => x.event_type === "vacation");
const hasAny = dayDuties.length > 0;
const isToday = key === today;
const eventSummaries = calendarEventsByDate[key] || [];
const hasEvent = eventSummaries.length > 0;
const showIndicator = !isOther;
const cell = document.createElement("div");
cell.className =
"day" +
(isOther ? " other-month" : "") +
(isToday ? " today" : "") +
(showIndicator && hasAny ? " has-duty" : "") +
(showIndicator && hasEvent ? " holiday" : "");
cell.setAttribute("data-date", key);
if (showIndicator) {
const dayPayload = dayDuties.map((x) => ({
event_type: x.event_type,
full_name: x.full_name,
start_at: x.start_at,
end_at: x.end_at
}));
cell.setAttribute("data-day-duties", JSON.stringify(dayPayload));
cell.setAttribute("data-day-events", JSON.stringify(eventSummaries));
}
const ariaParts = [];
ariaParts.push(dateKeyToDDMM(key));
if (hasAny || hasEvent) {
const counts = [];
const lang = state.lang;
if (dutyList.length) counts.push(dutyList.length + " " + t(lang, "event_type.duty"));
if (unavailableList.length) counts.push(unavailableList.length + " " + t(lang, "event_type.unavailable"));
if (vacationList.length) counts.push(vacationList.length + " " + t(lang, "event_type.vacation"));
if (hasEvent) counts.push(t(lang, "hint.events"));
ariaParts.push(counts.join(", "));
} else {
ariaParts.push(t(state.lang, "aria.day_info"));
}
cell.setAttribute("role", "button");
cell.setAttribute("tabindex", "0");
cell.setAttribute("aria-label", ariaParts.join("; "));
let indicatorHtml = "";
if (showIndicator && (hasAny || hasEvent)) {
indicatorHtml = '<div class="day-indicator">';
if (dutyList.length) indicatorHtml += '<span class="day-indicator-dot duty"></span>';
if (unavailableList.length) indicatorHtml += '<span class="day-indicator-dot unavailable"></span>';
if (vacationList.length) indicatorHtml += '<span class="day-indicator-dot vacation"></span>';
if (hasEvent) indicatorHtml += '<span class="day-indicator-dot events"></span>';
indicatorHtml += "</div>";
}
cell.innerHTML =
'<span class="num">' + d.getDate() + "</span>" + indicatorHtml;
calendarEl.appendChild(cell);
d.setDate(d.getDate() + 1);
}
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
}