From c30165c1536ffa874aedf57e86eac9d5bf320a7f Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Sun, 13 Sep 2026 11:10:20 +1000 Subject: [PATCH] Add longer battery ranges and a custom date-range picker Extends the battery card's range beyond Today/3 days/This week to Last 30 days, Last 365 days, All time, and a user-picked custom start/end date. Since battery telemetry is only ever captured on the live "today" poll and never backfilled, a long or all-time range typically has real gaps -- the card now shows how many of the requested days actually have battery data rather than implying continuous history. Co-Authored-By: Claude Sonnet 5 --- app/api/usage.py | 68 +++++++++++++++++++---- app/static/css/style.css | 9 ++++ app/web/templates/dashboard.html | 92 +++++++++++++++++++++++++------- 3 files changed, 141 insertions(+), 28 deletions(-) diff --git a/app/api/usage.py b/app/api/usage.py index 1003ee1..2ec63a5 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -33,6 +33,27 @@ def _resolve_start(session, days: str, end: datetime) -> datetime: return end - timedelta(days=n) +def _resolve_range( + session, days: str, start_date: str | None, end_date: str | None +) -> tuple[datetime, datetime]: + """Either a `days`/"all" preset (relative to now), or an explicit + `start_date`/`end_date` pair ("YYYY-MM-DD", inclusive, interpreted as + whole Brisbane-local calendar days) for a user-picked custom range.""" + if start_date or end_date: + if not start_date or not end_date: + raise HTTPException(400, "start_date and end_date must both be given for a custom range") + try: + start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=BNE) + end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=BNE) + timedelta(days=1) + except ValueError: + raise HTTPException(400, "start_date/end_date must be in YYYY-MM-DD format") + if end <= start: + raise HTTPException(400, "end_date must be on or after start_date") + return start, end + end = datetime.now(timezone.utc) + return _resolve_start(session, days, end), end + + def _bucket_key(local_date, granularity: str) -> str: if granularity == "month": return local_date.replace(day=1).isoformat() @@ -163,19 +184,27 @@ def tou_breakdown(days: str = Query("1")) -> dict: } +_MAX_BATTERY_CHART_POINTS = 2000 + + @router.get("/battery_history") -def battery_history(days: str = Query("1")) -> dict: +def battery_history( + days: str = Query("1"), start_date: str | None = Query(None), end_date: str | None = Query(None) +) -> dict: """SOC/charge/discharge curve for plants with a battery. Battery telemetry is only ever captured on the live "today" poll (see `IntervalReadingRow.battery_soc_percent`), never backfilled for historical rows, so a plant with no battery -- or one whose data - predates that feature -- simply returns no points here.""" - end = datetime.now(timezone.utc) + predates that feature -- simply returns no points here. Over a long or + "all time" range, most of that range typically has no battery data at + all (e.g. it predates the battery being installed, or this feature + shipping) -- `days_with_data`/`range_days` let the UI say so plainly + rather than implying a full, continuous history.""" with get_session() as session: - start = _resolve_start(session, days, end) + start, end = _resolve_range(session, days, start_date, end_date) rows = get_usage_series(session, start, end) - points = [ + all_points = [ { "ts": row.ts.isoformat(), "soc_percent": row.battery_soc_percent, @@ -185,23 +214,41 @@ def battery_history(days: str = Query("1")) -> dict: for row in rows if row.battery_soc_percent is not None ] - return {"available": bool(points), "points": points} + days_with_data = len({row.ts.astimezone(BNE).date() for row in rows if row.battery_soc_percent is not None}) + range_days = max(1, (end - start).days) + + points = all_points + if len(points) > _MAX_BATTERY_CHART_POINTS: + # Long ranges (a year, "all time") can accumulate more points than a + # line chart can usefully render -- evenly subsample rather than + # truncating, so the shape of the whole range is still represented. + step = len(points) / _MAX_BATTERY_CHART_POINTS + points = [points[int(i * step)] for i in range(_MAX_BATTERY_CHART_POINTS)] + + return { + "available": bool(all_points), + "points": points, + "days_with_data": days_with_data, + "range_days": range_days, + } @router.get("/battery_value") -def battery_value(days: str = Query("1")) -> dict: +def battery_value( + days: str = Query("1"), start_date: str | None = Query(None), end_date: str | None = Query(None) +) -> dict: """What the battery actually saved you: discharged energy valued at the import rate it avoided, minus charged energy valued at the feed-in credit it gave up, both matched to the current plan's own TOU windows.""" - end = datetime.now(timezone.utc) with get_session() as session: - start = _resolve_start(session, days, end) + start, end = _resolve_range(session, days, start_date, end_date) rows = get_usage_series(session, start, end) current_plan = get_current_plan(session) + range_days = max(1, (end - start).days) battery_rows = [r for r in rows if r.battery_charge_w is not None or r.battery_discharge_w is not None] if not battery_rows or current_plan is None: - return {"available": False} + return {"available": False, "range_days": range_days} battery_readings = [] for row in battery_rows: @@ -220,6 +267,7 @@ def battery_value(days: str = Query("1")) -> dict: return { "available": True, "days_with_data": result.days_with_data, + "range_days": range_days, "charged_kwh": round(result.charged_kwh, 2), "discharged_kwh": round(result.discharged_kwh, 2), "avoided_import_dollars": round(result.avoided_import_dollars, 2), diff --git a/app/static/css/style.css b/app/static/css/style.css index 5417203..4fde4c3 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -207,6 +207,15 @@ form.settings-form label.p-day-label input { .tou-bar-fill-credit { background: var(--good); } .tou-bar-amount { width: 130px; flex-shrink: 0; text-align: right; } +select, input[type="date"] { + background: var(--card-bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.35rem 0.5rem; + font-size: 0.9rem; +} + .pill-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } .pill-toggle button { margin: 0; diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index e135b67..f078354 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -99,12 +99,25 @@