All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s
Previously only showed import cost by tariff period; now also shows the feed-in/export credit components alongside it, plus a net (usage cost minus export credit) total. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
321 lines
16 KiB
HTML
321 lines
16 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="freshness-badge" id="live-updated"><span class="freshness-dot"></span><span id="live-updated-text">Loading…</span></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 -> Battery (charging) -- only shown for plants with a battery -->
|
|
<path id="flow-solar-battery" d="M 300 90 L 300 161" fill="none" stroke="var(--battery)" stroke-width="3"
|
|
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
|
|
<!-- Battery -> House (discharging), reverse of the above -->
|
|
<path id="flow-battery-house" d="M 266 195 L 155 195" fill="none" stroke="var(--battery)" stroke-width="3"
|
|
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></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">—</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">—</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">—</text>
|
|
|
|
<!-- Battery node -- hidden entirely for plants with no battery -->
|
|
<g id="live-battery-node" style="display:none;">
|
|
<g transform="translate(300 195)">
|
|
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
|
|
<path d="M -10,-14 L 10,-14 L 10,-9 L 13,-9 L 13,14 L -13,14 L -13,-9 L -10,-9 Z"
|
|
fill="none" stroke="var(--battery)" stroke-width="2" stroke-linejoin="round"></path>
|
|
<rect id="live-battery-fill" x="-10" y="8" width="20" height="0" fill="var(--battery)"></rect>
|
|
</g>
|
|
<text x="300" y="163" text-anchor="middle" class="live-label">Battery</text>
|
|
<text id="live-battery" x="300" y="230" text-anchor="middle" class="live-value">—</text>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="summary-card">
|
|
<div class="muted">Loading summary…</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
|
|
<h3 style="margin:0;">Cost by time-of-use period</h3>
|
|
<div class="pill-toggle" id="tou-range-toggle">
|
|
<button data-days="1" class="active">Today</button>
|
|
<button data-days="7">This week</button>
|
|
</div>
|
|
</div>
|
|
<div id="tou-breakdown-body" style="margin-top:0.75rem;">
|
|
<div class="muted">Loading…</div>
|
|
</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 '—';
|
|
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 badge = document.getElementById('live-updated');
|
|
const textEl = document.getElementById('live-updated-text');
|
|
const data = await fetch('/api/usage/live').then(r => r.json());
|
|
if (!data.available) {
|
|
badge.classList.add('stale');
|
|
textEl.textContent = 'No data yet';
|
|
return;
|
|
}
|
|
textEl.textContent = data.stale ? `${fmtAgo(data.ts)} (stale)` : fmtAgo(data.ts);
|
|
badge.classList.toggle('stale', !!data.stale);
|
|
|
|
const generation = data.generation_kw ?? 0;
|
|
const load = data.load_kw ?? 0;
|
|
const importKw = data.import_kw ?? 0;
|
|
const exportKw = data.export_kw ?? 0;
|
|
// From the house's own side of the meter, not generation's -- "generation
|
|
// minus export" overstates this for a battery system, since a chunk of
|
|
// that gap can be going into charging rather than the house.
|
|
const selfConsumed = Math.max(0, load - importKw);
|
|
|
|
document.getElementById('live-generation').textContent = fmtKw(generation).replace('—', '—');
|
|
document.getElementById('live-load').textContent = fmtKw(load).replace('—', '—');
|
|
|
|
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('—', '—')} 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('—', '—')} importing`;
|
|
} else {
|
|
gridLine.style.display = 'none';
|
|
gridLabel.textContent = 'no grid flow';
|
|
}
|
|
|
|
// Battery -- only plants with one report a SOC at all; the node stays
|
|
// hidden entirely (e.g. a solar-only plant) when it's null.
|
|
const batteryNode = document.getElementById('live-battery-node');
|
|
const soc = data.battery_soc_percent;
|
|
if (soc === null || soc === undefined) {
|
|
batteryNode.style.display = 'none';
|
|
document.getElementById('flow-solar-battery').style.display = 'none';
|
|
document.getElementById('flow-battery-house').style.display = 'none';
|
|
} else {
|
|
batteryNode.style.display = '';
|
|
const chargeKw = (data.battery_charge_w ?? 0) / 1000;
|
|
const dischargeKw = (data.battery_discharge_w ?? 0) / 1000;
|
|
document.getElementById('flow-solar-battery').style.display = chargeKw > 0.05 ? '' : 'none';
|
|
document.getElementById('flow-battery-house').style.display = dischargeKw > 0.05 ? '' : 'none';
|
|
|
|
const status = chargeKw > 0.05 ? `${fmtKw(chargeKw).replace('—', '—')} charging`
|
|
: dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('—', '—')} discharging`
|
|
: 'idle';
|
|
document.getElementById('live-battery').textContent = `${soc.toFixed(0)}% · ${status}`;
|
|
|
|
// Fill rises from the bottom of the battery icon's body (y=14) as SOC
|
|
// increases, capped at a max height of 20 (near the icon's top).
|
|
const fillHeight = Math.max(0, Math.min(20, (soc / 100) * 20));
|
|
const fill = document.getElementById('live-battery-fill');
|
|
fill.setAttribute('y', 14 - fillHeight);
|
|
fill.setAttribute('height', fillHeight);
|
|
}
|
|
}
|
|
|
|
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>`;
|
|
const selfConsumptionHtml = summary.self_consumption_pct === null || summary.self_consumption_pct === undefined
|
|
? '' : `<div title="Share of solar generation that wasn't exported"><div class="muted">Self-consumption</div><strong>${summary.self_consumption_pct}%</strong></div>`;
|
|
const solarCoverageHtml = summary.solar_coverage_pct === null || summary.solar_coverage_pct === undefined
|
|
? '' : `<div title="Share of household load met by solar rather than the grid"><div class="muted">Solar coverage</div><strong>${summary.solar_coverage_pct}%</strong></div>`;
|
|
summaryEl.innerHTML = `
|
|
<div class="muted">${summary.first_day} → ${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>
|
|
${selfConsumptionHtml}
|
|
${solarCoverageHtml}
|
|
${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' } } },
|
|
},
|
|
});
|
|
}
|
|
|
|
function touBarRows(components, maxAbsCost, fillClass) {
|
|
return components.map(c => `
|
|
<div class="tou-bar-row">
|
|
<div class="tou-bar-label">${c.label}</div>
|
|
<div class="tou-bar-track"><div class="tou-bar-fill ${fillClass}" style="width:${Math.max(2, 100 * Math.abs(c.cost_dollars) / maxAbsCost)}%;"></div></div>
|
|
<div class="tou-bar-amount">${c.cost_dollars < 0 ? '-' : ''}$${Math.abs(c.cost_dollars).toFixed(2)} · ${c.kwh.toFixed(1)} kWh</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function loadTouBreakdown(days) {
|
|
const body = document.getElementById('tou-breakdown-body');
|
|
const data = await fetch(`/api/usage/tou_breakdown?days=${days}`).then(r => r.json());
|
|
if (!data.available || (!data.usage_components.length && !data.feed_in_components.length)) {
|
|
body.innerHTML = '<div class="muted">No usage cost data yet for this period.</div>';
|
|
return;
|
|
}
|
|
const maxAbsCost = Math.max(
|
|
...data.usage_components.map(c => Math.abs(c.cost_dollars)),
|
|
...data.feed_in_components.map(c => Math.abs(c.cost_dollars)),
|
|
0.01
|
|
);
|
|
|
|
const usageHtml = data.usage_components.length
|
|
? `<div class="muted" style="margin-top:0.5rem;">Usage cost</div>${touBarRows(data.usage_components, maxAbsCost, 'tou-bar-fill-cost')}`
|
|
: '';
|
|
const feedInHtml = data.feed_in_components.length
|
|
? `<div class="muted" style="margin-top:0.75rem;">Solar export credit</div>${touBarRows(data.feed_in_components, maxAbsCost, 'tou-bar-fill-credit')}`
|
|
: '';
|
|
|
|
const net = data.net_usage_and_export_dollars;
|
|
body.innerHTML = usageHtml + feedInHtml + `
|
|
<div class="muted" style="margin-top:0.6rem;">
|
|
Usage cost $${data.total_usage_cost_dollars.toFixed(2)} − export credit $${Math.abs(data.total_feed_in_credit_dollars).toFixed(2)}
|
|
= <strong class="${net >= 0 ? 'savings-negative' : 'savings-positive'}">${net >= 0 ? '$' : '-$'}${Math.abs(net).toFixed(2)}</strong> net
|
|
over ${data.days_with_data} day(s) — excludes supply charge.
|
|
</div>`;
|
|
}
|
|
|
|
const touToggle = document.getElementById('tou-range-toggle');
|
|
touToggle.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button');
|
|
if (!btn) return;
|
|
touToggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
loadTouBreakdown(btn.dataset.days);
|
|
});
|
|
|
|
const rangeSelect = document.getElementById('range-select');
|
|
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
|
|
loadDashboard(rangeSelect.value);
|
|
|
|
loadTouBreakdown(1);
|
|
loadLiveFlow();
|
|
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
|
|
</script>
|
|
{% endblock %}
|