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,263 @@
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 subtitleEl = root.querySelector('[data-calendar-subtitle]');
const statusEl = root.querySelector('[data-calendar-status]');
const rangeEl = root.querySelector('[data-calendar-range]');
const emptyEl = root.querySelector('[data-calendar-empty]');
const listEl = root.querySelector('[data-calendar-list]');
const updatedEl = root.querySelector('[data-calendar-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
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 rangeDaysRaw = Number(state.range_days);
const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
if (!(statusEl instanceof HTMLElement)) return;
statusEl.textContent = label;
statusEl.style.color = color;
};
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 eventSortKey = (event) => {
const raw = normalizeDateValue(event && event.start);
const time = new Date(raw).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDay = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--';
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
};
const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const eventTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.MAX_SAFE_INTEGER;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!host.listTools) {
return { name: fallbackName, 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 || fallbackName,
availableCalendars: enumValues,
};
} catch {
return { name: fallbackName, availableCalendars: calendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = '';
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
for (const [index, event] of events.slice(0, maxEvents).entries()) {
const item = document.createElement('li');
item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
const summary = document.createElement('div');
summary.style.fontSize = '0.98rem';
summary.style.lineHeight = '1.3';
summary.style.fontWeight = '700';
summary.style.color = 'var(--theme-card-neutral-text)';
summary.textContent = String(event.summary || '(No title)');
item.appendChild(summary);
const timing = document.createElement('div');
timing.style.marginTop = '4px';
timing.style.fontSize = '0.9rem';
timing.style.lineHeight = '1.35';
timing.style.color = 'var(--theme-card-neutral-subtle)';
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
item.appendChild(timing);
listEl.appendChild(item);
}
};
const refresh = async () => {
setStatus('Refreshing', 'var(--theme-status-muted)');
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
end.setHours(23, 59, 59, 999);
rangeEl.textContent = formatRange(start, end);
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 resolvedSubtitle = subtitle || selectedCalendars.join(', ');
subtitleEl.textContent = resolvedSubtitle;
const allEvents = [];
const rangeMode = rangeDays > 1 ? 'week' : 'today';
const endExclusiveTime = end.getTime() + 1;
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: rangeMode,
});
const events = extractEvents(toolResult);
for (const event of events) {
const startTime = eventTime(event?.start);
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
allEvents.push({ ...event, _calendarName: calendarName });
}
}
allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
renderEvents(allEvents);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
setStatus(statusLabel, 'var(--theme-status-live)');
const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
return {
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
day_label: dayLabel === '--' ? null : dayLabel,
time_label: timeLabel,
all_day: isAllDay(event.start, event.end),
};
});
updateLiveContent({
kind: 'calendar_agenda',
subtitle: resolvedSubtitle || null,
tool_name: toolConfig.name,
calendar_names: selectedCalendars,
range_label: rangeEl.textContent || null,
status: statusLabel,
updated_at: updatedText,
event_count: snapshotEvents.length,
events: snapshotEvents,
});
} catch (error) {
const errorText = String(error);
renderEvents([]);
updatedEl.textContent = errorText;
setStatus('Unavailable', 'var(--theme-status-danger)');
updateLiveContent({
kind: 'calendar_agenda',
subtitle: subtitleEl.textContent || null,
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: calendarNames,
range_label: rangeEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
event_count: 0,
events: [],
error: errorText,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
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

@ -1,257 +1,10 @@
<div data-calendar-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-calendar-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-calendar-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<div data-calendar-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
</div>
<div data-calendar-range style="font-size:0.88rem; line-height:1.35; color:#6b7280; margin-bottom:6px;">--</div>
<div data-calendar-empty style="display:none; padding:10px 0 2px; color:#475569; font-size:0.96rem; line-height:1.4;">No events scheduled.</div>
<div data-calendar-range style="font-size:0.88rem; line-height:1.35; color:var(--theme-card-neutral-muted); margin-bottom:6px;">--</div>
<div data-calendar-empty style="display:none; padding:10px 0 2px; color:var(--theme-card-neutral-subtle); font-size:0.96rem; line-height:1.4;">No events scheduled.</div>
<ul data-calendar-list style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">Updated <span data-calendar-updated>--</span></div>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">Updated <span data-calendar-updated>--</span></div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-calendar-subtitle]');
const statusEl = root.querySelector('[data-calendar-status]');
const rangeEl = root.querySelector('[data-calendar-range]');
const emptyEl = root.querySelector('[data-calendar-empty]');
const listEl = root.querySelector('[data-calendar-list]');
const updatedEl = root.querySelector('[data-calendar-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
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 rangeDaysRaw = Number(state.range_days);
const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
if (!(statusEl instanceof HTMLElement)) return;
statusEl.textContent = label;
statusEl.style.color = color;
};
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 eventSortKey = (event) => {
const raw = normalizeDateValue(event && event.start);
const time = new Date(raw).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatDay = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--';
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
};
const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const eventTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.MAX_SAFE_INTEGER;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!window.__nanobotListTools) {
return { name: fallbackName, availableCalendars: calendarNames };
}
try {
const tools = await window.__nanobotListTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
availableCalendars: enumValues,
};
} catch {
return { name: fallbackName, availableCalendars: calendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = '';
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
for (const [index, event] of events.slice(0, maxEvents).entries()) {
const item = document.createElement('li');
item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
item.style.borderTop = index === 0 ? '1px solid #e5e7eb' : '1px solid #e5e7eb';
const summary = document.createElement('div');
summary.style.fontSize = '0.98rem';
summary.style.lineHeight = '1.3';
summary.style.fontWeight = '700';
summary.style.color = '#111827';
summary.textContent = String(event.summary || '(No title)');
item.appendChild(summary);
const timing = document.createElement('div');
timing.style.marginTop = '4px';
timing.style.fontSize = '0.9rem';
timing.style.lineHeight = '1.35';
timing.style.color = '#4b5563';
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
item.appendChild(timing);
listEl.appendChild(item);
}
};
const refresh = async () => {
setStatus('Refreshing', '#6b7280');
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
end.setHours(23, 59, 59, 999);
rangeEl.textContent = formatRange(start, end);
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 resolvedSubtitle = subtitle || selectedCalendars.join(', ');
subtitleEl.textContent = resolvedSubtitle;
const allEvents = [];
const rangeMode = rangeDays > 1 ? 'week' : 'today';
const endExclusiveTime = end.getTime() + 1;
for (const calendarName of selectedCalendars) {
const toolResult = await window.__nanobotCallTool?.(toolConfig.name, {
calendar: calendarName,
range: rangeMode,
});
const events = extractEvents(toolResult);
for (const event of events) {
const startTime = eventTime(event?.start);
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
allEvents.push({ ...event, _calendarName: calendarName });
}
}
allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
renderEvents(allEvents);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
setStatus(statusLabel, '#047857');
const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
const dayLabel = formatDay(event.start);
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
return {
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
day_label: dayLabel === '--' ? null : dayLabel,
time_label: timeLabel,
all_day: isAllDay(event.start, event.end),
};
});
updateLiveContent({
kind: 'calendar_agenda',
subtitle: resolvedSubtitle || null,
tool_name: toolConfig.name,
calendar_names: selectedCalendars,
range_label: rangeEl.textContent || null,
status: statusLabel,
updated_at: updatedText,
event_count: snapshotEvents.length,
events: snapshotEvents,
});
} catch (error) {
const errorText = String(error);
renderEvents([]);
updatedEl.textContent = errorText;
setStatus('Unavailable', '#b91c1c');
updateLiveContent({
kind: 'calendar_agenda',
subtitle: subtitleEl.textContent || null,
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: calendarNames,
range_label: rangeEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
event_count: 0,
events: [],
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

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>

View file

@ -0,0 +1,778 @@
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 weatherScaleEl = root.querySelector('[data-calendar-weather-scale]');
const weatherLowEl = root.querySelector('[data-calendar-weather-low]');
const weatherHighEl = root.querySelector('[data-calendar-weather-high]');
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) ||
!(weatherScaleEl instanceof HTMLElement) ||
!(weatherLowEl instanceof HTMLElement) ||
!(weatherHighEl 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 configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : 'exec';
const weatherCommand = typeof state.weather_command === 'string' ? state.weather_command.trim() : '';
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 latestWeatherPoints = [];
let latestWeatherRange = null;
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 computeWeatherWindow = () => {
if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length < 2) return null;
const sorted = latestWeatherPoints
.map((point) => minutesIntoDay(point.time))
.filter((minutes) => Number.isFinite(minutes))
.sort((left, right) => left - right);
if (sorted.length < 2) return null;
const start = roundDownToHalfHour(sorted[0]);
const end = roundUpToHalfHour(sorted[sorted.length - 1]);
if (end <= start) return null;
return { start, end };
};
const computeVisibleWindow = (events) => {
const now = new Date();
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const timedEvents = events.filter((event) => !event._allDay);
const weatherWindow = computeWeatherWindow();
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);
}
if (weatherWindow) {
start = Math.max(start, weatherWindow.start);
end = Math.min(end, weatherWindow.end);
if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end };
}
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);
}
if (weatherWindow) {
start = Math.max(start, weatherWindow.start);
end = Math.min(end, weatherWindow.end);
if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end };
}
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 stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const text = stripExecFooter(toolResult?.content);
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
};
const forecastTime = (entry) => {
const time = new Date(String(entry?.datetime || '')).getTime();
return Number.isFinite(time) ? time : Number.NaN;
};
const extractWeatherPoints = (payload) => {
const rows = Array.isArray(payload?.nws?.forecast) ? payload.nws.forecast : [];
const today = new Date();
const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0).getTime();
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
return rows
.map((entry) => {
const time = forecastTime(entry);
const temp = Number(entry?.temperature);
if (!Number.isFinite(time) || !Number.isFinite(temp)) return null;
if (time < dayStart || time >= dayEnd) return null;
return {
time,
temp,
};
})
.filter(Boolean);
};
const resolveWeatherForecast = async () => {
if (!weatherCommand) return [];
const toolResult = await host.callTool(configuredWeatherToolName || 'exec', {
command: weatherCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
if (!payload || typeof payload !== 'object') return [];
return extractWeatherPoints(payload);
};
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 computeWeatherRange = (points) => {
if (!Array.isArray(points) || points.length === 0) return null;
let low = Number.POSITIVE_INFINITY;
let high = Number.NEGATIVE_INFINITY;
for (const point of points) {
low = Math.min(low, point.temp);
high = Math.max(high, point.temp);
}
if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
if (low === high) high = low + 1;
return { low, high };
};
const renderWeatherGraph = (windowRange, timelineHeight) => {
weatherScaleEl.style.display = 'none';
weatherLowEl.textContent = '--';
weatherHighEl.textContent = '--';
if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length === 0 || !latestWeatherRange) return;
const visiblePoints = latestWeatherPoints.filter((point) => {
const minutes = minutesIntoDay(point.time);
return minutes >= windowRange.start && minutes <= windowRange.end;
});
if (visiblePoints.length < 2) return;
weatherScaleEl.style.display = 'flex';
weatherLowEl.textContent = `${Math.round(latestWeatherRange.low)}°`;
weatherHighEl.textContent = `${Math.round(latestWeatherRange.high)}°`;
const timelineWidth = timelineEl.getBoundingClientRect().width;
const overlayWidth = Math.max(1, timelineWidth - (LABEL_WIDTH + 6));
const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
overlay.setAttribute('viewBox', `0 0 ${overlayWidth} ${timelineHeight}`);
overlay.style.position = 'absolute';
overlay.style.left = `${LABEL_WIDTH + 6}px`;
overlay.style.top = '0';
overlay.style.height = `${timelineHeight}px`;
overlay.style.width = `${overlayWidth}px`;
overlay.style.pointerEvents = 'none';
overlay.style.opacity = '0.85';
const toPoint = (point) => {
const minutes = minutesIntoDay(point.time);
const y = TRACK_TOP_PAD + ((minutes - windowRange.start) / 30) * slotHeight;
const x = ((point.temp - latestWeatherRange.low) / (latestWeatherRange.high - latestWeatherRange.low)) * overlayWidth;
return { x, y };
};
const coords = visiblePoints.map(toPoint);
const buildSmoothPath = (points) => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index];
const next = points[index + 1];
const midX = (point.x + next.x) / 2;
const midY = (point.y + next.y) / 2;
d += ` Q ${point.x.toFixed(2)} ${point.y.toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`;
}
const penultimate = points[points.length - 2];
const last = points[points.length - 1];
d += ` Q ${penultimate.x.toFixed(2)} ${penultimate.y.toFixed(2)} ${last.x.toFixed(2)} ${last.y.toFixed(2)}`;
return d;
};
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', buildSmoothPath(coords));
line.setAttribute('fill', 'none');
line.setAttribute('stroke', 'rgba(184, 110, 58, 0.34)');
line.setAttribute('stroke-width', '1.8');
line.setAttribute('stroke-linecap', 'round');
line.setAttribute('stroke-linejoin', 'round');
line.setAttribute('vector-effect', 'non-scaling-stroke');
overlay.appendChild(line);
for (const point of coords) {
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', point.x.toFixed(2));
dot.setAttribute('cy', point.y.toFixed(2));
dot.setAttribute('r', '1.9');
dot.setAttribute('fill', 'rgba(184, 110, 58, 0.34)');
dot.setAttribute('stroke', 'rgba(226, 188, 156, 0.62)');
dot.setAttribute('stroke-width', '0.55');
overlay.appendChild(dot);
}
timelineEl.appendChild(overlay);
};
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,
weather_temperature_range: latestWeatherRange ? {
low: latestWeatherRange.low,
high: latestWeatherRange.high,
} : 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);
}
}
renderWeatherGraph(windowRange, timelineHeight);
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,
weather_temperature_range: latestWeatherRange ? {
low: latestWeatherRange.low,
high: latestWeatherRange.high,
} : 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 [weatherPoints, allEvents] = await Promise.all([
resolveWeatherForecast().catch(() => []),
(async () => {
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,
});
}
}
return allEvents;
})(),
]);
allEvents.sort((left, right) => left._start - right._start);
latestWeatherPoints = weatherPoints;
latestWeatherRange = computeWeatherRange(weatherPoints);
latestEvents = allEvents;
latestSelectedCalendars = selectedCalendars;
latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
renderState();
} catch (error) {
const errorText = String(error);
latestWeatherPoints = [];
latestWeatherRange = null;
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,
weather_temperature_range: 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,22 @@
{
"key": "calendar-timeline-weather-live",
"title": "Today Calendar Weather Timeline",
"notes": "Experimental copy of the today-only Home Assistant calendar timeline with a subtle hourly temperature graph behind the timeline. 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, empty_text, weather_tool_name (defaults to exec), and weather_command.",
"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.",
"weather_tool_name": "exec",
"weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48"
},
"created_at": "2026-04-02T00:00:00+00:00",
"updated_at": "2026-04-02T00:00:00+00:00"
}

View file

@ -0,0 +1,41 @@
<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 + Weather</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 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-weather-scale style="display:none; align-items:center; justify-content:space-between; margin:0 0 6px 48px; font-family:'M-1m Code', ui-monospace, Menlo, Consolas, monospace; font-size:0.54rem; line-height:1; color:var(--theme-card-warm-muted);">
<span data-calendar-weather-low>--</span>
<span data-calendar-weather-high>--</span>
</div>
<div data-calendar-timeline style="position:relative; min-height:160px;"></div>
</div>
</div>

View file

