Some checks failed
CI / lint-and-test (push) Failing after 45s
- Added Node.js setup and webapp testing steps to the CI workflow for improved integration. - Updated HTML to link multiple CSS files for better modularity and organization of styles. - Removed deprecated `style.css` and introduced new CSS files for base styles, calendar, day detail, hints, markers, states, and duty list to enhance maintainability and readability. - Implemented new styles for improved presentation of duty information and user interactions. - Added unit tests for new API functions and contact link rendering to ensure functionality and reliability.
264 lines
9.6 KiB
JavaScript
264 lines
9.6 KiB
JavaScript
/**
|
||
* Duty list (timeline) rendering.
|
||
*/
|
||
|
||
import { getDutyListEl, state } from "./dom.js";
|
||
import { t } from "./i18n.js";
|
||
import { escapeHtml } from "./utils.js";
|
||
import { buildContactLinksHtml } from "./contactHtml.js";
|
||
import {
|
||
localDateString,
|
||
firstDayOfMonth,
|
||
lastDayOfMonth,
|
||
dateKeyToDDMM,
|
||
formatHHMM,
|
||
formatDateKey
|
||
} from "./dateUtils.js";
|
||
|
||
/** Phone icon SVG for flip button (show contacts). */
|
||
const ICON_PHONE =
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
|
||
|
||
/** Back/arrow icon SVG for flip button (back to card). */
|
||
const ICON_BACK =
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
|
||
|
||
/**
|
||
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day.
|
||
* When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts).
|
||
* Otherwise returns a plain card without flip wrapper.
|
||
* @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" : "";
|
||
const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, {
|
||
classPrefix: "duty-contact",
|
||
showLabels: false,
|
||
separator: " · "
|
||
});
|
||
const hasContacts = Boolean(
|
||
(d.phone && String(d.phone).trim()) ||
|
||
(d.username && String(d.username).trim())
|
||
);
|
||
|
||
if (!hasContacts) {
|
||
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>"
|
||
);
|
||
}
|
||
|
||
const showLabel = t(lang, "contact.show");
|
||
const backLabel = t(lang, "contact.back");
|
||
return (
|
||
'<div class="duty-flip-card' +
|
||
extraClass +
|
||
'" data-flipped="false">' +
|
||
'<div class="duty-flip-inner">' +
|
||
'<div class="duty-flip-front 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>' +
|
||
'<button class="duty-flip-btn" type="button" aria-label="' +
|
||
escapeHtml(showLabel) +
|
||
'">' +
|
||
ICON_PHONE +
|
||
"</button>" +
|
||
"</div>" +
|
||
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
|
||
extraClass +
|
||
'">' +
|
||
'<span class="name">' +
|
||
escapeHtml(d.full_name) +
|
||
"</span>" +
|
||
contactHtml +
|
||
'<button class="duty-flip-btn" type="button" aria-label="' +
|
||
escapeHtml(backLabel) +
|
||
'">' +
|
||
ICON_BACK +
|
||
"</button>" +
|
||
"</div>" +
|
||
"</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">' +
|
||
escapeHtml(timeOrRange) +
|
||
"</div></div>"
|
||
);
|
||
}
|
||
|
||
/** Whether the delegated flip-button click listener has been attached to duty list element. */
|
||
let flipListenerAttached = false;
|
||
|
||
/**
|
||
* 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) {
|
||
const dutyListEl = getDutyListEl();
|
||
if (!dutyListEl) return;
|
||
|
||
if (!flipListenerAttached) {
|
||
flipListenerAttached = true;
|
||
dutyListEl.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".duty-flip-btn");
|
||
if (!btn) return;
|
||
const card = btn.closest(".duty-flip-card");
|
||
if (!card) return;
|
||
const flipped = card.getAttribute("data-flipped") === "true";
|
||
card.setAttribute("data-flipped", String(!flipped));
|
||
});
|
||
}
|
||
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 listEl = getDutyListEl();
|
||
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
|
||
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
|
||
if (currentDutyCard) {
|
||
scrollToEl(currentDutyCard);
|
||
} else if (todayBlock) {
|
||
scrollToEl(todayBlock);
|
||
}
|
||
}
|