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
266
examples/cards/templates/live-calendar-today/card.js
Normal file
266
examples/cards/templates/live-calendar-today/card.js
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
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 statusEl = root.querySelector("[data-cal-status]");
|
||||
const subtitleEl = root.querySelector("[data-cal-subtitle]");
|
||||
const dateEl = root.querySelector("[data-cal-date]");
|
||||
const listEl = root.querySelector("[data-cal-list]");
|
||||
const emptyEl = root.querySelector("[data-cal-empty]");
|
||||
const updatedEl = root.querySelector("[data-cal-updated]");
|
||||
|
||||
if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) ||
|
||||
!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||
const configuredCalendarNames = Array.isArray(state.calendar_names)
|
||||
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const refreshMsRaw = Number(state.refresh_ms);
|
||||
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
|
||||
|
||||
const setStatus = (label, color) => {
|
||||
statusEl.textContent = label;
|
||||
statusEl.style.color = color;
|
||||
};
|
||||
|
||||
const updateLiveContent = (snapshot) => {
|
||||
host.setLiveContent(snapshot);
|
||||
};
|
||||
|
||||
const dayBounds = () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(now);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const formatDateHeader = () => {
|
||||
const now = new Date();
|
||||
return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" });
|
||||
};
|
||||
|
||||
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 formatTime = (value) => {
|
||||
const raw = normalizeDateValue(value);
|
||||
if (!raw) return "--:--";
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day";
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return "--:--";
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const isAllDay = (start, end) => {
|
||||
const s = normalizeDateValue(start);
|
||||
const e = normalizeDateValue(end);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e);
|
||||
};
|
||||
|
||||
const eventSortKey = (evt) => {
|
||||
const raw = normalizeDateValue(evt && evt.start);
|
||||
const t = new Date(raw).getTime();
|
||||
return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
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 resolveToolConfig = async () => {
|
||||
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
|
||||
if (!host.listTools) {
|
||||
return { name: fallbackName, calendars: configuredCalendarNames };
|
||||
}
|
||||
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,
|
||||
calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues,
|
||||
};
|
||||
} catch {
|
||||
return { name: fallbackName, calendars: configuredCalendarNames };
|
||||
}
|
||||
};
|
||||
|
||||
const renderEvents = (events) => {
|
||||
listEl.innerHTML = "";
|
||||
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
emptyEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
|
||||
for (const evt of events) {
|
||||
const li = document.createElement("li");
|
||||
li.style.border = "1px solid var(--theme-card-neutral-border)";
|
||||
li.style.borderRadius = "10px";
|
||||
li.style.padding = "10px 12px";
|
||||
li.style.background = "#ffffff";
|
||||
|
||||
const summary = document.createElement("div");
|
||||
summary.style.fontSize = "0.96rem";
|
||||
summary.style.fontWeight = "600";
|
||||
summary.style.color = "var(--theme-card-neutral-text)";
|
||||
summary.textContent = String(evt.summary || "(No title)");
|
||||
|
||||
const timing = document.createElement("div");
|
||||
timing.style.marginTop = "4px";
|
||||
timing.style.fontSize = "0.86rem";
|
||||
timing.style.color = "var(--theme-card-neutral-subtle)";
|
||||
|
||||
if (isAllDay(evt.start, evt.end)) {
|
||||
timing.textContent = "All day";
|
||||
} else {
|
||||
timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`;
|
||||
}
|
||||
|
||||
li.appendChild(summary);
|
||||
li.appendChild(timing);
|
||||
|
||||
if (evt.location) {
|
||||
const location = document.createElement("div");
|
||||
location.style.marginTop = "4px";
|
||||
location.style.fontSize = "0.84rem";
|
||||
location.style.color = "var(--theme-card-neutral-muted)";
|
||||
location.textContent = `Location: ${String(evt.location)}`;
|
||||
li.appendChild(location);
|
||||
}
|
||||
|
||||
listEl.appendChild(li);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
dateEl.textContent = formatDateHeader();
|
||||
setStatus("Refreshing", "var(--theme-status-muted)");
|
||||
|
||||
try {
|
||||
const toolConfig = await resolveToolConfig();
|
||||
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
|
||||
if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) {
|
||||
subtitleEl.textContent = "No Home Assistant calendars available";
|
||||
renderEvents([]);
|
||||
updatedEl.textContent = new Date().toLocaleTimeString();
|
||||
setStatus("OK", "var(--theme-status-live)");
|
||||
updateLiveContent({
|
||||
kind: 'calendar_today',
|
||||
tool_name: toolConfig.name,
|
||||
calendar_names: [],
|
||||
updated_at: updatedEl.textContent || null,
|
||||
event_count: 0,
|
||||
events: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`;
|
||||
|
||||
const allEvents = [];
|
||||
for (const calendarName of toolConfig.calendars) {
|
||||
const toolResult = await host.callTool(toolConfig.name, {
|
||||
calendar: calendarName,
|
||||
range: 'today',
|
||||
});
|
||||
const events = extractEvents(toolResult);
|
||||
for (const evt of events) {
|
||||
allEvents.push({ ...evt, _calendarName: calendarName });
|
||||
}
|
||||
}
|
||||
|
||||
allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b));
|
||||
renderEvents(allEvents);
|
||||
updatedEl.textContent = new Date().toLocaleTimeString();
|
||||
setStatus("Daily", "var(--theme-status-live)");
|
||||
updateLiveContent({
|
||||
kind: 'calendar_today',
|
||||
tool_name: toolConfig.name,
|
||||
calendar_names: toolConfig.calendars,
|
||||
updated_at: updatedEl.textContent || null,
|
||||
event_count: allEvents.length,
|
||||
events: allEvents.map((evt) => ({
|
||||
summary: String(evt.summary || '(No title)'),
|
||||
start: normalizeDateValue(evt.start) || null,
|
||||
end: normalizeDateValue(evt.end) || null,
|
||||
location: typeof evt.location === 'string' ? evt.location : null,
|
||||
calendar_name: evt._calendarName || null,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
subtitleEl.textContent = "Could not load Home Assistant calendar";
|
||||
renderEvents([]);
|
||||
updatedEl.textContent = String(err);
|
||||
setStatus("Unavailable", "var(--theme-status-danger)");
|
||||
updateLiveContent({
|
||||
kind: 'calendar_today',
|
||||
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
|
||||
calendar_names: configuredCalendarNames,
|
||||
updated_at: String(err),
|
||||
event_count: 0,
|
||||
events: [],
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
host.setRefreshHandler(() => {
|
||||
void refresh();
|
||||
});
|
||||
void refresh();
|
||||
__setInterval(() => {
|
||||
void refresh();
|
||||
}, REFRESH_MS);
|
||||
|
||||
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