584 lines
22 KiB
JavaScript
584 lines
22 KiB
JavaScript
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();
|
||
},
|
||
};
|
||
}
|