Polish dashboard visuals and add a today's-net-position hero
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s
- 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>
This commit is contained in:
parent
591da31e60
commit
8d394c4dfa
2 changed files with 156 additions and 55 deletions
|
|
@ -65,11 +65,69 @@ body {
|
||||||
.card {
|
.card {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
background: linear-gradient(135deg, var(--card-bg), var(--bg));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-value {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-trend {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-trend.good { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good); }
|
||||||
|
.hero-trend.bad { background: color-mix(in srgb, var(--bad) 18%, transparent); color: var(--bad); }
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
@ -91,6 +149,10 @@ 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-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; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
|
|
@ -216,18 +278,6 @@ select, input[type="date"] {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
|
||||||
.pill-toggle button {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0.85rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background: var(--card-bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.pill-toggle button.active { background: var(--accent); color: white; }
|
|
||||||
|
|
||||||
.flow-line {
|
.flow-line {
|
||||||
animation: flow-dash 1s linear infinite;
|
animation: flow-dash 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,17 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Usage Dashboard</h1>
|
<h1>Usage Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="hero-card" id="hero-card" style="display:none;">
|
||||||
|
<div>
|
||||||
|
<div class="hero-label">Today's net position</div>
|
||||||
|
<div class="hero-value" id="hero-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-trend" id="hero-trend"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:0.5rem;">
|
<div class="card-header">
|
||||||
<h3 style="margin:0;">Live system status</h3>
|
<h3 class="card-title">Live system status</h3>
|
||||||
<span class="freshness-badge" id="live-updated"><span class="freshness-dot"></span><span id="live-updated-text">Loading…</span></span>
|
<span class="freshness-badge" id="live-updated"><span class="freshness-dot"></span><span id="live-updated-text">Loading…</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="live-flow-wrap" style="margin-top:0.5rem;">
|
<div id="live-flow-wrap" style="margin-top:0.5rem;">
|
||||||
|
|
@ -84,12 +92,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
|
<div class="card-header">
|
||||||
<h3 style="margin:0;">Cost by time-of-use period</h3>
|
<h3 class="card-title">Cost by time-of-use period</h3>
|
||||||
<div class="pill-toggle" id="tou-range-toggle">
|
<select id="tou-range-select">
|
||||||
<button data-days="1" class="active">Today</button>
|
<option value="1" selected>Today</option>
|
||||||
<button data-days="7">This week</button>
|
<option value="7">This week</option>
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="tou-breakdown-body" style="margin-top:0.75rem;">
|
<div id="tou-breakdown-body" style="margin-top:0.75rem;">
|
||||||
<div class="muted">Loading…</div>
|
<div class="muted">Loading…</div>
|
||||||
|
|
@ -97,8 +105,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="battery-card" style="display:none;">
|
<div class="card" id="battery-card" style="display:none;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
|
<div class="card-header">
|
||||||
<h3 style="margin:0;">Battery</h3>
|
<h3 class="card-title">Battery</h3>
|
||||||
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
||||||
<select id="battery-range-select">
|
<select id="battery-range-select">
|
||||||
<option value="1" selected>Today</option>
|
<option value="1" selected>Today</option>
|
||||||
|
|
@ -127,8 +135,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
<div class="card-header">
|
||||||
<h3 style="margin:0;">Generation / load / import / export</h3>
|
<h3 class="card-title">Generation / load / import / export</h3>
|
||||||
<select id="range-select">
|
<select id="range-select">
|
||||||
<option value="7">Last 7 days</option>
|
<option value="7">Last 7 days</option>
|
||||||
<option value="30" selected>Last 30 days</option>
|
<option value="30" selected>Last 30 days</option>
|
||||||
|
|
@ -150,6 +158,10 @@ function fmtKw(kw) {
|
||||||
return `${kw.toFixed(1)} kW`;
|
return `${kw.toFixed(1)} kW`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name) {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
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';
|
||||||
|
|
@ -240,23 +252,24 @@ async function loadDashboard(days) {
|
||||||
|
|
||||||
const summaryEl = document.getElementById('summary-card');
|
const summaryEl = document.getElementById('summary-card');
|
||||||
if (!summary.days_with_data) {
|
if (!summary.days_with_data) {
|
||||||
summaryEl.innerHTML = '<div class="muted">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 revenue = summary.net_revenue_dollars;
|
const revenue = summary.net_revenue_dollars;
|
||||||
const revenueHtml = revenue === null || revenue === undefined
|
const revenueHtml = revenue === null || revenue === undefined
|
||||||
? ''
|
? ''
|
||||||
: `<div><div class="muted">Net revenue</div><strong class="${revenue >= 0 ? 'savings-positive' : 'savings-negative'}">${revenue >= 0 ? '+$' : '-$'}${Math.abs(revenue).toFixed(2)}</strong></div>`;
|
: `<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 selfConsumptionHtml = summary.self_consumption_pct === null || summary.self_consumption_pct === undefined
|
||||||
? '' : `<div title="Share of solar generation that wasn't exported"><div class="muted">Self-consumption</div><strong>${summary.self_consumption_pct}%</strong></div>`;
|
? '' : `<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
|
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="muted">Solar coverage</div><strong>${summary.solar_coverage_pct}%</strong></div>`;
|
? '' : `<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 = `
|
summaryEl.innerHTML = `
|
||||||
<div class="muted">${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)</div>
|
<h3 class="card-title">Period summary</h3>
|
||||||
<div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;">
|
<div class="muted" style="margin-top:0.35rem;">${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)</div>
|
||||||
<div><div class="muted">Generated</div><strong>${summary.generation_kwh} kWh</strong></div>
|
<div class="stat-row">
|
||||||
<div><div class="muted">Consumed</div><strong>${summary.load_kwh} kWh</strong></div>
|
<div><div class="stat-label">Generated</div><div class="stat-value">${summary.generation_kwh} kWh</div></div>
|
||||||
<div><div class="muted">Imported</div><strong>${summary.import_kwh} kWh</strong></div>
|
<div><div class="stat-label">Consumed</div><div class="stat-value">${summary.load_kwh} kWh</div></div>
|
||||||
<div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></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}
|
${selfConsumptionHtml}
|
||||||
${solarCoverageHtml}
|
${solarCoverageHtml}
|
||||||
${revenueHtml}
|
${revenueHtml}
|
||||||
|
|
@ -277,10 +290,10 @@ async function loadDashboard(days) {
|
||||||
data: {
|
data: {
|
||||||
labels: daily.map(d => d.date),
|
labels: daily.map(d => d.date),
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label: 'Generation', data: daily.map(d => d.generation_kwh), backgroundColor: '#facc15' },
|
{ label: 'Generation', data: daily.map(d => d.generation_kwh), backgroundColor: cssVar('--battery') },
|
||||||
{ label: 'Load', data: daily.map(d => d.load_kwh), backgroundColor: '#60a5fa' },
|
{ label: 'Load', data: daily.map(d => d.load_kwh), backgroundColor: cssVar('--accent') },
|
||||||
{ label: 'Import', data: daily.map(d => d.import_kwh), backgroundColor: '#f87171' },
|
{ label: 'Import', data: daily.map(d => d.import_kwh), backgroundColor: cssVar('--bad') },
|
||||||
{ label: 'Export', data: daily.map(d => d.export_kwh), backgroundColor: '#4ade80' },
|
{ label: 'Export', data: daily.map(d => d.export_kwh), backgroundColor: cssVar('--good') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -331,14 +344,8 @@ async function loadTouBreakdown(days) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const touToggle = document.getElementById('tou-range-toggle');
|
const touRangeSelect = document.getElementById('tou-range-select');
|
||||||
touToggle.addEventListener('click', (e) => {
|
touRangeSelect.addEventListener('change', () => loadTouBreakdown(touRangeSelect.value));
|
||||||
const btn = e.target.closest('button');
|
|
||||||
if (!btn) return;
|
|
||||||
touToggle.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
loadTouBreakdown(btn.dataset.days);
|
|
||||||
});
|
|
||||||
|
|
||||||
function batteryQueryString(range) {
|
function batteryQueryString(range) {
|
||||||
return range.startDate
|
return range.startDate
|
||||||
|
|
@ -402,8 +409,8 @@ async function loadBatterySection(range) {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Battery SOC %',
|
label: 'Battery SOC %',
|
||||||
data: history.points.map(p => p.soc_percent),
|
data: history.points.map(p => p.soc_percent),
|
||||||
borderColor: '#fbbf24',
|
borderColor: cssVar('--battery'),
|
||||||
backgroundColor: 'rgba(251, 191, 36, 0.15)',
|
backgroundColor: cssVar('--battery') + '26',
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
|
@ -431,12 +438,12 @@ async function loadBatterySection(range) {
|
||||||
? '<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 = `
|
valueBody.innerHTML = `
|
||||||
<div style="display:flex; gap:2rem; flex-wrap:wrap;">
|
<div class="stat-row">
|
||||||
<div><div class="muted">Charged</div><strong>${value.charged_kwh.toFixed(1)} kWh</strong></div>
|
<div><div class="stat-label">Charged</div><div class="stat-value">${value.charged_kwh.toFixed(1)} kWh</div></div>
|
||||||
<div><div class="muted">Discharged</div><strong>${value.discharged_kwh.toFixed(1)} kWh</strong></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="muted">Avoided import cost</div><strong class="savings-positive">+$${value.avoided_import_dollars.toFixed(2)}</strong></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="muted">Foregone export credit</div><strong class="savings-negative">-$${value.foregone_export_dollars.toFixed(2)}</strong></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="muted">Estimated battery value</div><strong class="${net >= 0 ? 'savings-positive' : 'savings-negative'}">${net >= 0 ? '+' : '-'}$${Math.abs(net).toFixed(2)}</strong></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>
|
</div>
|
||||||
${approxNote}
|
${approxNote}
|
||||||
`;
|
`;
|
||||||
|
|
@ -470,10 +477,54 @@ batteryApplyRange.addEventListener('click', () => {
|
||||||
loadBatterySection({ startDate: batteryStartDate.value, endDate: batteryEndDate.value });
|
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');
|
const rangeSelect = document.getElementById('range-select');
|
||||||
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
|
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
|
||||||
loadDashboard(rangeSelect.value);
|
loadDashboard(rangeSelect.value);
|
||||||
|
|
||||||
|
loadHero();
|
||||||
loadTouBreakdown(1);
|
loadTouBreakdown(1);
|
||||||
loadBatterySection({ days: '1' });
|
loadBatterySection({ days: '1' });
|
||||||
loadLiveFlow();
|
loadLiveFlow();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue