export function mount({ root, state, host }) { state = state || {}; const __cleanup = []; const __setInterval = (...args) => { const id = window.setInterval(...args); __cleanup.push(() => window.clearInterval(id)); return id; }; const __setTimeout = (...args) => { const id = window.setTimeout(...args); __cleanup.push(() => window.clearTimeout(id)); return id; }; if (!(root instanceof HTMLElement)) return; const headlineEl = root.querySelector('[data-calendar-headline]'); const detailEl = root.querySelector('[data-calendar-detail]'); const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]'); const allDayEl = root.querySelector('[data-calendar-all-day]'); const emptyEl = root.querySelector('[data-calendar-empty]'); const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]'); const timelineEl = root.querySelector('[data-calendar-timeline]'); if (!(headlineEl instanceof HTMLElement) || !(detailEl instanceof HTMLElement) || !(allDayWrapEl instanceof HTMLElement) || !(allDayEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(timelineShellEl instanceof HTMLElement) || !(timelineEl instanceof HTMLElement)) { return; } const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : ''; const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; const calendarNames = Array.isArray(state.calendar_names) ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) : []; const refreshMsRaw = Number(state.refresh_ms); const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; const minStartHourRaw = Number(state.min_start_hour); const maxEndHourRaw = Number(state.max_end_hour); const minWindowHoursRaw = Number(state.min_window_hours); const slotHeightRaw = Number(state.slot_height); const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6; const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22; const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60; const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18; const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events for today.'; const LABEL_WIDTH = 42; const TRACK_TOP_PAD = 6; const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events'; let latestEvents = []; let latestSelectedCalendars = []; let latestUpdatedAt = ''; let clockIntervalId = null; emptyEl.textContent = emptyText; const updateLiveContent = (snapshot) => { host.setLiveContent(snapshot); }; const normalizeDateValue = (value) => { if (typeof value === 'string') return value; if (value && typeof value === 'object') { if (typeof value.dateTime === 'string') return value.dateTime; if (typeof value.date === 'string') return value.date; } return ''; }; const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); const parseDate = (value, allDay, endOfDay = false) => { const raw = normalizeDateValue(value); if (!raw) return null; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}` : raw; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? null : date; }; const eventBounds = (event) => { const allDay = isAllDay(event?.start, event?.end); const startDate = parseDate(event?.start, allDay, false); const endDate = parseDate(event?.end, allDay, allDay); if (!startDate) return null; const start = startDate.getTime(); let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000; if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000; return { start, end, allDay }; }; const formatClock = (date) => { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${hours}:${minutes}`; }; const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); const formatDistance = (ms) => { const totalMinutes = Math.max(0, Math.round(ms / 60000)); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours <= 0) return `${minutes}m`; if (minutes === 0) return `${hours}h`; return `${hours}h ${minutes}m`; }; const formatTimeRange = (event) => { const bounds = eventBounds(event); if (!bounds) return '--'; if (bounds.allDay) return 'All day'; return `${formatClock(new Date(bounds.start))}–${formatClock(new Date(bounds.end))}`; }; const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0'); const minutesIntoDay = (time) => { const date = new Date(time); return date.getHours() * 60 + date.getMinutes(); }; const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30; const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30; const computeVisibleWindow = (events) => { const now = new Date(); const nowMinutes = now.getHours() * 60 + now.getMinutes(); const timedEvents = events.filter((event) => !event._allDay); if (timedEvents.length === 0) { let start = roundDownToHalfHour(nowMinutes - 120); let end = start + minWindowMinutes; const minBound = minStartHour * 60; const maxBound = maxEndHour * 60; if (start < minBound) { start = minBound; end = start + minWindowMinutes; } if (end > maxBound) { end = maxBound; start = Math.max(minBound, end - minWindowMinutes); } return { start, end }; } const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start))); const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end))); let start = roundDownToHalfHour(earliest - 60); let end = roundUpToHalfHour(latest + 90); if (end - start < minWindowMinutes) { const center = roundDownToHalfHour((earliest + latest) / 2); start = center - Math.floor(minWindowMinutes / 2); end = start + minWindowMinutes; } const minBound = minStartHour * 60; const maxBound = maxEndHour * 60; if (start < minBound) { const shift = minBound - start; start += shift; end += shift; } if (end > maxBound) { const shift = end - maxBound; start -= shift; end -= shift; } start = Math.max(minBound, start); end = Math.min(maxBound, end); if (end - start < 120) { end = Math.min(maxBound, start + 120); } return { start, end }; }; const assignColumns = (events) => { const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start); let active = []; let cluster = []; let clusterEnd = -Infinity; let clusterMax = 1; const finalizeCluster = () => { for (const item of cluster) item._columns = clusterMax; }; for (const event of timed) { if (cluster.length > 0 && event._start >= clusterEnd) { finalizeCluster(); active = []; cluster = []; clusterEnd = -Infinity; clusterMax = 1; } active = active.filter((item) => item.end > event._start); const used = new Set(active.map((item) => item.column)); let column = 0; while (used.has(column)) column += 1; event._column = column; active.push({ end: event._end, column }); cluster.push(event); clusterEnd = Math.max(clusterEnd, event._end); clusterMax = Math.max(clusterMax, active.length, column + 1); } if (cluster.length > 0) finalizeCluster(); return timed; }; const extractEvents = (toolResult) => { if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) { return toolResult.parsed.result; } if (typeof toolResult?.content === 'string') { try { const parsed = JSON.parse(toolResult.content); if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result; } catch {} } return []; }; const resolveToolConfig = async () => { if (!host.listTools) { return { name: TOOL_FALLBACK, availableCalendars: calendarNames }; } try { const tools = await host.listTools(); const tool = Array.isArray(tools) ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || ''))) : null; const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) : []; return { name: tool?.name || TOOL_FALLBACK, availableCalendars: enumValues, }; } catch { return { name: TOOL_FALLBACK, availableCalendars: calendarNames }; } }; const computeScore = (events, nowTime) => { const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime); if (current) return 99; const next = events.find((event) => !event._allDay && event._start > nowTime); if (!next) { if (events.some((event) => event._allDay)) return 74; return 18; } const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000)); let score = 70; if (minutesAway <= 15) score = 98; else if (minutesAway <= 30) score = 95; else if (minutesAway <= 60) score = 92; else if (minutesAway <= 180) score = 88; else if (minutesAway <= 360) score = 82; else score = 76; score += Math.min(events.length, 3); return Math.min(100, score); }; const createAllDayChip = (event) => { const chip = document.createElement('div'); chip.style.padding = '3px 6px'; chip.style.border = '1px solid rgba(161, 118, 84, 0.28)'; chip.style.background = 'rgba(255, 249, 241, 0.94)'; chip.style.color = '#5e412d'; chip.style.fontSize = '0.62rem'; chip.style.lineHeight = '1.2'; chip.style.fontWeight = '700'; chip.style.minWidth = '0'; chip.style.maxWidth = '100%'; chip.style.overflow = 'hidden'; chip.style.textOverflow = 'ellipsis'; chip.style.whiteSpace = 'nowrap'; chip.textContent = String(event.summary || '(No title)'); return chip; }; const renderState = () => { const now = new Date(); const nowTime = now.getTime(); const todayLabel = formatShortDate(now); if (!Array.isArray(latestEvents) || latestEvents.length === 0) { headlineEl.textContent = todayLabel; detailEl.textContent = emptyText; allDayWrapEl.style.display = 'none'; timelineShellEl.style.display = 'none'; emptyEl.style.display = 'block'; updateLiveContent({ kind: 'calendar_timeline', subtitle: subtitle || null, tool_name: TOOL_FALLBACK, calendar_names: latestSelectedCalendars, updated_at: latestUpdatedAt || null, now_label: formatClock(now), headline: headlineEl.textContent || null, detail: detailEl.textContent || null, event_count: 0, all_day_count: 0, score: 18, events: [], }); return; } const allDayEvents = latestEvents.filter((event) => event._allDay); const timedEvents = latestEvents.filter((event) => !event._allDay); const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null; const nextEvent = timedEvents.find((event) => event._start > nowTime) || null; headlineEl.textContent = todayLabel; if (currentEvent) { detailEl.textContent = ''; } else if (nextEvent) { detailEl.textContent = ''; } else if (allDayEvents.length > 0) { detailEl.textContent = 'All-day events on your calendar.'; } else { detailEl.textContent = 'Your calendar is clear for the rest of the day.'; } const windowRange = computeVisibleWindow(latestEvents); allDayEl.innerHTML = ''; allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none'; for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event)); emptyEl.style.display = 'none'; timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none'; timelineEl.innerHTML = ''; if (timedEvents.length > 0) { const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30)); const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight; timelineEl.style.height = `${timelineHeight}px`; const gridLayer = document.createElement('div'); gridLayer.style.position = 'absolute'; gridLayer.style.inset = '0'; timelineEl.appendChild(gridLayer); for (let index = 0; index <= slotCount; index += 1) { const minutes = windowRange.start + index * 30; const top = TRACK_TOP_PAD + index * slotHeight; const line = document.createElement('div'); line.style.position = 'absolute'; line.style.left = `${LABEL_WIDTH}px`; line.style.right = '0'; line.style.top = `${top}px`; line.style.borderTop = minutes % 60 === 0 ? '1px solid rgba(143, 101, 69, 0.24)' : '1px dashed rgba(181, 145, 116, 0.18)'; gridLayer.appendChild(line); if (minutes % 60 === 0 && minutes < windowRange.end) { const label = document.createElement('div'); label.style.position = 'absolute'; label.style.left = '0'; label.style.top = `${Math.max(0, top - 7)}px`; label.style.width = `${LABEL_WIDTH - 8}px`; label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace"; label.style.fontSize = '0.54rem'; label.style.lineHeight = '1'; label.style.color = '#8a6248'; label.style.textAlign = 'right'; label.style.textTransform = 'uppercase'; label.style.letterSpacing = '0.05em'; label.textContent = hourLabel(minutes); gridLayer.appendChild(label); } } const eventsLayer = document.createElement('div'); eventsLayer.style.position = 'absolute'; eventsLayer.style.left = `${LABEL_WIDTH + 6}px`; eventsLayer.style.right = '0'; eventsLayer.style.top = `${TRACK_TOP_PAD}px`; eventsLayer.style.bottom = '0'; timelineEl.appendChild(eventsLayer); const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event }))); for (const event of layoutEvents) { const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start; const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start; const top = Math.max(0, (offsetStart / 30) * slotHeight); const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3); const block = document.createElement('div'); block.style.position = 'absolute'; block.style.top = `${top}px`; block.style.height = `${height}px`; block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`; block.style.width = `calc(${100 / event._columns}% - 4px)`; block.style.padding = '5px 6px'; block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end ? '1px solid rgba(169, 39, 29, 0.38)' : '1px solid rgba(162, 105, 62, 0.26)'; block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end ? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)' : 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)'; block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)'; block.style.overflow = 'hidden'; const title = document.createElement('div'); title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif"; title.style.fontSize = '0.74rem'; title.style.lineHeight = '0.98'; title.style.letterSpacing = '-0.01em'; title.style.color = '#22140c'; title.style.fontWeight = '700'; title.style.whiteSpace = 'nowrap'; title.style.textOverflow = 'ellipsis'; title.style.overflow = 'hidden'; title.textContent = String(event.summary || '(No title)'); block.appendChild(title); eventsLayer.appendChild(block); } if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() && nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) { const nowMinutes = now.getHours() * 60 + now.getMinutes(); const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight; const line = document.createElement('div'); line.style.position = 'absolute'; line.style.left = `${LABEL_WIDTH + 6}px`; line.style.right = '0'; line.style.top = `${nowTop}px`; line.style.borderTop = '1.5px solid #cf2f21'; line.style.transition = 'top 28s linear'; timelineEl.appendChild(line); const dot = document.createElement('div'); dot.style.position = 'absolute'; dot.style.left = `${LABEL_WIDTH + 1}px`; dot.style.top = `${nowTop - 4}px`; dot.style.width = '8px'; dot.style.height = '8px'; dot.style.borderRadius = '999px'; dot.style.background = '#cf2f21'; dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)'; dot.style.transition = 'top 28s linear'; timelineEl.appendChild(dot); } } updateLiveContent({ kind: 'calendar_timeline', subtitle: subtitle || null, tool_name: TOOL_FALLBACK, calendar_names: latestSelectedCalendars, updated_at: latestUpdatedAt || null, now_label: formatClock(now), headline: headlineEl.textContent || null, detail: detailEl.textContent || null, current_event: currentEvent ? { summary: String(currentEvent.summary || '(No title)'), start: normalizeDateValue(currentEvent.start) || null, end: normalizeDateValue(currentEvent.end) || null, } : null, next_event: nextEvent ? { summary: String(nextEvent.summary || '(No title)'), start: normalizeDateValue(nextEvent.start) || null, end: normalizeDateValue(nextEvent.end) || null, starts_in: formatDistance(nextEvent._start - nowTime), } : null, event_count: latestEvents.length, all_day_count: allDayEvents.length, score: computeScore(latestEvents, nowTime), events: latestEvents.map((event) => ({ summary: String(event.summary || '(No title)'), start: normalizeDateValue(event.start) || null, end: normalizeDateValue(event.end) || null, all_day: Boolean(event._allDay), })), }); }; const refresh = async () => { headlineEl.textContent = 'Loading today…'; detailEl.textContent = 'Checking your calendar.'; try { const toolConfig = await resolveToolConfig(); const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars; if (!toolConfig.name) throw new Error('Calendar tool unavailable'); if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { throw new Error('No calendars configured'); } const allEvents = []; for (const calendarName of selectedCalendars) { const toolResult = await host.callTool(toolConfig.name, { calendar: calendarName, range: 'today', }); const events = extractEvents(toolResult); for (const event of events) { const bounds = eventBounds(event); if (!bounds) continue; allEvents.push({ ...event, _calendarName: calendarName, _start: bounds.start, _end: bounds.end, _allDay: bounds.allDay, }); } } allEvents.sort((left, right) => left._start - right._start); latestEvents = allEvents; latestSelectedCalendars = selectedCalendars; latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); renderState(); } catch (error) { const errorText = String(error); latestEvents = []; latestSelectedCalendars = calendarNames; latestUpdatedAt = errorText; headlineEl.textContent = formatShortDate(new Date()); detailEl.textContent = errorText; allDayWrapEl.style.display = 'none'; timelineShellEl.style.display = 'none'; emptyEl.style.display = 'block'; updateLiveContent({ kind: 'calendar_timeline', subtitle: subtitle || null, tool_name: TOOL_FALLBACK, calendar_names: latestSelectedCalendars, updated_at: errorText, now_label: formatClock(new Date()), headline: headlineEl.textContent || null, detail: detailEl.textContent || null, event_count: 0, all_day_count: 0, score: 0, events: [], error: errorText, }); } }; host.setRefreshHandler(() => { void refresh(); }); if (clockIntervalId) window.clearInterval(clockIntervalId); clockIntervalId = __setInterval(() => { renderState(); }, 30000); void refresh(); __setInterval(() => { void refresh(); }, refreshMs); return { destroy() { host.setRefreshHandler(null); host.setLiveContent(null); host.clearSelection(); for (const cleanup of __cleanup.splice(0)) cleanup(); }, }; }