All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
- Period summary and battery stats now render as an icon-led, responsive stat grid (.stat-grid) instead of a flat flex row. - Generation/load/import/export stats get a real inline sparkline from the same daily series already fetched for the bar chart. Self-consumption, solar coverage and net revenue get a direction-aware trend badge against an equal-length prior period (new `offset_days` param on /api/usage/summary shifts the trailing window back for this comparison). - Live flow diagram: static "conduit" wires now stay visible underneath the animated overlay, flow lines redrawn as glowing round-dot particles (was a plain dashed line) whose speed now scales with the actual kW flowing, and each node's ring lights up in that flow's color while active. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
667 lines
33 KiB
HTML
667 lines
33 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<h1>Usage Dashboard</h1>
|
|
|
|
<div class="hero-card" id="hero-card" style="display:none;">
|
|
<div>
|
|
<div class="hero-label">Today's net position</div>
|
|
<div class="hero-value" id="hero-value">—</div>
|
|
</div>
|
|
<div class="hero-trend" id="hero-trend"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title">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="5" markerHeight="5" orient="auto-start-reverse">
|
|
<path d="M0,0 L10,5 L0,10 z" fill="context-stroke"></path>
|
|
</marker>
|
|
<filter id="flow-glow" x="-60%" y="-60%" width="220%" height="220%">
|
|
<feGaussianBlur stdDeviation="1.6" result="blur"></feGaussianBlur>
|
|
<feMerge>
|
|
<feMergeNode in="blur"></feMergeNode>
|
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- Static conduits: the wiring always exists, independent of whether
|
|
current is flowing right now -- only the animated overlay path on
|
|
top indicates live direction/flow. -->
|
|
<path d="M 260 70 C 200 130, 180 150, 150 175" fill="none" stroke="var(--border)" stroke-width="2"></path>
|
|
<path d="M 340 70 C 400 130, 420 150, 450 175" fill="none" stroke="var(--border)" stroke-width="2"></path>
|
|
<path id="conduit-battery" d="M 300 90 L 300 161" fill="none" stroke="var(--border)" stroke-width="2" style="display:none;"></path>
|
|
<path id="conduit-battery-house" d="M 266 195 L 155 195" fill="none" stroke="var(--border)" stroke-width="2" style="display:none;"></path>
|
|
|
|
<!-- 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="4"
|
|
stroke-linecap="round" stroke-dasharray="0.5 11" marker-end="url(#arrow)" class="flow-line" filter="url(#flow-glow)"></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="4"
|
|
stroke-linecap="round" stroke-dasharray="0.5 11" marker-end="url(#arrow)" class="flow-line" filter="url(#flow-glow)"></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="4"
|
|
stroke-linecap="round" stroke-dasharray="0.5 11" marker-end="url(#arrow)" class="flow-line" filter="url(#flow-glow)" 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="4"
|
|
stroke-linecap="round" stroke-dasharray="0.5 11" marker-end="url(#arrow)" class="flow-line" filter="url(#flow-glow)" style="display:none;"></path>
|
|
|
|
<!-- Solar node -->
|
|
<g transform="translate(300 55)">
|
|
<circle id="solar-node-ring" r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5" style="transition:stroke 0.4s;"></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 id="house-node-ring" r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5" style="transition:stroke 0.4s;"></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 id="grid-node-ring" r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5" style="transition:stroke 0.4s;"></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 id="battery-node-ring" r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5" style="transition:stroke 0.4s;"></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 class="card-header">
|
|
<h3 class="card-title">Cost by time-of-use period</h3>
|
|
<select id="tou-range-select">
|
|
<option value="1" selected>Today</option>
|
|
<option value="7">This week</option>
|
|
</select>
|
|
</div>
|
|
<div id="tou-breakdown-body" style="margin-top:0.75rem;">
|
|
<div class="muted">Loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="battery-card" style="display:none;">
|
|
<div class="card-header">
|
|
<h3 class="card-title">Battery</h3>
|
|
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
|
<select id="battery-range-select">
|
|
<option value="1" selected>Today</option>
|
|
<option value="3">Last 3 days</option>
|
|
<option value="7">This week</option>
|
|
<option value="30">Last 30 days</option>
|
|
<option value="365">Last 365 days</option>
|
|
<option value="all">All time</option>
|
|
<option value="custom">Custom range…</option>
|
|
</select>
|
|
<span id="battery-custom-range" style="display:none; gap:0.4rem; align-items:center;">
|
|
<input type="date" id="battery-start-date">
|
|
<span class="muted">to</span>
|
|
<input type="date" id="battery-end-date">
|
|
<button id="battery-apply-range" style="margin:0; padding:0.35rem 0.7rem; font-size:0.85rem;">Apply</button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div id="battery-coverage-note" class="muted" style="margin-top:0.4rem;"></div>
|
|
<div class="chart-container" style="height:220px;">
|
|
<canvas id="battery-soc-chart"></canvas>
|
|
</div>
|
|
<div id="battery-value-body" style="margin-top:0.75rem;">
|
|
<div class="muted">Loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title">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 cssVar(name) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
|
|
// Feather-style line icons (MIT), inlined so the dashboard doesn't need an
|
|
// icon font/sprite dependency -- just the handful actually used here.
|
|
const STAT_ICON_PATHS = {
|
|
sun: '<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>',
|
|
home: '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>',
|
|
arrowDown: '<line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline>',
|
|
arrowUp: '<line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline>',
|
|
loop: '<polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>',
|
|
pie: '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"></path><path d="M22 12A10 10 0 0 0 12 2v10z"></path>',
|
|
dollar: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>',
|
|
batteryCharging: '<path d="M5 18H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3.19"></path><path d="M15 6h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3.19"></path><line x1="23" y1="13" x2="23" y2="11"></line><polyline points="11 6 7 12 13 12 9 18"></polyline>',
|
|
battery: '<rect x="1" y="6" width="18" height="12" rx="2" ry="2"></rect><line x1="23" y1="13" x2="23" y2="11"></line>',
|
|
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>',
|
|
undo: '<polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path>',
|
|
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>',
|
|
};
|
|
|
|
function statIcon(name) {
|
|
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${STAT_ICON_PATHS[name] || ''}</svg>`;
|
|
}
|
|
|
|
// A tiny inline sparkline (no charting library) from a plain array of
|
|
// numbers -- used under the per-metric stats where we already have the
|
|
// daily series on hand from the main chart's own data fetch.
|
|
function sparkline(values, colorVar) {
|
|
if (!values || values.length < 2) return '';
|
|
const w = 90, h = 24, pad = 2;
|
|
const min = Math.min(...values), max = Math.max(...values);
|
|
const range = max - min || 1;
|
|
const points = values.map((v, i) => {
|
|
const x = pad + (i / (values.length - 1)) * (w - pad * 2);
|
|
const y = h - pad - ((v - min) / range) * (h - pad * 2);
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
}).join(' ');
|
|
return `<svg class="stat-sparkline" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" preserveAspectRatio="none">
|
|
<polyline points="${points}" fill="none" stroke="${colorVar}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></polyline>
|
|
</svg>`;
|
|
}
|
|
|
|
// Builds one .stat-item block. `trend` is optional HTML (a delta badge or
|
|
// a sparkline) shown under the value.
|
|
function statItem({ icon, colorVar, label, value, valueClass, trend }) {
|
|
return `
|
|
<div class="stat-item">
|
|
<div class="stat-icon" style="background:color-mix(in srgb, ${colorVar} 16%, transparent); color:${colorVar};">${statIcon(icon)}</div>
|
|
<div class="stat-body">
|
|
<div class="stat-label">${label}</div>
|
|
<div class="stat-value ${valueClass || ''}">${value}</div>
|
|
${trend || ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Direction-aware trend badge: the arrow always reflects which way the
|
|
// number actually moved, and the color reflects whether that direction is
|
|
// good for this particular metric (e.g. a rise in cost is bad, a rise in
|
|
// self-consumption is good).
|
|
function deltaTrend(delta, formatFn, higherIsBetter) {
|
|
if (Math.abs(delta) < 1e-9) return '<div class="stat-trend">No change vs previous period</div>';
|
|
const isIncrease = delta > 0;
|
|
const isGood = higherIsBetter ? isIncrease : !isIncrease;
|
|
return `<div class="stat-trend ${isGood ? 'good' : 'bad'}">${isIncrease ? '▲' : '▼'} ${formatFn(Math.abs(delta))} vs previous period</div>`;
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
function flowAnimationSpeed(kw) {
|
|
// Faster-moving particles read as "more power flowing" -- clamped so a
|
|
// tiny trickle doesn't crawl to a standstill and a huge spike doesn't
|
|
// strobe illegibly.
|
|
const seconds = Math.max(0.35, Math.min(2.2, 1.4 / Math.max(kw, 0.15)));
|
|
return `${seconds.toFixed(2)}s`;
|
|
}
|
|
|
|
function setNodeActive(id, active, color) {
|
|
document.getElementById(id).setAttribute('stroke', active ? color : 'var(--border)');
|
|
}
|
|
|
|
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('—', '—');
|
|
setNodeActive('solar-node-ring', generation > 0.05, 'var(--battery)');
|
|
setNodeActive('house-node-ring', load > 0.05, 'var(--accent)');
|
|
|
|
const solarHouseLine = document.getElementById('flow-solar-house');
|
|
solarHouseLine.style.display = selfConsumed > 0.05 ? '' : 'none';
|
|
if (selfConsumed > 0.05) solarHouseLine.style.animationDuration = flowAnimationSpeed(selfConsumed);
|
|
|
|
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 = '';
|
|
gridLine.style.animationDuration = flowAnimationSpeed(exportKw);
|
|
gridLabel.textContent = `${fmtKw(exportKw).replace('—', '—')} exporting`;
|
|
setNodeActive('grid-node-ring', true, 'var(--good)');
|
|
} 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 = '';
|
|
gridLine.style.animationDuration = flowAnimationSpeed(importKw);
|
|
gridLabel.textContent = `${fmtKw(importKw).replace('—', '—')} importing`;
|
|
setNodeActive('grid-node-ring', true, 'var(--bad)');
|
|
} else {
|
|
gridLine.style.display = 'none';
|
|
gridLabel.textContent = 'no grid flow';
|
|
setNodeActive('grid-node-ring', false);
|
|
}
|
|
|
|
// 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 batteryConduit = document.getElementById('conduit-battery');
|
|
const batteryHouseConduit = document.getElementById('conduit-battery-house');
|
|
const soc = data.battery_soc_percent;
|
|
if (soc === null || soc === undefined) {
|
|
batteryNode.style.display = 'none';
|
|
batteryConduit.style.display = 'none';
|
|
batteryHouseConduit.style.display = 'none';
|
|
document.getElementById('flow-solar-battery').style.display = 'none';
|
|
document.getElementById('flow-battery-house').style.display = 'none';
|
|
} else {
|
|
batteryNode.style.display = '';
|
|
batteryConduit.style.display = '';
|
|
batteryHouseConduit.style.display = '';
|
|
const chargeKw = (data.battery_charge_w ?? 0) / 1000;
|
|
const dischargeKw = (data.battery_discharge_w ?? 0) / 1000;
|
|
const solarBatteryLine = document.getElementById('flow-solar-battery');
|
|
const batteryHouseLine = document.getElementById('flow-battery-house');
|
|
solarBatteryLine.style.display = chargeKw > 0.05 ? '' : 'none';
|
|
batteryHouseLine.style.display = dischargeKw > 0.05 ? '' : 'none';
|
|
if (chargeKw > 0.05) solarBatteryLine.style.animationDuration = flowAnimationSpeed(chargeKw);
|
|
if (dischargeKw > 0.05) batteryHouseLine.style.animationDuration = flowAnimationSpeed(dischargeKw);
|
|
setNodeActive('battery-node-ring', chargeKw > 0.05 || dischargeKw > 0.05, 'var(--battery)');
|
|
|
|
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 fetches = [
|
|
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
|
|
fetch(`/api/usage/daily?days=${days}`).then(r => r.json()),
|
|
];
|
|
// "All time" has no equal-length prior period to compare against.
|
|
if (days !== 'all') fetches.push(fetch(`/api/usage/summary?days=${days}&offset_days=${days}`).then(r => r.json()));
|
|
const [summary, daily, previous] = await Promise.all(fetches);
|
|
|
|
const summaryEl = document.getElementById('summary-card');
|
|
if (!summary.days_with_data) {
|
|
summaryEl.innerHTML = '<h3 class="card-title">Period summary</h3><div class="muted" style="margin-top:0.5rem;">No usage data yet. If you\'re running on mock data, it should appear within a few minutes of startup.</div>';
|
|
} else {
|
|
const hasPrevious = previous && previous.days_with_data > 0;
|
|
|
|
const kwhTrend = (key, colorVar) => sparkline(daily.map(d => d[key]), colorVar);
|
|
|
|
const revenue = summary.net_revenue_dollars;
|
|
const revenueTrend = hasPrevious && revenue !== null && previous.net_revenue_dollars !== null
|
|
? deltaTrend(revenue - previous.net_revenue_dollars, v => `$${v.toFixed(2)}`, true)
|
|
: '';
|
|
const selfConTrend = hasPrevious && summary.self_consumption_pct !== null && previous.self_consumption_pct !== null
|
|
? deltaTrend(summary.self_consumption_pct - previous.self_consumption_pct, v => `${v.toFixed(1)}pp`, true)
|
|
: '';
|
|
const solarCovTrend = hasPrevious && summary.solar_coverage_pct !== null && previous.solar_coverage_pct !== null
|
|
? deltaTrend(summary.solar_coverage_pct - previous.solar_coverage_pct, v => `${v.toFixed(1)}pp`, true)
|
|
: '';
|
|
|
|
const items = [
|
|
statItem({ icon: 'sun', colorVar: 'var(--battery)', label: 'Generated', value: `${summary.generation_kwh} kWh`, trend: kwhTrend('generation_kwh', cssVar('--battery')) }),
|
|
statItem({ icon: 'home', colorVar: 'var(--accent)', label: 'Consumed', value: `${summary.load_kwh} kWh`, trend: kwhTrend('load_kwh', cssVar('--accent')) }),
|
|
statItem({ icon: 'arrowDown', colorVar: 'var(--bad)', label: 'Imported', value: `${summary.import_kwh} kWh`, trend: kwhTrend('import_kwh', cssVar('--bad')) }),
|
|
statItem({ icon: 'arrowUp', colorVar: 'var(--good)', label: 'Exported', value: `${summary.export_kwh} kWh`, trend: kwhTrend('export_kwh', cssVar('--good')) }),
|
|
];
|
|
if (summary.self_consumption_pct !== null && summary.self_consumption_pct !== undefined) {
|
|
items.push(statItem({ icon: 'loop', colorVar: 'var(--accent)', label: 'Self-consumption', value: `${summary.self_consumption_pct}%`, trend: selfConTrend }));
|
|
}
|
|
if (summary.solar_coverage_pct !== null && summary.solar_coverage_pct !== undefined) {
|
|
items.push(statItem({ icon: 'pie', colorVar: 'var(--accent)', label: 'Solar coverage', value: `${summary.solar_coverage_pct}%`, trend: solarCovTrend }));
|
|
}
|
|
if (revenue !== null && revenue !== undefined) {
|
|
items.push(statItem({
|
|
icon: 'dollar', colorVar: revenue >= 0 ? 'var(--good)' : 'var(--bad)', label: 'Net revenue',
|
|
value: `${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}`,
|
|
valueClass: revenue >= 0 ? 'savings-positive' : 'savings-negative', trend: revenueTrend,
|
|
}));
|
|
}
|
|
|
|
summaryEl.innerHTML = `
|
|
<h3 class="card-title">Period summary</h3>
|
|
<div class="muted" style="margin-top:0.35rem;">${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)</div>
|
|
<div class="stat-grid">${items.join('')}</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: cssVar('--battery') },
|
|
{ label: 'Load', data: daily.map(d => d.load_kwh), backgroundColor: cssVar('--accent') },
|
|
{ label: 'Import', data: daily.map(d => d.import_kwh), backgroundColor: cssVar('--bad') },
|
|
{ label: 'Export', data: daily.map(d => d.export_kwh), backgroundColor: cssVar('--good') },
|
|
],
|
|
},
|
|
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 touRangeSelect = document.getElementById('tou-range-select');
|
|
touRangeSelect.addEventListener('change', () => loadTouBreakdown(touRangeSelect.value));
|
|
|
|
function batteryQueryString(range) {
|
|
return range.startDate
|
|
? `start_date=${range.startDate}&end_date=${range.endDate}`
|
|
: `days=${range.days}`;
|
|
}
|
|
|
|
async function loadBatterySection(range) {
|
|
const qs = batteryQueryString(range);
|
|
const [history, value] = await Promise.all([
|
|
fetch(`/api/usage/battery_history?${qs}`).then(r => r.json()),
|
|
fetch(`/api/usage/battery_value?${qs}`).then(r => r.json()),
|
|
]);
|
|
|
|
const card = document.getElementById('battery-card');
|
|
// No battery telemetry ever recorded -> this plant has no battery (or
|
|
// predates the feature); hide the whole card rather than show an
|
|
// always-empty one, same convention as the live flow diagram's battery node.
|
|
if (!history.available) {
|
|
card.style.display = 'none';
|
|
return;
|
|
}
|
|
card.style.display = '';
|
|
|
|
const coverageNote = document.getElementById('battery-coverage-note');
|
|
if (history.days_with_data < history.range_days) {
|
|
// Battery telemetry is only ever captured on the live "today" poll,
|
|
// never backfilled -- so a long or "all time" range will usually have
|
|
// real gaps (before the battery existed, before this feature shipped,
|
|
// or a day the system was offline). Say so plainly rather than let a
|
|
// sparse chart look like a data bug.
|
|
coverageNote.textContent = `${history.days_with_data} of ${history.range_days} day(s) in this range have battery data.`;
|
|
} else {
|
|
coverageNote.textContent = `${history.days_with_data} day(s) of battery data.`;
|
|
}
|
|
|
|
// Multi-day spans get a date in the label; a single day just gets the
|
|
// time -- based on what the data actually spans, not the range picked,
|
|
// since a long range with sparse data can still resolve to under a day.
|
|
const first = history.points[0] ? new Date(history.points[0].ts) : null;
|
|
const last = history.points.length ? new Date(history.points[history.points.length - 1].ts) : null;
|
|
const multiDay = first && last && (last - first) > 24 * 60 * 60 * 1000;
|
|
const labels = history.points.map(p => {
|
|
const d = new Date(p.ts);
|
|
return d.toLocaleString('en-AU', {
|
|
day: multiDay ? '2-digit' : undefined,
|
|
month: multiDay ? '2-digit' : undefined,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
});
|
|
|
|
const ctx = document.getElementById('battery-soc-chart');
|
|
if (window.batteryChart) window.batteryChart.destroy();
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
window.batteryChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'Battery SOC %',
|
|
data: history.points.map(p => p.soc_percent),
|
|
borderColor: cssVar('--battery'),
|
|
backgroundColor: cssVar('--battery') + '26',
|
|
fill: true,
|
|
pointRadius: 0,
|
|
borderWidth: 2,
|
|
tension: 0.2,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
scales: {
|
|
x: { ticks: { maxTicksLimit: 8, autoSkip: true } },
|
|
y: { min: 0, max: 100, title: { display: true, text: 'SOC %' } },
|
|
},
|
|
},
|
|
});
|
|
|
|
const valueBody = document.getElementById('battery-value-body');
|
|
if (!value.available) {
|
|
valueBody.innerHTML = '<div class="muted">Not enough charge/discharge data yet to estimate value.</div>';
|
|
return;
|
|
}
|
|
const net = value.net_value_dollars;
|
|
const approxNote = value.uses_tiered_feed_in_approximation
|
|
? '<div class="muted" style="margin-top:0.5rem;">Export credit side is approximated: your plan\'s feed-in tariff depends on daily export volume, not time of day.</div>'
|
|
: '';
|
|
const batteryItems = [
|
|
statItem({ icon: 'batteryCharging', colorVar: 'var(--battery)', label: 'Charged', value: `${value.charged_kwh.toFixed(1)} kWh` }),
|
|
statItem({ icon: 'battery', colorVar: 'var(--battery)', label: 'Discharged', value: `${value.discharged_kwh.toFixed(1)} kWh` }),
|
|
statItem({ icon: 'shield', colorVar: 'var(--good)', label: 'Avoided import cost', value: `+$${value.avoided_import_dollars.toFixed(2)}`, valueClass: 'savings-positive' }),
|
|
statItem({ icon: 'undo', colorVar: 'var(--bad)', label: 'Foregone export credit', value: `-$${value.foregone_export_dollars.toFixed(2)}`, valueClass: 'savings-negative' }),
|
|
statItem({
|
|
icon: 'star', colorVar: net >= 0 ? 'var(--good)' : 'var(--bad)', label: 'Estimated battery value',
|
|
value: `${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}`, valueClass: net >= 0 ? 'savings-positive' : 'savings-negative',
|
|
}),
|
|
];
|
|
valueBody.innerHTML = `<div class="stat-grid">${batteryItems.join('')}</div>${approxNote}`;
|
|
}
|
|
|
|
const batteryRangeSelect = document.getElementById('battery-range-select');
|
|
const batteryCustomRange = document.getElementById('battery-custom-range');
|
|
const batteryStartDate = document.getElementById('battery-start-date');
|
|
const batteryEndDate = document.getElementById('battery-end-date');
|
|
const batteryApplyRange = document.getElementById('battery-apply-range');
|
|
|
|
batteryRangeSelect.addEventListener('change', () => {
|
|
if (batteryRangeSelect.value === 'custom') {
|
|
batteryCustomRange.style.display = 'inline-flex';
|
|
// Default the pickers to a sensible starting point (last 30 days)
|
|
// rather than leaving them empty with nothing to Apply yet.
|
|
if (!batteryEndDate.value) {
|
|
const today = new Date();
|
|
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
batteryEndDate.value = today.toISOString().slice(0, 10);
|
|
batteryStartDate.value = monthAgo.toISOString().slice(0, 10);
|
|
}
|
|
return;
|
|
}
|
|
batteryCustomRange.style.display = 'none';
|
|
loadBatterySection({ days: batteryRangeSelect.value });
|
|
});
|
|
|
|
batteryApplyRange.addEventListener('click', () => {
|
|
if (!batteryStartDate.value || !batteryEndDate.value) return;
|
|
loadBatterySection({ startDate: batteryStartDate.value, endDate: batteryEndDate.value });
|
|
});
|
|
|
|
async function loadHero() {
|
|
const [today, week] = await Promise.all([
|
|
fetch('/api/usage/summary?days=1').then(r => r.json()),
|
|
fetch('/api/usage/summary?days=8').then(r => r.json()),
|
|
]);
|
|
|
|
const heroCard = document.getElementById('hero-card');
|
|
const todayNet = today.net_revenue_dollars;
|
|
if (!today.days_with_data || todayNet === null || todayNet === undefined) {
|
|
heroCard.style.display = 'none';
|
|
return;
|
|
}
|
|
heroCard.style.display = '';
|
|
|
|
const valueEl = document.getElementById('hero-value');
|
|
valueEl.textContent = `${todayNet >= 0 ? '+$' : '-$'}${Math.abs(todayNet).toFixed(2)}`;
|
|
valueEl.className = `hero-value ${todayNet >= 0 ? 'savings-positive' : 'savings-negative'}`;
|
|
|
|
const trendEl = document.getElementById('hero-trend');
|
|
const weekNet = week.net_revenue_dollars;
|
|
// Costed in dollars, not swapped-sign "net revenue", so the comparison
|
|
// reads naturally: a lower cost today than the recent daily average is
|
|
// the good outcome, regardless of which household is running a surplus
|
|
// or a deficit overall.
|
|
if (!week.days_with_data || weekNet === null || weekNet === undefined) {
|
|
trendEl.textContent = '';
|
|
return;
|
|
}
|
|
const avgDailyCost = -weekNet / week.days_with_data;
|
|
const todayCost = -todayNet;
|
|
const delta = todayCost - avgDailyCost;
|
|
if (Math.abs(delta) < 0.01) {
|
|
trendEl.textContent = 'About the same as your recent daily average';
|
|
trendEl.className = 'hero-trend';
|
|
} else if (delta < 0) {
|
|
trendEl.textContent = `▼ $${Math.abs(delta).toFixed(2)} cheaper than your recent daily average`;
|
|
trendEl.className = 'hero-trend good';
|
|
} else {
|
|
trendEl.textContent = `▲ $${delta.toFixed(2)} more than your recent daily average`;
|
|
trendEl.className = 'hero-trend bad';
|
|
}
|
|
}
|
|
|
|
const rangeSelect = document.getElementById('range-select');
|
|
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
|
|
loadDashboard(rangeSelect.value);
|
|
|
|
loadHero();
|
|
loadTouBreakdown(1);
|
|
loadBatterySection({ days: '1' });
|
|
loadLiveFlow();
|
|
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
|
|
</script>
|
|
{% endblock %}
|