- 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.
185 lines
6.4 KiB
JavaScript
185 lines
6.4 KiB
JavaScript
/**
|
||
* Duty list (timeline) rendering.
|
||
*/
|
||
|
||
import { dutyListEl, state } from "./dom.js";
|
||
import { t } from "./i18n.js";
|
||
import { escapeHtml } from "./utils.js";
|
||
import {
|
||
localDateString,
|
||
firstDayOfMonth,
|
||
lastDayOfMonth,
|
||
dateKeyToDDMM,
|
||
formatHHMM,
|
||
formatDateKey
|
||
} from "./dateUtils.js";
|
||
|
||
/**
|
||
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day.
|
||
* @param {object} d - Duty
|
||
* @param {boolean} isCurrent - Whether this is "current" duty
|
||
* @returns {string}
|
||
*/
|
||
export function dutyTimelineCardHtml(d, isCurrent) {
|
||
const startLocal = localDateString(new Date(d.start_at));
|
||
const endLocal = localDateString(new Date(d.end_at));
|
||
const startDDMM = dateKeyToDDMM(startLocal);
|
||
const endDDMM = dateKeyToDDMM(endLocal);
|
||
const startTime = formatHHMM(d.start_at);
|
||
const endTime = formatHHMM(d.end_at);
|
||
let timeStr;
|
||
if (startLocal === endLocal) {
|
||
timeStr = startDDMM + ", " + startTime + " – " + endTime;
|
||
} else {
|
||
timeStr = startDDMM + " " + startTime + " – " + endDDMM + " " + endTime;
|
||
}
|
||
const lang = state.lang;
|
||
const typeLabel = isCurrent
|
||
? t(lang, "duty.now_on_duty")
|
||
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||
const extraClass = isCurrent ? " duty-item--current" : "";
|
||
return (
|
||
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
||
extraClass +
|
||
'"><span class="duty-item-type">' +
|
||
escapeHtml(typeLabel) +
|
||
'</span> <span class="name">' +
|
||
escapeHtml(d.full_name) +
|
||
'</span><div class="time">' +
|
||
escapeHtml(timeStr) +
|
||
"</div></div>"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Build HTML for one duty card.
|
||
* @param {object} d - Duty
|
||
* @param {string} [typeLabelOverride] - e.g. "Сейчас дежурит"
|
||
* @param {boolean} [showUntilEnd] - Show "до HH:MM" instead of range
|
||
* @param {string} [extraClass] - e.g. "duty-item--current"
|
||
* @returns {string}
|
||
*/
|
||
export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
||
const lang = state.lang;
|
||
const typeLabel =
|
||
typeLabelOverride != null
|
||
? typeLabelOverride
|
||
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||
let itemClass = "duty-item duty-item--" + (d.event_type || "duty");
|
||
if (extraClass) itemClass += " " + extraClass;
|
||
let timeOrRange = "";
|
||
if (showUntilEnd && d.event_type === "duty") {
|
||
timeOrRange = t(lang, "duty.until", { time: formatHHMM(d.end_at) });
|
||
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
|
||
const startStr = formatDateKey(d.start_at);
|
||
const endStr = formatDateKey(d.end_at);
|
||
timeOrRange = startStr === endStr ? startStr : startStr + " – " + endStr;
|
||
} else {
|
||
timeOrRange =
|
||
formatHHMM(d.start_at) + " – " + formatHHMM(d.end_at);
|
||
}
|
||
return (
|
||
'<div class="' +
|
||
itemClass +
|
||
'"><span class="duty-item-type">' +
|
||
escapeHtml(typeLabel) +
|
||
'</span> <span class="name">' +
|
||
escapeHtml(d.full_name) +
|
||
'</span><div class="time">' +
|
||
timeOrRange +
|
||
"</div></div>"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Render duty list (timeline) for current month; scroll to today if visible.
|
||
* @param {object[]} duties - Duties (only duty type used for timeline)
|
||
*/
|
||
export function renderDutyList(duties) {
|
||
if (!dutyListEl) return;
|
||
const filtered = duties.filter((d) => d.event_type === "duty");
|
||
if (filtered.length === 0) {
|
||
dutyListEl.classList.remove("duty-timeline");
|
||
dutyListEl.innerHTML = '<p class="muted">' + t(state.lang, "duty.none_this_month") + "</p>";
|
||
return;
|
||
}
|
||
dutyListEl.classList.add("duty-timeline");
|
||
const current = state.current;
|
||
const todayKey = localDateString(new Date());
|
||
const firstKey = localDateString(firstDayOfMonth(current));
|
||
const lastKey = localDateString(lastDayOfMonth(current));
|
||
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
|
||
const dateSet = new Set();
|
||
filtered.forEach((d) => {
|
||
dateSet.add(localDateString(new Date(d.start_at)));
|
||
});
|
||
if (showTodayInMonth) dateSet.add(todayKey);
|
||
const dates = Array.from(dateSet).sort();
|
||
const now = new Date();
|
||
let fullHtml = "";
|
||
dates.forEach((date) => {
|
||
const isToday = date === todayKey;
|
||
const dayClass =
|
||
"duty-timeline-day" + (isToday ? " duty-timeline-day--today" : "");
|
||
const dateLabel = dateKeyToDDMM(date);
|
||
const dateCellHtml = isToday
|
||
? '<span class="duty-timeline-date"><span class="duty-timeline-date-label">' +
|
||
escapeHtml(t(state.lang, "duty.today")) +
|
||
'</span><span class="duty-timeline-date-day">' +
|
||
escapeHtml(dateLabel) +
|
||
'</span><span class="duty-timeline-date-dot" aria-hidden="true"></span></span>'
|
||
: '<span class="duty-timeline-date">' + escapeHtml(dateLabel) + "</span>";
|
||
const dayDuties = filtered
|
||
.filter((d) => localDateString(new Date(d.start_at)) === date)
|
||
.sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
|
||
let dayHtml = "";
|
||
dayDuties.forEach((d) => {
|
||
const start = new Date(d.start_at);
|
||
const end = new Date(d.end_at);
|
||
const isCurrent = start <= now && now < end;
|
||
dayHtml +=
|
||
'<div class="duty-timeline-row">' +
|
||
dateCellHtml +
|
||
'<span class="duty-timeline-track" aria-hidden="true"></span><div class="duty-timeline-card-wrap">' +
|
||
dutyTimelineCardHtml(d, isCurrent) +
|
||
"</div></div>";
|
||
});
|
||
if (dayDuties.length === 0 && isToday) {
|
||
dayHtml +=
|
||
'<div class="duty-timeline-row duty-timeline-row--empty">' +
|
||
dateCellHtml +
|
||
'<span class="duty-timeline-track" aria-hidden="true"></span><div class="duty-timeline-card-wrap"></div></div>';
|
||
}
|
||
fullHtml +=
|
||
'<div class="' +
|
||
dayClass +
|
||
'" data-date="' +
|
||
escapeHtml(date) +
|
||
'">' +
|
||
dayHtml +
|
||
"</div>";
|
||
});
|
||
dutyListEl.innerHTML = fullHtml;
|
||
const calendarSticky = document.getElementById("calendarSticky");
|
||
const scrollToEl = (el) => {
|
||
if (!el) return;
|
||
if (calendarSticky) {
|
||
requestAnimationFrame(() => {
|
||
const calendarHeight = calendarSticky.offsetHeight;
|
||
const top = el.getBoundingClientRect().top + window.scrollY;
|
||
const scrollTop = Math.max(0, top - calendarHeight);
|
||
window.scrollTo({ top: scrollTop, behavior: "smooth" });
|
||
});
|
||
} else {
|
||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
};
|
||
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
|
||
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
|
||
if (currentDutyCard) {
|
||
scrollToEl(currentDutyCard);
|
||
} else if (todayBlock) {
|
||
scrollToEl(todayBlock);
|
||
}
|
||
}
|