- 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.
152 lines
5.2 KiB
JavaScript
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;
|
|
}
|