@ -0,0 +1,719 @@
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 subtitleEl = root.querySelector('[data-git-subtitle]');
const branchEl = root.querySelector('[data-git-branch]');
const statusEl = root.querySelector('[data-git-status]');
const changedEl = root.querySelector('[data-git-changed]');
const stagingEl = root.querySelector('[data-git-staging]');
const untrackedEl = root.querySelector('[data-git-untracked]');
const upstreamEl = root.querySelector('[data-git-upstream]');
const plusEl = root.querySelector('[data-git-plus]');
const minusEl = root.querySelector('[data-git-minus]');
const updatedEl = root.querySelector('[data-git-updated]');
const filesEl = root.querySelector('[data-git-files]');
if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 });
const setStatus = (label, fg, bg) => {
statusEl.textContent = label;
statusEl.style.color = fg;
statusEl.style.background = bg;
statusEl.style.padding = '3px 7px';
statusEl.style.borderRadius = '999px';
};
const statusTone = (value) => {
if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' };
if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' };
return { fg: '#a14d43', bg: '#f3d8d2' };
};
const formatBranch = (payload) => {
const parts = [];
const branch = typeof payload.branch === 'string' ? payload.branch : '';
if (branch) parts.push(branch);
if (typeof payload.upstream === 'string' && payload.upstream) {
parts.push(payload.upstream);
}
const ahead = Number(payload.ahead || 0);
const behind = Number(payload.behind || 0);
if (ahead || behind) {
parts.push(`+${ahead} / -${behind}`);
}
if (!parts.length && typeof payload.head === 'string' && payload.head) {
parts.push(payload.head);
}
return parts.join(' · ') || 'No branch information';
};
const formatUpdated = (raw) => {
if (typeof raw !== 'string' || !raw) return '--';
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const chipStyle = (status) => {
if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' };
if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' };
if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' };
return { fg: '#9a6a2f', bg: '#f3e1ba' };
};
const asLineNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
return null;
};
const formatRangePart = (label, start, end) => {
if (start === null || end === null) return '';
return start === end ? `${label} ${start}` : `${label} ${start}-${end}`;
};
const buildSelectionPayload = (filePath, lines) => {
const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null);
const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null);
const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null;
const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null;
const newStart = newNumbers.length ? Math.min(...newNumbers) : null;
const newEnd = newNumbers.length ? Math.max(...newNumbers) : null;
const rangeParts = [
formatRangePart('old', oldStart, oldEnd),
formatRangePart('new', newStart, newEnd),
].filter(Boolean);
const fileLabel = filePath || 'Selected diff';
const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines';
return {
kind: 'git_diff_range',
file_path: filePath || fileLabel,
file_label: fileLabel,
range_label: rangeLabel,
label: `${fileLabel} · ${rangeLabel}`,
old_start: oldStart,
old_end: oldEnd,
new_start: newStart,
new_end: newEnd,
};
};
let activeSelectionController = null;
const clearActiveSelection = () => {
if (activeSelectionController) {
const controller = activeSelectionController;
activeSelectionController = null;
controller.clear(false);
}
host.setSelection(null);
};
const renderPatchBody = (target, item) => {
target.innerHTML = '';
target.dataset.noSwipe = '1';
target.style.marginTop = '10px';
target.style.marginLeft = '-12px';
target.style.marginRight = '-12px';
target.style.width = 'calc(100% + 24px)';
target.style.paddingTop = '10px';
target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)';
target.style.overflow = 'hidden';
target.style.minWidth = '0';
target.style.maxWidth = 'none';
const viewport = document.createElement('div');
viewport.dataset.noSwipe = '1';
viewport.style.width = '100%';
viewport.style.maxWidth = 'none';
viewport.style.minWidth = '0';
viewport.style.overflowX = 'auto';
viewport.style.overflowY = 'hidden';
viewport.style.touchAction = 'auto';
viewport.style.overscrollBehavior = 'contain';
viewport.style.webkitOverflowScrolling = 'touch';
viewport.style.scrollbarWidth = 'thin';
viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent';
const diffText = typeof item?.diff === 'string' ? item.diff : '';
const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : [];
if (!diffText && diffLines.length === 0) {
const message = document.createElement('div');
message.textContent = 'No line diff available for this path.';
message.style.fontSize = '0.76rem';
message.style.lineHeight = '1.4';
message.style.color = '#9a7b68';
message.style.fontWeight = '600';
target.appendChild(message);
return;
}
const block = document.createElement('div');
block.dataset.noSwipe = '1';
block.style.display = 'grid';
block.style.gap = '0';
block.style.padding = '0';
block.style.borderRadius = '0';
block.style.background = 'rgba(255,255,255,0.58)';
block.style.border = '1px solid rgba(153, 118, 92, 0.14)';
block.style.borderLeft = '0';
block.style.borderRight = '0';
block.style.fontFamily =
"var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)";
block.style.fontSize = '0.64rem';
block.style.lineHeight = '1.45';
block.style.color = '#5f4a3f';
block.style.width = 'max-content';
block.style.minWidth = '100%';
const selectableLines = [];
let localSelection = null;
let localAnchorIndex = null;
const setSelectedState = (entry, selected) => {
if (selected) entry.lineEl.dataset.selected = 'true';
else delete entry.lineEl.dataset.selected;
};
const controller = {
clear(publish = true) {
localSelection = null;
localAnchorIndex = null;
for (const entry of selectableLines) setSelectedState(entry, false);
if (publish) {
if (activeSelectionController === controller) activeSelectionController = null;
host.setSelection(null);
}
},
};
const applySelection = (startIndex, endIndex) => {
const lower = Math.min(startIndex, endIndex);
const upper = Math.max(startIndex, endIndex);
localSelection = { startIndex: lower, endIndex: upper };
for (const [index, entry] of selectableLines.entries()) {
setSelectedState(entry, index >= lower && index <= upper);
}
if (activeSelectionController && activeSelectionController !== controller) {
activeSelectionController.clear(false);
}
activeSelectionController = controller;
host.setSelection(buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)),
);
};
const handleSelectableLine = (index) => {
if (!localSelection) {
localAnchorIndex = index;
applySelection(index, index);
return;
}
const singleLine = localSelection.startIndex === localSelection.endIndex;
if (singleLine) {
const anchorIndex = localAnchorIndex ?? localSelection.startIndex;
if (index === anchorIndex) {
controller.clear(true);
return;
}
applySelection(anchorIndex, index);
localAnchorIndex = null;
return;
}
localAnchorIndex = index;
applySelection(index, index);
};
const registerSelectableLine = (lineEl, oldNumber, newNumber) => {
const entry = {
lineEl,
oldNumber: asLineNumber(oldNumber),
newNumber: asLineNumber(newNumber),
};
const index = selectableLines.push(entry) - 1;
lineEl.dataset.selectable = 'true';
lineEl.tabIndex = 0;
lineEl.setAttribute('role', 'button');
lineEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
handleSelectableLine(index);
});
lineEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
handleSelectableLine(index);
});
};
if (diffLines.length > 0) {
for (const row of diffLines) {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const numberEl = document.createElement('span');
const lineNumber =
typeof row?.line_number === 'number' || typeof row?.line_number === 'string'
? String(row.line_number)
: '';
numberEl.textContent = lineNumber;
numberEl.style.minWidth = '2.2em';
numberEl.style.textAlign = 'right';
numberEl.style.color = '#8c7464';
numberEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = typeof row?.text === 'string' ? row.text : '';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
const kind = typeof row?.kind === 'string' ? row.kind : '';
if (kind === 'added') {
lineEl.style.color = '#0c3f12';
lineEl.style.background = 'rgba(158, 232, 147, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#6d0d08';
lineEl.style.background = 'rgba(249, 156, 145, 0.98)';
} else {
lineEl.style.color = '#5f4a3f';
}
lineEl.append(numberEl, textEl);
block.appendChild(lineEl);
}
} else {
const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const oldEl = document.createElement('span');
oldEl.textContent = oldNumber ? String(oldNumber) : '';
oldEl.style.minWidth = '2.4em';
oldEl.style.textAlign = 'right';
oldEl.style.color = '#8c7464';
oldEl.style.opacity = '0.92';
const newEl = document.createElement('span');
newEl.textContent = newNumber ? String(newNumber) : '';
newEl.style.minWidth = '2.4em';
newEl.style.textAlign = 'right';
newEl.style.color = '#8c7464';
newEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = line || ' ';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
if (kind === 'hunk') {
lineEl.style.color = '#6c523f';
lineEl.style.background = 'rgba(224, 204, 184, 0.94)';
lineEl.style.fontWeight = '800';
} else if (kind === 'added') {
lineEl.style.color = '#0f4515';
lineEl.style.background = 'rgba(170, 232, 160, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#74110a';
lineEl.style.background = 'rgba(247, 170, 160, 0.98)';
} else if (kind === 'context') {
lineEl.style.color = '#6f5b4d';
lineEl.style.background = 'rgba(247, 236, 223, 0.72)';
} else if (kind === 'meta') {
lineEl.style.color = '#725c4f';
lineEl.style.background = 'rgba(255, 255, 255, 0.42)';
} else if (kind === 'note') {
lineEl.style.color = '#8a6f5c';
lineEl.style.background = 'rgba(236, 226, 216, 0.72)';
lineEl.style.fontStyle = 'italic';
}
lineEl.append(oldEl, newEl, textEl);
if (kind === 'added' || kind === 'removed' || kind === 'context') {
registerSelectableLine(lineEl, oldNumber, newNumber);
}
return lineEl;
};
const parseHunkHeader = (line) => {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!match) return null;
return {
oldLine: Number(match[1] || '0'),
newLine: Number(match[3] || '0'),
};
};
const prelude = [];
const hunks = [];
let currentHunk = null;
for (const line of diffText.split('\n')) {
if (line.startsWith('@@')) {
if (currentHunk) hunks.push(currentHunk);
currentHunk = { header: line, lines: [] };
continue;
}
if (currentHunk) {
currentHunk.lines.push(line);
} else {
prelude.push(line);
}
}
if (currentHunk) hunks.push(currentHunk);
for (const line of prelude) {
let kind = 'meta';
if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note';
else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added';
else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed';
else if (line.startsWith(' ')) kind = 'context';
block.appendChild(makePatchLine(line, kind));
}
for (const hunk of hunks) {
const section = document.createElement('section');
section.style.display = 'grid';
section.style.gap = '0';
section.style.marginTop = block.childNodes.length ? '10px' : '0';
section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)';
section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)';
section.appendChild(makePatchLine(hunk.header, 'hunk'));
const parsed = parseHunkHeader(hunk.header);
let oldLine = parsed ? parsed.oldLine : 0;
let newLine = parsed ? parsed.newLine : 0;
for (const line of hunk.lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
section.appendChild(makePatchLine(line, 'added', '', newLine));
newLine += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'removed', oldLine, ''));
oldLine += 1;
} else if (line.startsWith('\\')) {
section.appendChild(makePatchLine(line, 'note'));
} else if (line.startsWith('+++') || line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'meta'));
} else {
const oldNumber = oldLine ? oldLine : '';
const newNumber = newLine ? newLine : '';
section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber));
oldLine += 1;
newLine += 1;
}
}
block.appendChild(section);
}
}
viewport.appendChild(block);
target.appendChild(viewport);
if (item?.diff_truncated) {
const note = document.createElement('div');
note.textContent = 'Diff truncated for readability.';
note.style.marginTop = '8px';
note.style.fontSize = '0.72rem';
note.style.lineHeight = '1.35';
note.style.color = '#9a7b68';
note.style.fontWeight = '600';
target.appendChild(note);
}
};
const renderFiles = (items) => {
clearActiveSelection();
filesEl.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'Working tree clean.';
empty.style.fontSize = '0.92rem';
empty.style.lineHeight = '1.4';
empty.style.color = '#7d8f73';
empty.style.fontWeight = '700';
empty.style.padding = '12px';
empty.style.borderRadius = '12px';
empty.style.background = 'rgba(223, 233, 216, 0.55)';
empty.style.border = '1px solid rgba(109, 138, 93, 0.12)';
filesEl.appendChild(empty);
return;
}
for (const item of items) {
const row = document.createElement('div');
row.style.display = 'block';
row.style.minWidth = '0';
row.style.maxWidth = '100%';
row.style.padding = '0';
row.style.borderRadius = '0';
row.style.background = 'transparent';
row.style.border = '0';
row.style.boxShadow = 'none';
const summaryButton = document.createElement('button');
summaryButton.type = 'button';
summaryButton.style.display = 'flex';
summaryButton.style.alignItems = 'flex-start';
summaryButton.style.justifyContent = 'space-between';
summaryButton.style.gap = '8px';
summaryButton.style.width = '100%';
summaryButton.style.minWidth = '0';
summaryButton.style.padding = '0';
summaryButton.style.margin = '0';
summaryButton.style.border = '0';
summaryButton.style.background = 'transparent';
summaryButton.style.textAlign = 'left';
summaryButton.style.cursor = 'pointer';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'flex-start';
left.style.gap = '8px';
left.style.minWidth = '0';
left.style.flex = '1 1 auto';
const chip = document.createElement('span');
const chipTone = chipStyle(String(item?.status || 'M'));
chip.textContent = String(item?.status || 'M');
chip.style.fontSize = '0.72rem';
chip.style.lineHeight = '1.1';
chip.style.fontWeight = '800';
chip.style.color = chipTone.fg;
chip.style.background = chipTone.bg;
chip.style.padding = '4px 7px';
chip.style.borderRadius = '999px';
chip.style.flex = '0 0 auto';
const pathWrap = document.createElement('div');
pathWrap.style.minWidth = '0';
const pathEl = document.createElement('div');
pathEl.textContent = String(item?.path || '--');
pathEl.style.fontSize = '0.92rem';
pathEl.style.lineHeight = '1.3';
pathEl.style.fontWeight = '700';
pathEl.style.color = '#65483a';
pathEl.style.wordBreak = 'break-word';
const detailEl = document.createElement('div');
detailEl.style.marginTop = '3px';
detailEl.style.fontSize = '0.77rem';
detailEl.style.lineHeight = '1.35';
detailEl.style.color = '#9a7b68';
const insertions = Number(item?.insertions || 0);
const deletions = Number(item?.deletions || 0);
detailEl.textContent = insertions || deletions
? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}`
: 'No line diff';
pathWrap.append(pathEl, detailEl);
left.append(chip, pathWrap);
const toggle = document.createElement('span');
toggle.setAttribute('aria-hidden', 'true');
toggle.style.fontSize = '0.95rem';
toggle.style.lineHeight = '1';
toggle.style.fontWeight = '800';
toggle.style.color = '#9a7b68';
toggle.style.whiteSpace = 'nowrap';
toggle.style.flex = '0 0 auto';
toggle.style.paddingTop = '1px';
const body = document.createElement('div');
body.hidden = true;
body.style.width = '100%';
body.style.maxWidth = '100%';
body.style.minWidth = '0';
renderPatchBody(body, item);
const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff);
const setExpanded = (expanded) => {
body.hidden = !expanded;
summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
toggle.textContent = expanded ? '▴' : '▾';
};
setExpanded(false);
summaryButton.addEventListener('click', () => {
setExpanded(body.hidden);
});
summaryButton.append(left, toggle);
row.append(summaryButton, body);
filesEl.appendChild(row);
}
};
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const render = (payload) => {
subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo';
branchEl.textContent = formatBranch(payload);
changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0));
stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`;
untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0));
upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--';
plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`;
minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`;
updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`;
const label = payload.dirty ? 'Dirty' : 'Clean';
const tone = statusTone(label);
setStatus(label, tone.fg, tone.bg);
renderFiles(payload.files);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: payload.repo_name || null,
repo_path: payload.repo_path || null,
branch: payload.branch || null,
upstream: payload.upstream || null,
ahead: Number(payload.ahead || 0),
behind: Number(payload.behind || 0),
dirty: Boolean(payload.dirty),
changed_files: Number(payload.changed_files || 0),
staged_files: Number(payload.staged_files || 0),
unstaged_files: Number(payload.unstaged_files || 0),
untracked_files: Number(payload.untracked_files || 0),
insertions: Number(payload.insertions || 0),
deletions: Number(payload.deletions || 0),
files: Array.isArray(payload.files)
? payload.files.map((item) => ({
path: item?.path || null,
status: item?.status || null,
insertions: Number(item?.insertions || 0),
deletions: Number(item?.deletions || 0),
diff_available: Boolean(item?.diff_available),
diff_truncated: Boolean(item?.diff_truncated),
}))
: [],
generated_at: payload.generated_at || null,
});
};
const renderError = (message) => {
clearActiveSelection();
subtitleEl.textContent = subtitle || 'Git repo';
branchEl.textContent = 'Unable to load repo diff';
changedEl.textContent = '--';
stagingEl.textContent = '--';
untrackedEl.textContent = '--';
upstreamEl.textContent = '--';
plusEl.textContent = '+--';
minusEl.textContent = '- --';
updatedEl.textContent = message;
const tone = statusTone('Unavailable');
setStatus('Unavailable', tone.fg, tone.bg);
filesEl.innerHTML = '';
const error = document.createElement('div');
error.textContent = message;
error.style.fontSize = '0.88rem';
error.style.lineHeight = '1.4';
error.style.color = '#a45b51';
error.style.fontWeight = '700';
error.style.padding = '12px';
error.style.borderRadius = '12px';
error.style.background = 'rgba(243, 216, 210, 0.55)';
error.style.border = '1px solid rgba(164, 91, 81, 0.14)';
filesEl.appendChild(error);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: null,
repo_path: null,
dirty: null,
error: message,
});
};
const loadPayload = async () => {
if (!configuredToolName) throw new Error('Missing template_state.tool_name');
if (!host.callToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await host.callToolAsync(
configuredToolName,
rawToolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : '';
if (rawContent) {
if (rawContent.includes('(truncated,')) {
throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.');
}
const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim();
if (!normalizedContent.startsWith('{')) {
throw new Error(rawContent);
}
try {
const parsed = JSON.parse(normalizedContent);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Tool returned invalid JSON: ${detail}`);
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' };
setStatus('Refreshing', loadingTone.fg, loadingTone.bg);
try {
const payload = await loadPayload();
render(payload);
} catch (error) {
renderError(String(error));
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -162,708 +162,3 @@
<div data-git-files></div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-git-subtitle]');
const branchEl = root.querySelector('[data-git-branch]');
const statusEl = root.querySelector('[data-git-status]');
const changedEl = root.querySelector('[data-git-changed]');
const stagingEl = root.querySelector('[data-git-staging]');
const untrackedEl = root.querySelector('[data-git-untracked]');
const upstreamEl = root.querySelector('[data-git-upstream]');
const plusEl = root.querySelector('[data-git-plus]');
const minusEl = root.querySelector('[data-git-minus]');
const updatedEl = root.querySelector('[data-git-updated]');
const filesEl = root.querySelector('[data-git-files]');
if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 });
const setStatus = (label, fg, bg) => {
statusEl.textContent = label;
statusEl.style.color = fg;
statusEl.style.background = bg;
statusEl.style.padding = '3px 7px';
statusEl.style.borderRadius = '999px';
};
const statusTone = (value) => {
if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' };
if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' };
return { fg: '#a14d43', bg: '#f3d8d2' };
};
const formatBranch = (payload) => {
const parts = [];
const branch = typeof payload.branch === 'string' ? payload.branch : '';
if (branch) parts.push(branch);
if (typeof payload.upstream === 'string' && payload.upstream) {
parts.push(payload.upstream);
}
const ahead = Number(payload.ahead || 0);
const behind = Number(payload.behind || 0);
if (ahead || behind) {
parts.push(`+${ahead} / -${behind}`);
}
if (!parts.length && typeof payload.head === 'string' && payload.head) {
parts.push(payload.head);
}
return parts.join(' · ') || 'No branch information';
};
const formatUpdated = (raw) => {
if (typeof raw !== 'string' || !raw) return '--';
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const chipStyle = (status) => {
if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' };
if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' };
if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' };
return { fg: '#9a6a2f', bg: '#f3e1ba' };
};
const asLineNumber = (value) => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
return null;
};
const formatRangePart = (label, start, end) => {
if (start === null || end === null) return '';
return start === end ? `${label} ${start}` : `${label} ${start}-${end}`;
};
const buildSelectionPayload = (filePath, lines) => {
const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null);
const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null);
const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null;
const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null;
const newStart = newNumbers.length ? Math.min(...newNumbers) : null;
const newEnd = newNumbers.length ? Math.max(...newNumbers) : null;
const rangeParts = [
formatRangePart('old', oldStart, oldEnd),
formatRangePart('new', newStart, newEnd),
].filter(Boolean);
const fileLabel = filePath || 'Selected diff';
const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines';
return {
kind: 'git_diff_range',
file_path: filePath || fileLabel,
file_label: fileLabel,
range_label: rangeLabel,
label: `${fileLabel} · ${rangeLabel}`,
old_start: oldStart,
old_end: oldEnd,
new_start: newStart,
new_end: newEnd,
};
};
let activeSelectionController = null;
const clearActiveSelection = () => {
if (activeSelectionController) {
const controller = activeSelectionController;
activeSelectionController = null;
controller.clear(false);
}
window.__nanobotSetCardSelection?.(script, null);
};
const renderPatchBody = (target, item) => {
target.innerHTML = '';
target.dataset.noSwipe = '1';
target.style.marginTop = '10px';
target.style.marginLeft = '-12px';
target.style.marginRight = '-12px';
target.style.width = 'calc(100% + 24px)';
target.style.paddingTop = '10px';
target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)';
target.style.overflow = 'hidden';
target.style.minWidth = '0';
target.style.maxWidth = 'none';
const viewport = document.createElement('div');
viewport.dataset.noSwipe = '1';
viewport.style.width = '100%';
viewport.style.maxWidth = 'none';
viewport.style.minWidth = '0';
viewport.style.overflowX = 'auto';
viewport.style.overflowY = 'hidden';
viewport.style.touchAction = 'auto';
viewport.style.overscrollBehavior = 'contain';
viewport.style.webkitOverflowScrolling = 'touch';
viewport.style.scrollbarWidth = 'thin';
viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent';
const diffText = typeof item?.diff === 'string' ? item.diff : '';
const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : [];
if (!diffText && diffLines.length === 0) {
const message = document.createElement('div');
message.textContent = 'No line diff available for this path.';
message.style.fontSize = '0.76rem';
message.style.lineHeight = '1.4';
message.style.color = '#9a7b68';
message.style.fontWeight = '600';
target.appendChild(message);
return;
}
const block = document.createElement('div');
block.dataset.noSwipe = '1';
block.style.display = 'grid';
block.style.gap = '0';
block.style.padding = '0';
block.style.borderRadius = '0';
block.style.background = 'rgba(255,255,255,0.58)';
block.style.border = '1px solid rgba(153, 118, 92, 0.14)';
block.style.borderLeft = '0';
block.style.borderRight = '0';
block.style.fontFamily =
"var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)";
block.style.fontSize = '0.64rem';
block.style.lineHeight = '1.45';
block.style.color = '#5f4a3f';
block.style.width = 'max-content';
block.style.minWidth = '100%';
const selectableLines = [];
let localSelection = null;
let localAnchorIndex = null;
const setSelectedState = (entry, selected) => {
if (selected) entry.lineEl.dataset.selected = 'true';
else delete entry.lineEl.dataset.selected;
};
const controller = {
clear(publish = true) {
localSelection = null;
localAnchorIndex = null;
for (const entry of selectableLines) setSelectedState(entry, false);
if (publish) {
if (activeSelectionController === controller) activeSelectionController = null;
window.__nanobotSetCardSelection?.(script, null);
}
},
};
const applySelection = (startIndex, endIndex) => {
const lower = Math.min(startIndex, endIndex);
const upper = Math.max(startIndex, endIndex);
localSelection = { startIndex: lower, endIndex: upper };
for (const [index, entry] of selectableLines.entries()) {
setSelectedState(entry, index >= lower && index <= upper);
}
if (activeSelectionController && activeSelectionController !== controller) {
activeSelectionController.clear(false);
}
activeSelectionController = controller;
window.__nanobotSetCardSelection?.(
script,
buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)),
);
};
const handleSelectableLine = (index) => {
if (!localSelection) {
localAnchorIndex = index;
applySelection(index, index);
return;
}
const singleLine = localSelection.startIndex === localSelection.endIndex;
if (singleLine) {
const anchorIndex = localAnchorIndex ?? localSelection.startIndex;
if (index === anchorIndex) {
controller.clear(true);
return;
}
applySelection(anchorIndex, index);
localAnchorIndex = null;
return;
}
localAnchorIndex = index;
applySelection(index, index);
};
const registerSelectableLine = (lineEl, oldNumber, newNumber) => {
const entry = {
lineEl,
oldNumber: asLineNumber(oldNumber),
newNumber: asLineNumber(newNumber),
};
const index = selectableLines.push(entry) - 1;
lineEl.dataset.selectable = 'true';
lineEl.tabIndex = 0;
lineEl.setAttribute('role', 'button');
lineEl.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
handleSelectableLine(index);
});
lineEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
handleSelectableLine(index);
});
};
if (diffLines.length > 0) {
for (const row of diffLines) {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const numberEl = document.createElement('span');
const lineNumber =
typeof row?.line_number === 'number' || typeof row?.line_number === 'string'
? String(row.line_number)
: '';
numberEl.textContent = lineNumber;
numberEl.style.minWidth = '2.2em';
numberEl.style.textAlign = 'right';
numberEl.style.color = '#8c7464';
numberEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = typeof row?.text === 'string' ? row.text : '';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
const kind = typeof row?.kind === 'string' ? row.kind : '';
if (kind === 'added') {
lineEl.style.color = '#0c3f12';
lineEl.style.background = 'rgba(158, 232, 147, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#6d0d08';
lineEl.style.background = 'rgba(249, 156, 145, 0.98)';
} else {
lineEl.style.color = '#5f4a3f';
}
lineEl.append(numberEl, textEl);
block.appendChild(lineEl);
}
} else {
const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => {
const lineEl = document.createElement('div');
lineEl.dataset.gitPatchRow = '1';
lineEl.style.display = 'grid';
lineEl.style.gridTemplateColumns = 'max-content max-content max-content';
lineEl.style.columnGap = '8px';
lineEl.style.alignItems = 'start';
lineEl.style.justifyContent = 'start';
lineEl.style.padding = '0';
lineEl.style.borderRadius = '0';
lineEl.style.width = 'max-content';
lineEl.style.minWidth = '100%';
const oldEl = document.createElement('span');
oldEl.textContent = oldNumber ? String(oldNumber) : '';
oldEl.style.minWidth = '2.4em';
oldEl.style.textAlign = 'right';
oldEl.style.color = '#8c7464';
oldEl.style.opacity = '0.92';
const newEl = document.createElement('span');
newEl.textContent = newNumber ? String(newNumber) : '';
newEl.style.minWidth = '2.4em';
newEl.style.textAlign = 'right';
newEl.style.color = '#8c7464';
newEl.style.opacity = '0.92';
const textEl = document.createElement('span');
textEl.dataset.gitPatchText = '1';
textEl.textContent = line || ' ';
textEl.style.whiteSpace = 'pre';
textEl.style.wordBreak = 'normal';
if (kind === 'hunk') {
lineEl.style.color = '#6c523f';
lineEl.style.background = 'rgba(224, 204, 184, 0.94)';
lineEl.style.fontWeight = '800';
} else if (kind === 'added') {
lineEl.style.color = '#0f4515';
lineEl.style.background = 'rgba(170, 232, 160, 0.98)';
} else if (kind === 'removed') {
lineEl.style.color = '#74110a';
lineEl.style.background = 'rgba(247, 170, 160, 0.98)';
} else if (kind === 'context') {
lineEl.style.color = '#6f5b4d';
lineEl.style.background = 'rgba(247, 236, 223, 0.72)';
} else if (kind === 'meta') {
lineEl.style.color = '#725c4f';
lineEl.style.background = 'rgba(255, 255, 255, 0.42)';
} else if (kind === 'note') {
lineEl.style.color = '#8a6f5c';
lineEl.style.background = 'rgba(236, 226, 216, 0.72)';
lineEl.style.fontStyle = 'italic';
}
lineEl.append(oldEl, newEl, textEl);
if (kind === 'added' || kind === 'removed' || kind === 'context') {
registerSelectableLine(lineEl, oldNumber, newNumber);
}
return lineEl;
};
const parseHunkHeader = (line) => {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (!match) return null;
return {
oldLine: Number(match[1] || '0'),
newLine: Number(match[3] || '0'),
};
};
const prelude = [];
const hunks = [];
let currentHunk = null;
for (const line of diffText.split('\n')) {
if (line.startsWith('@@')) {
if (currentHunk) hunks.push(currentHunk);
currentHunk = { header: line, lines: [] };
continue;
}
if (currentHunk) {
currentHunk.lines.push(line);
} else {
prelude.push(line);
}
}
if (currentHunk) hunks.push(currentHunk);
for (const line of prelude) {
let kind = 'meta';
if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note';
else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added';
else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed';
else if (line.startsWith(' ')) kind = 'context';
block.appendChild(makePatchLine(line, kind));
}
for (const hunk of hunks) {
const section = document.createElement('section');
section.style.display = 'grid';
section.style.gap = '0';
section.style.marginTop = block.childNodes.length ? '10px' : '0';
section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)';
section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)';
section.appendChild(makePatchLine(hunk.header, 'hunk'));
const parsed = parseHunkHeader(hunk.header);
let oldLine = parsed ? parsed.oldLine : 0;
let newLine = parsed ? parsed.newLine : 0;
for (const line of hunk.lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
section.appendChild(makePatchLine(line, 'added', '', newLine));
newLine += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'removed', oldLine, ''));
oldLine += 1;
} else if (line.startsWith('\\')) {
section.appendChild(makePatchLine(line, 'note'));
} else if (line.startsWith('+++') || line.startsWith('---')) {
section.appendChild(makePatchLine(line, 'meta'));
} else {
const oldNumber = oldLine ? oldLine : '';
const newNumber = newLine ? newLine : '';
section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber));
oldLine += 1;
newLine += 1;
}
}
block.appendChild(section);
}
}
viewport.appendChild(block);
target.appendChild(viewport);
if (item?.diff_truncated) {
const note = document.createElement('div');
note.textContent = 'Diff truncated for readability.';
note.style.marginTop = '8px';
note.style.fontSize = '0.72rem';
note.style.lineHeight = '1.35';
note.style.color = '#9a7b68';
note.style.fontWeight = '600';
target.appendChild(note);
}
};
const renderFiles = (items) => {
clearActiveSelection();
filesEl.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'Working tree clean.';
empty.style.fontSize = '0.92rem';
empty.style.lineHeight = '1.4';
empty.style.color = '#7d8f73';
empty.style.fontWeight = '700';
empty.style.padding = '12px';
empty.style.borderRadius = '12px';
empty.style.background = 'rgba(223, 233, 216, 0.55)';
empty.style.border = '1px solid rgba(109, 138, 93, 0.12)';
filesEl.appendChild(empty);
return;
}
for (const item of items) {
const row = document.createElement('div');
row.style.display = 'block';
row.style.minWidth = '0';
row.style.maxWidth = '100%';
row.style.padding = '0';
row.style.borderRadius = '0';
row.style.background = 'transparent';
row.style.border = '0';
row.style.boxShadow = 'none';
const summaryButton = document.createElement('button');
summaryButton.type = 'button';
summaryButton.style.display = 'flex';
summaryButton.style.alignItems = 'flex-start';
summaryButton.style.justifyContent = 'space-between';
summaryButton.style.gap = '8px';
summaryButton.style.width = '100%';
summaryButton.style.minWidth = '0';
summaryButton.style.padding = '0';
summaryButton.style.margin = '0';
summaryButton.style.border = '0';
summaryButton.style.background = 'transparent';
summaryButton.style.textAlign = 'left';
summaryButton.style.cursor = 'pointer';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'flex-start';
left.style.gap = '8px';
left.style.minWidth = '0';
left.style.flex = '1 1 auto';
const chip = document.createElement('span');
const chipTone = chipStyle(String(item?.status || 'M'));
chip.textContent = String(item?.status || 'M');
chip.style.fontSize = '0.72rem';
chip.style.lineHeight = '1.1';
chip.style.fontWeight = '800';
chip.style.color = chipTone.fg;
chip.style.background = chipTone.bg;
chip.style.padding = '4px 7px';
chip.style.borderRadius = '999px';
chip.style.flex = '0 0 auto';
const pathWrap = document.createElement('div');
pathWrap.style.minWidth = '0';
const pathEl = document.createElement('div');
pathEl.textContent = String(item?.path || '--');
pathEl.style.fontSize = '0.92rem';
pathEl.style.lineHeight = '1.3';
pathEl.style.fontWeight = '700';
pathEl.style.color = '#65483a';
pathEl.style.wordBreak = 'break-word';
const detailEl = document.createElement('div');
detailEl.style.marginTop = '3px';
detailEl.style.fontSize = '0.77rem';
detailEl.style.lineHeight = '1.35';
detailEl.style.color = '#9a7b68';
const insertions = Number(item?.insertions || 0);
const deletions = Number(item?.deletions || 0);
detailEl.textContent = insertions || deletions
? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}`
: 'No line diff';
pathWrap.append(pathEl, detailEl);
left.append(chip, pathWrap);
const toggle = document.createElement('span');
toggle.setAttribute('aria-hidden', 'true');
toggle.style.fontSize = '0.95rem';
toggle.style.lineHeight = '1';
toggle.style.fontWeight = '800';
toggle.style.color = '#9a7b68';
toggle.style.whiteSpace = 'nowrap';
toggle.style.flex = '0 0 auto';
toggle.style.paddingTop = '1px';
const body = document.createElement('div');
body.hidden = true;
body.style.width = '100%';
body.style.maxWidth = '100%';
body.style.minWidth = '0';
renderPatchBody(body, item);
const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff);
const setExpanded = (expanded) => {
body.hidden = !expanded;
summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
toggle.textContent = expanded ? '▴' : '▾';
};
setExpanded(false);
summaryButton.addEventListener('click', () => {
setExpanded(body.hidden);
});
summaryButton.append(left, toggle);
row.append(summaryButton, body);
filesEl.appendChild(row);
}
};
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const render = (payload) => {
subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo';
branchEl.textContent = formatBranch(payload);
changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0));
stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`;
untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0));
upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--';
plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`;
minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`;
updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`;
const label = payload.dirty ? 'Dirty' : 'Clean';
const tone = statusTone(label);
setStatus(label, tone.fg, tone.bg);
renderFiles(payload.files);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: payload.repo_name || null,
repo_path: payload.repo_path || null,
branch: payload.branch || null,
upstream: payload.upstream || null,
ahead: Number(payload.ahead || 0),
behind: Number(payload.behind || 0),
dirty: Boolean(payload.dirty),
changed_files: Number(payload.changed_files || 0),
staged_files: Number(payload.staged_files || 0),
unstaged_files: Number(payload.unstaged_files || 0),
untracked_files: Number(payload.untracked_files || 0),
insertions: Number(payload.insertions || 0),
deletions: Number(payload.deletions || 0),
files: Array.isArray(payload.files)
? payload.files.map((item) => ({
path: item?.path || null,
status: item?.status || null,
insertions: Number(item?.insertions || 0),
deletions: Number(item?.deletions || 0),
diff_available: Boolean(item?.diff_available),
diff_truncated: Boolean(item?.diff_truncated),
}))
: [],
generated_at: payload.generated_at || null,
});
};
const renderError = (message) => {
clearActiveSelection();
subtitleEl.textContent = subtitle || 'Git repo';
branchEl.textContent = 'Unable to load repo diff';
changedEl.textContent = '--';
stagingEl.textContent = '--';
untrackedEl.textContent = '--';
upstreamEl.textContent = '--';
plusEl.textContent = '+--';
minusEl.textContent = '- --';
updatedEl.textContent = message;
const tone = statusTone('Unavailable');
setStatus('Unavailable', tone.fg, tone.bg);
filesEl.innerHTML = '';
const error = document.createElement('div');
error.textContent = message;
error.style.fontSize = '0.88rem';
error.style.lineHeight = '1.4';
error.style.color = '#a45b51';
error.style.fontWeight = '700';
error.style.padding = '12px';
error.style.borderRadius = '12px';
error.style.background = 'rgba(243, 216, 210, 0.55)';
error.style.border = '1px solid rgba(164, 91, 81, 0.14)';
filesEl.appendChild(error);
updateLiveContent({
kind: 'git_repo_diff',
repo_name: null,
repo_path: null,
dirty: null,
error: message,
});
};
const loadPayload = async () => {
if (!configuredToolName) throw new Error('Missing template_state.tool_name');
if (!window.__nanobotCallToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await window.__nanobotCallToolAsync(
configuredToolName,
rawToolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : '';
if (rawContent) {
if (rawContent.includes('(truncated,')) {
throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.');
}
const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim();
if (!normalizedContent.startsWith('{')) {
throw new Error(rawContent);
}
try {
const parsed = JSON.parse(normalizedContent);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Tool returned invalid JSON: ${detail}`);
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' };
setStatus('Refreshing', loadingTone.fg, loadingTone.bg);
try {
const payload = await loadPayload();
render(payload);
} catch (error) {
renderError(String(error));
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
})();
</script>

