nanobot-voice-interface/examples/cards/templates/upcoming-conditions-live/card.js

411 lines
16 KiB
JavaScript
Raw Normal View History

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();
},
};
}