Files
duty-teller/webapp/js/dutyList.js
Nikolay Tatarinov 2fb553567f
Some checks failed
CI / lint-and-test (push) Failing after 45s
feat: enhance CI workflow and update webapp styles
- 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.
2026-03-02 17:20:33 +03:00

264 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
}