/** * 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 ( '