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.
428 lines
14 KiB
JavaScript
428 lines
14 KiB
JavaScript
/**
|
|
* Tooltips for calendar info buttons and duty markers.
|
|
*/
|
|
|
|
import { getCalendarEl, state } from "./dom.js";
|
|
import { t } from "./i18n.js";
|
|
import { escapeHtml } from "./utils.js";
|
|
import { localDateString, formatHHMM } from "./dateUtils.js";
|
|
|
|
/**
|
|
* Position hint element near button; flip below if needed, clamp to viewport.
|
|
* @param {HTMLElement} hintEl
|
|
* @param {DOMRect} btnRect
|
|
*/
|
|
export function positionHint(hintEl, btnRect) {
|
|
const vw = document.documentElement.clientWidth;
|
|
const margin = 12;
|
|
hintEl.classList.remove("below");
|
|
hintEl.classList.remove("calendar-event-hint--visible");
|
|
hintEl.style.left = btnRect.left + "px";
|
|
hintEl.style.top = btnRect.top - 4 + "px";
|
|
hintEl.hidden = false;
|
|
requestAnimationFrame(() => {
|
|
const hintRect = hintEl.getBoundingClientRect();
|
|
let left = parseFloat(hintEl.style.left);
|
|
let top = parseFloat(hintEl.style.top);
|
|
if (hintRect.top < margin) {
|
|
hintEl.classList.add("below");
|
|
top = btnRect.bottom + 4;
|
|
}
|
|
if (hintRect.right > vw - margin) {
|
|
left = vw - hintRect.width - margin;
|
|
}
|
|
if (left < margin) left = margin;
|
|
left = Math.min(left, vw - hintRect.width - margin);
|
|
hintEl.style.left = left + "px";
|
|
hintEl.style.top = top + "px";
|
|
if (hintEl.classList.contains("below")) {
|
|
requestAnimationFrame(() => {
|
|
const hintRect2 = hintEl.getBoundingClientRect();
|
|
let left2 = parseFloat(hintEl.style.left);
|
|
if (hintRect2.right > vw - margin) {
|
|
left2 = vw - hintRect2.width - margin;
|
|
}
|
|
if (left2 < margin) left2 = margin;
|
|
hintEl.style.left = left2 + "px";
|
|
});
|
|
}
|
|
requestAnimationFrame(() => {
|
|
hintEl.classList.add("calendar-event-hint--visible");
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse data-duty-items and data-date from marker; detect if any item has times.
|
|
* @param {HTMLElement} marker
|
|
* @returns {{ dutyItems: object[], hasTimes: boolean, hintDay: string }}
|
|
*/
|
|
function parseDutyMarkerData(marker) {
|
|
const dutyItemsRaw =
|
|
marker.getAttribute("data-duty-items") ||
|
|
(marker.dataset && marker.dataset.dutyItems) ||
|
|
"";
|
|
let dutyItems = [];
|
|
try {
|
|
if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw);
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
const hasTimes =
|
|
dutyItems.length > 0 &&
|
|
dutyItems.some((it) => {
|
|
const start = it.start_at != null ? it.start_at : it.startAt;
|
|
const end = it.end_at != null ? it.end_at : it.endAt;
|
|
return start || end;
|
|
});
|
|
const hintDay = marker.getAttribute("data-date") || "";
|
|
return { dutyItems, hasTimes, hintDay };
|
|
}
|
|
|
|
/**
|
|
* Get fullName from duty item (supports snake_case and camelCase).
|
|
* @param {object} item
|
|
* @returns {string}
|
|
*/
|
|
function getItemFullName(item) {
|
|
return item.full_name != null ? item.full_name : item.fullName;
|
|
}
|
|
|
|
/**
|
|
* Build timePrefix for one duty item in the list (shared logic for text and HTML).
|
|
* @param {object} item - duty item with start_at/startAt, end_at/endAt
|
|
* @param {number} idx - index in dutyItems
|
|
* @param {number} total - dutyItems.length
|
|
* @param {string} hintDay - YYYY-MM-DD
|
|
* @param {string} sep - separator after "from"/"to" (e.g. " - " for text, "\u00a0" for HTML)
|
|
* @param {string} fromLabel - translated "from" prefix
|
|
* @param {string} toLabel - translated "to" prefix
|
|
* @returns {string}
|
|
*/
|
|
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLabel) {
|
|
const startAt = item.start_at != null ? item.start_at : item.startAt;
|
|
const endAt = item.end_at != null ? item.end_at : item.endAt;
|
|
const endHHMM = endAt ? formatHHMM(endAt) : "";
|
|
const startHHMM = startAt ? formatHHMM(startAt) : "";
|
|
const startSameDay =
|
|
hintDay && startAt && localDateString(new Date(startAt)) === hintDay;
|
|
const endSameDay =
|
|
hintDay && endAt && localDateString(new Date(endAt)) === hintDay;
|
|
let timePrefix = "";
|
|
if (idx === 0) {
|
|
if (total === 1 && startSameDay && startHHMM) {
|
|
timePrefix = fromLabel + sep + startHHMM;
|
|
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
|
timePrefix += " " + toLabel + sep + endHHMM;
|
|
}
|
|
} else if (startSameDay && startHHMM) {
|
|
/* First of multiple, but starts today — show full range */
|
|
timePrefix = fromLabel + sep + startHHMM;
|
|
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
|
timePrefix += " " + toLabel + sep + endHHMM;
|
|
}
|
|
} else if (endHHMM) {
|
|
/* Continuation from previous day — only end time */
|
|
timePrefix = toLabel + sep + endHHMM;
|
|
}
|
|
} else if (idx > 0) {
|
|
if (startSameDay && startHHMM) {
|
|
timePrefix = fromLabel + sep + startHHMM;
|
|
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
|
timePrefix += " " + toLabel + sep + endHHMM;
|
|
}
|
|
} else if (endHHMM) {
|
|
/* Continuation from previous day — only end time */
|
|
timePrefix = toLabel + sep + endHHMM;
|
|
}
|
|
}
|
|
return timePrefix;
|
|
}
|
|
|
|
/**
|
|
* Get array of { timePrefix, fullName } for duty items (single source of time rules).
|
|
* @param {object[]} dutyItems
|
|
* @param {string} hintDay
|
|
* @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML
|
|
* @param {string} fromLabel
|
|
* @param {string} toLabel
|
|
* @returns {{ timePrefix: string, fullName: string }[]}
|
|
*/
|
|
export function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
|
|
return dutyItems.map((item, idx) => {
|
|
const timePrefix = buildDutyItemTimePrefix(
|
|
item,
|
|
idx,
|
|
dutyItems.length,
|
|
hintDay,
|
|
timeSep,
|
|
fromLabel,
|
|
toLabel
|
|
);
|
|
const fullName = getItemFullName(item);
|
|
return { timePrefix, fullName };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get plain text content for duty marker tooltip.
|
|
* @param {HTMLElement} marker - .duty-marker, .unavailable-marker or .vacation-marker
|
|
* @returns {string}
|
|
*/
|
|
export function getDutyMarkerHintContent(marker) {
|
|
const lang = state.lang;
|
|
const type = marker.getAttribute("data-event-type") || "duty";
|
|
const label = t(lang, "event_type." + type);
|
|
const names = marker.getAttribute("data-names") || "";
|
|
const fromLabel = t(lang, "hint.from");
|
|
const toLabel = t(lang, "hint.to");
|
|
let body;
|
|
if (type === "duty") {
|
|
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
|
if (dutyItems.length >= 1 && hasTimes) {
|
|
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ", fromLabel, toLabel);
|
|
body = rows
|
|
.map((r) =>
|
|
r.timePrefix ? r.timePrefix + " - " + r.fullName : r.fullName
|
|
)
|
|
.join("\n");
|
|
} else {
|
|
body = names;
|
|
}
|
|
} else {
|
|
body = names;
|
|
}
|
|
return body ? label + ":\n" + body : label;
|
|
}
|
|
|
|
/**
|
|
* Returns HTML for duty hint with aligned times, or null to use textContent.
|
|
* @param {HTMLElement} marker
|
|
* @returns {string|null}
|
|
*/
|
|
export function getDutyMarkerHintHtml(marker) {
|
|
const lang = state.lang;
|
|
const type = marker.getAttribute("data-event-type") || "duty";
|
|
if (type !== "duty") return null;
|
|
const names = marker.getAttribute("data-names") || "";
|
|
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
|
const nbsp = "\u00a0";
|
|
const fromLabel = t(lang, "hint.from");
|
|
const toLabel = t(lang, "hint.to");
|
|
let rowHtmls;
|
|
if (dutyItems.length >= 1 && hasTimes) {
|
|
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp, fromLabel, toLabel);
|
|
rowHtmls = rows.map((r) => {
|
|
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : "";
|
|
const sepHtml = r.timePrefix ? '<span class="calendar-event-hint-sep">-</span>' : '<span class="calendar-event-hint-sep"></span>';
|
|
const nameHtml = escapeHtml(r.fullName);
|
|
return (
|
|
'<span class="calendar-event-hint-time">' +
|
|
timeHtml +
|
|
"</span>" +
|
|
sepHtml +
|
|
'<span class="calendar-event-hint-name">' +
|
|
nameHtml +
|
|
"</span>"
|
|
);
|
|
});
|
|
} else {
|
|
rowHtmls = (names ? names.split("\n") : []).map((fullName) => {
|
|
const nameHtml = escapeHtml(fullName.trim());
|
|
return (
|
|
'<span class="calendar-event-hint-time"></span><span class="calendar-event-hint-sep"></span><span class="calendar-event-hint-name">' +
|
|
nameHtml +
|
|
"</span>"
|
|
);
|
|
});
|
|
}
|
|
if (rowHtmls.length === 0) return null;
|
|
return (
|
|
'<div class="calendar-event-hint-title">' + escapeHtml(t(lang, "hint.duty_title")) + '</div><div class="calendar-event-hint-rows">' +
|
|
rowHtmls
|
|
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
|
|
.join("") +
|
|
"</div>"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove active class from all duty/unavailable/vacation markers.
|
|
*/
|
|
export function clearActiveDutyMarker() {
|
|
const calendarEl = getCalendarEl();
|
|
if (!calendarEl) return;
|
|
calendarEl
|
|
.querySelectorAll(
|
|
".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active"
|
|
)
|
|
.forEach((m) => m.classList.remove("calendar-marker-active"));
|
|
}
|
|
|
|
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
|
|
let dutyMarkerHideTimeout = null;
|
|
|
|
const HINT_FADE_MS = 150;
|
|
|
|
/**
|
|
* Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active.
|
|
* @param {HTMLElement} hintEl - The hint element to dismiss
|
|
* @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide
|
|
* @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout)
|
|
*/
|
|
export function dismissHint(hintEl, opts = {}) {
|
|
hintEl.classList.remove("calendar-event-hint--visible");
|
|
const id = setTimeout(() => {
|
|
hintEl.hidden = true;
|
|
hintEl.removeAttribute("data-active");
|
|
if (opts.clearActive) clearActiveDutyMarker();
|
|
if (typeof opts.afterHide === "function") opts.afterHide();
|
|
}, HINT_FADE_MS);
|
|
return id;
|
|
}
|
|
|
|
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
|
|
|
|
/**
|
|
* Get or create the calendar event (info button) hint element.
|
|
* @returns {HTMLElement|null}
|
|
*/
|
|
function getOrCreateCalendarEventHint() {
|
|
let el = document.getElementById("calendarEventHint");
|
|
if (!el) {
|
|
el = document.createElement("div");
|
|
el.id = "calendarEventHint";
|
|
el.className = "calendar-event-hint";
|
|
el.setAttribute("role", "tooltip");
|
|
el.hidden = true;
|
|
document.body.appendChild(el);
|
|
}
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* Get or create the duty marker hint element.
|
|
* @returns {HTMLElement|null}
|
|
*/
|
|
function getOrCreateDutyMarkerHint() {
|
|
let el = document.getElementById("dutyMarkerHint");
|
|
if (!el) {
|
|
el = document.createElement("div");
|
|
el.id = "dutyMarkerHint";
|
|
el.className = "calendar-event-hint";
|
|
el.setAttribute("role", "tooltip");
|
|
el.hidden = true;
|
|
document.body.appendChild(el);
|
|
}
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* Set up event delegation on calendarEl for info button and duty marker tooltips.
|
|
* Call once at startup (e.g. alongside initDayDetail). No need to re-bind after render.
|
|
*/
|
|
export function initHints() {
|
|
const calendarEventHint = getOrCreateCalendarEventHint();
|
|
const dutyMarkerHint = getOrCreateDutyMarkerHint();
|
|
const calendarEl = getCalendarEl();
|
|
if (!calendarEl) return;
|
|
|
|
calendarEl.addEventListener("click", (e) => {
|
|
const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
|
|
if (btn) {
|
|
e.stopPropagation();
|
|
const summary = btn.getAttribute("data-summary") || "";
|
|
const content = t(state.lang, "hint.events") + "\n" + summary;
|
|
if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
|
|
calendarEventHint.textContent = content;
|
|
positionHint(calendarEventHint, btn.getBoundingClientRect());
|
|
calendarEventHint.dataset.active = "1";
|
|
} else {
|
|
dismissHint(calendarEventHint);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
|
|
if (marker) {
|
|
e.stopPropagation();
|
|
if (marker.classList.contains("calendar-marker-active")) {
|
|
dismissHint(dutyMarkerHint);
|
|
marker.classList.remove("calendar-marker-active");
|
|
return;
|
|
}
|
|
clearActiveDutyMarker();
|
|
const html = getDutyMarkerHintHtml(marker);
|
|
if (html) {
|
|
dutyMarkerHint.innerHTML = html;
|
|
} else {
|
|
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
|
|
}
|
|
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
|
|
dutyMarkerHint.hidden = false;
|
|
dutyMarkerHint.dataset.active = "1";
|
|
marker.classList.add("calendar-marker-active");
|
|
}
|
|
});
|
|
|
|
calendarEl.addEventListener("mouseover", (e) => {
|
|
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
|
|
if (!marker) return;
|
|
const related = e.relatedTarget instanceof Node ? e.relatedTarget : null;
|
|
if (related && marker.contains(related)) return;
|
|
if (dutyMarkerHideTimeout) {
|
|
clearTimeout(dutyMarkerHideTimeout);
|
|
dutyMarkerHideTimeout = null;
|
|
}
|
|
const html = getDutyMarkerHintHtml(marker);
|
|
if (html) {
|
|
dutyMarkerHint.innerHTML = html;
|
|
} else {
|
|
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
|
|
}
|
|
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
|
|
dutyMarkerHint.hidden = false;
|
|
});
|
|
|
|
calendarEl.addEventListener("mouseout", (e) => {
|
|
const fromMarker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
|
|
if (!fromMarker) return;
|
|
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
|
|
if (toMarker) return;
|
|
if (dutyMarkerHint.dataset.active) return;
|
|
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
|
|
afterHide: () => {
|
|
dutyMarkerHideTimeout = null;
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!state.calendarHintBound) {
|
|
state.calendarHintBound = true;
|
|
document.addEventListener("click", () => {
|
|
if (calendarEventHint.dataset.active) {
|
|
dismissHint(calendarEventHint);
|
|
}
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && calendarEventHint.dataset.active) {
|
|
dismissHint(calendarEventHint);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!state.dutyMarkerHintBound) {
|
|
state.dutyMarkerHintBound = true;
|
|
document.addEventListener("click", () => {
|
|
if (dutyMarkerHint.dataset.active) {
|
|
dismissHint(dutyMarkerHint, { clearActive: true });
|
|
}
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
|
|
dismissHint(dutyMarkerHint, { clearActive: true });
|
|
}
|
|
});
|
|
}
|
|
}
|