Replace CLI + SQLite architecture with a Go web server + vanilla JS frontend using IndexedDB for all client-side data storage. - Remove: cli, store, report, static packages - Add: compute engine (BuildDashboard), server package, web UI - Add: setup page with CRUD for profiles, rooms, devices, occupants, AC - Add: dashboard with SVG temperature timeline, risk analysis, care checklist - Add: i18n support (English/German) with server-side Go templates - Add: LLM provider selection UI with client-side API key storage - Add: per-room indoor temperature, edit buttons, language-aware AI summary
355 lines
14 KiB
JavaScript
355 lines
14 KiB
JavaScript
// Dashboard page logic
|
|
(function() {
|
|
"use strict";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
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 budgetColors = {
|
|
comfortable: "bg-green-500",
|
|
marginal: "bg-yellow-500",
|
|
overloaded: "bg-red-500",
|
|
};
|
|
|
|
const budgetHexColors = {
|
|
comfortable: "#22c55e",
|
|
marginal: "#eab308",
|
|
overloaded: "#ef4444",
|
|
};
|
|
|
|
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";
|
|
}
|
|
|
|
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 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: window.HG && window.HG.lang === "de" ? "German" : "English",
|
|
};
|
|
|
|
// Include client-side LLM settings if available
|
|
const llmProvider = await getSetting("llmProvider");
|
|
const llmApiKey = await getSetting("llmApiKey");
|
|
const llmModel = await getSetting("llmModel");
|
|
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) {
|
|
$("llm-summary").innerHTML = `<p class="text-sm whitespace-pre-line">${esc(llmData.summary)}</p>
|
|
<p class="text-xs text-gray-400 mt-2">AI-generated summary. Not a substitute for professional advice.</p>`;
|
|
} else {
|
|
$("llm-section").classList.add("hidden");
|
|
}
|
|
}
|
|
} catch (e) {
|
|
$("llm-section").classList.add("hidden");
|
|
}
|
|
|
|
} catch (err) {
|
|
hide("loading");
|
|
show("error-state");
|
|
console.error("Dashboard error:", err);
|
|
}
|
|
};
|
|
|
|
function renderDashboard(data) {
|
|
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
|
|
|
|
// Risk card
|
|
const rc = riskColors[data.riskLevel] || riskColors.low;
|
|
$("risk-card").className = `rounded-xl p-4 text-center shadow-sm ${rc.bg}`;
|
|
$("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
|
|
if (data.warnings && data.warnings.length > 0) {
|
|
show("warnings-section");
|
|
$("warnings-section").innerHTML = '<h2 class="text-lg font-semibold mb-2">Warnings</h2>' +
|
|
data.warnings.map(w => `
|
|
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
|
<div class="font-medium text-red-700 dark:text-red-300">${esc(w.headline)}</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">
|
|
<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 SVG chart + budget strip
|
|
if (data.timeline && data.timeline.length > 0) {
|
|
renderTimelineChart(data.timeline);
|
|
renderBudgetStrip(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 gainPct = Math.min((rb.totalGainBtuh / maxVal) * 100, 100);
|
|
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
|
|
return `
|
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="font-medium">${esc(rb.roomName)}</span>
|
|
</div>
|
|
<div class="space-y-1 text-xs">
|
|
<div class="flex justify-between"><span>Internal</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
|
|
<div class="flex justify-between"><span>Solar</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
|
|
<div class="flex justify-between"><span>Ventilation</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
|
|
<div class="flex justify-between font-medium"><span>Total</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
|
|
<div class="flex justify-between"><span>AC Capacity</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
|
|
<div class="flex justify-between font-medium"><span>Headroom</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
|
|
</div>
|
|
<div class="mt-2 space-y-1">
|
|
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
<div class="h-full rounded-full bg-red-400" style="width: ${gainPct}%"></div>
|
|
</div>
|
|
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
|
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct}%"></div>
|
|
</div>
|
|
<div class="flex justify-between text-xs text-gray-400">
|
|
<span>Gain</span><span>AC</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("");
|
|
}
|
|
}
|
|
|
|
// ========== SVG Timeline Chart ==========
|
|
function renderTimelineChart(timeline) {
|
|
const container = $("timeline-chart");
|
|
const width = 720;
|
|
const height = 200;
|
|
const padLeft = 40;
|
|
const padRight = 10;
|
|
const padTop = 15;
|
|
const padBottom = 25;
|
|
const chartW = width - padLeft - padRight;
|
|
const chartH = height - padTop - padBottom;
|
|
|
|
const temps = timeline.map(s => s.tempC);
|
|
const minTemp = Math.floor(Math.min(...temps) / 5) * 5;
|
|
const maxTemp = Math.ceil(Math.max(...temps) / 5) * 5;
|
|
const tempRange = maxTemp - minTemp || 10;
|
|
|
|
function x(i) { return padLeft + (i / (timeline.length - 1)) * chartW; }
|
|
function y(t) { return padTop + (1 - (t - minTemp) / tempRange) * chartH; }
|
|
|
|
// Build area path (temperature curve filled to bottom)
|
|
const points = timeline.map((s, i) => `${x(i).toFixed(1)},${y(s.tempC).toFixed(1)}`);
|
|
const linePath = `M${points.join(" L")}`;
|
|
const areaPath = `${linePath} L${x(timeline.length - 1).toFixed(1)},${(padTop + chartH).toFixed(1)} L${padLeft},${(padTop + chartH).toFixed(1)} Z`;
|
|
|
|
// Build gradient stops based on temperature
|
|
const gradientStops = timeline.map((s, i) => {
|
|
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
|
|
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}" stop-opacity="0.4"/>`;
|
|
}).join("");
|
|
|
|
const gradientStopsLine = timeline.map((s, i) => {
|
|
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
|
|
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}"/>`;
|
|
}).join("");
|
|
|
|
// Threshold lines
|
|
const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp);
|
|
const thresholdLines = thresholds.map(t =>
|
|
`<line x1="${padLeft}" y1="${y(t).toFixed(1)}" x2="${padLeft + chartW}" y2="${y(t).toFixed(1)}" stroke="#9ca3af" stroke-width="0.5" stroke-dasharray="4,3"/>
|
|
<text x="${padLeft - 3}" y="${(y(t) + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${t}\u00b0</text>`
|
|
).join("");
|
|
|
|
// Y-axis labels (min, max)
|
|
const yLabels = `
|
|
<text x="${padLeft - 3}" y="${(padTop + 4).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${maxTemp}\u00b0</text>
|
|
<text x="${padLeft - 3}" y="${(padTop + chartH + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${minTemp}\u00b0</text>
|
|
`;
|
|
|
|
// X-axis labels (every 3 hours)
|
|
const xLabels = timeline
|
|
.filter((s, i) => s.hour % 3 === 0)
|
|
.map((s, i) => {
|
|
const idx = timeline.findIndex(t => t.hour === s.hour);
|
|
return `<text x="${x(idx).toFixed(1)}" y="${(height - 3).toFixed(1)}" text-anchor="middle" fill="#9ca3af" font-size="9">${String(s.hour).padStart(2, '0')}</text>`;
|
|
}).join("");
|
|
|
|
// Data points (circles)
|
|
const circles = timeline.map((s, i) =>
|
|
`<circle cx="${x(i).toFixed(1)}" cy="${y(s.tempC).toFixed(1)}" r="4" fill="${tempColorHex(s.tempC)}" stroke="white" stroke-width="1.5" class="timeline-dot" data-idx="${i}" style="cursor:pointer"/>`
|
|
).join("");
|
|
|
|
const svg = `<svg viewBox="0 0 ${width} ${height}" class="w-full" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="areaGrad" x1="0" y1="0" x2="1" y2="0">${gradientStops}</linearGradient>
|
|
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">${gradientStopsLine}</linearGradient>
|
|
</defs>
|
|
<path d="${areaPath}" fill="url(#areaGrad)"/>
|
|
<path d="${linePath}" fill="none" stroke="url(#lineGrad)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
|
${thresholdLines}
|
|
${yLabels}
|
|
${xLabels}
|
|
${circles}
|
|
</svg>`;
|
|
|
|
container.innerHTML = svg;
|
|
|
|
// Tooltip on hover/click
|
|
const tooltip = $("timeline-tooltip");
|
|
container.querySelectorAll(".timeline-dot").forEach(dot => {
|
|
const handler = (e) => {
|
|
const idx = parseInt(dot.dataset.idx);
|
|
const slot = timeline[idx];
|
|
const actions = slot.actions && slot.actions.length > 0
|
|
? slot.actions.map(a => `\u2022 ${esc(a.name)}`).join("<br>")
|
|
: "No actions";
|
|
tooltip.innerHTML = `
|
|
<div class="font-medium mb-1">${slot.hourStr}</div>
|
|
<div>${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
|
|
<div class="capitalize">${slot.budgetStatus}</div>
|
|
<div class="mt-1 text-gray-300">${actions}</div>
|
|
`;
|
|
const rect = dot.getBoundingClientRect();
|
|
tooltip.style.left = (rect.left + window.scrollX - 60) + "px";
|
|
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 8) + "px";
|
|
tooltip.classList.remove("hidden");
|
|
};
|
|
dot.addEventListener("mouseenter", handler);
|
|
dot.addEventListener("click", handler);
|
|
});
|
|
|
|
container.addEventListener("mouseleave", () => tooltip.classList.add("hidden"));
|
|
}
|
|
|
|
// ========== Budget Status Strip ==========
|
|
function renderBudgetStrip(timeline) {
|
|
const strip = $("timeline-strip");
|
|
strip.innerHTML = timeline.map(slot => {
|
|
const color = budgetHexColors[slot.budgetStatus] || "#d1d5db";
|
|
return `<div class="flex-1 h-3 rounded-sm" style="background:${color}" title="${slot.hourStr}: ${slot.budgetStatus}"></div>`;
|
|
}).join("");
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return "";
|
|
const div = document.createElement("div");
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Init
|
|
loadDashboard();
|
|
})();
|