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 @@