power-compare/app/web/templates/dashboard.html
dlawler489 3d7c8d9ca0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
Add live power-flow diagram and net revenue to the dashboard
New /api/usage/live endpoint derives instantaneous-ish kW from the most
recent interval reading, shown as a solar -> house/grid flow diagram
that updates every 30s and flips direction/color between exporting and
importing. /api/usage/summary now also reports net revenue for the
selected period, reusing the existing costing engine against the
current plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:52:16 +10:00

201 lines
8.8 KiB
HTML

{% extends "base.html" %}
{% block content %}
<h1>Usage Dashboard</h1>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Live system status</h3>
<span class="muted" id="live-updated">Loading&hellip;</span>
</div>
<div id="live-flow-wrap" style="margin-top:0.5rem;">
<svg id="live-flow" viewBox="0 0 600 230" style="width:100%; max-width:560px; display:block; margin:0 auto;">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="context-stroke"></path>
</marker>
</defs>
<!-- Solar -> House -->
<path id="flow-solar-house" d="M 260 70 C 200 130, 180 150, 150 175" fill="none" stroke="var(--accent)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
<!-- Solar -> Grid (export) / Grid -> House (import), same path, direction/color/arrow flipped by JS -->
<path id="flow-solar-grid" d="M 340 70 C 400 130, 420 150, 450 175" fill="none" stroke="var(--good)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
<!-- Solar node -->
<g transform="translate(300 55)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<g stroke="var(--fg)" stroke-width="2" fill="none" stroke-linecap="round">
<circle r="8" cy="0"></circle>
<line x1="0" y1="-18" x2="0" y2="-13"></line>
<line x1="0" y1="13" x2="0" y2="18"></line>
<line x1="-18" y1="0" x2="-13" y2="0"></line>
<line x1="13" y1="0" x2="18" y2="0"></line>
<line x1="-12.7" y1="-12.7" x2="-9.2" y2="-9.2"></line>
<line x1="9.2" y1="9.2" x2="12.7" y2="12.7"></line>
<line x1="12.7" y1="-12.7" x2="9.2" y2="-9.2"></line>
<line x1="-9.2" y1="9.2" x2="-12.7" y2="12.7"></line>
</g>
</g>
<text x="300" y="10" text-anchor="middle" class="live-label">Solar</text>
<text id="live-generation" x="300" y="107" text-anchor="middle" class="live-value">&mdash;</text>
<!-- House node -->
<g transform="translate(120 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<path d="M -14,4 L -14,-4 L 0,-16 L 14,-4 L 14,4 L 8,4 L 8,14 L -8,14 L -8,4 Z"
fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
</g>
<text x="120" y="163" text-anchor="middle" class="live-label">House</text>
<text id="live-load" x="120" y="230" text-anchor="middle" class="live-value">&mdash;</text>
<!-- Grid node -->
<g transform="translate(480 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<path d="M 2,-16 L -8,2 L -1,2 L -3,16 L 10,-4 L 2,-4 Z"
fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
</g>
<text x="480" y="163" text-anchor="middle" class="live-label">Grid</text>
<text id="live-grid" x="480" y="230" text-anchor="middle" class="live-value">&mdash;</text>
</svg>
</div>
</div>
<div class="card" id="summary-card">
<div class="muted">Loading summary&hellip;</div>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Generation / load / import / export</h3>
<select id="range-select">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last 365 days</option>
<option value="all">All time</option>
</select>
</div>
<div class="chart-container">
<canvas id="usage-chart"></canvas>
</div>
</div>
<script>
const FLOW_REFRESH_MS = 30000;
function fmtKw(kw) {
if (kw === null || kw === undefined) return '&mdash;';
return `${kw.toFixed(1)} kW`;
}
function fmtAgo(isoTs) {
const seconds = Math.max(0, Math.round((Date.now() - new Date(isoTs).getTime()) / 1000));
if (seconds < 90) return 'updated just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `updated ${minutes} min ago`;
return `updated ${Math.round(minutes / 60)} h ago`;
}
async function loadLiveFlow() {
const el = document.getElementById('live-updated');
const data = await fetch('/api/usage/live').then(r => r.json());
if (!data.available) {
el.textContent = 'No data yet';
return;
}
el.textContent = fmtAgo(data.ts);
const generation = data.generation_kw ?? 0;
const load = data.load_kw ?? 0;
const importKw = data.import_kw ?? 0;
const exportKw = data.export_kw ?? 0;
const selfConsumed = Math.max(0, generation - exportKw);
document.getElementById('live-generation').textContent = fmtKw(generation).replace('&mdash;', '—');
document.getElementById('live-load').textContent = fmtKw(load).replace('&mdash;', '—');
const solarHouseLine = document.getElementById('flow-solar-house');
solarHouseLine.style.display = selfConsumed > 0.05 ? '' : 'none';
const gridLine = document.getElementById('flow-solar-grid');
const gridLabel = document.getElementById('live-grid');
if (exportKw > 0.05) {
gridLine.setAttribute('stroke', 'var(--good)');
gridLine.setAttribute('d', 'M 340 70 C 400 130, 420 150, 450 175');
gridLine.style.display = '';
gridLabel.textContent = `${fmtKw(exportKw).replace('&mdash;', '—')} exporting`;
} else if (importKw > 0.05) {
gridLine.setAttribute('stroke', 'var(--bad)');
// Same curve, reversed direction (grid -> solar node's side, feeding the house).
gridLine.setAttribute('d', 'M 450 175 C 420 150, 400 130, 340 70');
gridLine.style.display = '';
gridLabel.textContent = `${fmtKw(importKw).replace('&mdash;', '—')} importing`;
} else {
gridLine.style.display = 'none';
gridLabel.textContent = 'no grid flow';
}
}
async function loadDashboard(days) {
const [summary, daily] = await Promise.all([
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
fetch(`/api/usage/daily?days=${days}`).then(r => r.json()),
]);
const summaryEl = document.getElementById('summary-card');
if (!summary.days_with_data) {
summaryEl.innerHTML = '<div class="muted">No usage data yet. If you\'re running on mock data, it should appear within a few minutes of startup.</div>';
} else {
const revenue = summary.net_revenue_dollars;
const revenueHtml = revenue === null || revenue === undefined
? ''
: `<div><div class="muted">Net revenue</div><strong class="${revenue >= 0 ? 'savings-positive' : 'savings-negative'}">${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}</strong></div>`;
summaryEl.innerHTML = `
<div class="muted">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div>
<div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;">
<div><div class="muted">Generated</div><strong>${summary.generation_kwh} kWh</strong></div>
<div><div class="muted">Consumed</div><strong>${summary.load_kwh} kWh</strong></div>
<div><div class="muted">Imported</div><strong>${summary.import_kwh} kWh</strong></div>
<div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></div>
${revenueHtml}
</div>
`;
}
const ctx = document.getElementById('usage-chart');
if (window.usageChart) window.usageChart.destroy();
// Chart.js can measure a stale (pre-layout) container size when created
// synchronously during initial page parse. Wait a couple of frames so the
// browser has actually settled the flex/grid layout first.
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
window.usageChart = new Chart(ctx, {
type: 'bar',
data: {
labels: daily.map(d => d.date),
datasets: [
{ label: 'Generation', data: daily.map(d => d.generation_kwh), backgroundColor: '#facc15' },
{ label: 'Load', data: daily.map(d => d.load_kwh), backgroundColor: '#60a5fa' },
{ label: 'Import', data: daily.map(d => d.import_kwh), backgroundColor: '#f87171' },
{ label: 'Export', data: daily.map(d => d.export_kwh), backgroundColor: '#4ade80' },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
scales: { x: { stacked: false }, y: { title: { display: true, text: 'kWh' } } },
},
});
}
const rangeSelect = document.getElementById('range-select');
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
loadDashboard(rangeSelect.value);
loadLiveFlow();
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
</script>
{% endblock %}