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
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:
parent
8d394c4dfa
commit
8423f01014
3 changed files with 218 additions and 49 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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('—', '—');
|
document.getElementById('live-generation').textContent = fmtKw(generation).replace('—', '—');
|
||||||
document.getElementById('live-load').textContent = fmtKw(load).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');
|
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('—', '—')} exporting`;
|
gridLabel.textContent = `${fmtKw(exportKw).replace('—', '—')} 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('—', '—')} importing`;
|
gridLabel.textContent = `${fmtKw(importKw).replace('—', '—')} 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('—', '—')} charging`
|
const status = chargeKw > 0.05 ? `${fmtKw(chargeKw).replace('—', '—')} charging`
|
||||||
: dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('—', '—')} discharging`
|
: dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('—', '—')} 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} → ${summary.last_day} (${summary.days_with_data} days with data)</div>
|
<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-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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue