nanobot-voice-interface/examples/cards/templates/calendar-timeline-live/card.js
kacper 4dfb7ca3cc
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s
feat: unify card runtime and event-driven web ui
2026-04-06 15:42:53 -04:00

584 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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