feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
410
examples/cards/templates/upcoming-conditions-live/card.js
Normal file
410
examples/cards/templates/upcoming-conditions-live/card.js
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue