/** * Calendar grid and events-by-date mapping. */ import { getCalendarEl, getMonthTitleEl, 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} */ 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} */ 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} dutiesByDateMap * @param {Record} calendarEventsByDateMap */ export function renderCalendar( year, month, dutiesByDateMap, calendarEventsByDateMap ) { const calendarEl = getCalendarEl(); const monthTitleEl = getMonthTitleEl(); 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 = '
'; if (dutyList.length) indicatorHtml += ''; if (unavailableList.length) indicatorHtml += ''; if (vacationList.length) indicatorHtml += ''; if (hasEvent) indicatorHtml += ''; indicatorHtml += "
"; } cell.innerHTML = '' + d.getDate() + "" + indicatorHtml; calendarEl.appendChild(cell); d.setDate(d.getDate() + 1); } monthTitleEl.textContent = monthName(state.lang, month) + " " + year; }