From 8423f01014be31c723cbb71b971302cadb3eb968 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Sun, 13 Sep 2026 15:49:39 +1000 Subject: [PATCH] Add trend sparklines, icon-based stat grid, and redraw the live flow diagram - 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 --- app/api/usage.py | 11 +- app/static/css/style.css | 36 ++++- app/web/templates/dashboard.html | 220 +++++++++++++++++++++++++------ 3 files changed, 218 insertions(+), 49 deletions(-) diff --git a/app/api/usage.py b/app/api/usage.py index 278beeb..7a16b5e 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -103,8 +103,15 @@ def daily_usage(days: str = Query("30")) -> list[dict]: @router.get("/summary") -def usage_summary(days: str = Query("30")) -> dict: - end = datetime.now(timezone.utc) +def usage_summary(days: str = Query("30"), offset_days: int = Query(0)) -> dict: + """`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: start = _resolve_start(session, days, end) rows = get_usage_series(session, start, end) diff --git a/app/static/css/style.css b/app/static/css/style.css index f223d67..37ee670 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -149,9 +149,37 @@ th { .savings-positive { color: var(--good); font-weight: 600; } .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-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 { display: inline-block; @@ -279,8 +307,8 @@ select, input[type="date"] { } .flow-line { - animation: flow-dash 1s linear infinite; + animation: flow-dash 1.2s linear infinite; } @keyframes flow-dash { - to { stroke-dashoffset: -12; } + to { stroke-dashoffset: -11.5; } } diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 5195894..1c74024 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -18,27 +18,42 @@
- + + + + + + + + + + + + + + - + - + - + - + - + @@ -56,7 +71,7 @@ - + @@ -65,7 +80,7 @@ - + @@ -75,7 +90,7 @@
+
${statIcon(icon)}
+
+
${label}
+
${value}
+ ${trend || ''} +
+
+ `; +} + +// 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 '
No change vs previous period
'; + const isIncrease = delta > 0; + const isGood = higherIsBetter ? isIncrease : !isIncrease; + return `
${isIncrease ? '▲' : '▼'} ${formatFn(Math.abs(delta))} vs previous period
`; +} + function fmtAgo(isoTs) { const seconds = Math.max(0, Math.round((Date.now() - new Date(isoTs).getTime()) / 1000)); if (seconds < 90) return 'updated just now'; @@ -170,6 +250,18 @@ function fmtAgo(isoTs) { 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'); @@ -193,9 +285,12 @@ async function loadLiveFlow() { 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'); @@ -203,32 +298,48 @@ async function loadLiveFlow() { 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; - document.getElementById('flow-solar-battery').style.display = chargeKw > 0.05 ? '' : 'none'; - document.getElementById('flow-battery-house').style.display = dischargeKw > 0.05 ? '' : 'none'; + 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` @@ -245,35 +356,57 @@ async function loadLiveFlow() { } 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/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 = '

Period summary

No usage data yet. If you\'re running on mock data, it should appear within a few minutes of startup.
'; } 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 revenueHtml = revenue === null || revenue === undefined - ? '' - : `
Net revenue
${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}
`; - const selfConsumptionHtml = summary.self_consumption_pct === null || summary.self_consumption_pct === undefined - ? '' : `
Self-consumption
${summary.self_consumption_pct}%
`; - const solarCoverageHtml = summary.solar_coverage_pct === null || summary.solar_coverage_pct === undefined - ? '' : `
Solar coverage
${summary.solar_coverage_pct}%
`; + 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 = `

Period summary

${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)
-
-
Generated
${summary.generation_kwh} kWh
-
Consumed
${summary.load_kwh} kWh
-
Imported
${summary.import_kwh} kWh
-
Exported
${summary.export_kwh} kWh
- ${selfConsumptionHtml} - ${solarCoverageHtml} - ${revenueHtml} -
+
${items.join('')}
`; } @@ -437,16 +570,17 @@ async function loadBatterySection(range) { const approxNote = value.uses_tiered_feed_in_approximation ? '
Export credit side is approximated: your plan\'s feed-in tariff depends on daily export volume, not time of day.
' : ''; - valueBody.innerHTML = ` -
-
Charged
${value.charged_kwh.toFixed(1)} kWh
-
Discharged
${value.discharged_kwh.toFixed(1)} kWh
-
Avoided import cost
+$${value.avoided_import_dollars.toFixed(2)}
-
Foregone export credit
-$${value.foregone_export_dollars.toFixed(2)}
-
Estimated battery value
${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}
-
- ${approxNote} - `; + 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 = `
${batteryItems.join('')}
${approxNote}`; } const batteryRangeSelect = document.getElementById('battery-range-select');