diff --git a/app/api/usage.py b/app/api/usage.py index 5d3396a..b23b0df 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -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), diff --git a/app/static/css/style.css b/app/static/css/style.css index 71c0efc..2d07903 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; } diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index c667e39..e99d9cc 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -5,7 +5,7 @@

Live system status

- Loading… + Loading…
@@ -83,6 +83,19 @@
Loading summary…
+
+
+

Cost by time-of-use period

+
+ + +
+
+
+
Loading…
+
+
+

Generation / load / import / export

@@ -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 ? '' : `
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}%
`; summaryEl.innerHTML = `
${summary.first_day} → ${summary.last_day} (${summary.days_with_data} days with data)
@@ -207,6 +227,8 @@ async function loadDashboard(days) {
Consumed
${summary.load_kwh} kWh
Imported
${summary.import_kwh} kWh
Exported
${summary.export_kwh} kWh
+ ${selfConsumptionHtml} + ${solarCoverageHtml} ${revenueHtml}
`; @@ -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 = '
No usage cost data yet for this period.
'; + return; + } + const maxCost = Math.max(...data.components.map(c => c.cost_dollars), 0.01); + body.innerHTML = data.components.map(c => ` +
+
${c.label}
+
+
$${c.cost_dollars.toFixed(2)} · ${c.kwh.toFixed(1)} kWh
+
+ `).join('') + `
Total usage cost: $${data.total_usage_cost_dollars.toFixed(2)} over ${data.days_with_data} day(s) — excludes supply charge and feed-in credit.
`; +} + +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);