Add trend sparklines, icon-based stat grid, and redraw the live flow diagram
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>
This commit is contained in:
dlawler489 2026-09-13 15:49:39 +10:00
parent 8d394c4dfa
commit 8423f01014
3 changed files with 218 additions and 49 deletions

View file

@ -103,8 +103,15 @@ def daily_usage(days: str = Query("30")) -> list[dict]:
@router.get("/summary") @router.get("/summary")
def usage_summary(days: str = Query("30")) -> dict: def usage_summary(days: str = Query("30"), offset_days: int = Query(0)) -> dict:
end = datetime.now(timezone.utc) """`offset_days` shifts the whole trailing window back in time by that
many days (e.g. days=30&offset_days=30 is "the 30 days before the
current 30-day period") -- lets the dashboard fetch an equal-length
prior period for a simple trend comparison, without a separate
calendar-date endpoint."""
if offset_days < 0:
raise HTTPException(400, "offset_days must be >= 0")
end = datetime.now(timezone.utc) - timedelta(days=offset_days)
with get_session() as session: with get_session() as session:
start = _resolve_start(session, days, end) start = _resolve_start(session, days, end)
rows = get_usage_series(session, start, end) rows = get_usage_series(session, start, end)

View file

@ -149,9 +149,37 @@ th {
.savings-positive { color: var(--good); font-weight: 600; } .savings-positive { color: var(--good); font-weight: 600; }
.savings-negative { color: var(--bad); } .savings-negative { color: var(--bad); }
.stat-row { display: flex; gap: 2rem; flex-wrap: wrap; margin-top: 0.75rem; } .stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.25rem 1.5rem;
margin-top: 1rem;
}
.stat-item { display: flex; align-items: flex-start; gap: 0.65rem; min-width: 0; }
.stat-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon svg { width: 17px; height: 17px; }
.stat-body { min-width: 0; }
.stat-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; } .stat-label { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; }
.stat-value { font-size: 1.3rem; font-weight: 700; margin-top: 0.2rem; } .stat-value { font-size: 1.3rem; font-weight: 700; margin-top: 0.15rem; }
.stat-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.2rem;
}
.stat-trend.good { color: var(--good); }
.stat-trend.bad { color: var(--bad); }
.stat-sparkline { display: block; margin-top: 0.3rem; }
.badge { .badge {
display: inline-block; display: inline-block;
@ -279,8 +307,8 @@ select, input[type="date"] {
} }
.flow-line { .flow-line {
animation: flow-dash 1s linear infinite; animation: flow-dash 1.2s linear infinite;
} }
@keyframes flow-dash { @keyframes flow-dash {
to { stroke-dashoffset: -12; } to { stroke-dashoffset: -11.5; }
} }

View file

@ -18,27 +18,42 @@
<div id="live-flow-wrap" style="margin-top:0.5rem;"> <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;"> <svg id="live-flow" viewBox="0 0 600 230" style="width:100%; max-width:560px; display:block; margin:0 auto;">
<defs> <defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <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> <path d="M0,0 L10,5 L0,10 z" fill="context-stroke"></path>
</marker> </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> </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 --> <!-- 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" <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-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path> 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 --> <!-- 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" <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-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path> 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 --> <!-- 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" <path id="flow-solar-battery" d="M 300 90 L 300 161" fill="none" stroke="var(--battery)" stroke-width="4"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path> 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 --> <!-- 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" <path id="flow-battery-house" d="M 266 195 L 155 195" fill="none" stroke="var(--battery)" stroke-width="4"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path> 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 --> <!-- Solar node -->
<g transform="translate(300 55)"> <g transform="translate(300 55)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle> <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"> <g stroke="var(--fg)" stroke-width="2" fill="none" stroke-linecap="round">
<circle r="8" cy="0"></circle> <circle r="8" cy="0"></circle>
<line x1="0" y1="-18" x2="0" y2="-13"></line> <line x1="0" y1="-18" x2="0" y2="-13"></line>
@ -56,7 +71,7 @@
<!-- House node --> <!-- House node -->
<g transform="translate(120 195)"> <g transform="translate(120 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle> <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" <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> fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
</g> </g>
@ -65,7 +80,7 @@
<!-- Grid node --> <!-- Grid node -->
<g transform="translate(480 195)"> <g transform="translate(480 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle> <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" <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> fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
</g> </g>
@ -75,7 +90,7 @@
<!-- Battery node -- hidden entirely for plants with no battery --> <!-- Battery node -- hidden entirely for plants with no battery -->
<g id="live-battery-node" style="display:none;"> <g id="live-battery-node" style="display:none;">
<g transform="translate(300 195)"> <g transform="translate(300 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle> <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" <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> 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> <rect id="live-battery-fill" x="-10" y="8" width="20" height="0" fill="var(--battery)"></rect>
@ -162,6 +177,71 @@ function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); 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) { function fmtAgo(isoTs) {
const seconds = Math.max(0, Math.round((Date.now() - new Date(isoTs).getTime()) / 1000)); const seconds = Math.max(0, Math.round((Date.now() - new Date(isoTs).getTime()) / 1000));
if (seconds < 90) return 'updated just now'; if (seconds < 90) return 'updated just now';
@ -170,6 +250,18 @@ function fmtAgo(isoTs) {
return `updated ${Math.round(minutes / 60)} h 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() { async function loadLiveFlow() {
const badge = document.getElementById('live-updated'); const badge = document.getElementById('live-updated');
const textEl = document.getElementById('live-updated-text'); const textEl = document.getElementById('live-updated-text');
@ -193,9 +285,12 @@ async function loadLiveFlow() {
document.getElementById('live-generation').textContent = fmtKw(generation).replace('&mdash;', '—'); document.getElementById('live-generation').textContent = fmtKw(generation).replace('&mdash;', '—');
document.getElementById('live-load').textContent = fmtKw(load).replace('&mdash;', '—'); document.getElementById('live-load').textContent = fmtKw(load).replace('&mdash;', '—');
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'); const solarHouseLine = document.getElementById('flow-solar-house');
solarHouseLine.style.display = selfConsumed > 0.05 ? '' : 'none'; solarHouseLine.style.display = selfConsumed > 0.05 ? '' : 'none';
if (selfConsumed > 0.05) solarHouseLine.style.animationDuration = flowAnimationSpeed(selfConsumed);
const gridLine = document.getElementById('flow-solar-grid'); const gridLine = document.getElementById('flow-solar-grid');
const gridLabel = document.getElementById('live-grid'); const gridLabel = document.getElementById('live-grid');
@ -203,32 +298,48 @@ async function loadLiveFlow() {
gridLine.setAttribute('stroke', 'var(--good)'); gridLine.setAttribute('stroke', 'var(--good)');
gridLine.setAttribute('d', 'M 340 70 C 400 130, 420 150, 450 175'); gridLine.setAttribute('d', 'M 340 70 C 400 130, 420 150, 450 175');
gridLine.style.display = ''; gridLine.style.display = '';
gridLine.style.animationDuration = flowAnimationSpeed(exportKw);
gridLabel.textContent = `${fmtKw(exportKw).replace('&mdash;', '—')} exporting`; gridLabel.textContent = `${fmtKw(exportKw).replace('&mdash;', '—')} exporting`;
setNodeActive('grid-node-ring', true, 'var(--good)');
} else if (importKw > 0.05) { } else if (importKw > 0.05) {
gridLine.setAttribute('stroke', 'var(--bad)'); gridLine.setAttribute('stroke', 'var(--bad)');
// Same curve, reversed direction (grid -> solar node's side, feeding the house). // 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.setAttribute('d', 'M 450 175 C 420 150, 400 130, 340 70');
gridLine.style.display = ''; gridLine.style.display = '';
gridLine.style.animationDuration = flowAnimationSpeed(importKw);
gridLabel.textContent = `${fmtKw(importKw).replace('&mdash;', '—')} importing`; gridLabel.textContent = `${fmtKw(importKw).replace('&mdash;', '—')} importing`;
setNodeActive('grid-node-ring', true, 'var(--bad)');
} else { } else {
gridLine.style.display = 'none'; gridLine.style.display = 'none';
gridLabel.textContent = 'no grid flow'; gridLabel.textContent = 'no grid flow';
setNodeActive('grid-node-ring', false);
} }
// Battery -- only plants with one report a SOC at all; the node stays // 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. // hidden entirely (e.g. a solar-only plant) when it's null.
const batteryNode = document.getElementById('live-battery-node'); 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; const soc = data.battery_soc_percent;
if (soc === null || soc === undefined) { if (soc === null || soc === undefined) {
batteryNode.style.display = 'none'; batteryNode.style.display = 'none';
batteryConduit.style.display = 'none';
batteryHouseConduit.style.display = 'none';
document.getElementById('flow-solar-battery').style.display = 'none'; document.getElementById('flow-solar-battery').style.display = 'none';
document.getElementById('flow-battery-house').style.display = 'none'; document.getElementById('flow-battery-house').style.display = 'none';
} else { } else {
batteryNode.style.display = ''; batteryNode.style.display = '';
batteryConduit.style.display = '';
batteryHouseConduit.style.display = '';
const chargeKw = (data.battery_charge_w ?? 0) / 1000; const chargeKw = (data.battery_charge_w ?? 0) / 1000;
const dischargeKw = (data.battery_discharge_w ?? 0) / 1000; const dischargeKw = (data.battery_discharge_w ?? 0) / 1000;
document.getElementById('flow-solar-battery').style.display = chargeKw > 0.05 ? '' : 'none'; const solarBatteryLine = document.getElementById('flow-solar-battery');
document.getElementById('flow-battery-house').style.display = dischargeKw > 0.05 ? '' : 'none'; 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('&mdash;', '—')} charging` const status = chargeKw > 0.05 ? `${fmtKw(chargeKw).replace('&mdash;', '—')} charging`
: dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('&mdash;', '—')} discharging` : dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('&mdash;', '—')} discharging`
@ -245,35 +356,57 @@ async function loadLiveFlow() {
} }
async function loadDashboard(days) { async function loadDashboard(days) {
const [summary, daily] = await Promise.all([ const fetches = [
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()), fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
fetch(`/api/usage/daily?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'); const summaryEl = document.getElementById('summary-card');
if (!summary.days_with_data) { 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>'; 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 { } 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 revenue = summary.net_revenue_dollars;
const revenueHtml = revenue === null || revenue === undefined const revenueTrend = hasPrevious && revenue !== null && previous.net_revenue_dollars !== null
? '' ? deltaTrend(revenue - previous.net_revenue_dollars, v => `$${v.toFixed(2)}`, true)
: `<div><div class="stat-label">Net revenue</div><div class="stat-value ${revenue >= 0 ? 'savings-positive' : 'savings-negative'}">${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}</div></div>`; : '';
const selfConsumptionHtml = summary.self_consumption_pct === null || summary.self_consumption_pct === undefined const selfConTrend = hasPrevious && summary.self_consumption_pct !== null && previous.self_consumption_pct !== null
? '' : `<div title="Share of solar generation that wasn't exported"><div class="stat-label">Self-consumption</div><div class="stat-value">${summary.self_consumption_pct}%</div></div>`; ? deltaTrend(summary.self_consumption_pct - previous.self_consumption_pct, v => `${v.toFixed(1)}pp`, true)
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="stat-label">Solar coverage</div><div class="stat-value">${summary.solar_coverage_pct}%</div></div>`; 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 = ` summaryEl.innerHTML = `
<h3 class="card-title">Period summary</h3> <h3 class="card-title">Period summary</h3>
<div class="muted" style="margin-top:0.35rem;">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div> <div class="muted" style="margin-top:0.35rem;">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div>
<div class="stat-row"> <div class="stat-grid">${items.join('')}</div>
<div><div class="stat-label">Generated</div><div class="stat-value">${summary.generation_kwh} kWh</div></div>
<div><div class="stat-label">Consumed</div><div class="stat-value">${summary.load_kwh} kWh</div></div>
<div><div class="stat-label">Imported</div><div class="stat-value">${summary.import_kwh} kWh</div></div>
<div><div class="stat-label">Exported</div><div class="stat-value">${summary.export_kwh} kWh</div></div>
${selfConsumptionHtml}
${solarCoverageHtml}
${revenueHtml}
</div>
`; `;
} }
@ -437,16 +570,17 @@ async function loadBatterySection(range) {
const approxNote = value.uses_tiered_feed_in_approximation 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>' ? '<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>'
: ''; : '';
valueBody.innerHTML = ` const batteryItems = [
<div class="stat-row"> statItem({ icon: 'batteryCharging', colorVar: 'var(--battery)', label: 'Charged', value: `${value.charged_kwh.toFixed(1)} kWh` }),
<div><div class="stat-label">Charged</div><div class="stat-value">${value.charged_kwh.toFixed(1)} kWh</div></div> statItem({ icon: 'battery', colorVar: 'var(--battery)', label: 'Discharged', value: `${value.discharged_kwh.toFixed(1)} kWh` }),
<div><div class="stat-label">Discharged</div><div class="stat-value">${value.discharged_kwh.toFixed(1)} kWh</div></div> statItem({ icon: 'shield', colorVar: 'var(--good)', label: 'Avoided import cost', value: `+$${value.avoided_import_dollars.toFixed(2)}`, valueClass: 'savings-positive' }),
<div title="What the discharged energy would have cost to import instead"><div class="stat-label">Avoided import cost</div><div class="stat-value savings-positive">+$${value.avoided_import_dollars.toFixed(2)}</div></div> statItem({ icon: 'undo', colorVar: 'var(--bad)', label: 'Foregone export credit', value: `-$${value.foregone_export_dollars.toFixed(2)}`, valueClass: 'savings-negative' }),
<div title="What the charged energy would have earned as feed-in credit had it been exported instead"><div class="stat-label">Foregone export credit</div><div class="stat-value savings-negative">-$${value.foregone_export_dollars.toFixed(2)}</div></div> statItem({
<div><div class="stat-label">Estimated battery value</div><div class="stat-value ${net >= 0 ? 'savings-positive' : 'savings-negative'}">${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}</div></div> icon: 'star', colorVar: net >= 0 ? 'var(--good)' : 'var(--bad)', label: 'Estimated battery value',
</div> value: `${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}`, valueClass: net >= 0 ? 'savings-positive' : 'savings-negative',
${approxNote} }),
`; ];
valueBody.innerHTML = `<div class="stat-grid">${batteryItems.join('')}</div>${approxNote}`;
} }
const batteryRangeSelect = document.getElementById('battery-range-select'); const batteryRangeSelect = document.getElementById('battery-range-select');