411 lines
16 KiB
JavaScript
411 lines
16 KiB
JavaScript
|
|
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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|