View file

@ -0,0 +1,258 @@
function clampDigits(raw) {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 4;
return Math.max(1, Math.min(4, Math.round(parsed)));
}
function sanitizeValue(raw, maxDigits) {
return String(raw || "")
.replace(/\D+/g, "")
.slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || "")
.replace(/\s+/g, " ")
.trimStart();
}
function isBlankRow(row) {
return !row || (!String(row.value || "").trim() && !String(row.name || "").trim());
}
function normalizeRows(raw, maxDigits) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value, maxDigits),
name: sanitizeName(row.name),
}));
}
function ensureTrailingBlankRow(rows) {
const next = rows.map((row) => ({
value: String(row.value || ""),
name: String(row.name || ""),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: "", name: "" });
}
return next;
}
function persistedRows(rows) {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: String(row.value || ""),
name: String(row.name || ""),
}));
}
function totalValue(rows) {
return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function normalizeConfig(state) {
const maxDigits = clampDigits(state.max_digits);
return {
leftLabel: String(state.left_label || "Value").trim() || "Value",
rightLabel: String(state.right_label || "Item").trim() || "Item",
totalLabel: String(state.total_label || "Total").trim() || "Total",
totalSuffix: String(state.total_suffix || "").trim(),
maxDigits,
score:
typeof state.score === "number" && Number.isFinite(state.score)
? Math.max(0, Math.min(100, state.score))
: 24,
rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)),
};
}
function configState(config) {
return {
left_label: config.leftLabel,
right_label: config.rightLabel,
total_label: config.totalLabel,
total_suffix: config.totalSuffix,
max_digits: config.maxDigits,
score: config.score,
rows: persistedRows(config.rows),
};
}
function autoscore(config) {
if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) {
return config.score;
}
return persistedRows(config.rows).length ? 24 : 16;
}
export function mount({ root, state, host }) {
const labelsEl = root.querySelector(".list-total-card-ui__labels");
const rowsEl = root.querySelector(".list-total-card-ui__rows");
const statusEl = root.querySelector(".list-total-card-ui__status");
const totalLabelEl = root.querySelector(".list-total-card-ui__total-label");
const totalEl = root.querySelector(".list-total-card-ui__total-value");
if (
!(labelsEl instanceof HTMLElement) ||
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement)
) {
return;
}
const leftLabelEl = labelsEl.children.item(0);
const rightLabelEl = labelsEl.children.item(1);
if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return;
let config = normalizeConfig(state);
let saveTimer = null;
let busy = false;
const clearSaveTimer = () => {
if (saveTimer !== null) {
window.clearTimeout(saveTimer);
saveTimer = null;
}
};
const setStatus = (text, kind) => {
statusEl.textContent = text || "";
statusEl.dataset.kind = kind || "";
};
const publishLiveContent = () => {
host.setLiveContent({
kind: "list_total",
item_count: persistedRows(config.rows).length,
total: totalValue(config.rows),
total_suffix: config.totalSuffix || null,
score: autoscore(config),
});
};
const renderTotal = () => {
const total = totalValue(config.rows);
totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`;
publishLiveContent();
};
const persist = async () => {
clearSaveTimer();
busy = true;
setStatus("Saving", "ok");
try {
const nextState = configState(config);
await host.replaceState(nextState);
config = normalizeConfig(nextState);
setStatus("", "");
} catch (error) {
console.error("List total card save failed", error);
setStatus("Unavailable", "error");
} finally {
busy = false;
render();
}
};
const schedulePersist = () => {
clearSaveTimer();
saveTimer = window.setTimeout(() => {
void persist();
}, 280);
};
const normalizeRowsAfterBlur = () => {
config.rows = ensureTrailingBlankRow(persistedRows(config.rows));
render();
schedulePersist();
};
const renderRows = () => {
rowsEl.innerHTML = "";
config.rows.forEach((row, index) => {
const rowEl = document.createElement("div");
rowEl.className = "list-total-card-ui__row";
const valueInput = document.createElement("input");
valueInput.className = "list-total-card-ui__input list-total-card-ui__value";
valueInput.type = "text";
valueInput.inputMode = "numeric";
valueInput.maxLength = config.maxDigits;
valueInput.placeholder = "0";
valueInput.value = row.value;
valueInput.disabled = busy;
const nameInput = document.createElement("input");
nameInput.className = "list-total-card-ui__input list-total-card-ui__name";
nameInput.type = "text";
nameInput.placeholder = "Item";
nameInput.value = row.name;
nameInput.disabled = busy;
valueInput.addEventListener("input", () => {
config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits);
valueInput.value = config.rows[index].value;
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
config.rows = ensureTrailingBlankRow(config.rows);
render();
schedulePersist();
return;
}
renderTotal();
schedulePersist();
});
nameInput.addEventListener("input", () => {
config.rows[index].name = sanitizeName(nameInput.value);
nameInput.value = config.rows[index].name;
if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
config.rows = ensureTrailingBlankRow(config.rows);
render();
schedulePersist();
return;
}
renderTotal();
schedulePersist();
});
valueInput.addEventListener("blur", normalizeRowsAfterBlur);
nameInput.addEventListener("blur", normalizeRowsAfterBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
};
const render = () => {
leftLabelEl.textContent = config.leftLabel;
rightLabelEl.textContent = config.rightLabel;
totalLabelEl.textContent = config.totalLabel;
renderRows();
renderTotal();
};
host.setRefreshHandler(() => {
config.rows = ensureTrailingBlankRow(config.rows);
render();
});
render();
return {
update({ state: nextState }) {
config = normalizeConfig(nextState);
render();
},
destroy() {
clearSaveTimer();
host.setRefreshHandler(null);
host.setLiveContent(null);
},
};
}

View file

@ -1,336 +1,12 @@
<style>
.list-total-card {
display: grid;
gap: 10px;
color: #4d392d;
}
.list-total-card__labels,
.list-total-card__row,
.list-total-card__total {
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.list-total-card__labels {
color: rgba(77, 57, 45, 0.72);
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__rows {
display: grid;
gap: 6px;
}
.list-total-card__input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid rgba(92, 70, 55, 0.14);
border-radius: 0;
background: transparent;
color: #473429;
padding: 4px 0;
outline: none;
box-shadow: none;
}
.list-total-card__input::placeholder {
color: rgba(77, 57, 45, 0.42);
}
.list-total-card__value {
font: 700 0.84rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
.list-total-card__name {
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 0.92rem;
line-height: 1.08;
font-weight: 600;
letter-spacing: -0.008em;
}
.list-total-card__status {
min-height: 0.9rem;
color: #8e3023;
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list-total-card__status[data-kind='ok'] {
color: rgba(77, 57, 45, 0.5);
}
.list-total-card__total {
padding-top: 8px;
border-top: 1px solid rgba(92, 70, 55, 0.18);
color: #35271f;
}
.list-total-card__total-label {
font: 700 0.66rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__total-value {
font: 700 0.98rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
</style>
<div class="list-total-card" data-list-total-card>
<div class="list-total-card__labels">
<div data-list-total-left-label>Value</div>
<div data-list-total-right-label>Item</div>
<div class="list-total-card-ui">
<div class="list-total-card-ui__labels">
<div>Value</div>
<div>Item</div>
</div>
<div class="list-total-card__rows" data-list-total-rows></div>
<div class="list-total-card__status" data-list-total-status></div>
<div class="list-total-card__total">
<div class="list-total-card__total-label" data-list-total-total-label>Total</div>
<div class="list-total-card__total-value" data-list-total-total>0</div>
<div class="list-total-card-ui__rows"></div>
<div class="list-total-card-ui__status"></div>
<div class="list-total-card-ui__total">
<div class="list-total-card-ui__total-label">Total</div>
<div class="list-total-card-ui__total-value">0</div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const cardId = String(root.dataset.cardId || '').trim();
const rowsEl = root.querySelector('[data-list-total-rows]');
const statusEl = root.querySelector('[data-list-total-status]');
const totalEl = root.querySelector('[data-list-total-total]');
const totalLabelEl = root.querySelector('[data-list-total-total-label]');
const leftLabelEl = root.querySelector('[data-list-total-left-label]');
const rightLabelEl = root.querySelector('[data-list-total-right-label]');
if (
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(leftLabelEl instanceof HTMLElement) ||
!(rightLabelEl instanceof HTMLElement)
) {
return;
}
const maxDigits = Math.max(
1,
Math.min(4, Number.isFinite(Number(state.max_digits)) ? Number(state.max_digits) : 4),
);
const totalSuffix = String(state.total_suffix || '').trim();
const leftLabel = String(state.left_label || 'Value').trim() || 'Value';
const rightLabel = String(state.right_label || 'Item').trim() || 'Item';
const totalLabel = String(state.total_label || 'Total').trim() || 'Total';
leftLabelEl.textContent = leftLabel;
rightLabelEl.textContent = rightLabel;
totalLabelEl.textContent = totalLabel;
function sanitizeValue(raw) {
return String(raw || '').replace(/\D+/g, '').slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || '').replace(/\s+/g, ' ').trimStart();
}
function normalizeRows(raw) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function isBlankRow(row) {
return !row || (!String(row.value || '').trim() && !String(row.name || '').trim());
}
function ensureTrailingBlankRow(items) {
const next = items.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: '', name: '' });
}
return next;
}
function persistedRows() {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function computeTotal() {
return persistedRows().reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function updateTotal() {
const total = computeTotal();
totalEl.textContent = `${total.toLocaleString()}${totalSuffix ? totalSuffix : ''}`;
window.__nanobotSetCardLiveContent?.(script, {
kind: 'list_total',
item_count: persistedRows().length,
total,
total_suffix: totalSuffix || null,
score: persistedRows().length ? 24 : 16,
});
}
function setStatus(text, kind) {
statusEl.textContent = text || '';
statusEl.dataset.kind = kind || '';
}
let rows = ensureTrailingBlankRow(normalizeRows(state.rows));
let saveTimer = null;
let inFlightSave = null;
async function persistState() {
if (!cardId) return;
const nextState = {
...state,
left_label: leftLabel,
right_label: rightLabel,
total_label: totalLabel,
total_suffix: totalSuffix,
max_digits: maxDigits,
rows: persistedRows(),
};
try {
setStatus('Saving', 'ok');
inFlightSave = fetch(`/cards/${encodeURIComponent(cardId)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template_state: nextState }),
});
const response = await inFlightSave;
if (!response.ok) {
let message = `save failed (${response.status})`;
try {
const payload = await response.json();
if (payload && typeof payload.error === 'string' && payload.error) {
message = payload.error;
}
} catch (_) {}
throw new Error(message);
}
setStatus('', '');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'save failed', 'error');
} finally {
inFlightSave = null;
}
}
function schedulePersist() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
void persistState();
}, 280);
}
function pruneRows() {
rows = ensureTrailingBlankRow(
rows.filter((row, index) => !isBlankRow(row) || index === rows.length - 1),
);
}
function renderRows() {
rowsEl.innerHTML = '';
rows.forEach((row, index) => {
const rowEl = document.createElement('div');
rowEl.className = 'list-total-card__row';
const valueInput = document.createElement('input');
valueInput.className = 'list-total-card__input list-total-card__value';
valueInput.type = 'text';
valueInput.inputMode = 'numeric';
valueInput.maxLength = maxDigits;
valueInput.placeholder = '0';
valueInput.value = row.value;
const nameInput = document.createElement('input');
nameInput.className = 'list-total-card__input list-total-card__name';
nameInput.type = 'text';
nameInput.placeholder = 'Item';
nameInput.value = row.name;
valueInput.addEventListener('input', () => {
rows[index].value = sanitizeValue(valueInput.value);
valueInput.value = rows[index].value;
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
nameInput.addEventListener('input', () => {
rows[index].name = sanitizeName(nameInput.value);
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
const handleBlur = () => {
rows[index].value = sanitizeValue(valueInput.value);
rows[index].name = sanitizeName(nameInput.value);
const nextRows = ensureTrailingBlankRow(
rows.filter((candidate, candidateIndex) => !isBlankRow(candidate) || candidateIndex === rows.length - 1),
);
const changed = JSON.stringify(nextRows) !== JSON.stringify(rows);
rows = nextRows;
if (changed) {
renderRows();
} else {
updateTotal();
}
schedulePersist();
};
valueInput.addEventListener('blur', handleBlur);
nameInput.addEventListener('blur', handleBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
updateTotal();
}
window.__nanobotSetCardRefresh?.(script, () => {
pruneRows();
renderRows();
});
renderRows();
})();
</script>

View file

@ -0,0 +1,268 @@
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 subtitleEl = root.querySelector('[data-usage-subtitle]');
const statusEl = root.querySelector('[data-usage-status]');
const updatedEl = root.querySelector('[data-usage-updated]');
const gridEl = root.querySelector('[data-usage-grid]');
const monthSectionEl = root.querySelector('[data-usage-month-section]');
const tokens24hEl = root.querySelector('[data-usage-tokens-24h]');
const power24hEl = root.querySelector('[data-usage-power-24h]');
const window24hEl = root.querySelector('[data-usage-window-24h]');
const tokensMonthEl = root.querySelector('[data-usage-tokens-month]');
const powerMonthEl = root.querySelector('[data-usage-power-month]');
const windowMonthEl = root.querySelector('[data-usage-window-month]');
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName24h = typeof state.tool_name_24h === 'string'
? state.tool_name_24h.trim()
: typeof state.tool_name === 'string'
? state.tool_name.trim()
: '';
const configuredToolNameMonth = typeof state.tool_name_month === 'string'
? state.tool_name_month.trim()
: '';
const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h)
? state.tool_arguments_24h
: state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month)
? state.tool_arguments_month
: {};
const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : '';
const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 });
const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy';
const hasMonthSource = Boolean(
sourceMonth ||
configuredToolNameMonth ||
Object.keys(rawToolArgumentsMonth).length,
);
if (!hasMonthSource) {
monthSectionEl.style.display = 'none';
gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)';
}
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const parseLocalTimestamp = (raw) => {
if (typeof raw !== 'string' || !raw.trim()) return null;
const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/);
if (!match) return null;
const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const formatRangeLabel = (payload, fallbackLabel) => {
const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : '';
const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : '';
const start = parseLocalTimestamp(startRaw);
const end = parseLocalTimestamp(endRaw);
if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) {
return fallbackLabel;
}
return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
};
const renderSection = (elements, payload, fallbackLabel) => {
const tokens = Number(payload?.total_tokens_processed);
const kwh = Number(payload?.total_ups_kwh_in_range);
const localCost = Number(payload?.local_cost_usd_in_range);
elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--';
elements.power.textContent =
Number.isFinite(kwh) && Number.isFinite(localCost)
? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}`
: Number.isFinite(kwh)
? `${kwhFormatter.format(kwh)} kWh`
: '--';
elements.window.textContent = formatRangeLabel(payload, fallbackLabel);
};
const blankSection = (elements, fallbackLabel) => {
elements.tokens.textContent = '--';
elements.power.textContent = '--';
elements.window.textContent = fallbackLabel;
};
const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`;
const buildLegacyExecCommand = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return '';
const [pathPart, queryPart = ''] = rawUrl.split('?', 2);
const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, '');
if (!relativeScript) return '';
const params = new URLSearchParams(queryPart);
const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean);
const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`;
return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`;
};
const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => {
if (toolName) {
return {
toolName,
toolArguments,
};
}
const legacyCommand = buildLegacyExecCommand(legacySourceUrl);
if (!legacyCommand) return null;
return {
toolName: 'exec',
toolArguments: { command: legacyCommand },
};
};
const loadPayload = async (toolCall) => {
if (!toolCall) throw new Error('Missing tool_name/tool_arguments');
if (!host.callToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await host.callToolAsync(
toolCall.toolName,
toolCall.toolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
if (typeof toolResult?.content === 'string' && toolResult.content.trim()) {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
setStatus('Refreshing', 'var(--theme-status-muted)');
try {
const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h);
const toolCallMonth = hasMonthSource
? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth)
: null;
const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)];
const results = await Promise.allSettled(jobs);
const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null;
const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null;
if (twentyFourHour) {
renderSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
twentyFourHour,
'Last 24 hours',
);
} else {
blankSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
'Last 24 hours',
);
}
if (hasMonthSource && month) {
renderSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
month,
'This month',
);
} else if (hasMonthSource) {
blankSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
'This month',
);
}
const successCount = [twentyFourHour, month].filter(Boolean).length;
const expectedCount = hasMonthSource ? 2 : 1;
if (successCount === expectedCount) {
setStatus('Live', 'var(--theme-status-live)');
} else if (successCount === 1) {
setStatus('Partial', 'var(--theme-status-warning)');
} else {
setStatus('Unavailable', 'var(--theme-status-danger)');
}
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: statusEl.textContent || null,
updated_at: updatedText,
last_24h: twentyFourHour
? {
total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0,
range_start_local: twentyFourHour.range_start_local || null,
range_end_local: twentyFourHour.range_end_local || null,
}
: null,
this_month: month
? {
total_tokens_processed: Number(month.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0,
range_start_local: month.range_start_local || null,
range_end_local: month.range_end_local || null,
}
: null,
});
} catch (error) {
const errorText = String(error);
blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours');
if (hasMonthSource) {
blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month');
}
updatedEl.textContent = errorText;
setStatus('Unavailable', 'var(--theme-status-danger)');
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
last_24h: null,
this_month: null,
error: errorText,
});
}
};
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

