Add data freshness indicator, TOU cost breakdown, and solar self-consumption stats to dashboard
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-13 07:45:05 +10:00
parent 8cbcd0155f
commit ad232447b8
3 changed files with 155 additions and 4 deletions

View file

@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.costing.engine import IntervalReading, evaluate
from app.db.repositories import (
get_current_plan,
@ -94,15 +95,61 @@ def usage_summary(days: str = Query("30")) -> dict:
readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows]
net_revenue_dollars = round(-evaluate(current_plan, readings).total_dollars, 2)
# Self-consumption: the share of solar generation that never left the
# property (used directly or, on a battery system, charged into it)
# rather than being exported. Solar coverage: the share of household
# load that was met by solar rather than drawn from the grid. Both are
# derived from metered totals (generation/load/import/export), which
# stay ground truth even on a battery system.
self_consumption_pct = None
if totals["generation_kwh"] > 0:
self_consumed_kwh = max(0.0, totals["generation_kwh"] - totals["export_kwh"])
self_consumption_pct = round(100 * self_consumed_kwh / totals["generation_kwh"], 1)
solar_coverage_pct = None
if totals["load_kwh"] > 0:
solar_covered_kwh = max(0.0, totals["load_kwh"] - totals["import_kwh"])
solar_coverage_pct = round(100 * solar_covered_kwh / totals["load_kwh"], 1)
return {
"days_with_data": len(dates_seen),
"first_day": min(dates_seen),
"last_day": max(dates_seen),
"net_revenue_dollars": net_revenue_dollars,
"self_consumption_pct": self_consumption_pct,
"solar_coverage_pct": solar_coverage_pct,
**{k: round(v, 2) for k, v in totals.items()},
}
@router.get("/tou_breakdown")
def tou_breakdown(days: str = Query("1")) -> dict:
"""Cost breakdown by the current plan's own usage components (e.g. Peak /
Off-Peak / Shoulder), so you can see where your spend is actually
landing rather than just the headline total."""
end = datetime.now(timezone.utc)
with get_session() as session:
start = _resolve_start(session, days, end)
rows = get_usage_series(session, start, end)
current_plan = get_current_plan(session)
if not rows or current_plan is None:
return {"available": False}
readings = [IntervalReading(ts=row.ts, import_kwh=row.import_kwh, export_kwh=row.export_kwh) for row in rows]
result = evaluate(current_plan, readings)
components = [
{"label": c.label, "kwh": round(c.kwh, 2), "cost_dollars": round(c.cost_dollars, 2)}
for c in result.usage_breakdown
if c.kwh > 1e-9
]
return {
"available": True,
"days_with_data": result.days_with_data,
"components": sorted(components, key=lambda c: c["cost_dollars"], reverse=True),
"total_usage_cost_dollars": round(sum(c["cost_dollars"] for c in components), 2),
}
@router.get("/live")
def live_status() -> dict:
with get_session() as session:
@ -115,9 +162,17 @@ def live_status() -> dict:
def _kw(kwh: float) -> float | None:
return round(kwh / step_hours, 3) if step_hours else None
# A single missed poll cycle is normal (transient API hiccup); flag
# staleness only once it's had time for two, since that's the pattern a
# genuinely broken poll (e.g. an invalidated OAuth token, which fails
# silently every cycle) actually produces.
age_minutes = (datetime.now(timezone.utc) - row.ts).total_seconds() / 60
stale = age_minutes > 2 * settings.isolarcloud_poll_interval_minutes
return {
"available": True,
"ts": row.ts.isoformat(),
"stale": stale,
"generation_kw": _kw(row.generation_kwh),
"load_kw": _kw(row.load_kwh),
"import_kw": _kw(row.import_kwh),

View file

@ -170,6 +170,53 @@ form.settings-form label.p-day-label input {
.live-label { fill: var(--muted); font-size: 13px; }
.live-value { fill: var(--fg); font-size: 15px; font-weight: 600; }
.freshness-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--muted);
}
.freshness-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--good);
flex-shrink: 0;
}
.freshness-badge.stale { color: var(--bad); }
.freshness-badge.stale .freshness-dot { background: var(--bad); }
.tou-bar-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.tou-bar-label { width: 110px; flex-shrink: 0; color: var(--muted); }
.tou-bar-track {
flex: 1;
height: 10px;
background: var(--border);
border-radius: 5px;
overflow: hidden;
}
.tou-bar-fill { height: 100%; background: var(--accent); border-radius: 5px; }
.tou-bar-amount { width: 130px; flex-shrink: 0; text-align: right; }
.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 {
animation: flow-dash 1s linear infinite;
}

View file

@ -5,7 +5,7 @@
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Live system status</h3>
<span class="muted" id="live-updated">Loading&hellip;</span>
<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;">
@ -83,6 +83,19 @@
<div class="muted">Loading summary&hellip;</div>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem;">
<h3 style="margin:0;">Cost by time-of-use period</h3>
<div class="pill-toggle" id="tou-range-toggle">
<button data-days="1" class="active">Today</button>
<button data-days="7">This week</button>
</div>
</div>
<div id="tou-breakdown-body" style="margin-top:0.75rem;">
<div class="muted">Loading&hellip;</div>
</div>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Generation / load / import / export</h3>
@ -116,13 +129,16 @@ function fmtAgo(isoTs) {
}
async function loadLiveFlow() {
const el = document.getElementById('live-updated');
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) {
el.textContent = 'No data yet';
badge.classList.add('stale');
textEl.textContent = 'No data yet';
return;
}
el.textContent = fmtAgo(data.ts);
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;
@ -200,6 +216,10 @@ async function loadDashboard(days) {
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>`;
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>`;
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>`;
summaryEl.innerHTML = `
<div class="muted">${summary.first_day} &rarr; ${summary.last_day} (${summary.days_with_data} days with data)</div>
<div style="display:flex; gap:2rem; margin-top:0.75rem; flex-wrap:wrap;">
@ -207,6 +227,8 @@ async function loadDashboard(days) {
<div><div class="muted">Consumed</div><strong>${summary.load_kwh} kWh</strong></div>
<div><div class="muted">Imported</div><strong>${summary.import_kwh} kWh</strong></div>
<div><div class="muted">Exported</div><strong>${summary.export_kwh} kWh</strong></div>
${selfConsumptionHtml}
${solarCoverageHtml}
${revenueHtml}
</div>
`;
@ -240,10 +262,37 @@ async function loadDashboard(days) {
});
}
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.components.length) {
body.innerHTML = '<div class="muted">No usage cost data yet for this period.</div>';
return;
}
const maxCost = Math.max(...data.components.map(c => c.cost_dollars), 0.01);
body.innerHTML = data.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" style="width:${Math.max(2, 100 * c.cost_dollars / maxCost)}%;"></div></div>
<div class="tou-bar-amount">$${c.cost_dollars.toFixed(2)} &middot; ${c.kwh.toFixed(1)} kWh</div>
</div>
`).join('') + `<div class="muted" style="margin-top:0.6rem;">Total usage cost: $${data.total_usage_cost_dollars.toFixed(2)} over ${data.days_with_data} day(s) &mdash; excludes supply charge and feed-in credit.</div>`;
}
const touToggle = document.getElementById('tou-range-toggle');
touToggle.addEventListener('click', (e) => {
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);
});
const rangeSelect = document.getElementById('range-select');
rangeSelect.addEventListener('change', () => loadDashboard(rangeSelect.value));
loadDashboard(rangeSelect.value);
loadTouBreakdown(1);
loadLiveFlow();
setInterval(loadLiveFlow, FLOW_REFRESH_MS);
</script>