/** * Tooltips for calendar info buttons and duty markers. */ import { calendarEl, 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 ? '-' : ''; const nameHtml = escapeHtml(r.fullName); return ( '' + timeHtml + "" + sepHtml + '' + nameHtml + "" ); }); } else { rowHtmls = (names ? names.split("\n") : []).map((fullName) => { const nameHtml = escapeHtml(fullName.trim()); return ( '' + nameHtml + "" ); }); } if (rowHtmls.length === 0) return null; return ( '
' + escapeHtml(t(lang, "hint.duty_title")) + '
' + rowHtmls .map((r) => '
' + r + "
") .join("") + "
" ); } /** * Remove active class from all duty/unavailable/vacation markers. */ export function clearActiveDutyMarker() { 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 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(); 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 { calendarEventHint.classList.remove("calendar-event-hint--visible"); setTimeout(() => { calendarEventHint.hidden = true; calendarEventHint.removeAttribute("data-active"); }, 150); } 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")) { dutyMarkerHint.classList.remove("calendar-event-hint--visible"); setTimeout(() => { dutyMarkerHint.hidden = true; dutyMarkerHint.removeAttribute("data-active"); }, 150); 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; dutyMarkerHint.classList.remove("calendar-event-hint--visible"); dutyMarkerHideTimeout = setTimeout(() => { dutyMarkerHint.hidden = true; dutyMarkerHideTimeout = null; }, 150); }); if (!state.calendarHintBound) { state.calendarHintBound = true; document.addEventListener("click", () => { if (calendarEventHint.dataset.active) { calendarEventHint.classList.remove("calendar-event-hint--visible"); setTimeout(() => { calendarEventHint.hidden = true; calendarEventHint.removeAttribute("data-active"); }, 150); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && calendarEventHint.dataset.active) { calendarEventHint.classList.remove("calendar-event-hint--visible"); setTimeout(() => { calendarEventHint.hidden = true; calendarEventHint.removeAttribute("data-active"); }, 150); } }); } if (!state.dutyMarkerHintBound) { state.dutyMarkerHintBound = true; document.addEventListener("click", () => { if (dutyMarkerHint.dataset.active) { dutyMarkerHint.classList.remove("calendar-event-hint--visible"); setTimeout(() => { dutyMarkerHint.hidden = true; dutyMarkerHint.removeAttribute("data-active"); clearActiveDutyMarker(); }, 150); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && dutyMarkerHint.dataset.active) { dutyMarkerHint.classList.remove("calendar-event-hint--visible"); setTimeout(() => { dutyMarkerHint.hidden = true; dutyMarkerHint.removeAttribute("data-active"); clearActiveDutyMarker(); }, 150); } }); } }