@ -1,282 +1,30 @@
<div data-litellm-usage-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-litellm-usage-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:10px;">
<div data-usage-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-usage-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-usage-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-usage-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div data-usage-grid style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:12px;">
<section style="min-width:0;">
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Last 24h</div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:var(--theme-card-neutral-muted);">Last 24h</div>
<div style="display:flex; align-items:flex-end; gap:6px; margin-top:4px;">
<span data-usage-tokens-24h style="font-size:2rem; line-height:0.95; font-weight:800; letter-spacing:-0.04em;">--</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:#4b5563; padding-bottom:0.22rem;">tokens</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.22rem;">tokens</span>
</div>
<div data-usage-power-24h style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:#1f2937; font-weight:700;">--</div>
<div data-usage-window-24h style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:#6b7280;">--</div>
<div data-usage-power-24h style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:var(--theme-card-neutral-text); font-weight:700;">--</div>
<div data-usage-window-24h style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:var(--theme-card-neutral-muted);">--</div>
</section>
<section data-usage-month-section style="min-width:0;">
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">This Month</div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:var(--theme-card-neutral-muted);">This Month</div>
<div style="display:flex; align-items:flex-end; gap:6px; margin-top:4px;">
<span data-usage-tokens-month style="font-size:2rem; line-height:0.95; font-weight:800; letter-spacing:-0.04em;">--</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:#4b5563; padding-bottom:0.22rem;">tokens</span>
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.22rem;">tokens</span>
</div>
<div data-usage-power-month style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:#1f2937; font-weight:700;">--</div>
<div data-usage-window-month style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:#6b7280;">--</div>
<div data-usage-power-month style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:var(--theme-card-neutral-text); font-weight:700;">--</div>
<div data-usage-window-month style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:var(--theme-card-neutral-muted);">--</div>
</section>
</div>
<div style="margin-top:10px; font-size:0.82rem; line-height:1.35; color:#6b7280;">Updated <span data-usage-updated>--</span></div>
<div style="margin-top:10px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">Updated <span data-usage-updated>--</span></div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-usage-subtitle]');
const statusEl = root.querySelector('[data-usage-status]');
const updatedEl = root.querySelector('[data-usage-updated]');
const gridEl = root.querySelector('[data-usage-grid]');
const monthSectionEl = root.querySelector('[data-usage-month-section]');
const tokens24hEl = root.querySelector('[data-usage-tokens-24h]');
const power24hEl = root.querySelector('[data-usage-power-24h]');
const window24hEl = root.querySelector('[data-usage-window-24h]');
const tokensMonthEl = root.querySelector('[data-usage-tokens-month]');
const powerMonthEl = root.querySelector('[data-usage-power-month]');
const windowMonthEl = root.querySelector('[data-usage-window-month]');
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName24h = typeof state.tool_name_24h === 'string'
? state.tool_name_24h.trim()
: typeof state.tool_name === 'string'
? state.tool_name.trim()
: '';
const configuredToolNameMonth = typeof state.tool_name_month === 'string'
? state.tool_name_month.trim()
: '';
const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h)
? state.tool_arguments_24h
: state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
? state.tool_arguments
: {};
const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month)
? state.tool_arguments_month
: {};
const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : '';
const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 });
const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy';
const hasMonthSource = Boolean(
sourceMonth ||
configuredToolNameMonth ||
Object.keys(rawToolArgumentsMonth).length,
);
if (!hasMonthSource) {
monthSectionEl.style.display = 'none';
gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)';
}
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const parseLocalTimestamp = (raw) => {
if (typeof raw !== 'string' || !raw.trim()) return null;
const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/);
if (!match) return null;
const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const formatRangeLabel = (payload, fallbackLabel) => {
const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : '';
const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : '';
const start = parseLocalTimestamp(startRaw);
const end = parseLocalTimestamp(endRaw);
if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) {
return fallbackLabel;
}
return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
};
const renderSection = (elements, payload, fallbackLabel) => {
const tokens = Number(payload?.total_tokens_processed);
const kwh = Number(payload?.total_ups_kwh_in_range);
const localCost = Number(payload?.local_cost_usd_in_range);
elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--';
elements.power.textContent =
Number.isFinite(kwh) && Number.isFinite(localCost)
? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}`
: Number.isFinite(kwh)
? `${kwhFormatter.format(kwh)} kWh`
: '--';
elements.window.textContent = formatRangeLabel(payload, fallbackLabel);
};
const blankSection = (elements, fallbackLabel) => {
elements.tokens.textContent = '--';
elements.power.textContent = '--';
elements.window.textContent = fallbackLabel;
};
const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`;
const buildLegacyExecCommand = (rawUrl) => {
if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return '';
const [pathPart, queryPart = ''] = rawUrl.split('?', 2);
const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, '');
if (!relativeScript) return '';
const params = new URLSearchParams(queryPart);
const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean);
const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`;
return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`;
};
const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => {
if (toolName) {
return {
toolName,
toolArguments,
};
}
const legacyCommand = buildLegacyExecCommand(legacySourceUrl);
if (!legacyCommand) return null;
return {
toolName: 'exec',
toolArguments: { command: legacyCommand },
};
};
const loadPayload = async (toolCall) => {
if (!toolCall) throw new Error('Missing tool_name/tool_arguments');
if (!window.__nanobotCallToolAsync) throw new Error('Async tool helper unavailable');
const toolResult = await window.__nanobotCallToolAsync(
toolCall.toolName,
toolCall.toolArguments,
{ timeoutMs: 180000 },
);
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
return toolResult.parsed;
}
if (typeof toolResult?.content === 'string' && toolResult.content.trim()) {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
}
throw new Error('Tool returned invalid JSON');
};
const refresh = async () => {
setStatus('Refreshing', '#6b7280');
try {
const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h);
const toolCallMonth = hasMonthSource
? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth)
: null;
const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)];
const results = await Promise.allSettled(jobs);
const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null;
const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null;
if (twentyFourHour) {
renderSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
twentyFourHour,
'Last 24 hours',
);
} else {
blankSection(
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
'Last 24 hours',
);
}
if (hasMonthSource && month) {
renderSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
month,
'This month',
);
} else if (hasMonthSource) {
blankSection(
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
'This month',
);
}
const successCount = [twentyFourHour, month].filter(Boolean).length;
const expectedCount = hasMonthSource ? 2 : 1;
if (successCount === expectedCount) {
setStatus('Live', '#047857');
} else if (successCount === 1) {
setStatus('Partial', '#b45309');
} else {
setStatus('Unavailable', '#b91c1c');
}
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: statusEl.textContent || null,
updated_at: updatedText,
last_24h: twentyFourHour
? {
total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0,
range_start_local: twentyFourHour.range_start_local || null,
range_end_local: twentyFourHour.range_end_local || null,
}
: null,
this_month: month
? {
total_tokens_processed: Number(month.total_tokens_processed) || 0,
total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0,
local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0,
range_start_local: month.range_start_local || null,
range_end_local: month.range_end_local || null,
}
: null,
});
} catch (error) {
const errorText = String(error);
blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours');
if (hasMonthSource) {
blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month');
}
updatedEl.textContent = errorText;
setStatus('Unavailable', '#b91c1c');
updateLiveContent({
kind: 'litellm_ups_usage',
subtitle: subtitleEl.textContent || null,
status: 'Unavailable',
updated_at: errorText,
last_24h: null,
this_month: null,
error: errorText,
});
}
};
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

View file

