Files
HeatGuard/web/js/dashboard.js
vikingowl c23ac1611a feat: gate AI actions on heat threshold and restructure dashboard layout
Skip LLM actions call when peakTempC < 22°C and risk is low. Add
low-risk guidance to both summary and actions system prompts so the
LLM returns appropriate responses on mild days. Restructure dashboard
into a top-level two-column grid with sidebar beside all main content
and remove max-w-7xl cap for full-width layout.
2026-02-09 15:14:01 +01:00

552 lines
24 KiB
JavaScript

// Dashboard page logic
(function() {
"use strict";
const $ = (id) => document.getElementById(id);
const t = () => window.HG.t || {};
function show(id) { $(id).classList.remove("hidden"); }
function hide(id) { $(id).classList.add("hidden"); }
const riskColors = {
low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" },
moderate: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300", border: "border-yellow-500" },
high: { bg: "bg-orange-100 dark:bg-orange-900", text: "text-orange-700 dark:text-orange-300", border: "border-orange-500" },
extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" },
};
const riskGradients = {
low: { from: "#f0fdf4", to: "#dcfce7", darkFrom: "#052e16", darkTo: "#064e3b" },
moderate: { from: "#fefce8", to: "#fef9c3", darkFrom: "#422006", darkTo: "#3f3f00" },
high: { from: "#fff7ed", to: "#ffedd5", darkFrom: "#431407", darkTo: "#7c2d12" },
extreme: { from: "#fef2f2", to: "#fecaca", darkFrom: "#450a0a", darkTo: "#7f1d1d" },
};
const warningSeverityColors = {
Minor: { bg: "bg-yellow-50 dark:bg-yellow-900/30", border: "border-yellow-300 dark:border-yellow-700", text: "text-yellow-700 dark:text-yellow-300", pill: "bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200" },
Moderate: { bg: "bg-orange-50 dark:bg-orange-900/30", border: "border-orange-300 dark:border-orange-700", text: "text-orange-700 dark:text-orange-300", pill: "bg-orange-200 text-orange-800 dark:bg-orange-800 dark:text-orange-200" },
Severe: { bg: "bg-red-50 dark:bg-red-900/30", border: "border-red-300 dark:border-red-800", text: "text-red-700 dark:text-red-300", pill: "bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200" },
Extreme: { bg: "bg-red-100 dark:bg-red-900/50", border: "border-red-500 dark:border-red-600", text: "text-red-800 dark:text-red-200", pill: "bg-red-300 text-red-900 dark:bg-red-700 dark:text-red-100" },
};
const budgetBorderColors = {
comfortable: "border-green-400",
marginal: "border-yellow-400",
overloaded: "border-red-400",
};
const effortColors = {
none: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
low: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
high: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
};
const impactColors = {
low: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300",
medium: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
high: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
};
const categoryIcons = {
shading: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>',
ventilation: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>',
internal_gains: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
ac_strategy: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
hydration: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>',
care: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>',
};
function tempColorHex(temp) {
if (temp >= 40) return "#dc2626";
if (temp >= 35) return "#f97316";
if (temp >= 30) return "#facc15";
if (temp >= 25) return "#fde68a";
if (temp >= 20) return "#bbf7d0";
return "#bfdbfe";
}
function isDark() {
return document.documentElement.classList.contains("dark") ||
window.matchMedia("(prefers-color-scheme: dark)").matches;
}
window.loadDashboard = async function() {
hide("no-data");
hide("no-forecast");
hide("error-state");
hide("data-display");
show("loading");
try {
const profileId = await getActiveProfileId();
if (!profileId) {
hide("loading");
show("no-data");
return;
}
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
if (forecasts.length === 0) {
hide("loading");
show("no-forecast");
return;
}
const today = new Date().toISOString().slice(0, 10);
const payload = await getComputePayload(profileId, today);
if (!payload) {
hide("loading");
show("no-data");
return;
}
const resp = await fetch("/api/compute/dashboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || "Compute failed");
}
const data = await resp.json();
hide("loading");
show("data-display");
renderDashboard(data);
// LLM credentials (shared between summary and actions)
const llmProvider = await getSetting("llmProvider");
const llmApiKey = await getSetting("llmApiKey");
const llmModel = await getSetting("llmModel");
const language = window.HG && window.HG.lang === "de" ? "German" : "English";
// LLM summary (async)
try {
const llmBody = {
date: data.date,
peakTempC: data.peakTempC,
minNightTempC: data.minNightTempC,
riskLevel: data.riskLevel,
acHeadroomBTUH: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].headroomBtuh : 0,
budgetStatus: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].status : "comfortable",
language: language,
};
if (llmProvider && llmApiKey) {
llmBody.provider = llmProvider;
llmBody.apiKey = llmApiKey;
if (llmModel) llmBody.model = llmModel;
}
const llmResp = await fetch("/api/llm/summarize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(llmBody),
});
if (llmResp.ok) {
const llmData = await llmResp.json();
if (llmData.summary) {
renderLlmSummary(llmData.summary);
} else {
$("llm-section").classList.add("hidden");
}
}
} catch (e) {
$("llm-section").classList.add("hidden");
}
// AI actions (async) — skip when no heat risk
const needsHeatActions = data.peakTempC >= 22 || data.riskLevel !== "low";
if (llmProvider && llmApiKey && needsHeatActions) {
show("actions-loading");
try {
const rooms = (payload.rooms || []).map(r => ({
name: r.name,
orientation: r.orientation || "",
shadingType: r.shadingType || "none",
hasAC: (payload.acAssignments || []).some(a => a.roomId === r.id),
}));
const actionsBody = {
date: data.date,
indoorTempC: data.indoorTempC,
peakTempC: data.peakTempC,
minNightTempC: data.minNightTempC,
poorNightCool: data.poorNightCool,
riskLevel: data.riskLevel,
riskWindows: data.riskWindows || [],
timeline: (data.timeline || []).map(s => ({
hour: s.hour,
tempC: s.tempC,
humidityPct: s.humidityPct,
budgetStatus: s.budgetStatus,
coolMode: s.coolMode,
})),
roomBudgets: (data.roomBudgets || []).map(rb => ({
totalGainW: rb.totalGainW,
})),
rooms: rooms,
provider: llmProvider,
apiKey: llmApiKey,
language: language,
};
if (llmModel) actionsBody.model = llmModel;
const actResp = await fetch("/api/llm/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(actionsBody),
});
if (actResp.ok) {
const actData = await actResp.json();
if (actData.actions && actData.actions.length > 0) {
renderAIActions(actData.actions);
} else {
hide("actions-loading");
}
} else {
hide("actions-loading");
}
} catch (e) {
hide("actions-loading");
}
}
} catch (err) {
hide("loading");
show("error-state");
console.error("Dashboard error:", err);
}
};
// ========== Mini Markdown ==========
function miniMarkdown(text) {
return text
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/^- (.+)$/gm, "<li class=\"ml-4 list-disc\">$1</li>");
}
// ========== LLM Summary ==========
function renderLlmSummary(summary) {
const paragraphs = summary.split(/\n\n+/).filter(Boolean);
const html = paragraphs.map(p => {
const rendered = miniMarkdown(esc(p));
if (rendered.includes("<li")) {
return `<ul class="text-sm space-y-1">${rendered}</ul>`;
}
return `<p class="text-sm">${rendered}</p>`;
}).join("");
const el = $("llm-summary");
el.innerHTML = `
<div class="space-y-2">${html}</div>
<p class="text-xs text-gray-400 mt-3">${esc(t().aiDisclaimer)}</p>
`;
// Fade in
requestAnimationFrame(() => el.classList.remove("opacity-0"));
}
// ========== Main Render ==========
function renderDashboard(data) {
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
// Risk card with gradient
const rc = riskColors[data.riskLevel] || riskColors.low;
const rg = riskGradients[data.riskLevel] || riskGradients.low;
const dark = isDark();
const gradFrom = dark ? rg.darkFrom : rg.from;
const gradTo = dark ? rg.darkTo : rg.to;
const riskCard = $("risk-card");
riskCard.className = "rounded-xl p-4 text-center shadow-sm transition-all duration-200 hover:shadow-md";
riskCard.style.background = `linear-gradient(135deg, ${gradFrom}, ${gradTo})`;
const shieldSvg = '<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
$("risk-icon").innerHTML = `<span class="${rc.text}">${shieldSvg}</span>`;
$("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`;
$("risk-level").textContent = data.riskLevel;
// Peak temp
$("peak-temp").textContent = data.peakTempC.toFixed(1) + "\u00b0C";
// Min night temp
$("min-night-temp").textContent = data.minNightTempC.toFixed(1) + "\u00b0C";
if (data.poorNightCool) {
$("poor-night-cool").classList.remove("hidden");
}
// Warnings — severity-based coloring
if (data.warnings && data.warnings.length > 0) {
show("warnings-section");
$("warnings-section").innerHTML = '<h2 class="text-lg font-semibold mb-2">' + esc(t().warnings) + '</h2>' +
data.warnings.map(w => {
const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
return `
<div class="${sc.bg} border ${sc.border} rounded-lg p-3 transition-all duration-200 hover:shadow-md">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium ${sc.text}">${esc(w.headline)}</span>
<span class="text-xs px-2 py-0.5 rounded-full ${sc.pill}">${esc(w.severity)}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
</div>
`;
}).join("");
}
// Risk windows
if (data.riskWindows && data.riskWindows.length > 0) {
show("risk-windows-section");
$("risk-windows").innerHTML = data.riskWindows.map(w => {
const wc = riskColors[w.level] || riskColors.low;
return `
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
</div>
`;
}).join("");
}
// Timeline heatmap
if (data.timeline && data.timeline.length > 0) {
renderTimelineHeatmap(data.timeline);
}
// Room budgets
if (data.roomBudgets && data.roomBudgets.length > 0) {
show("budgets-section");
$("room-budgets").innerHTML = data.roomBudgets.map(rb => {
const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1);
const internalPct = (rb.internalGainsW / maxVal) * 100;
const solarPct = (rb.solarGainW / maxVal) * 100;
const ventPct = (rb.ventGainW / maxVal) * 100;
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
const bc = budgetBorderColors[rb.status] || budgetBorderColors.comfortable;
return `
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4 ${bc}">
<div class="font-medium text-sm mb-2">${esc(rb.roomName)}</div>
<div class="space-y-0.5 text-xs">
<div class="flex justify-between"><span>${esc(t().internalGains)}</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
<div class="flex justify-between"><span>${esc(t().solarGain)}</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
<div class="flex justify-between"><span>${esc(t().ventGain)}</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
<div class="flex justify-between font-medium"><span>${esc(t().totalGain)}</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
<div class="flex justify-between"><span>${esc(t().acCapacity)}</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
<div class="flex justify-between font-medium"><span>${esc(t().headroom)}</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
</div>
<div class="mt-2 space-y-1">
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
<div class="h-full bg-amber-400" style="width: ${internalPct.toFixed(1)}%" title="${esc(t().internalGains)}"></div>
<div class="h-full bg-orange-400" style="width: ${solarPct.toFixed(1)}%" title="${esc(t().solarGain)}"></div>
<div class="h-full bg-rose-400" style="width: ${ventPct.toFixed(1)}%" title="${esc(t().ventGain)}"></div>
</div>
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct.toFixed(1)}%"></div>
</div>
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>${esc(t().internalGains)}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>${esc(t().solarGain)}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>${esc(t().ventGain)}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>${esc(t().acCapacity)}</span>
</div>
</div>
</div>
`;
}).join("");
}
// Care checklist
if (data.careChecklist && data.careChecklist.length > 0) {
show("care-section");
$("care-checklist").innerHTML = data.careChecklist.map(item => `
<li class="flex items-start gap-2">
<input type="checkbox" class="mt-1 rounded">
<span class="text-sm">${esc(item)}</span>
</li>
`).join("");
}
// Fade in LLM skeleton
requestAnimationFrame(() => $("llm-summary").classList.remove("opacity-0"));
}
function formatHourRange(hours) {
if (hours.length === 0) return "";
if (hours.length === 1) return String(hours[0]).padStart(2, '0') + ":00";
const ranges = [];
let start = hours[0], end = hours[0];
for (let i = 1; i < hours.length; i++) {
if (hours[i] === end + 1) {
end = hours[i];
} else {
ranges.push(start === end
? String(start).padStart(2, '0') + ":00"
: String(start).padStart(2, '0') + ":00\u2013" + String(end).padStart(2, '0') + ":00");
start = end = hours[i];
}
}
ranges.push(start === end
? String(start).padStart(2, '0') + ":00"
: String(start).padStart(2, '0') + ":00\u2013" + String(end).padStart(2, '0') + ":00");
return ranges.join(", ");
}
// ========== AI Actions ==========
function renderAIActions(actions) {
const groups = {};
actions.forEach(a => {
const cat = a.category || "other";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(a);
});
const categoryOrder = ["shading", "ventilation", "internal_gains", "ac_strategy", "hydration", "care"];
const sortedCats = Object.keys(groups).sort((a, b) => {
const ia = categoryOrder.indexOf(a);
const ib = categoryOrder.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
const html = sortedCats.map(cat => {
const icon = categoryIcons[cat] || '';
const label = (t().category && t().category[cat]) || cat;
const cards = groups[cat].map(a => {
const hours = (a.hours || []).sort((x, y) => x - y);
const hourRange = formatHourRange(hours);
const ec = effortColors[a.effort] || effortColors.none;
const ic = impactColors[a.impact] || impactColors.low;
return `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
<div class="flex items-start justify-between gap-2">
<div class="font-medium text-sm">${esc(a.name)}</div>
<div class="text-xs text-gray-400 whitespace-nowrap">${hourRange}</div>
</div>
${a.description ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${esc(a.description)}</div>` : ''}
<div class="flex gap-2 mt-2">
<span class="text-xs px-2 py-0.5 rounded-full ${ec}">${esc(t().effort)}: ${esc(a.effort || 'none')}</span>
<span class="text-xs px-2 py-0.5 rounded-full ${ic}">${esc(t().impact)}: ${esc(a.impact || 'low')}</span>
</div>
</div>
`;
}).join("");
return `
<div>
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
${icon}
<span class="font-medium text-sm">${esc(label)}</span>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2">${cards}</div>
</div>
`;
}).join("");
hide("actions-loading");
show("actions-section");
const badge = $("actions-badge");
if (badge) {
badge.textContent = t().aiActions || "AI";
badge.classList.remove("hidden");
}
$("actions-list").innerHTML = html;
}
// ========== Heatmap Timeline ==========
const coolModeColors = {
ventilate: "#38bdf8",
ac: "#4ade80",
overloaded: "#f87171",
};
const coolModeLabels = () => ({
ventilate: t().coolVentilate || "Open windows",
ac: t().coolAC || "AC cooling",
overloaded: t().coolOverloaded || "AC overloaded",
});
function renderTimelineHeatmap(timeline) {
const container = $("timeline-chart");
const tooltip = $("timeline-tooltip");
const labels = coolModeLabels();
// Hour labels (every 3h)
const hourLabelsHtml = timeline.map(s => {
if (s.hour % 3 !== 0) return `<div></div>`;
return `<div class="text-center text-xs text-gray-400">${String(s.hour).padStart(2, "0")}</div>`;
}).join("");
// Temp cells
const tempCellsHtml = timeline.map((s, i) => {
const color = tempColorHex(s.tempC);
const textColor = s.tempC >= 35 ? "white" : "#1f2937";
return `<div class="hm-temp-cell flex items-center justify-center rounded-sm cursor-pointer" data-idx="${i}" style="background:${color};color:${textColor};height:48px">`
+ `<span class="hm-temp-label hidden sm:inline text-xs font-medium">${Math.round(s.tempC)}</span></div>`;
}).join("");
// Cooling mode cells
const coolCellsHtml = timeline.map(s => {
const mode = s.coolMode || "ac";
const color = coolModeColors[mode] || "#d1d5db";
return `<div class="rounded-sm" style="background:${color};height:14px"></div>`;
}).join("");
container.innerHTML = `
<div class="grid gap-px" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
${hourLabelsHtml}
</div>
<div class="grid gap-px mt-1" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
${tempCellsHtml}
</div>
<div class="grid gap-px mt-px" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
${coolCellsHtml}
</div>
`;
// Tooltip handlers
container.querySelectorAll(".hm-temp-cell").forEach(cell => {
const handler = () => {
const idx = parseInt(cell.dataset.idx);
const slot = timeline[idx];
const modeLabel = labels[slot.coolMode] || slot.coolMode || "";
tooltip.innerHTML = `
<div class="font-medium mb-1">${slot.hourStr}</div>
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
`;
const rect = cell.getBoundingClientRect();
const parentRect = tooltip.parentElement.getBoundingClientRect();
tooltip.style.left = (rect.left - parentRect.left + rect.width / 2 - 60) + "px";
tooltip.style.top = (rect.top - parentRect.top - tooltip.offsetHeight - 8) + "px";
tooltip.classList.remove("hidden");
};
cell.addEventListener("mouseenter", handler);
cell.addEventListener("click", handler);
});
container.addEventListener("mouseleave", () => tooltip.classList.add("hidden"));
// Legend
const usedModes = [...new Set(timeline.map(s => s.coolMode || "ac"))];
const legend = $("cooling-legend");
if (legend) {
legend.innerHTML = usedModes.map(mode => {
const color = coolModeColors[mode] || "#d1d5db";
const lbl = labels[mode] || mode;
return `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
}).join("");
}
}
function esc(s) {
if (!s) return "";
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Init
loadDashboard();
})();