power-compare/app/web/templates/dashboard.html
dlawler489 8d394c4dfa
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s
Polish dashboard visuals and add a today's-net-position hero
- Chart colors now resolve from the app's own CSS tokens instead of
  hardcoded hex, so they stay in sync with the rest of the theme.
- Unified all three range pickers (TOU, battery, generation chart) on the
  same <select> pattern, replacing the one-off pill-toggle buttons.
- Added shared .card-header/.card-title/.stat-row/.stat-label/.stat-value
  utility classes so cards share consistent typography instead of
  scattered inline styles.
- Added a hero strip showing today's net cost/revenue up front, with a
  dollar-delta comparison against the recent 7-day daily average, so the
  one number that matters most isn't buried mid-page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:38:01 +10:00

533 lines
24 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">&mdash;</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&hellip;</span></span>
</div>
<div id="live-flow-wrap" style="margin-top:0.5rem;">
<svg id="live-flow" viewBox="0 0 600 230" style="width:100%; max-width:560px; display:block; margin:0 auto;">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="context-stroke"></path>
</marker>
</defs>
<!-- Solar -> House -->
<path id="flow-solar-house" d="M 260 70 C 200 130, 180 150, 150 175" fill="none" stroke="var(--accent)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
<!-- Solar -> Grid (export) / Grid -> House (import), same path, direction/color/arrow flipped by JS -->
<path id="flow-solar-grid" d="M 340 70 C 400 130, 420 150, 450 175" fill="none" stroke="var(--good)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
<!-- Solar -> Battery (charging) -- only shown for plants with a battery -->
<path id="flow-solar-battery" d="M 300 90 L 300 161" fill="none" stroke="var(--battery)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
<!-- Battery -> House (discharging), reverse of the above -->
<path id="flow-battery-house" d="M 266 195 L 155 195" fill="none" stroke="var(--battery)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
<!-- Solar node -->
<g transform="translate(300 55)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<g stroke="var(--fg)" stroke-width="2" fill="none" stroke-linecap="round">
<circle r="8" cy="0"></circle>
<line x1="0" y1="-18" x2="0" y2="-13"></line>
<line x1="0" y1="13" x2="0" y2="18"></line>
<line x1="-18" y1="0" x2="-13" y2="0"></line>
<line x1="13" y1="0" x2="18" y2="0"></line>
<line x1="-12.7" y1="-12.7" x2="-9.2" y2="-9.2"></line>
<line x1="9.2" y1="9.2" x2="12.7" y2="12.7"></line>
<line x1="12.7" y1="-12.7" x2="9.2" y2="-9.2"></line>
<line x1="-9.2" y1="9.2" x2="-12.7" y2="12.7"></line>
</g>
</g>
<text x="300" y="10" text-anchor="middle" class="live-label">Solar</text>
<text id="live-generation" x="300" y="107" text-anchor="middle" class="live-value">&mdash;</text>
<!-- House node -->
<g transform="translate(120 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<path d="M -14,4 L -14,-4 L 0,-16 L 14,-4 L 14,4 L 8,4 L 8,14 L -8,14 L -8,4 Z"
fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
</g>
<text x="120" y="163" text-anchor="middle" class="live-label">House</text>
<text id="live-load" x="120" y="230" text-anchor="middle" class="live-value">&mdash;</text>
<!-- Grid node -->
<g transform="translate(480 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<path d="M 2,-16 L -8,2 L -1,2 L -3,16 L 10,-4 L 2,-4 Z"
fill="none" stroke="var(--fg)" stroke-width="2" stroke-linejoin="round"></path>
</g>
<text x="480" y="163" text-anchor="middle" class="live-label">Grid</text>
<text id="live-grid" x="480" y="230" text-anchor="middle" class="live-value">&mdash;</text>
<!-- Battery node -- hidden entirely for plants with no battery -->
<g id="live-battery-node" style="display:none;">
<g transform="translate(300 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<path d="M -10,-14 L 10,-14 L 10,-9 L 13,-9 L 13,14 L -13,14 L -13,-9 L -10,-9 Z"
fill="none" stroke="var(--battery)" stroke-width="2" stroke-linejoin="round"></path>
<rect id="live-battery-fill" x="-10" y="8" width="20" height="0" fill="var(--battery)"></rect>
</g>
<text x="300" y="163" text-anchor="middle" class="live-label">Battery</text>
<text id="live-battery" x="300" y="230" text-anchor="middle" class="live-value">&mdash;</text>
</g>
</svg>
</div>
</div>
<div class="card" id="summary-card">
<div class="muted">Loading summary&hellip;</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&hellip;</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&hellip;</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&hellip;</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 '&mdash;';
return `${kw.toFixed(1)} kW`;
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function fmtAgo(isoTs) {
const seconds = Math.max(0, Math.round((Date.now() - new Date(isoTs).getTime()) / 1000));
if (seconds < 90) return 'updated just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `updated ${minutes} min ago`;
return `updated ${Math.round(minutes / 60)} h ago`;
}
async function loadLiveFlow() {
const badge = document.getElementById('live-updated');
const textEl = document.getElementById('live-updated-text');
const data = await fetch('/api/usage/live').then(r => r.json());
if (!data.available) {
badge.classList.add('stale');
textEl.textContent = 'No data yet';
return;
}
textEl.textContent = data.stale ? `${fmtAgo(data.ts)} (stale)` : fmtAgo(data.ts);
badge.classList.toggle('stale', !!data.stale);
const generation = data.generation_kw ?? 0;
const load = data.load_kw ?? 0;
const importKw = data.import_kw ?? 0;
const exportKw = data.export_kw ?? 0;
// From the house's own side of the meter, not generation's -- "generation
// minus export" overstates this for a battery system, since a chunk of
// that gap can be going into charging rather than the house.
const selfConsumed = Math.max(0, load - importKw);
document.getElementById('live-generation').textContent = fmtKw(generation).replace('&mdash;', '—');
document.getElementById('live-load').textContent = fmtKw(load).replace('&mdash;', '—');
const solarHouseLine = document.getElementById('flow-solar-house');
solarHouseLine.style.display = selfConsumed > 0.05 ? '' : 'none';
const gridLine = document.getElementById('flow-solar-grid');
const gridLabel = document.getElementById('live-grid');
if (exportKw > 0.05) {
gridLine.setAttribute('stroke', 'var(--good)');
gridLine.setAttribute('d', 'M 340 70 C 400 130, 420 150, 450 175');
gridLine.style.display = '';
gridLabel.textContent = `${fmtKw(exportKw).replace('&mdash;', '—')} exporting`;
} else if (importKw > 0.05) {
gridLine.setAttribute('stroke', 'var(--bad)');
// Same curve, reversed direction (grid -> solar node's side, feeding the house).
gridLine.setAttribute('d', 'M 450 175 C 420 150, 400 130, 340 70');
gridLine.style.display = '';
gridLabel.textContent = `${fmtKw(importKw).replace('&mdash;', '—')} importing`;
} else {
gridLine.style.display = 'none';
gridLabel.textContent = 'no grid flow';
}
// Battery -- only plants with one report a SOC at all; the node stays
// hidden entirely (e.g. a solar-only plant) when it's null.
const batteryNode = document.getElementById('live-battery-node');
const soc = data.battery_soc_percent;
if (soc === null || soc === undefined) {
batteryNode.style.display = 'none';
document.getElementById('flow-solar-battery').style.display = 'none';
document.getElementById('flow-battery-house').style.display = 'none';
} else {
batteryNode.style.display = '';
const chargeKw = (data.battery_charge_w ?? 0) / 1000;
const dischargeKw = (data.battery_discharge_w ?? 0) / 1000;
document.getElementById('flow-solar-battery').style.display = chargeKw > 0.05 ? '' : 'none';
document.getElementById('flow-battery-house').style.display = dischargeKw > 0.05 ? '' : 'none';
const status = chargeKw > 0.05 ? `${fmtKw(chargeKw).replace('&mdash;', '—')} charging`
: dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('&mdash;', '—')} discharging`
: 'idle';
document.getElementById('live-battery').textContent = `${soc.toFixed(0)}% · ${status}`;
// Fill rises from the bottom of the battery icon's body (y=14) as SOC
// increases, capped at a max height of 20 (near the icon's top).
const fillHeight = Math.max(0, Math.min(20, (soc / 100) * 20));
const fill = document.getElementById('live-battery-fill');
fill.setAttribute('y', 14 - fillHeight);
fill.setAttribute('height', fillHeight);
}
}
async function loadDashboard(days) {
const [summary, daily] = await Promise.all([
fetch(`/api/usage/summary?days=${days}`).then(r => r.json()),
fetch(`/api/usage/daily?days=${days}`).then(r => r.json()),
]);
const summaryEl = document.getElementById('summary-card');
if (!summary.days_with_data) {
summaryEl.innerHTML = '<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 revenue = summary.net_revenue_dollars;
const revenueHtml = revenue === null || revenue === undefined
? ''
: `<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
? '' : `<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>`;
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>`;
summaryEl.innerHTML = `
<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="stat-row">
<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>
`;
}
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)} &middot; ${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)} &minus; 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) &mdash; 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>'
: '';
valueBody.innerHTML = `
<div class="stat-row">
<div><div class="stat-label">Charged</div><div class="stat-value">${value.charged_kwh.toFixed(1)} kWh</div></div>
<div><div class="stat-label">Discharged</div><div class="stat-value">${value.discharged_kwh.toFixed(1)} kWh</div></div>
<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>
<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>
<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>
</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 %}