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
584
examples/cards/templates/calendar-timeline-live/card.js
Normal file
584
examples/cards/templates/calendar-timeline-live/card.js
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
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 headlineEl = root.querySelector('[data-calendar-headline]');
|
||||
const detailEl = root.querySelector('[data-calendar-detail]');
|
||||
const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]');
|
||||
const allDayEl = root.querySelector('[data-calendar-all-day]');
|
||||
const emptyEl = root.querySelector('[data-calendar-empty]');
|
||||
const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]');
|
||||
const timelineEl = root.querySelector('[data-calendar-timeline]');
|
||||
|
||||
if (!(headlineEl instanceof HTMLElement) ||
|
||||
!(detailEl instanceof HTMLElement) ||
|
||||
!(allDayWrapEl instanceof HTMLElement) ||
|
||||
!(allDayEl instanceof HTMLElement) ||
|
||||
!(emptyEl instanceof HTMLElement) ||
|
||||
!(timelineShellEl instanceof HTMLElement) ||
|
||||
!(timelineEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
|
||||
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||
const calendarNames = Array.isArray(state.calendar_names)
|
||||
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const refreshMsRaw = Number(state.refresh_ms);
|
||||
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
|
||||
const minStartHourRaw = Number(state.min_start_hour);
|
||||
const maxEndHourRaw = Number(state.max_end_hour);
|
||||
const minWindowHoursRaw = Number(state.min_window_hours);
|
||||
const slotHeightRaw = Number(state.slot_height);
|
||||
const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6;
|
||||
const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22;
|
||||
const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60;
|
||||
const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18;
|
||||
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
|
||||
? state.empty_text.trim()
|
||||
: 'No events for today.';
|
||||
|
||||
const LABEL_WIDTH = 42;
|
||||
const TRACK_TOP_PAD = 6;
|
||||
const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events';
|
||||
|
||||
let latestEvents = [];
|
||||
let latestSelectedCalendars = [];
|
||||
let latestUpdatedAt = '';
|
||||
let clockIntervalId = null;
|
||||
|
||||
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 parseDate = (value, allDay, endOfDay = false) => {
|
||||
const raw = normalizeDateValue(value);
|
||||
if (!raw) return null;
|
||||
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
|
||||
? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}`
|
||||
: raw;
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
const eventBounds = (event) => {
|
||||
const allDay = isAllDay(event?.start, event?.end);
|
||||
const startDate = parseDate(event?.start, allDay, false);
|
||||
const endDate = parseDate(event?.end, allDay, allDay);
|
||||
if (!startDate) return null;
|
||||
const start = startDate.getTime();
|
||||
let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000;
|
||||
if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000;
|
||||
return { start, end, allDay };
|
||||
};
|
||||
|
||||
const formatClock = (date) => {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
|
||||
const formatDistance = (ms) => {
|
||||
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours <= 0) return `${minutes}m`;
|
||||
if (minutes === 0) return `${hours}h`;
|
||||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
const formatTimeRange = (event) => {
|
||||
const bounds = eventBounds(event);
|
||||
if (!bounds) return '--';
|
||||
if (bounds.allDay) return 'All day';
|
||||
return `${formatClock(new Date(bounds.start))}–${formatClock(new Date(bounds.end))}`;
|
||||
};
|
||||
|
||||
const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0');
|
||||
|
||||
const minutesIntoDay = (time) => {
|
||||
const date = new Date(time);
|
||||
return date.getHours() * 60 + date.getMinutes();
|
||||
};
|
||||
|
||||
const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30;
|
||||
const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30;
|
||||
|
||||
const computeVisibleWindow = (events) => {
|
||||
const now = new Date();
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const timedEvents = events.filter((event) => !event._allDay);
|
||||
|
||||
if (timedEvents.length === 0) {
|
||||
let start = roundDownToHalfHour(nowMinutes - 120);
|
||||
let end = start + minWindowMinutes;
|
||||
const minBound = minStartHour * 60;
|
||||
const maxBound = maxEndHour * 60;
|
||||
if (start < minBound) {
|
||||
start = minBound;
|
||||
end = start + minWindowMinutes;
|
||||
}
|
||||
if (end > maxBound) {
|
||||
end = maxBound;
|
||||
start = Math.max(minBound, end - minWindowMinutes);
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start)));
|
||||
const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end)));
|
||||
|
||||
let start = roundDownToHalfHour(earliest - 60);
|
||||
let end = roundUpToHalfHour(latest + 90);
|
||||
if (end - start < minWindowMinutes) {
|
||||
const center = roundDownToHalfHour((earliest + latest) / 2);
|
||||
start = center - Math.floor(minWindowMinutes / 2);
|
||||
end = start + minWindowMinutes;
|
||||
}
|
||||
|
||||
const minBound = minStartHour * 60;
|
||||
const maxBound = maxEndHour * 60;
|
||||
|
||||
if (start < minBound) {
|
||||
const shift = minBound - start;
|
||||
start += shift;
|
||||
end += shift;
|
||||
}
|
||||
if (end > maxBound) {
|
||||
const shift = end - maxBound;
|
||||
start -= shift;
|
||||
end -= shift;
|
||||
}
|
||||
start = Math.max(minBound, start);
|
||||
end = Math.min(maxBound, end);
|
||||
if (end - start < 120) {
|
||||
end = Math.min(maxBound, start + 120);
|
||||
}
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const assignColumns = (events) => {
|
||||
const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start);
|
||||
let active = [];
|
||||
let cluster = [];
|
||||
let clusterEnd = -Infinity;
|
||||
let clusterMax = 1;
|
||||
|
||||
const finalizeCluster = () => {
|
||||
for (const item of cluster) item._columns = clusterMax;
|
||||
};
|
||||
|
||||
for (const event of timed) {
|
||||
if (cluster.length > 0 && event._start >= clusterEnd) {
|
||||
finalizeCluster();
|
||||
active = [];
|
||||
cluster = [];
|
||||
clusterEnd = -Infinity;
|
||||
clusterMax = 1;
|
||||
}
|
||||
active = active.filter((item) => item.end > event._start);
|
||||
const used = new Set(active.map((item) => item.column));
|
||||
let column = 0;
|
||||
while (used.has(column)) column += 1;
|
||||
event._column = column;
|
||||
active.push({ end: event._end, column });
|
||||
cluster.push(event);
|
||||
clusterEnd = Math.max(clusterEnd, event._end);
|
||||
clusterMax = Math.max(clusterMax, active.length, column + 1);
|
||||
}
|
||||
|
||||
if (cluster.length > 0) finalizeCluster();
|
||||
return timed;
|
||||
};
|
||||
|
||||
const extractEvents = (toolResult) => {
|
||||
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) {
|
||||
return toolResult.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 [];
|
||||
};
|
||||
|
||||
const resolveToolConfig = async () => {
|
||||
if (!host.listTools) {
|
||||
return { name: TOOL_FALLBACK, 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 || TOOL_FALLBACK,
|
||||
availableCalendars: enumValues,
|
||||
};
|
||||
} catch {
|
||||
return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
|
||||
}
|
||||
};
|
||||
|
||||
const computeScore = (events, nowTime) => {
|
||||
const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime);
|
||||
if (current) return 99;
|
||||
const next = events.find((event) => !event._allDay && event._start > nowTime);
|
||||
if (!next) {
|
||||
if (events.some((event) => event._allDay)) return 74;
|
||||
return 18;
|
||||
}
|
||||
const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000));
|
||||
let score = 70;
|
||||
if (minutesAway <= 15) score = 98;
|
||||
else if (minutesAway <= 30) score = 95;
|
||||
else if (minutesAway <= 60) score = 92;
|
||||
else if (minutesAway <= 180) score = 88;
|
||||
else if (minutesAway <= 360) score = 82;
|
||||
else score = 76;
|
||||
score += Math.min(events.length, 3);
|
||||
return Math.min(100, score);
|
||||
};
|
||||
|
||||
const createAllDayChip = (event) => {
|
||||
const chip = document.createElement('div');
|
||||
chip.style.padding = '3px 6px';
|
||||
chip.style.border = '1px solid rgba(161, 118, 84, 0.28)';
|
||||
chip.style.background = 'rgba(255, 249, 241, 0.94)';
|
||||
chip.style.color = '#5e412d';
|
||||
chip.style.fontSize = '0.62rem';
|
||||
chip.style.lineHeight = '1.2';
|
||||
chip.style.fontWeight = '700';
|
||||
chip.style.minWidth = '0';
|
||||
chip.style.maxWidth = '100%';
|
||||
chip.style.overflow = 'hidden';
|
||||
chip.style.textOverflow = 'ellipsis';
|
||||
chip.style.whiteSpace = 'nowrap';
|
||||
chip.textContent = String(event.summary || '(No title)');
|
||||
return chip;
|
||||
};
|
||||
|
||||
const renderState = () => {
|
||||
const now = new Date();
|
||||
const nowTime = now.getTime();
|
||||
const todayLabel = formatShortDate(now);
|
||||
if (!Array.isArray(latestEvents) || latestEvents.length === 0) {
|
||||
headlineEl.textContent = todayLabel;
|
||||
detailEl.textContent = emptyText;
|
||||
allDayWrapEl.style.display = 'none';
|
||||
timelineShellEl.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
updateLiveContent({
|
||||
kind: 'calendar_timeline',
|
||||
subtitle: subtitle || null,
|
||||
tool_name: TOOL_FALLBACK,
|
||||
calendar_names: latestSelectedCalendars,
|
||||
updated_at: latestUpdatedAt || null,
|
||||
now_label: formatClock(now),
|
||||
headline: headlineEl.textContent || null,
|
||||
detail: detailEl.textContent || null,
|
||||
event_count: 0,
|
||||
all_day_count: 0,
|
||||
score: 18,
|
||||
events: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const allDayEvents = latestEvents.filter((event) => event._allDay);
|
||||
const timedEvents = latestEvents.filter((event) => !event._allDay);
|
||||
const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null;
|
||||
const nextEvent = timedEvents.find((event) => event._start > nowTime) || null;
|
||||
|
||||
headlineEl.textContent = todayLabel;
|
||||
|
||||
if (currentEvent) {
|
||||
detailEl.textContent = '';
|
||||
} else if (nextEvent) {
|
||||
detailEl.textContent = '';
|
||||
} else if (allDayEvents.length > 0) {
|
||||
detailEl.textContent = 'All-day events on your calendar.';
|
||||
} else {
|
||||
detailEl.textContent = 'Your calendar is clear for the rest of the day.';
|
||||
}
|
||||
|
||||
const windowRange = computeVisibleWindow(latestEvents);
|
||||
allDayEl.innerHTML = '';
|
||||
allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none';
|
||||
for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event));
|
||||
|
||||
emptyEl.style.display = 'none';
|
||||
timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none';
|
||||
timelineEl.innerHTML = '';
|
||||
|
||||
if (timedEvents.length > 0) {
|
||||
const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30));
|
||||
const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight;
|
||||
timelineEl.style.height = `${timelineHeight}px`;
|
||||
|
||||
const gridLayer = document.createElement('div');
|
||||
gridLayer.style.position = 'absolute';
|
||||
gridLayer.style.inset = '0';
|
||||
timelineEl.appendChild(gridLayer);
|
||||
|
||||
for (let index = 0; index <= slotCount; index += 1) {
|
||||
const minutes = windowRange.start + index * 30;
|
||||
const top = TRACK_TOP_PAD + index * slotHeight;
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.style.position = 'absolute';
|
||||
line.style.left = `${LABEL_WIDTH}px`;
|
||||
line.style.right = '0';
|
||||
line.style.top = `${top}px`;
|
||||
line.style.borderTop = minutes % 60 === 0
|
||||
? '1px solid rgba(143, 101, 69, 0.24)'
|
||||
: '1px dashed rgba(181, 145, 116, 0.18)';
|
||||
gridLayer.appendChild(line);
|
||||
|
||||
if (minutes % 60 === 0 && minutes < windowRange.end) {
|
||||
const label = document.createElement('div');
|
||||
label.style.position = 'absolute';
|
||||
label.style.left = '0';
|
||||
label.style.top = `${Math.max(0, top - 7)}px`;
|
||||
label.style.width = `${LABEL_WIDTH - 8}px`;
|
||||
label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace";
|
||||
label.style.fontSize = '0.54rem';
|
||||
label.style.lineHeight = '1';
|
||||
label.style.color = '#8a6248';
|
||||
label.style.textAlign = 'right';
|
||||
label.style.textTransform = 'uppercase';
|
||||
label.style.letterSpacing = '0.05em';
|
||||
label.textContent = hourLabel(minutes);
|
||||
gridLayer.appendChild(label);
|
||||
}
|
||||
}
|
||||
|
||||
const eventsLayer = document.createElement('div');
|
||||
eventsLayer.style.position = 'absolute';
|
||||
eventsLayer.style.left = `${LABEL_WIDTH + 6}px`;
|
||||
eventsLayer.style.right = '0';
|
||||
eventsLayer.style.top = `${TRACK_TOP_PAD}px`;
|
||||
eventsLayer.style.bottom = '0';
|
||||
timelineEl.appendChild(eventsLayer);
|
||||
|
||||
const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event })));
|
||||
for (const event of layoutEvents) {
|
||||
const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start;
|
||||
const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start;
|
||||
const top = Math.max(0, (offsetStart / 30) * slotHeight);
|
||||
const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3);
|
||||
|
||||
const block = document.createElement('div');
|
||||
block.style.position = 'absolute';
|
||||
block.style.top = `${top}px`;
|
||||
block.style.height = `${height}px`;
|
||||
block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`;
|
||||
block.style.width = `calc(${100 / event._columns}% - 4px)`;
|
||||
block.style.padding = '5px 6px';
|
||||
block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
|
||||
? '1px solid rgba(169, 39, 29, 0.38)'
|
||||
: '1px solid rgba(162, 105, 62, 0.26)';
|
||||
block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
|
||||
? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)';
|
||||
block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)';
|
||||
block.style.overflow = 'hidden';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif";
|
||||
title.style.fontSize = '0.74rem';
|
||||
title.style.lineHeight = '0.98';
|
||||
title.style.letterSpacing = '-0.01em';
|
||||
title.style.color = '#22140c';
|
||||
title.style.fontWeight = '700';
|
||||
title.style.whiteSpace = 'nowrap';
|
||||
title.style.textOverflow = 'ellipsis';
|
||||
title.style.overflow = 'hidden';
|
||||
title.textContent = String(event.summary || '(No title)');
|
||||
block.appendChild(title);
|
||||
|
||||
eventsLayer.appendChild(block);
|
||||
}
|
||||
|
||||
if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() &&
|
||||
nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) {
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight;
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.style.position = 'absolute';
|
||||
line.style.left = `${LABEL_WIDTH + 6}px`;
|
||||
line.style.right = '0';
|
||||
line.style.top = `${nowTop}px`;
|
||||
line.style.borderTop = '1.5px solid #cf2f21';
|
||||
line.style.transition = 'top 28s linear';
|
||||
timelineEl.appendChild(line);
|
||||
|
||||
const dot = document.createElement('div');
|
||||
dot.style.position = 'absolute';
|
||||
dot.style.left = `${LABEL_WIDTH + 1}px`;
|
||||
dot.style.top = `${nowTop - 4}px`;
|
||||
dot.style.width = '8px';
|
||||
dot.style.height = '8px';
|
||||
dot.style.borderRadius = '999px';
|
||||
dot.style.background = '#cf2f21';
|
||||
dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)';
|
||||
dot.style.transition = 'top 28s linear';
|
||||
timelineEl.appendChild(dot);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
updateLiveContent({
|
||||
kind: 'calendar_timeline',
|
||||
subtitle: subtitle || null,
|
||||
tool_name: TOOL_FALLBACK,
|
||||
calendar_names: latestSelectedCalendars,
|
||||
updated_at: latestUpdatedAt || null,
|
||||
now_label: formatClock(now),
|
||||
headline: headlineEl.textContent || null,
|
||||
detail: detailEl.textContent || null,
|
||||
current_event: currentEvent ? {
|
||||
summary: String(currentEvent.summary || '(No title)'),
|
||||
start: normalizeDateValue(currentEvent.start) || null,
|
||||
end: normalizeDateValue(currentEvent.end) || null,
|
||||
} : null,
|
||||
next_event: nextEvent ? {
|
||||
summary: String(nextEvent.summary || '(No title)'),
|
||||
start: normalizeDateValue(nextEvent.start) || null,
|
||||
end: normalizeDateValue(nextEvent.end) || null,
|
||||
starts_in: formatDistance(nextEvent._start - nowTime),
|
||||
} : null,
|
||||
event_count: latestEvents.length,
|
||||
all_day_count: allDayEvents.length,
|
||||
score: computeScore(latestEvents, nowTime),
|
||||
events: latestEvents.map((event) => ({
|
||||
summary: String(event.summary || '(No title)'),
|
||||
start: normalizeDateValue(event.start) || null,
|
||||
end: normalizeDateValue(event.end) || null,
|
||||
all_day: Boolean(event._allDay),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
headlineEl.textContent = 'Loading today…';
|
||||
detailEl.textContent = 'Checking your calendar.';
|
||||
|
||||
try {
|
||||
const toolConfig = await resolveToolConfig();
|
||||
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 allEvents = [];
|
||||
for (const calendarName of selectedCalendars) {
|
||||
const toolResult = await host.callTool(toolConfig.name, {
|
||||
calendar: calendarName,
|
||||
range: 'today',
|
||||
});
|
||||
const events = extractEvents(toolResult);
|
||||
for (const event of events) {
|
||||
const bounds = eventBounds(event);
|
||||
if (!bounds) continue;
|
||||
allEvents.push({
|
||||
...event,
|
||||
_calendarName: calendarName,
|
||||
_start: bounds.start,
|
||||
_end: bounds.end,
|
||||
_allDay: bounds.allDay,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allEvents.sort((left, right) => left._start - right._start);
|
||||
latestEvents = allEvents;
|
||||
latestSelectedCalendars = selectedCalendars;
|
||||
latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
renderState();
|
||||
} catch (error) {
|
||||
const errorText = String(error);
|
||||
latestEvents = [];
|
||||
latestSelectedCalendars = calendarNames;
|
||||
latestUpdatedAt = errorText;
|
||||
headlineEl.textContent = formatShortDate(new Date());
|
||||
detailEl.textContent = errorText;
|
||||
allDayWrapEl.style.display = 'none';
|
||||
timelineShellEl.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
updateLiveContent({
|
||||
kind: 'calendar_timeline',
|
||||
subtitle: subtitle || null,
|
||||
tool_name: TOOL_FALLBACK,
|
||||
calendar_names: latestSelectedCalendars,
|
||||
updated_at: errorText,
|
||||
now_label: formatClock(new Date()),
|
||||
headline: headlineEl.textContent || null,
|
||||
detail: detailEl.textContent || null,
|
||||
event_count: 0,
|
||||
all_day_count: 0,
|
||||
score: 0,
|
||||
events: [],
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
host.setRefreshHandler(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
if (clockIntervalId) window.clearInterval(clockIntervalId);
|
||||
clockIntervalId = __setInterval(() => {
|
||||
renderState();
|
||||
}, 30000);
|
||||
|
||||
void refresh();
|
||||
__setInterval(() => {
|
||||
void refresh();
|
||||
}, refreshMs);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
host.setRefreshHandler(null);
|
||||
host.setLiveContent(null);
|
||||
host.clearSelection();
|
||||
for (const cleanup of __cleanup.splice(0)) cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"key": "calendar-timeline-live",
|
||||
"title": "Today Calendar Timeline",
|
||||
"notes": "Today-only Home Assistant calendar timeline with half-hour grid, current time marker, and next-event distance. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, and empty_text.",
|
||||
"example_state": {
|
||||
"subtitle": "Family Calendar",
|
||||
"tool_name": "mcp_home_assistant_calendar_get_events",
|
||||
"calendar_names": [
|
||||
"Family Calendar"
|
||||
],
|
||||
"refresh_ms": 900000,
|
||||
"min_start_hour": 6,
|
||||
"max_end_hour": 22,
|
||||
"min_window_hours": 6,
|
||||
"slot_height": 24,
|
||||
"empty_text": "No events for today."
|
||||
},
|
||||
"created_at": "2026-04-02T00:00:00+00:00",
|
||||
"updated_at": "2026-04-02T00:00:00+00:00"
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<style>
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Condensed';
|
||||
src: url('/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'M-1m Code';
|
||||
src: url('/card-templates/todo-item-live/assets/mplus-1m-regular-sub.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
<div data-calendar-timeline-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-warm-bg); color:var(--theme-card-warm-text); padding:12px; border:1px solid var(--theme-card-warm-border);">
|
||||
<div style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:0.86rem; line-height:1.02; letter-spacing:-0.01em; color:var(--theme-card-warm-text); font-weight:700;">Today Calendar</div>
|
||||
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-top:8px;">
|
||||
<div style="min-width:0; flex:1 1 auto;">
|
||||
<div data-calendar-headline style="font-family:'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; font-size:1.08rem; line-height:0.98; letter-spacing:-0.03em; color:var(--theme-card-warm-text); font-weight:700;">Loading today…</div>
|
||||
<div data-calendar-detail style="margin-top:4px; font-size:0.72rem; line-height:1.18; color:var(--theme-card-warm-muted);">Checking your calendar.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-calendar-all-day-wrap style="display:none; margin-top:8px;">
|
||||
<div style="font-family:'M-1m Code', ui-monospace, Menlo, Consolas, monospace; font-size:0.58rem; line-height:1.1; letter-spacing:0.09em; text-transform:uppercase; color:var(--theme-card-warm-muted); margin-bottom:5px;">All day</div>
|
||||
<div data-calendar-all-day style="display:flex; flex-wrap:wrap; gap:4px;"></div>
|
||||
</div>
|
||||
|
||||
<div data-calendar-empty style="display:none; margin-top:10px; padding:10px 0 2px; color:var(--theme-card-warm-muted); font-size:0.92rem; line-height:1.35;">No events for today.</div>
|
||||
|
||||
<div data-calendar-timeline-shell style="display:none; margin-top:10px;">
|
||||
<div data-calendar-timeline style="position:relative; min-height:160px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue