diff --git a/app/api/usage.py b/app/api/usage.py index 18aa9ed..283eb25 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -6,7 +6,13 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException, Query -from app.db.repositories import get_earliest_reading_ts, get_usage_series +from app.costing.engine import IntervalReading, evaluate +from app.db.repositories import ( + get_current_plan, + get_earliest_reading_ts, + get_latest_reading, + get_usage_series, +) from app.db.session import get_session router = APIRouter(prefix="/api/usage", tags=["usage"]) @@ -66,6 +72,7 @@ def usage_summary(days: str = Query("30")) -> dict: 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: return {"days_with_data": 0} @@ -78,9 +85,41 @@ def usage_summary(days: str = Query("30")) -> dict: totals["import_kwh"] += row.import_kwh totals["export_kwh"] += row.export_kwh + net_revenue_dollars = None + if current_plan is not None: + # Reuses the same costing engine the plan comparison runs on -- + # "net revenue" is just the negative of what the current plan would + # bill for this period (positive when export credit outweighs the + # cost of imports). + 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) + return { "days_with_data": len(dates_seen), "first_day": min(dates_seen), "last_day": max(dates_seen), + "net_revenue_dollars": net_revenue_dollars, **{k: round(v, 2) for k, v in totals.items()}, } + + +@router.get("/live") +def live_status() -> dict: + with get_session() as session: + row = get_latest_reading(session) + if row is None: + return {"available": False} + + step_hours = row.resolution_minutes / 60 if row.resolution_minutes else None + + def _kw(kwh: float) -> float | None: + return round(kwh / step_hours, 3) if step_hours else None + + return { + "available": True, + "ts": row.ts.isoformat(), + "generation_kw": _kw(row.generation_kwh), + "load_kw": _kw(row.load_kwh), + "import_kw": _kw(row.import_kwh), + "export_kw": _kw(row.export_kwh), + } diff --git a/app/db/repositories.py b/app/db/repositories.py index 1ed3e1c..ea6f673 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -50,6 +50,11 @@ def get_earliest_reading_ts(session: Session) -> datetime | None: return session.scalar(select(func.min(IntervalReadingRow.ts))) +def get_latest_reading(session: Session) -> IntervalReadingRow | None: + stmt = select(IntervalReadingRow).order_by(IntervalReadingRow.ts.desc()).limit(1) + return session.scalars(stmt).first() + + def get_usage_series(session: Session, start: datetime, end: datetime) -> list[IntervalReadingRow]: stmt = ( select(IntervalReadingRow) diff --git a/app/static/css/style.css b/app/static/css/style.css index 1e716e5..109f713 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -164,3 +164,13 @@ form.settings-form label.p-day-label input { .muted { color: var(--muted); font-size: 0.85rem; } .chart-container { position: relative; height: 320px; margin-top: 0.75rem; } .notes { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; } + +.live-label { fill: var(--muted); font-size: 13px; } +.live-value { fill: var(--fg); font-size: 15px; font-weight: 600; } + +.flow-line { + animation: flow-dash 1s linear infinite; +} +@keyframes flow-dash { + to { stroke-dashoffset: -12; } +} diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 3848ebe..39a0611 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -2,6 +2,65 @@ {% block content %}