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