@ -0,0 +1,218 @@
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 valueEl = root.querySelector("[data-co2-value]");
const statusEl = root.querySelector("[data-co2-status]");
const updatedEl = root.querySelector("[data-co2-updated]");
if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
const INTERVAL_RAW = Number(state.refresh_ms);
const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const setUpdatedNow = () => {
updatedEl.textContent = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
const parseValue = (raw) => {
const n = Number(raw);
return Number.isFinite(n) ? Math.round(n) : null;
};
const computeScore = (value) => {
if (!Number.isFinite(value)) return 0;
if (value >= 1600) return 96;
if (value >= 1400) return 90;
if (value >= 1200) return 82;
if (value >= 1000) return 68;
if (value >= 900) return 54;
if (value >= 750) return 34;
return 16;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const refresh = async () => {
setStatus("Refreshing", "var(--theme-status-muted)");
try {
const toolName = await resolveToolName();
const toolResult = await host.callTool(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
if (!entry) throw new Error(`Missing sensor ${matchName}`);
const value = parseValue(entry.state);
if (value === null) throw new Error("Invalid sensor payload");
valueEl.textContent = String(value);
if (value >= 1200) setStatus("High", "var(--theme-status-danger)");
else if (value >= 900) setStatus("Elevated", "var(--theme-status-warning)");
else setStatus("Good", "var(--theme-status-live)");
setUpdatedNow();
host.setLiveContent({
kind: 'sensor',
tool_name: toolName,
match_name: entry.name,
value,
display_value: String(value),
unit: entry.attributes?.unit_of_measurement || 'ppm',
status: statusEl.textContent || null,
updated_at: updatedEl.textContent || null,
score: computeScore(value),
});
} catch (err) {
valueEl.textContent = "--";
setStatus("Unavailable", "var(--theme-status-danger)");
updatedEl.textContent = String(err);
host.setLiveContent({
kind: 'sensor',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
match_name: matchName,
value: null,
display_value: '--',
unit: 'ppm',
status: 'Unavailable',
updated_at: String(err),
error: String(err),
score: 0,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => {
void refresh();
}, INTERVAL_MS);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,204 +1,15 @@
<div data-co2-card="bedroom" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 520px;">
<div data-co2-card="bedroom" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: var(--theme-card-neutral-bg); color: var(--theme-card-neutral-text); border: 1px solid var(--theme-card-neutral-border); border-radius: 16px; box-shadow: var(--theme-shadow); padding: 24px; max-width: 520px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 12px;">
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Bedroom CO2</h2>
<span data-co2-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--theme-card-neutral-text);">Bedroom CO2</h2>
<span data-co2-status style="font-size:0.82rem; color:var(--theme-status-muted);">Loading...</span>
</div>
<div style="display:flex; align-items:baseline; gap:8px;">
<span data-co2-value style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
<span style="font-size:1rem; font-weight:600; color:#4b5563;">ppm</span>
<span style="font-size:1rem; font-weight:600; color:var(--theme-card-neutral-subtle);">ppm</span>
</div>
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
<div style="margin-top:12px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">
Updated: <span data-co2-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const valueEl = root.querySelector("[data-co2-value]");
const statusEl = root.querySelector("[data-co2-status]");
const updatedEl = root.querySelector("[data-co2-updated]");
if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
const INTERVAL_RAW = Number(state.refresh_ms);
const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const setUpdatedNow = () => {
updatedEl.textContent = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
const parseValue = (raw) => {
const n = Number(raw);
return Number.isFinite(n) ? Math.round(n) : null;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const refresh = async () => {
setStatus("Refreshing", "#6b7280");
try {
const toolName = await resolveToolName();
const toolResult = await window.__nanobotCallTool?.(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
if (!entry) throw new Error(`Missing sensor ${matchName}`);
const value = parseValue(entry.state);
if (value === null) throw new Error("Invalid sensor payload");
valueEl.textContent = String(value);
if (value >= 1200) setStatus("High", "#b91c1c");
else if (value >= 900) setStatus("Elevated", "#b45309");
else setStatus("Good", "#047857");
setUpdatedNow();
window.__nanobotSetCardLiveContent?.(script, {
kind: 'sensor',
tool_name: toolName,
match_name: entry.name,
value,
display_value: String(value),
unit: entry.attributes?.unit_of_measurement || 'ppm',
status: statusEl.textContent || null,
updated_at: updatedEl.textContent || null,
});
} catch (err) {
valueEl.textContent = "--";
setStatus("Unavailable", "#b91c1c");
updatedEl.textContent = String(err);
window.__nanobotSetCardLiveContent?.(script, {
kind: 'sensor',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
match_name: matchName,
value: null,
display_value: '--',
unit: 'ppm',
status: 'Unavailable',
updated_at: String(err),
error: String(err),
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => {
void refresh();
}, INTERVAL_MS);
})();
</script>

View file

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

View file

@ -1,273 +1,23 @@
<div data-calendar-card="today" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 640px;">
<div data-calendar-card="today" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: var(--theme-card-neutral-bg); color: var(--theme-card-neutral-text); border: 1px solid var(--theme-card-neutral-border); border-radius: 16px; box-shadow: var(--theme-shadow); padding: 24px; max-width: 640px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom: 12px;">
<div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Today's Calendar</h2>
<div data-cal-subtitle style="margin-top:4px; font-size:0.84rem; color:#6b7280;">Loading calendars...</div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--theme-card-neutral-text);">Today's Calendar</h2>
<div data-cal-subtitle style="margin-top:4px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">Loading calendars...</div>
</div>
<span data-cal-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
<span data-cal-status style="font-size:0.82rem; color:var(--theme-status-muted);">Loading...</span>
</div>
<div style="font-size:0.88rem; color:#6b7280; margin-bottom:10px;">
Date: <strong data-cal-date style="color:#374151;">--</strong>
<div style="font-size:0.88rem; color:var(--theme-card-neutral-muted); margin-bottom:10px;">
Date: <strong data-cal-date style="color:var(--theme-card-neutral-subtle);">--</strong>
</div>
<div data-cal-empty style="display:none; padding:12px; border-radius:10px; background:#f8fafc; color:#475569; font-size:0.94rem;">
<div data-cal-empty style="display:none; padding:12px; border-radius:10px; background:color-mix(in srgb, var(--theme-card-neutral-border) 40%, white); color:var(--theme-card-neutral-subtle); font-size:0.94rem;">
No events for today.
</div>
<ul data-cal-list style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px;"></ul>
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
<div style="margin-top:12px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">
Updated: <span data-cal-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const statusEl = root.querySelector("[data-cal-status]");
const subtitleEl = root.querySelector("[data-cal-subtitle]");
const dateEl = root.querySelector("[data-cal-date]");
const listEl = root.querySelector("[data-cal-list]");
const emptyEl = root.querySelector("[data-cal-empty]");
const updatedEl = root.querySelector("[data-cal-updated]");
if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) ||
!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredCalendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const dayBounds = () => {
const now = new Date();
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(now);
end.setHours(23, 59, 59, 999);
return { start, end };
};
const formatDateHeader = () => {
const now = new Date();
return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" });
};
const normalizeDateValue = (value) => {
if (typeof value === "string") return value;
if (value && typeof value === "object") {
if (typeof value.dateTime === "string") return value.dateTime;
if (typeof value.date === "string") return value.date;
}
return "";
};
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return "--:--";
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day";
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return "--:--";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const isAllDay = (start, end) => {
const s = normalizeDateValue(start);
const e = normalizeDateValue(end);
return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e);
};
const eventSortKey = (evt) => {
const raw = normalizeDateValue(evt && evt.start);
const t = new Date(raw).getTime();
return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER;
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const resolveToolConfig = async () => {
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
if (!window.__nanobotListTools) {
return { name: fallbackName, calendars: configuredCalendarNames };
}
try {
const tools = await window.__nanobotListTools();
const tool = Array.isArray(tools)
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return {
name: tool?.name || fallbackName,
calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues,
};
} catch {
return { name: fallbackName, calendars: configuredCalendarNames };
}
};
const renderEvents = (events) => {
listEl.innerHTML = "";
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = "block";
return;
}
emptyEl.style.display = "none";
for (const evt of events) {
const li = document.createElement("li");
li.style.border = "1px solid #e5e7eb";
li.style.borderRadius = "10px";
li.style.padding = "10px 12px";
li.style.background = "#ffffff";
const summary = document.createElement("div");
summary.style.fontSize = "0.96rem";
summary.style.fontWeight = "600";
summary.style.color = "#111827";
summary.textContent = String(evt.summary || "(No title)");
const timing = document.createElement("div");
timing.style.marginTop = "4px";
timing.style.fontSize = "0.86rem";
timing.style.color = "#475569";
if (isAllDay(evt.start, evt.end)) {
timing.textContent = "All day";
} else {
timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`;
}
li.appendChild(summary);
li.appendChild(timing);
if (evt.location) {
const location = document.createElement("div");
location.style.marginTop = "4px";
location.style.fontSize = "0.84rem";
location.style.color = "#6b7280";
location.textContent = `Location: ${String(evt.location)}`;
li.appendChild(location);
}
listEl.appendChild(li);
}
};
const refresh = async () => {
dateEl.textContent = formatDateHeader();
setStatus("Refreshing", "#6b7280");
try {
const toolConfig = await resolveToolConfig();
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) {
subtitleEl.textContent = "No Home Assistant calendars available";
renderEvents([]);
updatedEl.textContent = new Date().toLocaleTimeString();
setStatus("OK", "#047857");
updateLiveContent({
kind: 'calendar_today',
tool_name: toolConfig.name,
calendar_names: [],
updated_at: updatedEl.textContent || null,
event_count: 0,
events: [],
});
return;
}
subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`;
const allEvents = [];
for (const calendarName of toolConfig.calendars) {
const toolResult = await window.__nanobotCallTool?.(toolConfig.name, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const evt of events) {
allEvents.push({ ...evt, _calendarName: calendarName });
}
}
allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b));
renderEvents(allEvents);
updatedEl.textContent = new Date().toLocaleTimeString();
setStatus("Daily", "#047857");
updateLiveContent({
kind: 'calendar_today',
tool_name: toolConfig.name,
calendar_names: toolConfig.calendars,
updated_at: updatedEl.textContent || null,
event_count: allEvents.length,
events: allEvents.map((evt) => ({
summary: String(evt.summary || '(No title)'),
start: normalizeDateValue(evt.start) || null,
end: normalizeDateValue(evt.end) || null,
location: typeof evt.location === 'string' ? evt.location : null,
calendar_name: evt._calendarName || null,
})),
});
} catch (err) {
subtitleEl.textContent = "Could not load Home Assistant calendar";
renderEvents([]);
updatedEl.textContent = String(err);
setStatus("Unavailable", "#b91c1c");
updateLiveContent({
kind: 'calendar_today',
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
calendar_names: configuredCalendarNames,
updated_at: String(err),
event_count: 0,
events: [],
error: String(err),
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => {
void refresh();
}, REFRESH_MS);
})();
</script>

View file

@ -0,0 +1,243 @@
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 tempEl = root.querySelector("[data-weather-temp]");
const unitEl = root.querySelector("[data-weather-unit]");
const condEl = root.querySelector("[data-weather-condition]");
const humidityEl = root.querySelector("[data-weather-humidity]");
const windEl = root.querySelector("[data-weather-wind]");
const pressureEl = root.querySelector("[data-weather-pressure]");
const updatedEl = root.querySelector("[data-weather-updated]");
const statusEl = root.querySelector("[data-weather-status]");
if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
!(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
!(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const nowLabel = () => new Date().toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const refresh = async () => {
setStatus("Refreshing", "var(--theme-status-muted)");
try {
const toolName = await resolveToolName();
const toolResult = await host.callTool(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [temperatureName]);
const humidityEntry = findEntry(entries, [humidityName]);
const pressureEntry = findEntry(entries, [pressureName]);
const windEntry = findEntry(entries, [windName]);
const tempNum = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
condEl.textContent = conditionLabel;
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
const pressureNum = Number(pressureEntry?.state);
pressureEl.textContent = Number.isFinite(pressureNum)
? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
: "--";
updatedEl.textContent = nowLabel();
setStatus("Live", "var(--theme-status-live)");
host.setLiveContent({
kind: 'weather',
tool_name: toolName,
provider_prefix: providerPrefix,
temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
pressure: pressureEl.textContent || null,
condition: condEl.textContent || null,
updated_at: updatedEl.textContent || null,
status: statusEl.textContent || null,
});
} catch (err) {
setStatus("Unavailable", "var(--theme-status-danger)");
updatedEl.textContent = String(err);
host.setLiveContent({
kind: 'weather',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
provider_prefix: providerPrefix,
temperature: null,
humidity: null,
wind: null,
pressure: null,
condition: null,
updated_at: String(err),
status: 'Unavailable',
error: String(err),
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
void refresh();
__setInterval(() => {
void refresh();
}, REFRESH_MS);
return {
destroy() {
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
for (const cleanup of __cleanup.splice(0)) cleanup();
},
};
}

View file

@ -1,250 +1,23 @@
<div data-weather-card="01545" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 560px;">
<div data-weather-card="01545" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: var(--theme-card-neutral-bg); color: var(--theme-card-neutral-text); border: 1px solid var(--theme-card-neutral-border); border-radius: 16px; box-shadow: var(--theme-shadow); padding: 24px; max-width: 560px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom: 10px;">
<div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Weather 01545</h2>
<div style="margin-top:4px; font-size:0.84rem; color:#6b7280;">Source: Home Assistant (weather.openweathermap)</div>
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--theme-card-neutral-text);">Weather 01545</h2>
<div style="margin-top:4px; font-size:0.84rem; color:var(--theme-card-neutral-muted);">Source: Home Assistant (weather.openweathermap)</div>
</div>
<span data-weather-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
<span data-weather-status style="font-size:0.82rem; color:var(--theme-status-muted);">Loading...</span>
</div>
<div style="display:flex; align-items:baseline; gap:10px; margin-bottom:10px;">
<span data-weather-temp style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
<span data-weather-unit style="font-size:1rem; font-weight:600; color:#4b5563;">°F</span>
<span data-weather-unit style="font-size:1rem; font-weight:600; color:var(--theme-card-neutral-subtle);">°F</span>
</div>
<div data-weather-condition style="font-size:1rem; font-weight:600; color:#1f2937; margin-bottom:8px; text-transform:capitalize;">--</div>
<div data-weather-condition style="font-size:1rem; font-weight:600; color:var(--theme-card-neutral-text); margin-bottom:8px; text-transform:capitalize;">--</div>
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:8px; font-size:0.9rem; color:#374151;">
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:8px; font-size:0.9rem; color:var(--theme-card-neutral-subtle);">
<div>Humidity: <strong data-weather-humidity>--</strong></div>
<div>Wind: <strong data-weather-wind>--</strong></div>
<div>Pressure: <strong data-weather-pressure>--</strong></div>
<div>Updated: <strong data-weather-updated>--</strong></div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const tempEl = root.querySelector("[data-weather-temp]");
const unitEl = root.querySelector("[data-weather-unit]");
const condEl = root.querySelector("[data-weather-condition]");
const humidityEl = root.querySelector("[data-weather-humidity]");
const windEl = root.querySelector("[data-weather-wind]");
const pressureEl = root.querySelector("[data-weather-pressure]");
const updatedEl = root.querySelector("[data-weather-updated]");
const statusEl = root.querySelector("[data-weather-status]");
if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
!(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
!(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
return;
}
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
const refreshMsRaw = Number(state.refresh_ms);
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const nowLabel = () => new Date().toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const refresh = async () => {
setStatus("Refreshing", "#6b7280");
try {
const toolName = await resolveToolName();
const toolResult = await window.__nanobotCallTool?.(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [temperatureName]);
const humidityEntry = findEntry(entries, [humidityName]);
const pressureEntry = findEntry(entries, [pressureName]);
const windEntry = findEntry(entries, [windName]);
const tempNum = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
condEl.textContent = conditionLabel;
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
const pressureNum = Number(pressureEntry?.state);
pressureEl.textContent = Number.isFinite(pressureNum)
? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
: "--";
updatedEl.textContent = nowLabel();
setStatus("Live", "#047857");
window.__nanobotSetCardLiveContent?.(script, {
kind: 'weather',
tool_name: toolName,
provider_prefix: providerPrefix,
temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
pressure: pressureEl.textContent || null,
condition: condEl.textContent || null,
updated_at: updatedEl.textContent || null,
status: statusEl.textContent || null,
});
} catch (err) {
setStatus("Unavailable", "#b91c1c");
updatedEl.textContent = String(err);
window.__nanobotSetCardLiveContent?.(script, {
kind: 'weather',
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
provider_prefix: providerPrefix,
temperature: null,
humidity: null,
wind: null,
pressure: null,
condition: null,
updated_at: String(err),
status: 'Unavailable',
error: String(err),
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => {
void refresh();
}, REFRESH_MS);
})();
</script>

View file

@ -0,0 +1,309 @@
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 subtitleEl = root.querySelector('[data-sensor-subtitle]');
const valueEl = root.querySelector('[data-sensor-value]');
const unitEl = root.querySelector('[data-sensor-unit]');
const statusEl = root.querySelector('[data-sensor-status]');
const updatedEl = root.querySelector('[data-sensor-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
const matchNames = Array.isArray(state.match_names)
? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const searchTerms = Array.isArray(state.search_terms)
? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
const decimalsRaw = Number(state.value_decimals);
const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
const goodMax = Number(thresholds.good_max);
const elevatedMax = Number(thresholds.elevated_max);
const alertOnly = state.alert_only === true;
const elevatedScoreRaw = Number(state.alert_score_elevated);
const highScoreRaw = Number(state.alert_score_high);
const elevatedScore = Number.isFinite(elevatedScoreRaw) ? elevatedScoreRaw : 88;
const highScore = Number.isFinite(highScoreRaw) ? highScoreRaw : 98;
subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
unitEl.textContent = fallbackUnit || '--';
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const renderValue = (value) => {
if (!Number.isFinite(value)) return '--';
return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const allMatchNames = [matchName, ...matchNames].filter(Boolean);
const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
const scoreEntry = (entry) => {
if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
const entryName = normalizeText(entry.name);
let score = 0;
for (const candidate of allMatchNames) {
const normalized = normalizeText(candidate);
if (!normalized) continue;
if (entryName === normalized) score += 100;
else if (entryName.includes(normalized)) score += 40;
}
for (const term of allSearchTerms) {
const normalized = normalizeText(term);
if (!normalized) continue;
if (entryName.includes(normalized)) score += 10;
}
const entryDeviceClass = normalizeText(entry.attributes?.device_class);
if (deviceClass && entryDeviceClass === deviceClass) score += 30;
if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
return score;
};
const findSensorEntry = (entries) => {
const scored = entries
.map((entry) => ({ entry, score: scoreEntry(entry) }))
.filter((item) => Number.isFinite(item.score) && item.score > 0)
.sort((left, right) => right.score - left.score);
return scored.length > 0 ? scored[0].entry : null;
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const classify = (value) => {
if (!Number.isFinite(value)) return { label: 'Unavailable', color: 'var(--theme-status-danger)' };
if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: 'var(--theme-status-danger)' };
if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: 'var(--theme-status-warning)' };
return { label: 'Good', color: 'var(--theme-status-live)' };
};
const computeAlertVisibility = (statusLabel) => {
if (!alertOnly) return false;
return statusLabel === 'Good' || statusLabel === 'Unavailable';
};
const computeAlertScore = (statusLabel, value) => {
if (!alertOnly) return Number.isFinite(value) ? 0 : null;
if (statusLabel === 'High') return highScore;
if (statusLabel === 'Elevated') return elevatedScore;
return 0;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
valueEl.textContent = '--';
setStatus('No tool', 'var(--theme-status-danger)');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: null,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'No tool',
updated_at: errorText,
error: errorText,
});
return;
}
setStatus('Refreshing', 'var(--theme-status-muted)');
try {
const toolResult = await host.callTool(resolvedToolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = findSensorEntry(entries);
if (!entry) throw new Error('Matching sensor not found in live context');
const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
const numericValue = Number(entry.state);
const renderedValue = renderValue(numericValue);
valueEl.textContent = renderedValue;
const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
unitEl.textContent = unit;
subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
const status = classify(numericValue);
setStatus(status.label, status.color);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: entry.name || matchName || matchNames[0] || null,
value: Number.isFinite(numericValue) ? numericValue : null,
display_value: renderedValue,
unit,
status: status.label,
updated_at: updatedText,
score: computeAlertScore(status.label, numericValue),
hidden: computeAlertVisibility(status.label),
});
} catch (error) {
const errorText = String(error);
valueEl.textContent = '--';
setStatus('Unavailable', 'var(--theme-status-danger)');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'Unavailable',
updated_at: errorText,
error: errorText,
score: alertOnly ? 0 : null,
hidden: alertOnly,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
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

@ -1,7 +1,7 @@
{
"key": "sensor-live",
"title": "Live Sensor",
"notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. The card title comes from the feed header, not the template body.",
"notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. Set alert_only=true to hide the card while readings are good and surface it only when elevated/high, with optional alert_score_elevated/alert_score_high overrides. The card title comes from the feed header, not the template body.",
"example_state": {
"subtitle": "Home Assistant sensor",
"tool_name": "mcp_home_assistant_GetLiveContext",
@ -13,7 +13,8 @@
"thresholds": {
"good_max": 900,
"elevated_max": 1200
}
},
"alert_only": false
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
"updated_at": "2026-03-11T19:18:04.632189+00:00"

View file

@ -1,287 +1,15 @@
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div style="display:flex; align-items:flex-end; gap:8px;">
<span data-sensor-value style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">--</span>
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.28rem;">--</span>
</div>
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:var(--theme-card-neutral-muted);">
Updated <span data-sensor-updated>--</span>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-sensor-subtitle]');
const valueEl = root.querySelector('[data-sensor-value]');
const unitEl = root.querySelector('[data-sensor-unit]');
const statusEl = root.querySelector('[data-sensor-status]');
const updatedEl = root.querySelector('[data-sensor-updated]');
if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
const matchNames = Array.isArray(state.match_names)
? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const searchTerms = Array.isArray(state.search_terms)
? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
const decimalsRaw = Number(state.value_decimals);
const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
const goodMax = Number(thresholds.good_max);
const elevatedMax = Number(thresholds.elevated_max);
subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
unitEl.textContent = fallbackUnit || '--';
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const renderValue = (value) => {
if (!Number.isFinite(value)) return '--';
return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const allMatchNames = [matchName, ...matchNames].filter(Boolean);
const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
const scoreEntry = (entry) => {
if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
const entryName = normalizeText(entry.name);
let score = 0;
for (const candidate of allMatchNames) {
const normalized = normalizeText(candidate);
if (!normalized) continue;
if (entryName === normalized) score += 100;
else if (entryName.includes(normalized)) score += 40;
}
for (const term of allSearchTerms) {
const normalized = normalizeText(term);
if (!normalized) continue;
if (entryName.includes(normalized)) score += 10;
}
const entryDeviceClass = normalizeText(entry.attributes?.device_class);
if (deviceClass && entryDeviceClass === deviceClass) score += 30;
if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
return score;
};
const findSensorEntry = (entries) => {
const scored = entries
.map((entry) => ({ entry, score: scoreEntry(entry) }))
.filter((item) => Number.isFinite(item.score) && item.score > 0)
.sort((left, right) => right.score - left.score);
return scored.length > 0 ? scored[0].entry : null;
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const classify = (value) => {
if (!Number.isFinite(value)) return { label: 'Unavailable', color: '#b91c1c' };
if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: '#b91c1c' };
if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: '#b45309' };
return { label: 'Good', color: '#047857' };
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
valueEl.textContent = '--';
setStatus('No tool', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: null,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'No tool',
updated_at: errorText,
error: errorText,
});
return;
}
setStatus('Refreshing', '#6b7280');
try {
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
const entry = findSensorEntry(entries);
if (!entry) throw new Error('Matching sensor not found in live context');
const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
const numericValue = Number(entry.state);
const renderedValue = renderValue(numericValue);
valueEl.textContent = renderedValue;
const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
unitEl.textContent = unit;
subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
const status = classify(numericValue);
setStatus(status.label, status.color);
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
updatedEl.textContent = updatedText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: entry.name || matchName || matchNames[0] || null,
value: Number.isFinite(numericValue) ? numericValue : null,
display_value: renderedValue,
unit,
status: status.label,
updated_at: updatedText,
});
} catch (error) {
const errorText = String(error);
valueEl.textContent = '--';
setStatus('Unavailable', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({
kind: 'sensor',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
match_name: matchName || matchNames[0] || null,
value: null,
display_value: '--',
unit: fallbackUnit || null,
status: 'Unavailable',
updated_at: errorText,
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>

View file

@ -0,0 +1,494 @@
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 dateEl = root.querySelector('[data-today-date]');
const tempEl = root.querySelector('[data-today-temp]');
const unitEl = root.querySelector('[data-today-unit]');
const feelsLikeEl = root.querySelector('[data-today-feels-like]');
const aqiChipEl = root.querySelector('[data-today-aqi-chip]');
const countEl = root.querySelector('[data-today-events-count]');
const emptyEl = root.querySelector('[data-today-empty]');
const listEl = root.querySelector('[data-today-events-list]');
const updatedEl = root.querySelector('[data-today-updated]');
if (!(dateEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(feelsLikeEl instanceof HTMLElement) || !(aqiChipEl instanceof HTMLElement) || !(countEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : '';
const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const weatherPrefix = typeof state.weather_prefix === 'string' ? state.weather_prefix.trim() : 'OpenWeatherMap';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const apparentTemperatureName = typeof state.apparent_temperature_name === 'string' ? state.apparent_temperature_name.trim() : '';
const aqiName = typeof state.aqi_name === 'string' ? state.aqi_name.trim() : '';
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 6;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'Nothing scheduled today.';
emptyEl.textContent = emptyText;
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
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 formatHeaderDate = (value) => value.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
const formatTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--:--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatEventDay = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return '--';
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString([], { weekday: 'short' });
};
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const eventTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.MAX_SAFE_INTEGER;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const findByDeviceClass = (entries, deviceClass) => entries.find((entry) => normalizeText(entry.attributes?.device_class) === normalizeText(deviceClass)) || null;
const parseNumericValue = (entry) => {
const value = Number(entry?.state);
return Number.isFinite(value) ? value : null;
};
const formatMetric = (value, unit) => {
if (!Number.isFinite(value)) return '--';
return `${Math.round(value)}${unit ? ` ${unit}` : ''}`;
};
const buildAqiStyle = (aqiValue) => {
if (!Number.isFinite(aqiValue)) {
return { label: 'AQI --', tone: 'Unavailable', background: 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)', color: 'var(--theme-card-neutral-subtle)' };
}
if (aqiValue <= 50) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Good', background: 'color-mix(in srgb, var(--theme-status-live) 16%, white)', color: 'var(--theme-status-live)' };
if (aqiValue <= 100) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Moderate', background: 'color-mix(in srgb, var(--theme-status-warning) 16%, white)', color: 'var(--theme-status-warning)' };
if (aqiValue <= 150) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Sensitive', background: 'color-mix(in srgb, var(--theme-status-warning) 24%, white)', color: 'var(--theme-status-warning)' };
if (aqiValue <= 200) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Unhealthy', background: 'color-mix(in srgb, var(--theme-status-danger) 14%, white)', color: 'var(--theme-status-danger)' };
if (aqiValue <= 300) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Very unhealthy', background: 'color-mix(in srgb, var(--theme-accent) 14%, white)', color: 'var(--theme-accent-strong)' };
return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Hazardous', background: 'color-mix(in srgb, var(--theme-accent-strong) 18%, white)', color: 'var(--theme-accent-strong)' };
};
const startTime = (value) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.NaN;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00` : raw;
const time = new Date(normalized).getTime();
return Number.isFinite(time) ? time : Number.NaN;
};
const computeTodayScore = (events, status) => {
if (status === 'Unavailable') return 5;
if (!Array.isArray(events) || events.length === 0) {
return status === 'Partial' ? 18 : 24;
}
const now = Date.now();
const soonestMs = events
.map((event) => startTime(event?.start))
.filter((time) => Number.isFinite(time) && time >= now)
.sort((left, right) => left - right)[0];
const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null;
let score = 34;
if (soonestHours !== null) {
if (soonestHours <= 1) score = 80;
else if (soonestHours <= 3) score = 70;
else if (soonestHours <= 8) score = 58;
else if (soonestHours <= 24) score = 46;
}
score += Math.min(events.length, 3) * 3;
if (status === 'Partial') score -= 8;
return Math.max(0, Math.min(100, Math.round(score)));
};
const renderEvents = (events) => {
listEl.innerHTML = '';
if (!Array.isArray(events) || events.length === 0) {
emptyEl.style.display = 'block';
countEl.textContent = 'No events';
return;
}
emptyEl.style.display = 'none';
countEl.textContent = `${events.length} ${events.length === 1 ? 'event' : 'events'}`;
for (const [index, event] of events.slice(0, maxEvents).entries()) {
const item = document.createElement('li');
item.style.padding = index === 0 ? '10px 0 0' : '10px 0 0';
item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
const timing = document.createElement('div');
timing.style.fontSize = '0.76rem';
timing.style.lineHeight = '1.2';
timing.style.textTransform = 'uppercase';
timing.style.letterSpacing = '0.05em';
timing.style.color = 'var(--theme-card-neutral-muted)';
timing.style.fontWeight = '700';
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatEventDay(event.start)} · ${formatTime(event.start)}`;
timing.textContent = timeLabel;
item.appendChild(timing);
const summary = document.createElement('div');
summary.style.marginTop = '4px';
summary.style.fontSize = '0.95rem';
summary.style.lineHeight = '1.35';
summary.style.color = 'var(--theme-card-neutral-text)';
summary.style.fontWeight = '700';
summary.textContent = String(event.summary || '(No title)');
item.appendChild(summary);
listEl.appendChild(item);
}
};
const resolveToolName = async (configuredName, pattern, fallbackName) => {
if (configuredName) return configuredName;
if (!host.listTools) return fallbackName;
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => pattern.test(String(item?.name || '')))
: null;
return tool?.name || fallbackName;
} catch {
return fallbackName;
}
};
const resolveCalendarNames = async (toolName) => {
if (calendarNames.length > 0) return calendarNames;
if (!host.listTools) return calendarNames;
try {
const tools = await host.listTools();
const tool = Array.isArray(tools)
? tools.find((item) => String(item?.name || '') === toolName)
: null;
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
: [];
return enumValues;
} catch {
return calendarNames;
}
};
const loadWeather = async (toolName) => {
const toolResult = await host.callTool(toolName, {});
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const temperatureEntry = findEntry(entries, [
temperatureName,
`${weatherPrefix} Temperature`,
]) || findByDeviceClass(entries, 'temperature');
const apparentEntry = findEntry(entries, [
apparentTemperatureName,
`${weatherPrefix} Apparent temperature`,
`${weatherPrefix} Feels like`,
]);
const aqiEntry = findEntry(entries, [
aqiName,
'Air quality index',
]) || findByDeviceClass(entries, 'aqi');
return {
toolName,
temperature: parseNumericValue(temperatureEntry),
temperatureUnit: String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'),
feelsLike: parseNumericValue(apparentEntry),
feelsLikeUnit: String(apparentEntry?.attributes?.unit_of_measurement || temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'),
aqi: parseNumericValue(aqiEntry),
};
};
const loadEvents = async (toolName) => {
const selectedCalendars = await resolveCalendarNames(toolName);
if (!toolName) throw new Error('Calendar tool unavailable');
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
throw new Error('No calendars configured');
}
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setHours(23, 59, 59, 999);
const endExclusiveTime = end.getTime() + 1;
const allEvents = [];
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolName, {
calendar: calendarName,
range: 'today',
});
const events = extractEvents(toolResult);
for (const event of events) {
const startTime = eventTime(event?.start);
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
allEvents.push({ ...event, _calendarName: calendarName });
}
}
allEvents.sort((left, right) => eventTime(left?.start) - eventTime(right?.start));
return {
toolName,
calendarNames: selectedCalendars,
events: allEvents.slice(0, maxEvents),
};
};
const refresh = async () => {
const now = new Date();
dateEl.textContent = formatHeaderDate(now);
const [weatherToolName, calendarToolName] = await Promise.all([
resolveToolName(configuredWeatherToolName, /(^|_)GetLiveContext$/i, 'mcp_home_assistant_GetLiveContext'),
resolveToolName(configuredCalendarToolName, /(^|_)calendar_get_events$/i, 'mcp_home_assistant_calendar_get_events'),
]);
const [weatherResult, eventsResult] = await Promise.allSettled([
loadWeather(weatherToolName),
loadEvents(calendarToolName),
]);
const snapshot = {
kind: 'today_briefing',
date_label: dateEl.textContent || null,
weather_tool_name: weatherToolName || null,
calendar_tool_name: calendarToolName || null,
updated_at: null,
status: null,
weather: null,
events: [],
errors: {},
};
let successCount = 0;
if (weatherResult.status === 'fulfilled') {
successCount += 1;
const weather = weatherResult.value;
tempEl.textContent = Number.isFinite(weather.temperature) ? String(Math.round(weather.temperature)) : '--';
unitEl.textContent = weather.temperatureUnit || '°F';
feelsLikeEl.textContent = formatMetric(weather.feelsLike, weather.feelsLikeUnit);
const aqiStyle = buildAqiStyle(weather.aqi);
aqiChipEl.textContent = `${aqiStyle.tone} · ${aqiStyle.label}`;
aqiChipEl.style.background = aqiStyle.background;
aqiChipEl.style.color = aqiStyle.color;
snapshot.weather = {
temperature: Number.isFinite(weather.temperature) ? Math.round(weather.temperature) : null,
temperature_unit: weather.temperatureUnit || null,
feels_like: Number.isFinite(weather.feelsLike) ? Math.round(weather.feelsLike) : null,
aqi: Number.isFinite(weather.aqi) ? Math.round(weather.aqi) : null,
aqi_tone: aqiStyle.tone,
};
} else {
tempEl.textContent = '--';
unitEl.textContent = '°F';
feelsLikeEl.textContent = '--';
aqiChipEl.textContent = 'AQI unavailable';
aqiChipEl.style.background = 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)';
aqiChipEl.style.color = 'var(--theme-card-neutral-subtle)';
snapshot.errors.weather = String(weatherResult.reason);
}
if (eventsResult.status === 'fulfilled') {
successCount += 1;
const eventsData = eventsResult.value;
renderEvents(eventsData.events);
snapshot.events = eventsData.events.map((event) => ({
summary: String(event.summary || '(No title)'),
start: normalizeDateValue(event.start) || null,
end: normalizeDateValue(event.end) || null,
all_day: isAllDay(event.start, event.end),
}));
} else {
renderEvents([]);
countEl.textContent = 'Unavailable';
emptyEl.style.display = 'block';
emptyEl.textContent = 'Calendar unavailable.';
snapshot.errors.events = String(eventsResult.reason);
}
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
updatedEl.textContent = updatedText;
snapshot.updated_at = updatedText;
if (successCount === 2) {
snapshot.status = 'Ready';
} else if (successCount === 1) {
snapshot.status = 'Partial';
} else {
snapshot.status = 'Unavailable';
}
snapshot.score = computeTodayScore(snapshot.events, snapshot.status);
updateLiveContent(snapshot);
};
host.setRefreshHandler(() => {
void refresh();
});
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,21 @@
{
"key": "today-briefing-live",
"title": "Today Briefing",
"notes": "Single-card day overview for local use. Fill template_state with weather_tool_name (defaults to Home Assistant GetLiveContext), calendar_tool_name (defaults to calendar_get_events), calendar_names, weather_prefix or exact sensor names for temperature/apparent/AQI, max_events, refresh_ms, and empty_text.",
"example_state": {
"weather_tool_name": "mcp_home_assistant_GetLiveContext",
"calendar_tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"weather_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"apparent_temperature_name": "OpenWeatherMap Apparent temperature",
"aqi_name": "Worcester Summer St Air quality index",
"max_events": 6,
"refresh_ms": 900000,
"empty_text": "Nothing scheduled today."
},
"created_at": "2026-03-15T22:35:00+00:00",
"updated_at": "2026-03-15T22:35:00+00:00"
}

View file

@ -0,0 +1,32 @@
<div data-today-briefing-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px; border:1px solid var(--theme-card-neutral-border);">
<div data-today-date style="font-size:0.86rem; line-height:1.2; letter-spacing:0.02em; color:var(--theme-card-neutral-muted); font-weight:700; margin-bottom:10px; white-space:nowrap;">Today</div>
<div>
<div style="font-size:0.76rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.05em; color:var(--theme-card-neutral-muted); font-weight:700;">Weather</div>
<div style="display:flex; align-items:flex-end; gap:8px; margin-top:10px;">
<span data-today-temp style="font-size:3rem; line-height:0.92; letter-spacing:-0.05em; font-weight:800;">--</span>
<span data-today-unit style="font-size:1.05rem; line-height:1.1; color:var(--theme-card-neutral-subtle); font-weight:700; padding-bottom:0.32rem;">°F</span>
</div>
<div style="display:flex; flex-wrap:wrap; align-items:baseline; gap:8px; margin-top:8px; font-size:0.92rem; line-height:1.3;">
<span style="color:var(--theme-card-neutral-muted); font-weight:700;">Feels like</span>
<span data-today-feels-like style="color:var(--theme-card-neutral-text); font-weight:800;">--</span>
</div>
<div style="margin-top:8px;">
<span data-today-aqi-chip style="display:inline-flex; padding:4px 8px; background:color-mix(in srgb, var(--theme-card-neutral-border) 65%, white); color:var(--theme-card-neutral-subtle); font-size:0.78rem; line-height:1.1; font-weight:800; text-transform:uppercase; letter-spacing:0.05em;">AQI --</span>
</div>
</div>
<div style="margin-top:14px; padding-top:12px; border-top:1px solid color-mix(in srgb, var(--theme-card-neutral-border) 82%, transparent);">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px;">
<div style="font-size:0.76rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.05em; color:var(--theme-card-neutral-muted); font-weight:700;">Agenda</div>
<div data-today-events-count style="font-size:0.82rem; line-height:1.2; color:var(--theme-card-neutral-subtle); font-weight:700;">--</div>
</div>
<div data-today-empty style="display:none; margin-top:8px; color:var(--theme-card-neutral-subtle); font-size:0.96rem; line-height:1.4;">Nothing scheduled today.</div>
<ul data-today-events-list style="list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
</div>
<div style="margin-top:10px; font-size:0.8rem; line-height:1.3; color:var(--theme-card-neutral-muted);">Updated <span data-today-updated>--</span></div>
</div>

View file

@ -0,0 +1,708 @@
const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"];
const TASK_ACTION_LABELS = {
backlog: "Backlog",
committed: "Commit",
"in-progress": "Start",
blocked: "Block",
done: "Done",
canceled: "Cancel",
};
const TASK_LANE_LABELS = {
backlog: "Backlog",
committed: "Committed",
"in-progress": "In Progress",
blocked: "Blocked",
done: "Done",
canceled: "Canceled",
};
const TASK_LANE_THEMES = {
backlog: {
accent: "#5f7884",
accentSoft: "rgba(95, 120, 132, 0.13)",
muted: "#6b7e87",
buttonInk: "#294a57",
},
committed: {
accent: "#8a6946",
accentSoft: "rgba(138, 105, 70, 0.14)",
muted: "#7f664a",
buttonInk: "#5a3b19",
},
"in-progress": {
accent: "#4f7862",
accentSoft: "rgba(79, 120, 98, 0.13)",
muted: "#5e7768",
buttonInk: "#214437",
},
blocked: {
accent: "#a55f4b",
accentSoft: "rgba(165, 95, 75, 0.13)",
muted: "#906659",
buttonInk: "#6c2f21",
},
done: {
accent: "#6d7f58",
accentSoft: "rgba(109, 127, 88, 0.12)",
muted: "#6b755d",
buttonInk: "#304121",
},
canceled: {
accent: "#7b716a",
accentSoft: "rgba(123, 113, 106, 0.12)",
muted: "#7b716a",
buttonInk: "#433932",
},
};
function isTaskLane(value) {
return typeof value === "string" && TASK_LANES.includes(value);
}
function normalizeTag(raw) {
const trimmed = String(raw || "")
.trim()
.replace(/^#+/, "")
.replace(/\s+/g, "-");
return trimmed ? `#${trimmed}` : "";
}
function normalizeTags(raw) {
if (!Array.isArray(raw)) return [];
const seen = new Set();
const tags = [];
for (const value of raw) {
const tag = normalizeTag(value);
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
return tags;
}
function normalizeMetadata(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const entries = Object.entries(raw).filter(([, value]) => value !== undefined);
return Object.fromEntries(entries);
}
function normalizeTask(raw, fallbackTitle) {
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
const lane = isTaskLane(record.lane) ? record.lane : "backlog";
return {
taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "",
taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "",
title:
typeof record.title === "string" && record.title.trim()
? record.title.trim()
: fallbackTitle || "(Untitled task)",
lane,
created: typeof record.created === "string" ? record.created.trim() : "",
updated: typeof record.updated === "string" ? record.updated.trim() : "",
due: typeof record.due === "string" ? record.due.trim() : "",
tags: normalizeTags(record.tags),
body: typeof record.body === "string" ? record.body : "",
metadata: normalizeMetadata(record.metadata),
};
}
function normalizeTaskFromPayload(raw, fallback) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback;
return {
taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath,
taskKey: fallback.taskKey,
title:
typeof raw.title === "string" && raw.title.trim()
? raw.title.trim()
: fallback.title,
lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane,
created: typeof raw.created === "string" ? raw.created.trim() : fallback.created,
updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated,
due: typeof raw.due === "string" ? raw.due.trim() : fallback.due,
tags: normalizeTags(raw.tags),
body: typeof raw.body === "string" ? raw.body : fallback.body,
metadata: normalizeMetadata(raw.metadata),
};
}
function parseToolPayload(result) {
if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") {
return result.parsed;
}
const raw = typeof result?.content === "string" ? result.content : "";
if (!raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function dueScore(hoursUntilDue) {
if (hoursUntilDue <= 0) return 100;
if (hoursUntilDue <= 6) return 96;
if (hoursUntilDue <= 24) return 92;
if (hoursUntilDue <= 72) return 82;
if (hoursUntilDue <= 168) return 72;
return 62;
}
function ageScore(ageDays) {
if (ageDays >= 30) return 80;
if (ageDays >= 21) return 76;
if (ageDays >= 14) return 72;
if (ageDays >= 7) return 68;
if (ageDays >= 3) return 62;
if (ageDays >= 1) return 58;
return 54;
}
function computeTaskScore(task) {
const now = Date.now();
const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : "";
const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN;
let score = 54;
if (Number.isFinite(dueMs)) {
score = dueScore((dueMs - now) / (60 * 60 * 1000));
} else {
const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN;
if (Number.isFinite(createdMs)) {
score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)));
}
}
if (task.lane === "committed") return Math.min(100, score + 1);
if (task.lane === "blocked") return Math.min(100, score + 4);
if (task.lane === "in-progress") return Math.min(100, score + 2);
return score;
}
function summarizeTaskBody(task) {
const trimmed = String(task.body || "").trim();
if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return "";
return trimmed;
}
function renderTaskBodyMarkdown(host, body) {
if (!body) return "";
return body
.replace(/\r\n?/g, "\n")
.trim()
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '<span class="task-card-ui__md-break" aria-hidden="true"></span>';
let className = "task-card-ui__md-line";
let content = trimmed;
let prefix = "";
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
className += " task-card-ui__md-line--heading";
content = headingMatch[2];
} else if (/^[-*]\s+/.test(trimmed)) {
className += " task-card-ui__md-line--bullet";
content = trimmed.replace(/^[-*]\s+/, "");
prefix = "\u2022 ";
} else if (/^\d+\.\s+/.test(trimmed)) {
className += " task-card-ui__md-line--bullet";
content = trimmed.replace(/^\d+\.\s+/, "");
prefix = "\u2022 ";
} else if (/^>\s+/.test(trimmed)) {
className += " task-card-ui__md-line--quote";
content = trimmed.replace(/^>\s+/, "");
prefix = "> ";
}
const html = host.renderMarkdown(content, { inline: true });
return `<span class="${className}">${
prefix ? `<span class="task-card-ui__md-prefix">${prefix}</span>` : ""
}${html}</span>`;
})
.join("");
}
function formatTaskDue(task) {
if (!task.due) return "";
const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return task.due;
if (task.due.includes("T")) {
const label = parsed.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return label.replace(/\s([AP]M)$/i, "$1");
}
return parsed.toLocaleDateString([], { month: "short", day: "numeric" });
}
function taskMoveOptions(lane) {
return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({
lane: targetLane,
label: TASK_ACTION_LABELS[targetLane],
}));
}
function taskLiveContent(task, errorText) {
return {
kind: "file_task",
exists: true,
task_path: task.taskPath || null,
task_key: task.taskKey || null,
title: task.title || null,
lane: task.lane,
created: task.created || null,
updated: task.updated || null,
due: task.due || null,
tags: task.tags,
metadata: task.metadata,
score: computeTaskScore(task),
status: task.lane,
error: errorText || null,
};
}
function autosizeEditor(editor) {
editor.style.height = "0px";
editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`;
}
export function mount({ root, item, state, host }) {
const cardEl = root.querySelector(".task-card-ui");
const laneToggleEl = root.querySelector(".task-card-ui__lane-button");
const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap");
const laneMenuEl = root.querySelector(".task-card-ui__lane-menu");
const statusEl = root.querySelector(".task-card-ui__status");
const titleEl = root.querySelector(".task-card-ui__title-slot");
const tagsEl = root.querySelector(".task-card-ui__tags");
const bodyEl = root.querySelector(".task-card-ui__body-slot");
const metaEl = root.querySelector(".task-card-ui__meta");
const dueEl = root.querySelector(".task-card-ui__chip");
if (
!(cardEl instanceof HTMLElement) ||
!(laneToggleEl instanceof HTMLButtonElement) ||
!(laneWrapEl instanceof HTMLElement) ||
!(laneMenuEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(titleEl instanceof HTMLElement) ||
!(tagsEl instanceof HTMLElement) ||
!(bodyEl instanceof HTMLElement) ||
!(metaEl instanceof HTMLElement) ||
!(dueEl instanceof HTMLElement)
) {
return;
}
let task = normalizeTask(state, item.title);
let busy = false;
let errorText = "";
let statusLabel = "";
let statusKind = "neutral";
let laneMenuOpen = false;
let editingField = null;
let holdTimer = null;
const clearHoldTimer = () => {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
};
const setStatus = (label, kind) => {
statusLabel = label || "";
statusKind = kind || "neutral";
statusEl.textContent = statusLabel;
statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`;
};
const publishLiveContent = () => {
host.setLiveContent(taskLiveContent(task, errorText));
};
const closeLaneMenu = () => {
laneMenuOpen = false;
laneToggleEl.setAttribute("aria-expanded", "false");
laneMenuEl.style.display = "none";
};
const openLaneMenu = () => {
if (busy || !task.taskPath) return;
laneMenuOpen = true;
laneToggleEl.setAttribute("aria-expanded", "true");
laneMenuEl.style.display = "flex";
};
const refreshFeed = () => {
closeLaneMenu();
host.requestFeedRefresh();
};
const setBusy = (nextBusy) => {
busy = !!nextBusy;
laneToggleEl.disabled = busy || !task.taskPath;
titleEl.style.pointerEvents = busy ? "none" : "";
bodyEl.style.pointerEvents = busy ? "none" : "";
tagsEl
.querySelectorAll("button")
.forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop")));
laneMenuEl.querySelectorAll("button").forEach((button) => {
button.disabled = busy;
});
};
const runBusyAction = async (action) => {
setBusy(true);
errorText = "";
setStatus("Saving", "neutral");
try {
await action();
setStatus("", "neutral");
} catch (error) {
console.error("Task card action failed", error);
errorText = error instanceof Error ? error.message : String(error);
setStatus("Unavailable", "error");
} finally {
setBusy(false);
render();
}
};
const callTaskBoard = async (argumentsValue) => {
const result = await host.callTool("task_board", argumentsValue);
const payload = parseToolPayload(result);
if (payload && typeof payload.error === "string" && payload.error.trim()) {
throw new Error(payload.error);
}
return payload;
};
const moveTask = async (lane) =>
runBusyAction(async () => {
const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane });
const nextTask = normalizeTaskFromPayload(payload?.task, {
...task,
lane,
taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath,
updated: new Date().toISOString(),
});
task = nextTask;
refreshFeed();
});
const editField = async (field, rawValue) => {
const nextValue = rawValue.trim();
const currentValue = field === "title" ? task.title : task.body;
if (field === "title" && !nextValue) return false;
if (nextValue === currentValue) {
editingField = null;
render();
return true;
}
await runBusyAction(async () => {
const payload = await callTaskBoard({
action: "edit",
task: task.taskPath,
...(field === "title" ? { title: nextValue } : { description: nextValue }),
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
...(field === "title" ? { title: nextValue } : { body: nextValue }),
});
editingField = null;
refreshFeed();
});
return true;
};
const addTag = async () => {
const raw = window.prompt("Add tag to task", "");
const tag = raw == null ? "" : normalizeTag(raw);
if (!tag) return;
await runBusyAction(async () => {
const payload = await callTaskBoard({
action: "add_tag",
task: task.taskPath,
tags: [tag],
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
tags: Array.from(new Set([...task.tags, tag])),
});
refreshFeed();
});
};
const removeTag = async (tag) =>
runBusyAction(async () => {
const payload = await callTaskBoard({
action: "remove_tag",
task: task.taskPath,
tags: [tag],
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
tags: task.tags.filter((value) => value !== tag),
});
refreshFeed();
});
const beginTitleEdit = () => {
if (!task.taskPath || busy || editingField) return;
closeLaneMenu();
editingField = "title";
render();
};
const beginBodyEdit = () => {
if (!task.taskPath || busy || editingField) return;
closeLaneMenu();
editingField = "body";
render();
};
const renderInlineEditor = (targetEl, field, value, placeholder) => {
targetEl.innerHTML = "";
const editor = document.createElement("textarea");
editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`;
editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length);
editor.value = value;
if (placeholder) editor.placeholder = placeholder;
editor.disabled = busy;
targetEl.appendChild(editor);
autosizeEditor(editor);
const cancel = () => {
editingField = null;
render();
};
editor.addEventListener("input", () => {
autosizeEditor(editor);
});
editor.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
cancel();
return;
}
if (field === "title" && event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
editor.blur();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
editor.blur();
}
});
editor.addEventListener("blur", () => {
if (editingField !== field) return;
void editField(field, editor.value);
});
requestAnimationFrame(() => {
editor.focus();
const end = editor.value.length;
editor.setSelectionRange(end, end);
});
};
const renderTags = () => {
tagsEl.innerHTML = "";
task.tags.forEach((tag) => {
const button = document.createElement("button");
button.className = "task-card-ui__tag";
button.type = "button";
button.textContent = tag;
button.title = `Hold to remove ${tag}`;
button.disabled = busy;
button.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
clearHoldTimer();
button.classList.add("is-holding");
holdTimer = window.setTimeout(() => {
holdTimer = null;
button.classList.remove("is-holding");
if (window.confirm(`Remove ${tag} from this task?`)) {
void removeTag(tag);
}
}, 650);
});
["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => {
button.addEventListener(eventName, () => {
clearHoldTimer();
button.classList.remove("is-holding");
});
});
button.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
tagsEl.appendChild(button);
});
const addButton = document.createElement("button");
addButton.className = "task-card-ui__tag task-card-ui__tag--action";
addButton.type = "button";
addButton.textContent = "+";
addButton.title = "Add tag";
addButton.disabled = busy || !task.taskPath;
addButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void addTag();
});
tagsEl.appendChild(addButton);
};
const renderLaneMenu = () => {
laneMenuEl.innerHTML = "";
if (!task.taskPath) {
closeLaneMenu();
return;
}
taskMoveOptions(task.lane).forEach((option) => {
const button = document.createElement("button");
button.className = "task-card-ui__lane-menu-item";
button.type = "button";
button.textContent = option.label;
button.disabled = busy;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void moveTask(option.lane);
});
laneMenuEl.appendChild(button);
});
laneMenuEl.style.display = laneMenuOpen ? "flex" : "none";
};
const applyTheme = () => {
const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog;
cardEl.style.setProperty("--task-accent", theme.accent);
cardEl.style.setProperty("--task-accent-soft", theme.accentSoft);
cardEl.style.setProperty("--task-muted", theme.muted);
cardEl.style.setProperty("--task-button-ink", theme.buttonInk);
};
const render = () => {
applyTheme();
laneToggleEl.textContent = "";
const laneLabelEl = document.createElement("span");
laneLabelEl.className = "task-card-ui__lane";
laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task";
const caretEl = document.createElement("span");
caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`;
caretEl.textContent = "▾";
laneToggleEl.append(laneLabelEl, caretEl);
laneToggleEl.disabled = busy || !task.taskPath;
laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false");
setStatus(statusLabel, statusKind);
if (editingField === "title") {
renderInlineEditor(titleEl, "title", task.title, "");
} else {
titleEl.innerHTML = "";
const button = document.createElement("button");
button.className = "task-card-ui__title task-card-ui__text-button";
button.type = "button";
button.disabled = busy || !task.taskPath;
button.textContent = task.title || "(Untitled task)";
button.addEventListener("click", beginTitleEdit);
titleEl.appendChild(button);
}
const bodySummary = summarizeTaskBody(task);
if (editingField === "body") {
renderInlineEditor(bodyEl, "body", task.body, "Add description");
} else {
bodyEl.innerHTML = "";
const button = document.createElement("button");
button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${
bodySummary ? "" : " is-placeholder"
}`;
button.type = "button";
button.disabled = busy || !task.taskPath;
const inner = document.createElement("span");
inner.className = "task-card-ui__body-markdown-inner";
inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description";
button.appendChild(inner);
button.addEventListener("click", beginBodyEdit);
bodyEl.appendChild(button);
}
renderTags();
renderLaneMenu();
const dueText = formatTaskDue(task);
dueEl.textContent = dueText;
metaEl.style.display = dueText ? "flex" : "none";
publishLiveContent();
setBusy(busy);
};
const handleLaneToggle = (event) => {
event.preventDefault();
event.stopPropagation();
if (laneMenuOpen) closeLaneMenu();
else openLaneMenu();
render();
};
const handleDocumentPointerDown = (event) => {
if (!laneMenuOpen) return;
if (!(event.target instanceof Node)) return;
if (laneWrapEl.contains(event.target)) return;
closeLaneMenu();
render();
};
const handleEscape = (event) => {
if (event.key !== "Escape" || !laneMenuOpen) return;
closeLaneMenu();
render();
};
laneToggleEl.addEventListener("click", handleLaneToggle);
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleEscape);
render();
return {
update({ item: nextItem, state: nextState }) {
task = normalizeTask(nextState, nextItem.title);
errorText = "";
statusLabel = "";
statusKind = "neutral";
laneMenuOpen = false;
editingField = null;
render();
},
destroy() {
clearHoldTimer();
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleEscape);
laneToggleEl.removeEventListener("click", handleLaneToggle);
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,410 @@
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 emptyEl = root.querySelector('[data-upcoming-empty]');
const listEl = root.querySelector('[data-upcoming-list]');
if (!(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement)) return;
const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const calendarNames = Array.isArray(state.calendar_names)
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
: [];
const eventWindowHoursRaw = Number(state.event_window_hours);
const eventWindowHours = Number.isFinite(eventWindowHoursRaw) && eventWindowHoursRaw >= 1 ? Math.min(eventWindowHoursRaw, 168) : 36;
const maxEventsRaw = Number(state.max_events);
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 4;
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
? state.empty_text.trim()
: `No upcoming events in the next ${eventWindowHours} hours.`;
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 eventTime = (value, allDay = false) => {
const raw = normalizeDateValue(value);
if (!raw) return Number.NaN;
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? `${raw}T${allDay ? '12:00:00' : '00:00:00'}`
: raw;
return new Date(normalized).getTime();
};
const formatEventLabel = (event) => {
const raw = normalizeDateValue(event?.start);
if (!raw) return '--';
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const date = new Date(`${raw}T12:00:00`);
return `${date.toLocaleDateString([], { weekday: 'short' })} · all day`;
}
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleString([], {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
});
};
const extractEvents = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (Array.isArray(parsed.result)) return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
return parsed.result;
}
} catch {
return [];
}
}
return [];
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveCalendarToolConfig = async () => {
const fallbackName = configuredCalendarToolName || 'mcp_home_assistant_calendar_get_events';
if (!host.listTools) {
return { name: fallbackName, 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 || fallbackName,
availableCalendars: enumValues,
};
} catch {
return { name: fallbackName, availableCalendars: calendarNames };
}
};
const resolveUpcomingEvents = async (toolConfig) => {
const now = Date.now();
const windowEnd = now + eventWindowHours * 60 * 60 * 1000;
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 upcomingEvents = [];
for (const calendarName of selectedCalendars) {
const toolResult = await host.callTool(toolConfig.name, {
calendar: calendarName,
range: 'week',
});
const events = extractEvents(toolResult);
for (const event of events) {
const allDay = isAllDay(event?.start, event?.end);
const startTime = eventTime(event?.start, allDay);
if (!Number.isFinite(startTime) || startTime < now || startTime > windowEnd) continue;
upcomingEvents.push({ ...event, _calendarName: calendarName, _allDay: allDay, _startTime: startTime });
}
}
upcomingEvents.sort((left, right) => left._startTime - right._startTime);
return upcomingEvents.slice(0, maxEvents);
};
const resolveForecastBundle = async () => {
if (!forecastCommand) throw new Error('Missing forecast_command');
const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid forecast payload');
}
return payload;
};
const forecastTime = (entry) => {
const time = new Date(String(entry?.datetime || '')).getTime();
return Number.isFinite(time) ? time : Number.NaN;
};
const nearestForecast = (entries, targetTime) => {
if (!Array.isArray(entries) || entries.length === 0 || !Number.isFinite(targetTime)) return null;
let bestEntry = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const entry of entries) {
const entryTime = forecastTime(entry);
if (!Number.isFinite(entryTime)) continue;
const distance = Math.abs(entryTime - targetTime);
if (distance < bestDistance) {
bestDistance = distance;
bestEntry = entry;
}
}
return bestDistance <= 3 * 60 * 60 * 1000 ? bestEntry : null;
};
const computeUpcomingScore = (events) => {
if (!Array.isArray(events) || events.length === 0) return 0;
const now = Date.now();
const soonestMs = events
.map((event) => Number(event?._startTime))
.filter((time) => Number.isFinite(time) && time >= now)
.sort((left, right) => left - right)[0];
const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null;
let score = 44;
if (soonestHours !== null) {
if (soonestHours <= 1) score = 100;
else if (soonestHours <= 3) score = 97;
else if (soonestHours <= 8) score = 94;
else if (soonestHours <= 24) score = 90;
else if (soonestHours <= 36) score = 86;
else if (soonestHours <= 48) score = 82;
else if (soonestHours <= 72) score = 76;
else if (soonestHours <= 168) score = 62;
}
score += Math.min(events.length, 3);
return Math.max(0, Math.min(100, Math.round(score)));
};
const metricValue = (value, fallback = '--') => {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
};
const createMetricCell = (glyph, label, value) => {
const cell = document.createElement('div');
cell.style.display = 'flex';
cell.style.alignItems = 'baseline';
cell.style.columnGap = '3px';
cell.style.flex = '0 0 auto';
cell.style.minWidth = '0';
cell.style.whiteSpace = 'nowrap';
cell.title = label;
const glyphEl = document.createElement('div');
glyphEl.style.fontSize = '0.54rem';
glyphEl.style.lineHeight = '1';
glyphEl.style.color = 'var(--theme-card-neutral-muted)';
glyphEl.style.fontWeight = '700';
glyphEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace";
glyphEl.style.flex = '0 0 auto';
glyphEl.textContent = glyph;
cell.appendChild(glyphEl);
const valueEl = document.createElement('div');
valueEl.style.fontSize = '0.53rem';
valueEl.style.lineHeight = '1.1';
valueEl.style.color = 'var(--theme-card-neutral-text)';
valueEl.style.fontWeight = '700';
valueEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace";
valueEl.style.whiteSpace = 'nowrap';
valueEl.style.overflow = 'hidden';
valueEl.style.textOverflow = 'ellipsis';
valueEl.style.textAlign = 'right';
valueEl.style.flex = '1 1 auto';
valueEl.textContent = metricValue(value);
cell.appendChild(valueEl);
return cell;
};
const renderEvents = (items, temperatureUnit, windSpeedUnit) => {
listEl.innerHTML = '';
if (!Array.isArray(items) || items.length === 0) {
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
for (const [index, item] of items.entries()) {
const event = item.event;
const forecast = item.forecast;
const entry = document.createElement('li');
entry.style.padding = index === 0 ? '8px 0 0' : '8px 0 0';
entry.style.borderTop = '1px solid var(--theme-card-neutral-border)';
const whenEl = document.createElement('div');
whenEl.style.fontSize = '0.72rem';
whenEl.style.lineHeight = '1.2';
whenEl.style.letterSpacing = '0.02em';
whenEl.style.color = 'var(--theme-card-neutral-muted)';
whenEl.style.fontWeight = '700';
whenEl.textContent = formatEventLabel(event);
entry.appendChild(whenEl);
const titleEl = document.createElement('div');
titleEl.style.marginTop = '3px';
titleEl.style.fontSize = '0.9rem';
titleEl.style.lineHeight = '1.25';
titleEl.style.color = 'var(--theme-card-neutral-text)';
titleEl.style.fontWeight = '700';
titleEl.style.whiteSpace = 'normal';
titleEl.style.wordBreak = 'break-word';
titleEl.textContent = String(event.summary || '(No title)');
entry.appendChild(titleEl);
const detailGrid = document.createElement('div');
detailGrid.style.marginTop = '4px';
detailGrid.style.display = 'flex';
detailGrid.style.flexWrap = 'nowrap';
detailGrid.style.alignItems = 'baseline';
detailGrid.style.gap = '6px';
detailGrid.style.overflowX = 'auto';
detailGrid.style.overflowY = 'hidden';
detailGrid.style.scrollbarWidth = 'none';
detailGrid.style.msOverflowStyle = 'none';
detailGrid.style.webkitOverflowScrolling = 'touch';
const tempValue = Number.isFinite(Number(forecast?.temperature))
? `${Math.round(Number(forecast.temperature))}${temperatureUnit || ''}`
: null;
const windValue = Number.isFinite(Number(forecast?.wind_speed))
? `${Math.round(Number(forecast.wind_speed))}${windSpeedUnit || ''}`
: null;
const rainValue = Number.isFinite(Number(forecast?.precipitation_probability))
? `${Math.round(Number(forecast.precipitation_probability))}%`
: null;
const uvValue = Number.isFinite(Number(forecast?.uv_index))
? `${Math.round(Number(forecast.uv_index))}`
: null;
detailGrid.appendChild(createMetricCell('\uf2c9', 'Temperature', tempValue));
detailGrid.appendChild(createMetricCell('\uef16', 'Wind', windValue));
detailGrid.appendChild(createMetricCell('\uf043', 'Rain', rainValue));
detailGrid.appendChild(createMetricCell('\uf522', 'UV', uvValue));
entry.appendChild(detailGrid);
listEl.appendChild(entry);
}
};
const refresh = async () => {
const snapshot = {
kind: 'upcoming_conditions',
event_window_hours: eventWindowHours,
updated_at: null,
events: [],
errors: {},
};
try {
const [toolConfig, forecastBundle] = await Promise.all([
resolveCalendarToolConfig(),
resolveForecastBundle(),
]);
const events = await resolveUpcomingEvents(toolConfig);
const nwsSource = forecastBundle?.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const uvSource = forecastBundle?.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
const temperatureUnit = String(nwsSource?.temperature_unit || uvSource?.temperature_unit || '°F');
const windSpeedUnit = String(nwsSource?.wind_speed_unit || uvSource?.wind_speed_unit || 'mph');
const mergedItems = events.map((event) => {
const nwsForecast = nearestForecast(nwsSource?.forecast, event._startTime);
const uvForecast = nearestForecast(uvSource?.forecast, event._startTime);
return {
event,
forecast: {
datetime: nwsForecast?.datetime || uvForecast?.datetime || null,
condition: nwsForecast?.condition || uvForecast?.condition || null,
temperature: nwsForecast?.temperature ?? uvForecast?.temperature ?? null,
apparent_temperature: uvForecast?.apparent_temperature ?? null,
precipitation_probability: nwsForecast?.precipitation_probability ?? uvForecast?.precipitation_probability ?? null,
wind_speed: nwsForecast?.wind_speed ?? uvForecast?.wind_speed ?? null,
uv_index: uvForecast?.uv_index ?? null,
},
};
});
renderEvents(mergedItems, temperatureUnit, windSpeedUnit);
snapshot.events = mergedItems.map((item) => ({
summary: String(item.event.summary || '(No title)'),
start: normalizeDateValue(item.event.start) || null,
end: normalizeDateValue(item.event.end) || null,
all_day: Boolean(item.event._allDay),
calendar_name: String(item.event._calendarName || ''),
forecast_time: item.forecast.datetime || null,
condition: item.forecast.condition || null,
temperature: Number.isFinite(Number(item.forecast.temperature)) ? Number(item.forecast.temperature) : null,
apparent_temperature: Number.isFinite(Number(item.forecast.apparent_temperature)) ? Number(item.forecast.apparent_temperature) : null,
precipitation_probability: Number.isFinite(Number(item.forecast.precipitation_probability)) ? Number(item.forecast.precipitation_probability) : null,
wind_speed: Number.isFinite(Number(item.forecast.wind_speed)) ? Number(item.forecast.wind_speed) : null,
uv_index: Number.isFinite(Number(item.forecast.uv_index)) ? Number(item.forecast.uv_index) : null,
}));
snapshot.score = computeUpcomingScore(events);
} catch (error) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
emptyEl.textContent = String(error);
snapshot.errors.load = String(error);
snapshot.score = 0;
}
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
snapshot.updated_at = updatedText;
updateLiveContent(snapshot);
};
host.setRefreshHandler(() => {
void refresh();
});
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,19 @@
{
"key": "upcoming-conditions-live",
"title": "Upcoming Events",
"notes": "Upcoming event card with raw event-time forecast context. Fill template_state with calendar_tool_name (defaults to calendar_get_events), calendar_names, forecast_tool_name (defaults to exec), forecast_command, event_window_hours, max_events, refresh_ms, and empty_text. The card joins calendar events to the nearest hourly forecast rows without generating suggestions.",
"example_state": {
"calendar_tool_name": "mcp_home_assistant_calendar_get_events",
"calendar_names": [
"Family Calendar"
],
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48",
"event_window_hours": 36,
"max_events": 3,
"refresh_ms": 900000,
"empty_text": "No upcoming events in the next 36 hours."
},
"created_at": "2026-03-16T14:00:00+00:00",
"updated_at": "2026-03-16T14:00:00+00:00"
}

View file

@ -0,0 +1,23 @@
<style>
@font-face {
font-family: 'BlexMono Nerd Font Mono';
src: url('/card-templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@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;
}
</style>
<div data-upcoming-conditions-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:12px; border:1px solid var(--theme-card-neutral-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-neutral-text); font-weight:700;">Upcoming Events</div>
<div data-upcoming-empty style="display:none; margin-top:8px; color:var(--theme-card-neutral-subtle); font-size:0.9rem; line-height:1.4;">No upcoming events.</div>
<ul data-upcoming-list style="list-style:none; margin:6px 0 0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
</div>

View file

@ -0,0 +1,356 @@
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 subtitleEl = root.querySelector('[data-weather-subtitle]');
const tempEl = root.querySelector('[data-weather-temp]');
const unitEl = root.querySelector('[data-weather-unit]');
const humidityEl = root.querySelector('[data-weather-humidity]');
const windEl = root.querySelector('[data-weather-wind]');
const rainEl = root.querySelector('[data-weather-rain]');
const uvEl = root.querySelector('[data-weather-uv]');
const statusEl = root.querySelector('[data-weather-status]');
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
const uvName = typeof state.uv_name === 'string' ? state.uv_name.trim() : '';
const morningStartHourRaw = Number(state.morning_start_hour);
const morningEndHourRaw = Number(state.morning_end_hour);
const morningScoreRaw = Number(state.morning_score);
const defaultScoreRaw = Number(state.default_score);
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
const morningStartHour = Number.isFinite(morningStartHourRaw) ? morningStartHourRaw : 6;
const morningEndHour = Number.isFinite(morningEndHourRaw) ? morningEndHourRaw : 11;
const morningScore = Number.isFinite(morningScoreRaw) ? morningScoreRaw : 84;
const defaultScore = Number.isFinite(defaultScoreRaw) ? defaultScoreRaw : 38;
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
const updateLiveContent = (snapshot) => {
host.setLiveContent(snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await host.listTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const resolveForecastBundle = async () => {
if (!forecastCommand) return null;
const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
return payload && typeof payload === 'object' ? payload : null;
};
const firstForecastEntry = (bundle, key, metricKey = '') => {
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
if (!metricKey) {
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
}
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
};
const estimateUvIndex = (cloudCoverage) => {
const now = new Date();
const hour = now.getHours() + now.getMinutes() / 60;
const daylightPhase = Math.sin(((hour - 6) / 12) * Math.PI);
if (!Number.isFinite(daylightPhase) || daylightPhase <= 0) return 0;
const normalizedCloudCoverage = Number.isFinite(cloudCoverage)
? Math.min(Math.max(cloudCoverage, 0), 100)
: null;
const cloudFactor = normalizedCloudCoverage === null
? 1
: Math.max(0.2, 1 - normalizedCloudCoverage * 0.0065);
return Math.max(0, Math.round(7 * daylightPhase * cloudFactor));
};
const computeWeatherScore = () => {
const now = new Date();
const hour = now.getHours() + now.getMinutes() / 60;
if (hour >= morningStartHour && hour < morningEndHour) return morningScore;
return defaultScore;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
setStatus('No tool', 'var(--theme-status-danger)');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: null,
temperature: null,
temperature_unit: String(state.unit || '°F'),
humidity: null,
wind: null,
rain: null,
uv: null,
status: 'No tool',
error: errorText,
});
return;
}
setStatus('Refreshing', 'var(--theme-status-muted)');
try {
const [toolResult, forecastBundle] = await Promise.all([
host.callTool(resolvedToolName, {}),
resolveForecastBundle(),
]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [
temperatureName,
`${prefix} Temperature`,
]);
const humidityEntry = findEntry(entries, [
humidityName,
`${prefix} Humidity`,
]);
const uvSensorEntry = findEntry(entries, [
uvName,
`${prefix} UV index`,
]);
const temperature = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const uvSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
const windSpeed = Number(nwsEntry?.wind_speed);
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
const rainChance = Number(nwsEntry?.precipitation_probability);
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
const liveUvValue = Number(uvSensorEntry?.state);
const forecastUvValue = Number(uvEntry?.uv_index);
const cloudCoverage = Number.isFinite(Number(nwsEntry?.cloud_coverage))
? Number(nwsEntry?.cloud_coverage)
: Number(uvSource?.forecast?.[0]?.cloud_coverage);
const estimatedUvValue = estimateUvIndex(cloudCoverage);
const uvValue = Number.isFinite(liveUvValue)
? liveUvValue
: (Number.isFinite(forecastUvValue) ? forecastUvValue : estimatedUvValue);
const uvEstimated = !Number.isFinite(liveUvValue) && !Number.isFinite(forecastUvValue);
uvEl.textContent = Number.isFinite(uvValue)
? `${uvEstimated && uvValue > 0 ? '~' : ''}${Math.round(uvValue)}`
: '--';
subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', 'var(--theme-status-live)');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
rain: rainEl.textContent || null,
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
uv_estimated: uvEstimated,
score: computeWeatherScore(),
status: 'Live',
});
} catch (error) {
const errorText = String(error);
setStatus('Unavailable', 'var(--theme-status-danger)');
tempEl.textContent = '--';
unitEl.textContent = String(state.unit || '°F');
humidityEl.textContent = '--';
windEl.textContent = '--';
rainEl.textContent = '--';
uvEl.textContent = '--';
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: null,
temperature_unit: unitEl.textContent || null,
humidity: null,
wind: null,
rain: null,
uv: null,
score: computeWeatherScore(),
status: 'Unavailable',
error: errorText,
});
}
};
host.setRefreshHandler(() => {
void refresh();
});
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

@ -1,7 +1,7 @@
{
"key": "weather-live",
"title": "Live Weather",
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.",
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional uv_name, optional condition_label, optional morning_start_hour/morning_end_hour/morning_score/default_score, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload. If a live UV reading is unavailable, the card falls back to a clearly approximate current UV estimate.",
"example_state": {
"subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext",
@ -10,8 +10,13 @@
"provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity",
"uv_name": "OpenWeatherMap UV index",
"condition_label": "Weather",
"refresh_ms": 86400000
"morning_start_hour": 6,
"morning_end_hour": 11,
"morning_score": 84,
"default_score": 38,
"refresh_ms": 300000
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
"updated_at": "2026-03-11T19:18:04.632189+00:00"

View file

@ -1,4 +1,4 @@
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:var(--theme-card-neutral-bg); color:var(--theme-card-neutral-text); padding:14px 16px; border:1px solid var(--theme-card-neutral-border);">
<style>
@font-face {
font-family: 'BlexMono Nerd Font Mono';
@ -9,322 +9,31 @@
}
</style>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:var(--theme-card-neutral-subtle); font-weight:600;">Loading…</div>
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:var(--theme-status-muted); white-space:nowrap;">Loading…</span>
</div>
<div style="display:flex; align-items:flex-end; gap:8px; margin-bottom:4px;">
<span data-weather-temp style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span>
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:var(--theme-card-neutral-subtle); padding-bottom:0.28rem;">°F</span>
</div>
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
<div>
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;">󰖎</div>
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);">󰖎</div>
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
<div>
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
<div>
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
<div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
<div>
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
<div title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:var(--theme-card-neutral-muted);"></div>
<div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:var(--theme-card-neutral-text);">--</div>
</div>
</div>
</div>
<script>
(() => {
const script = document.currentScript;
const root = script?.closest('[data-nanobot-card-root]');
const state = window.__nanobotGetCardState?.(script) || {};
if (!(root instanceof HTMLElement)) return;
const subtitleEl = root.querySelector('[data-weather-subtitle]');
const tempEl = root.querySelector('[data-weather-temp]');
const unitEl = root.querySelector('[data-weather-unit]');
const humidityEl = root.querySelector('[data-weather-humidity]');
const windEl = root.querySelector('[data-weather-wind]');
const rainEl = root.querySelector('[data-weather-rain]');
const uvEl = root.querySelector('[data-weather-uv]');
const statusEl = root.querySelector('[data-weather-status]');
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
const refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
const updateLiveContent = (snapshot) => {
window.__nanobotSetCardLiveContent?.(script, snapshot);
};
const setStatus = (label, color) => {
statusEl.textContent = label;
statusEl.style.color = color;
};
const stripQuotes = (value) => {
const text = String(value ?? '').trim();
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1);
}
return text;
};
const normalizeText = (value) => String(value || '').trim().toLowerCase();
const parseLiveContextEntries = (payloadText) => {
const text = String(payloadText || '').replace(/\r/g, '');
const startIndex = text.indexOf('- names: ');
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
const entries = [];
let current = null;
let inAttributes = false;
const pushCurrent = () => {
if (current) entries.push(current);
current = null;
inAttributes = false;
};
for (const rawLine of relevant.split('\n')) {
if (rawLine.startsWith('- names: ')) {
pushCurrent();
current = {
name: stripQuotes(rawLine.slice(9)),
domain: '',
state: '',
areas: '',
attributes: {},
};
continue;
}
if (!current) continue;
const trimmed = rawLine.trim();
if (!trimmed) continue;
if (trimmed === 'attributes:') {
inAttributes = true;
continue;
}
if (rawLine.startsWith(' domain:')) {
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' state:')) {
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (rawLine.startsWith(' areas:')) {
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
inAttributes = false;
continue;
}
if (inAttributes && rawLine.startsWith(' ')) {
const separatorIndex = rawLine.indexOf(':');
if (separatorIndex >= 0) {
const key = rawLine.slice(4, separatorIndex).trim();
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
current.attributes[key] = value;
}
continue;
}
inAttributes = false;
}
pushCurrent();
return entries;
};
const extractLiveContextText = (toolResult) => {
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
const parsed = toolResult.parsed;
if (typeof parsed.result === 'string') return parsed.result;
}
if (typeof toolResult?.content === 'string') {
try {
const parsed = JSON.parse(toolResult.content);
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
return parsed.result;
}
} catch {
return toolResult.content;
}
return toolResult.content;
}
return '';
};
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveToolName = async () => {
if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
try {
const tools = await window.__nanobotListTools();
const liveContextTool = Array.isArray(tools)
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
: null;
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
} catch {
return 'mcp_home_assistant_GetLiveContext';
}
};
const findEntry = (entries, candidates) => {
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
if (normalizedCandidates.length === 0) return null;
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
if (exactMatch) return exactMatch;
return entries.find((entry) => {
const entryName = normalizeText(entry.name);
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
}) || null;
};
const resolveForecastBundle = async () => {
if (!forecastCommand) return null;
const toolResult = await window.__nanobotCallTool?.(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
return payload && typeof payload === 'object' ? payload : null;
};
const firstForecastEntry = (bundle, key, metricKey = '') => {
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
if (!metricKey) {
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
}
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
};
const refresh = async () => {
const resolvedToolName = await resolveToolName();
if (!resolvedToolName) {
const errorText = 'Missing tool_name';
setStatus('No tool', '#b91c1c');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: null,
temperature: null,
temperature_unit: String(state.unit || '°F'),
humidity: null,
wind: null,
rain: null,
uv: null,
status: 'No tool',
error: errorText,
});
return;
}
setStatus('Refreshing', '#6b7280');
try {
const [toolResult, forecastBundle] = await Promise.all([
window.__nanobotCallTool?.(resolvedToolName, {}),
resolveForecastBundle(),
]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [
temperatureName,
`${prefix} Temperature`,
]);
const humidityEntry = findEntry(entries, [
humidityName,
`${prefix} Humidity`,
]);
const temperature = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
const windSpeed = Number(nwsEntry?.wind_speed);
const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
const rainChance = Number(nwsEntry?.precipitation_probability);
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
const uvValue = Number(uvEntry?.uv_index);
uvEl.textContent = Number.isFinite(uvValue) ? String(Math.round(uvValue)) : '--';
subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', '#047857');
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null,
rain: rainEl.textContent || null,
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
status: 'Live',
});
} catch (error) {
const errorText = String(error);
setStatus('Unavailable', '#b91c1c');
tempEl.textContent = '--';
unitEl.textContent = String(state.unit || '°F');
humidityEl.textContent = '--';
windEl.textContent = '--';
rainEl.textContent = '--';
uvEl.textContent = '--';
updateLiveContent({
kind: 'weather',
subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName,
temperature: null,
temperature_unit: unitEl.textContent || null,
humidity: null,
wind: null,
rain: null,
uv: null,
status: 'Unavailable',
error: errorText,
});
}
};
window.__nanobotSetCardRefresh?.(script, () => {
void refresh();
});
void refresh();
window.setInterval(() => { void refresh(); }, refreshMs);
})();
</script>