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 emptyEl = root.querySelector('[data-upcoming-empty]'); const listEl = root.querySelector('[data-upcoming-list]'); if (!(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement)) return; const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : ''; const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec'; const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : ''; const calendarNames = Array.isArray(state.calendar_names) ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) : []; const eventWindowHoursRaw = Number(state.event_window_hours); const eventWindowHours = Number.isFinite(eventWindowHoursRaw) && eventWindowHoursRaw >= 1 ? Math.min(eventWindowHoursRaw, 168) : 36; const maxEventsRaw = Number(state.max_events); const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 4; const refreshMsRaw = Number(state.refresh_ms); const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000; const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : `No upcoming events in the next ${eventWindowHours} hours.`; 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 eventTime = (value, allDay = false) => { const raw = normalizeDateValue(value); if (!raw) return Number.NaN; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T${allDay ? '12:00:00' : '00:00:00'}` : raw; return new Date(normalized).getTime(); }; const formatEventLabel = (event) => { const raw = normalizeDateValue(event?.start); if (!raw) return '--'; if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { const date = new Date(`${raw}T12:00:00`); return `${date.toLocaleDateString([], { weekday: 'short' })} · all day`; } const date = new Date(raw); if (Number.isNaN(date.getTime())) return '--'; return date.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit', }); }; const extractEvents = (toolResult) => { if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { const parsed = toolResult.parsed; if (Array.isArray(parsed.result)) return 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 []; } } return []; }; const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim(); const extractExecJson = (toolResult) => { const parsedText = stripExecFooter(toolResult?.content); if (!parsedText) return null; try { return JSON.parse(parsedText); } catch { return null; } }; const resolveCalendarToolConfig = async () => { const fallbackName = configuredCalendarToolName || 'mcp_home_assistant_calendar_get_events'; if (!host.listTools) { return { name: fallbackName, 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 || fallbackName, availableCalendars: enumValues, }; } catch { return { name: fallbackName, availableCalendars: calendarNames }; } }; const resolveUpcomingEvents = async (toolConfig) => { const now = Date.now(); const windowEnd = now + eventWindowHours * 60 * 60 * 1000; 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 upcomingEvents = []; for (const calendarName of selectedCalendars) { const toolResult = await host.callTool(toolConfig.name, { calendar: calendarName, range: 'week', }); const events = extractEvents(toolResult); for (const event of events) { const allDay = isAllDay(event?.start, event?.end); const startTime = eventTime(event?.start, allDay); if (!Number.isFinite(startTime) || startTime < now || startTime > windowEnd) continue; upcomingEvents.push({ ...event, _calendarName: calendarName, _allDay: allDay, _startTime: startTime }); } } upcomingEvents.sort((left, right) => left._startTime - right._startTime); return upcomingEvents.slice(0, maxEvents); }; const resolveForecastBundle = async () => { if (!forecastCommand) throw new Error('Missing forecast_command'); const toolResult = await host.callTool(configuredForecastToolName || 'exec', { command: forecastCommand, max_output_chars: 200000, }); const payload = extractExecJson(toolResult); if (!payload || typeof payload !== 'object') { throw new Error('Invalid forecast payload'); } return payload; }; const forecastTime = (entry) => { const time = new Date(String(entry?.datetime || '')).getTime(); return Number.isFinite(time) ? time : Number.NaN; }; const nearestForecast = (entries, targetTime) => { if (!Array.isArray(entries) || entries.length === 0 || !Number.isFinite(targetTime)) return null; let bestEntry = null; let bestDistance = Number.POSITIVE_INFINITY; for (const entry of entries) { const entryTime = forecastTime(entry); if (!Number.isFinite(entryTime)) continue; const distance = Math.abs(entryTime - targetTime); if (distance < bestDistance) { bestDistance = distance; bestEntry = entry; } } return bestDistance <= 3 * 60 * 60 * 1000 ? bestEntry : null; }; const computeUpcomingScore = (events) => { if (!Array.isArray(events) || events.length === 0) return 0; const now = Date.now(); const soonestMs = events .map((event) => Number(event?._startTime)) .filter((time) => Number.isFinite(time) && time >= now) .sort((left, right) => left - right)[0]; const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null; let score = 44; if (soonestHours !== null) { if (soonestHours <= 1) score = 100; else if (soonestHours <= 3) score = 97; else if (soonestHours <= 8) score = 94; else if (soonestHours <= 24) score = 90; else if (soonestHours <= 36) score = 86; else if (soonestHours <= 48) score = 82; else if (soonestHours <= 72) score = 76; else if (soonestHours <= 168) score = 62; } score += Math.min(events.length, 3); return Math.max(0, Math.min(100, Math.round(score))); }; const metricValue = (value, fallback = '--') => { if (value === null || value === undefined || value === '') return fallback; return String(value); }; const createMetricCell = (glyph, label, value) => { const cell = document.createElement('div'); cell.style.display = 'flex'; cell.style.alignItems = 'baseline'; cell.style.columnGap = '3px'; cell.style.flex = '0 0 auto'; cell.style.minWidth = '0'; cell.style.whiteSpace = 'nowrap'; cell.title = label; const glyphEl = document.createElement('div'); glyphEl.style.fontSize = '0.54rem'; glyphEl.style.lineHeight = '1'; glyphEl.style.color = 'var(--theme-card-neutral-muted)'; glyphEl.style.fontWeight = '700'; glyphEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace"; glyphEl.style.flex = '0 0 auto'; glyphEl.textContent = glyph; cell.appendChild(glyphEl); const valueEl = document.createElement('div'); valueEl.style.fontSize = '0.53rem'; valueEl.style.lineHeight = '1.1'; valueEl.style.color = 'var(--theme-card-neutral-text)'; valueEl.style.fontWeight = '700'; valueEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace"; valueEl.style.whiteSpace = 'nowrap'; valueEl.style.overflow = 'hidden'; valueEl.style.textOverflow = 'ellipsis'; valueEl.style.textAlign = 'right'; valueEl.style.flex = '1 1 auto'; valueEl.textContent = metricValue(value); cell.appendChild(valueEl); return cell; }; const renderEvents = (items, temperatureUnit, windSpeedUnit) => { listEl.innerHTML = ''; if (!Array.isArray(items) || items.length === 0) { emptyEl.style.display = 'block'; return; } emptyEl.style.display = 'none'; for (const [index, item] of items.entries()) { const event = item.event; const forecast = item.forecast; const entry = document.createElement('li'); entry.style.padding = index === 0 ? '8px 0 0' : '8px 0 0'; entry.style.borderTop = '1px solid var(--theme-card-neutral-border)'; const whenEl = document.createElement('div'); whenEl.style.fontSize = '0.72rem'; whenEl.style.lineHeight = '1.2'; whenEl.style.letterSpacing = '0.02em'; whenEl.style.color = 'var(--theme-card-neutral-muted)'; whenEl.style.fontWeight = '700'; whenEl.textContent = formatEventLabel(event); entry.appendChild(whenEl); const titleEl = document.createElement('div'); titleEl.style.marginTop = '3px'; titleEl.style.fontSize = '0.9rem'; titleEl.style.lineHeight = '1.25'; titleEl.style.color = 'var(--theme-card-neutral-text)'; titleEl.style.fontWeight = '700'; titleEl.style.whiteSpace = 'normal'; titleEl.style.wordBreak = 'break-word'; titleEl.textContent = String(event.summary || '(No title)'); entry.appendChild(titleEl); const detailGrid = document.createElement('div'); detailGrid.style.marginTop = '4px'; detailGrid.style.display = 'flex'; detailGrid.style.flexWrap = 'nowrap'; detailGrid.style.alignItems = 'baseline'; detailGrid.style.gap = '6px'; detailGrid.style.overflowX = 'auto'; detailGrid.style.overflowY = 'hidden'; detailGrid.style.scrollbarWidth = 'none'; detailGrid.style.msOverflowStyle = 'none'; detailGrid.style.webkitOverflowScrolling = 'touch'; const tempValue = Number.isFinite(Number(forecast?.temperature)) ? `${Math.round(Number(forecast.temperature))}${temperatureUnit || ''}` : null; const windValue = Number.isFinite(Number(forecast?.wind_speed)) ? `${Math.round(Number(forecast.wind_speed))}${windSpeedUnit || ''}` : null; const rainValue = Number.isFinite(Number(forecast?.precipitation_probability)) ? `${Math.round(Number(forecast.precipitation_probability))}%` : null; const uvValue = Number.isFinite(Number(forecast?.uv_index)) ? `${Math.round(Number(forecast.uv_index))}` : null; detailGrid.appendChild(createMetricCell('\uf2c9', 'Temperature', tempValue)); detailGrid.appendChild(createMetricCell('\uef16', 'Wind', windValue)); detailGrid.appendChild(createMetricCell('\uf043', 'Rain', rainValue)); detailGrid.appendChild(createMetricCell('\uf522', 'UV', uvValue)); entry.appendChild(detailGrid); listEl.appendChild(entry); } }; const refresh = async () => { const snapshot = { kind: 'upcoming_conditions', event_window_hours: eventWindowHours, updated_at: null, events: [], errors: {}, }; try { const [toolConfig, forecastBundle] = await Promise.all([ resolveCalendarToolConfig(), resolveForecastBundle(), ]); const events = await resolveUpcomingEvents(toolConfig); const nwsSource = forecastBundle?.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null; const uvSource = forecastBundle?.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null; const temperatureUnit = String(nwsSource?.temperature_unit || uvSource?.temperature_unit || '°F'); const windSpeedUnit = String(nwsSource?.wind_speed_unit || uvSource?.wind_speed_unit || 'mph'); const mergedItems = events.map((event) => { const nwsForecast = nearestForecast(nwsSource?.forecast, event._startTime); const uvForecast = nearestForecast(uvSource?.forecast, event._startTime); return { event, forecast: { datetime: nwsForecast?.datetime || uvForecast?.datetime || null, condition: nwsForecast?.condition || uvForecast?.condition || null, temperature: nwsForecast?.temperature ?? uvForecast?.temperature ?? null, apparent_temperature: uvForecast?.apparent_temperature ?? null, precipitation_probability: nwsForecast?.precipitation_probability ?? uvForecast?.precipitation_probability ?? null, wind_speed: nwsForecast?.wind_speed ?? uvForecast?.wind_speed ?? null, uv_index: uvForecast?.uv_index ?? null, }, }; }); renderEvents(mergedItems, temperatureUnit, windSpeedUnit); snapshot.events = mergedItems.map((item) => ({ summary: String(item.event.summary || '(No title)'), start: normalizeDateValue(item.event.start) || null, end: normalizeDateValue(item.event.end) || null, all_day: Boolean(item.event._allDay), calendar_name: String(item.event._calendarName || ''), forecast_time: item.forecast.datetime || null, condition: item.forecast.condition || null, temperature: Number.isFinite(Number(item.forecast.temperature)) ? Number(item.forecast.temperature) : null, apparent_temperature: Number.isFinite(Number(item.forecast.apparent_temperature)) ? Number(item.forecast.apparent_temperature) : null, precipitation_probability: Number.isFinite(Number(item.forecast.precipitation_probability)) ? Number(item.forecast.precipitation_probability) : null, wind_speed: Number.isFinite(Number(item.forecast.wind_speed)) ? Number(item.forecast.wind_speed) : null, uv_index: Number.isFinite(Number(item.forecast.uv_index)) ? Number(item.forecast.uv_index) : null, })); snapshot.score = computeUpcomingScore(events); } catch (error) { listEl.innerHTML = ''; emptyEl.style.display = 'block'; emptyEl.textContent = String(error); snapshot.errors.load = String(error); snapshot.score = 0; } const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); snapshot.updated_at = updatedText; updateLiveContent(snapshot); }; host.setRefreshHandler(() => { void refresh(); }); void refresh(); __setInterval(() => { void refresh(); }, refreshMs); return { destroy() { host.setRefreshHandler(null); host.setLiveContent(null); host.clearSelection(); for (const cleanup of __cleanup.splice(0)) cleanup(); }, }; }