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 weatherScaleEl = root.querySelector('[data-calendar-weather-scale]'); const weatherLowEl = root.querySelector('[data-calendar-weather-low]'); const weatherHighEl = root.querySelector('[data-calendar-weather-high]'); 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) || !(weatherScaleEl instanceof HTMLElement) || !(weatherLowEl instanceof HTMLElement) || !(weatherHighEl 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 configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : 'exec'; const weatherCommand = typeof state.weather_command === 'string' ? state.weather_command.trim() : ''; 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 latestWeatherPoints = []; let latestWeatherRange = null; 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 computeWeatherWindow = () => { if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length < 2) return null; const sorted = latestWeatherPoints .map((point) => minutesIntoDay(point.time)) .filter((minutes) => Number.isFinite(minutes)) .sort((left, right) => left - right); if (sorted.length < 2) return null; const start = roundDownToHalfHour(sorted[0]); const end = roundUpToHalfHour(sorted[sorted.length - 1]); if (end <= start) return null; return { start, end }; }; const computeVisibleWindow = (events) => { const now = new Date(); const nowMinutes = now.getHours() * 60 + now.getMinutes(); const timedEvents = events.filter((event) => !event._allDay); const weatherWindow = computeWeatherWindow(); 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); } if (weatherWindow) { start = Math.max(start, weatherWindow.start); end = Math.min(end, weatherWindow.end); if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end }; } 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); } if (weatherWindow) { start = Math.max(start, weatherWindow.start); end = Math.min(end, weatherWindow.end); if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end }; } 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 stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim(); const extractExecJson = (toolResult) => { const text = stripExecFooter(toolResult?.content); if (!text) return null; try { return JSON.parse(text); } catch { return null; } }; const forecastTime = (entry) => { const time = new Date(String(entry?.datetime || '')).getTime(); return Number.isFinite(time) ? time : Number.NaN; }; const extractWeatherPoints = (payload) => { const rows = Array.isArray(payload?.nws?.forecast) ? payload.nws.forecast : []; const today = new Date(); const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0).getTime(); const dayEnd = dayStart + 24 * 60 * 60 * 1000; return rows .map((entry) => { const time = forecastTime(entry); const temp = Number(entry?.temperature); if (!Number.isFinite(time) || !Number.isFinite(temp)) return null; if (time < dayStart || time >= dayEnd) return null; return { time, temp, }; }) .filter(Boolean); }; const resolveWeatherForecast = async () => { if (!weatherCommand) return []; const toolResult = await host.callTool(configuredWeatherToolName || 'exec', { command: weatherCommand, max_output_chars: 200000, }); const payload = extractExecJson(toolResult); if (!payload || typeof payload !== 'object') return []; return extractWeatherPoints(payload); }; 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 computeWeatherRange = (points) => { if (!Array.isArray(points) || points.length === 0) return null; let low = Number.POSITIVE_INFINITY; let high = Number.NEGATIVE_INFINITY; for (const point of points) { low = Math.min(low, point.temp); high = Math.max(high, point.temp); } if (!Number.isFinite(low) || !Number.isFinite(high)) return null; if (low === high) high = low + 1; return { low, high }; }; const renderWeatherGraph = (windowRange, timelineHeight) => { weatherScaleEl.style.display = 'none'; weatherLowEl.textContent = '--'; weatherHighEl.textContent = '--'; if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length === 0 || !latestWeatherRange) return; const visiblePoints = latestWeatherPoints.filter((point) => { const minutes = minutesIntoDay(point.time); return minutes >= windowRange.start && minutes <= windowRange.end; }); if (visiblePoints.length < 2) return; weatherScaleEl.style.display = 'flex'; weatherLowEl.textContent = `${Math.round(latestWeatherRange.low)}°`; weatherHighEl.textContent = `${Math.round(latestWeatherRange.high)}°`; const timelineWidth = timelineEl.getBoundingClientRect().width; const overlayWidth = Math.max(1, timelineWidth - (LABEL_WIDTH + 6)); const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); overlay.setAttribute('viewBox', `0 0 ${overlayWidth} ${timelineHeight}`); overlay.style.position = 'absolute'; overlay.style.left = `${LABEL_WIDTH + 6}px`; overlay.style.top = '0'; overlay.style.height = `${timelineHeight}px`; overlay.style.width = `${overlayWidth}px`; overlay.style.pointerEvents = 'none'; overlay.style.opacity = '0.85'; const toPoint = (point) => { const minutes = minutesIntoDay(point.time); const y = TRACK_TOP_PAD + ((minutes - windowRange.start) / 30) * slotHeight; const x = ((point.temp - latestWeatherRange.low) / (latestWeatherRange.high - latestWeatherRange.low)) * overlayWidth; return { x, y }; }; const coords = visiblePoints.map(toPoint); const buildSmoothPath = (points) => { if (points.length === 0) return ''; if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; for (let index = 1; index < points.length - 1; index += 1) { const point = points[index]; const next = points[index + 1]; const midX = (point.x + next.x) / 2; const midY = (point.y + next.y) / 2; d += ` Q ${point.x.toFixed(2)} ${point.y.toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`; } const penultimate = points[points.length - 2]; const last = points[points.length - 1]; d += ` Q ${penultimate.x.toFixed(2)} ${penultimate.y.toFixed(2)} ${last.x.toFixed(2)} ${last.y.toFixed(2)}`; return d; }; const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); line.setAttribute('d', buildSmoothPath(coords)); line.setAttribute('fill', 'none'); line.setAttribute('stroke', 'rgba(184, 110, 58, 0.34)'); line.setAttribute('stroke-width', '1.8'); line.setAttribute('stroke-linecap', 'round'); line.setAttribute('stroke-linejoin', 'round'); line.setAttribute('vector-effect', 'non-scaling-stroke'); overlay.appendChild(line); for (const point of coords) { const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); dot.setAttribute('cx', point.x.toFixed(2)); dot.setAttribute('cy', point.y.toFixed(2)); dot.setAttribute('r', '1.9'); dot.setAttribute('fill', 'rgba(184, 110, 58, 0.34)'); dot.setAttribute('stroke', 'rgba(226, 188, 156, 0.62)'); dot.setAttribute('stroke-width', '0.55'); overlay.appendChild(dot); } timelineEl.appendChild(overlay); }; 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, weather_temperature_range: latestWeatherRange ? { low: latestWeatherRange.low, high: latestWeatherRange.high, } : 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); } } renderWeatherGraph(windowRange, timelineHeight); 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, weather_temperature_range: latestWeatherRange ? { low: latestWeatherRange.low, high: latestWeatherRange.high, } : 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 [weatherPoints, allEvents] = await Promise.all([ resolveWeatherForecast().catch(() => []), (async () => { 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, }); } } return allEvents; })(), ]); allEvents.sort((left, right) => left._start - right._start); latestWeatherPoints = weatherPoints; latestWeatherRange = computeWeatherRange(weatherPoints); latestEvents = allEvents; latestSelectedCalendars = selectedCalendars; latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); renderState(); } catch (error) { const errorText = String(error); latestWeatherPoints = []; latestWeatherRange = null; 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, weather_temperature_range: 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(); }, }; }