feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

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

View file

@ -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"
}

View file

@ -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>