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 dateEl = root.querySelector('[data-today-date]'); const tempEl = root.querySelector('[data-today-temp]'); const unitEl = root.querySelector('[data-today-unit]'); const feelsLikeEl = root.querySelector('[data-today-feels-like]'); const aqiChipEl = root.querySelector('[data-today-aqi-chip]'); const countEl = root.querySelector('[data-today-events-count]'); const emptyEl = root.querySelector('[data-today-empty]'); const listEl = root.querySelector('[data-today-events-list]'); const updatedEl = root.querySelector('[data-today-updated]'); if (!(dateEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(feelsLikeEl instanceof HTMLElement) || !(aqiChipEl instanceof HTMLElement) || !(countEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return; const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : ''; const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : ''; const calendarNames = Array.isArray(state.calendar_names) ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean) : []; const weatherPrefix = typeof state.weather_prefix === 'string' ? state.weather_prefix.trim() : 'OpenWeatherMap'; const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : ''; const apparentTemperatureName = typeof state.apparent_temperature_name === 'string' ? state.apparent_temperature_name.trim() : ''; const aqiName = typeof state.aqi_name === 'string' ? state.aqi_name.trim() : ''; const maxEventsRaw = Number(state.max_events); const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 6; 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() : 'Nothing scheduled today.'; emptyEl.textContent = emptyText; const updateLiveContent = (snapshot) => { host.setLiveContent(snapshot); }; const stripQuotes = (value) => { const text = String(value ?? '').trim(); if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { return text.slice(1, -1); } return text; }; const normalizeText = (value) => String(value || '').trim().toLowerCase(); const parseLiveContextEntries = (payloadText) => { const text = String(payloadText || '').replace(/\r/g, ''); const startIndex = text.indexOf('- names: '); const relevant = startIndex >= 0 ? text.slice(startIndex) : text; const entries = []; let current = null; let inAttributes = false; const pushCurrent = () => { if (current) entries.push(current); current = null; inAttributes = false; }; for (const rawLine of relevant.split('\n')) { if (rawLine.startsWith('- names: ')) { pushCurrent(); current = { name: stripQuotes(rawLine.slice(9)), domain: '', state: '', areas: '', attributes: {}, }; continue; } if (!current) continue; const trimmed = rawLine.trim(); if (!trimmed) continue; if (trimmed === 'attributes:') { inAttributes = true; continue; } if (rawLine.startsWith(' domain:')) { current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); inAttributes = false; continue; } if (rawLine.startsWith(' state:')) { current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); inAttributes = false; continue; } if (rawLine.startsWith(' areas:')) { current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1)); inAttributes = false; continue; } if (inAttributes && rawLine.startsWith(' ')) { const separatorIndex = rawLine.indexOf(':'); if (separatorIndex >= 0) { const key = rawLine.slice(4, separatorIndex).trim(); const value = stripQuotes(rawLine.slice(separatorIndex + 1)); current.attributes[key] = value; } continue; } inAttributes = false; } pushCurrent(); return entries; }; const extractLiveContextText = (toolResult) => { if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') { const parsed = toolResult.parsed; if (typeof parsed.result === 'string') return parsed.result; } if (typeof toolResult?.content === 'string') { try { const parsed = JSON.parse(toolResult.content); if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') { return parsed.result; } } catch { return toolResult.content; } return toolResult.content; } return ''; }; 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 formatHeaderDate = (value) => value.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); const formatTime = (value) => { const raw = normalizeDateValue(value); if (!raw) return '--:--'; if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day'; const date = new Date(raw); if (Number.isNaN(date.getTime())) return '--:--'; return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; const formatEventDay = (value) => { const raw = normalizeDateValue(value); if (!raw) return '--'; const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw); if (Number.isNaN(date.getTime())) return '--'; return date.toLocaleDateString([], { weekday: 'short' }); }; const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end)); 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 eventTime = (value) => { const raw = normalizeDateValue(value); if (!raw) return Number.MAX_SAFE_INTEGER; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw; const time = new Date(normalized).getTime(); return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER; }; const findEntry = (entries, candidates) => { const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean); if (normalizedCandidates.length === 0) return null; const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name))); if (exactMatch) return exactMatch; return entries.find((entry) => { const entryName = normalizeText(entry.name); return normalizedCandidates.some((candidate) => entryName.includes(candidate)); }) || null; }; const findByDeviceClass = (entries, deviceClass) => entries.find((entry) => normalizeText(entry.attributes?.device_class) === normalizeText(deviceClass)) || null; const parseNumericValue = (entry) => { const value = Number(entry?.state); return Number.isFinite(value) ? value : null; }; const formatMetric = (value, unit) => { if (!Number.isFinite(value)) return '--'; return `${Math.round(value)}${unit ? ` ${unit}` : ''}`; }; const buildAqiStyle = (aqiValue) => { if (!Number.isFinite(aqiValue)) { return { label: 'AQI --', tone: 'Unavailable', background: 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)', color: 'var(--theme-card-neutral-subtle)' }; } if (aqiValue <= 50) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Good', background: 'color-mix(in srgb, var(--theme-status-live) 16%, white)', color: 'var(--theme-status-live)' }; if (aqiValue <= 100) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Moderate', background: 'color-mix(in srgb, var(--theme-status-warning) 16%, white)', color: 'var(--theme-status-warning)' }; if (aqiValue <= 150) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Sensitive', background: 'color-mix(in srgb, var(--theme-status-warning) 24%, white)', color: 'var(--theme-status-warning)' }; if (aqiValue <= 200) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Unhealthy', background: 'color-mix(in srgb, var(--theme-status-danger) 14%, white)', color: 'var(--theme-status-danger)' }; if (aqiValue <= 300) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Very unhealthy', background: 'color-mix(in srgb, var(--theme-accent) 14%, white)', color: 'var(--theme-accent-strong)' }; return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Hazardous', background: 'color-mix(in srgb, var(--theme-accent-strong) 18%, white)', color: 'var(--theme-accent-strong)' }; }; const startTime = (value) => { const raw = normalizeDateValue(value); if (!raw) return Number.NaN; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00` : raw; const time = new Date(normalized).getTime(); return Number.isFinite(time) ? time : Number.NaN; }; const computeTodayScore = (events, status) => { if (status === 'Unavailable') return 5; if (!Array.isArray(events) || events.length === 0) { return status === 'Partial' ? 18 : 24; } const now = Date.now(); const soonestMs = events .map((event) => startTime(event?.start)) .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 = 34; if (soonestHours !== null) { if (soonestHours <= 1) score = 80; else if (soonestHours <= 3) score = 70; else if (soonestHours <= 8) score = 58; else if (soonestHours <= 24) score = 46; } score += Math.min(events.length, 3) * 3; if (status === 'Partial') score -= 8; return Math.max(0, Math.min(100, Math.round(score))); }; const renderEvents = (events) => { listEl.innerHTML = ''; if (!Array.isArray(events) || events.length === 0) { emptyEl.style.display = 'block'; countEl.textContent = 'No events'; return; } emptyEl.style.display = 'none'; countEl.textContent = `${events.length} ${events.length === 1 ? 'event' : 'events'}`; for (const [index, event] of events.slice(0, maxEvents).entries()) { const item = document.createElement('li'); item.style.padding = index === 0 ? '10px 0 0' : '10px 0 0'; item.style.borderTop = '1px solid var(--theme-card-neutral-border)'; const timing = document.createElement('div'); timing.style.fontSize = '0.76rem'; timing.style.lineHeight = '1.2'; timing.style.textTransform = 'uppercase'; timing.style.letterSpacing = '0.05em'; timing.style.color = 'var(--theme-card-neutral-muted)'; timing.style.fontWeight = '700'; const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatEventDay(event.start)} · ${formatTime(event.start)}`; timing.textContent = timeLabel; item.appendChild(timing); const summary = document.createElement('div'); summary.style.marginTop = '4px'; summary.style.fontSize = '0.95rem'; summary.style.lineHeight = '1.35'; summary.style.color = 'var(--theme-card-neutral-text)'; summary.style.fontWeight = '700'; summary.textContent = String(event.summary || '(No title)'); item.appendChild(summary); listEl.appendChild(item); } }; const resolveToolName = async (configuredName, pattern, fallbackName) => { if (configuredName) return configuredName; if (!host.listTools) return fallbackName; try { const tools = await host.listTools(); const tool = Array.isArray(tools) ? tools.find((item) => pattern.test(String(item?.name || ''))) : null; return tool?.name || fallbackName; } catch { return fallbackName; } }; const resolveCalendarNames = async (toolName) => { if (calendarNames.length > 0) return calendarNames; if (!host.listTools) return calendarNames; try { const tools = await host.listTools(); const tool = Array.isArray(tools) ? tools.find((item) => String(item?.name || '') === toolName) : null; const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum) ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean) : []; return enumValues; } catch { return calendarNames; } }; const loadWeather = async (toolName) => { const toolResult = await host.callTool(toolName, {}); const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor'); const temperatureEntry = findEntry(entries, [ temperatureName, `${weatherPrefix} Temperature`, ]) || findByDeviceClass(entries, 'temperature'); const apparentEntry = findEntry(entries, [ apparentTemperatureName, `${weatherPrefix} Apparent temperature`, `${weatherPrefix} Feels like`, ]); const aqiEntry = findEntry(entries, [ aqiName, 'Air quality index', ]) || findByDeviceClass(entries, 'aqi'); return { toolName, temperature: parseNumericValue(temperatureEntry), temperatureUnit: String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'), feelsLike: parseNumericValue(apparentEntry), feelsLikeUnit: String(apparentEntry?.attributes?.unit_of_measurement || temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'), aqi: parseNumericValue(aqiEntry), }; }; const loadEvents = async (toolName) => { const selectedCalendars = await resolveCalendarNames(toolName); if (!toolName) throw new Error('Calendar tool unavailable'); if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) { throw new Error('No calendars configured'); } const start = new Date(); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setHours(23, 59, 59, 999); const endExclusiveTime = end.getTime() + 1; const allEvents = []; for (const calendarName of selectedCalendars) { const toolResult = await host.callTool(toolName, { calendar: calendarName, range: 'today', }); const events = extractEvents(toolResult); for (const event of events) { const startTime = eventTime(event?.start); if (startTime < start.getTime() || startTime >= endExclusiveTime) continue; allEvents.push({ ...event, _calendarName: calendarName }); } } allEvents.sort((left, right) => eventTime(left?.start) - eventTime(right?.start)); return { toolName, calendarNames: selectedCalendars, events: allEvents.slice(0, maxEvents), }; }; const refresh = async () => { const now = new Date(); dateEl.textContent = formatHeaderDate(now); const [weatherToolName, calendarToolName] = await Promise.all([ resolveToolName(configuredWeatherToolName, /(^|_)GetLiveContext$/i, 'mcp_home_assistant_GetLiveContext'), resolveToolName(configuredCalendarToolName, /(^|_)calendar_get_events$/i, 'mcp_home_assistant_calendar_get_events'), ]); const [weatherResult, eventsResult] = await Promise.allSettled([ loadWeather(weatherToolName), loadEvents(calendarToolName), ]); const snapshot = { kind: 'today_briefing', date_label: dateEl.textContent || null, weather_tool_name: weatherToolName || null, calendar_tool_name: calendarToolName || null, updated_at: null, status: null, weather: null, events: [], errors: {}, }; let successCount = 0; if (weatherResult.status === 'fulfilled') { successCount += 1; const weather = weatherResult.value; tempEl.textContent = Number.isFinite(weather.temperature) ? String(Math.round(weather.temperature)) : '--'; unitEl.textContent = weather.temperatureUnit || '°F'; feelsLikeEl.textContent = formatMetric(weather.feelsLike, weather.feelsLikeUnit); const aqiStyle = buildAqiStyle(weather.aqi); aqiChipEl.textContent = `${aqiStyle.tone} · ${aqiStyle.label}`; aqiChipEl.style.background = aqiStyle.background; aqiChipEl.style.color = aqiStyle.color; snapshot.weather = { temperature: Number.isFinite(weather.temperature) ? Math.round(weather.temperature) : null, temperature_unit: weather.temperatureUnit || null, feels_like: Number.isFinite(weather.feelsLike) ? Math.round(weather.feelsLike) : null, aqi: Number.isFinite(weather.aqi) ? Math.round(weather.aqi) : null, aqi_tone: aqiStyle.tone, }; } else { tempEl.textContent = '--'; unitEl.textContent = '°F'; feelsLikeEl.textContent = '--'; aqiChipEl.textContent = 'AQI unavailable'; aqiChipEl.style.background = 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)'; aqiChipEl.style.color = 'var(--theme-card-neutral-subtle)'; snapshot.errors.weather = String(weatherResult.reason); } if (eventsResult.status === 'fulfilled') { successCount += 1; const eventsData = eventsResult.value; renderEvents(eventsData.events); snapshot.events = eventsData.events.map((event) => ({ summary: String(event.summary || '(No title)'), start: normalizeDateValue(event.start) || null, end: normalizeDateValue(event.end) || null, all_day: isAllDay(event.start, event.end), })); } else { renderEvents([]); countEl.textContent = 'Unavailable'; emptyEl.style.display = 'block'; emptyEl.textContent = 'Calendar unavailable.'; snapshot.errors.events = String(eventsResult.reason); } const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); updatedEl.textContent = updatedText; snapshot.updated_at = updatedText; if (successCount === 2) { snapshot.status = 'Ready'; } else if (successCount === 1) { snapshot.status = 'Partial'; } else { snapshot.status = 'Unavailable'; } snapshot.score = computeTodayScore(snapshot.events, snapshot.status); 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(); }, }; }