From 591da31e605961c3af0015bdfdfc66172fbdbe58 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Sun, 13 Sep 2026 11:13:19 +1000 Subject: [PATCH] Fix range_days undercounting for the "all time" battery range (end - start).days truncates instead of rounding, so "all time" (which starts at the earliest reading's exact timestamp, rarely local midnight) could report fewer range_days than days_with_data -- reads backwards ("5 of 4 days have data"). Round the duration up instead; day-aligned presets and custom ranges are unaffected since they're already exact multiples of 24h. Co-Authored-By: Claude Sonnet 5 --- app/api/usage.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/api/usage.py b/app/api/usage.py index 2ec63a5..278beeb 100644 --- a/app/api/usage.py +++ b/app/api/usage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from collections import defaultdict from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo @@ -54,6 +55,19 @@ def _resolve_range( return _resolve_start(session, days, end), end +def _range_days(start: datetime, end: datetime) -> int: + """Number of calendar days a [start, end) range could span. Rounds up + (not truncates) the raw duration, since `start` isn't always exactly at + a day boundary -- "all time" starts at the first reading's actual + timestamp, which is very rarely local midnight -- and truncating there + would make `days_with_data` (a whole-calendar-day count) look bigger + than the range it's supposedly a fraction of. A day-aligned preset + (`days=N`, or a custom Y-M-D range) already lands on an exact multiple + of 24h, so rounding up is a no-op for those and only changes the + "all time" case.""" + return max(1, math.ceil((end - start).total_seconds() / 86400)) + + def _bucket_key(local_date, granularity: str) -> str: if granularity == "month": return local_date.replace(day=1).isoformat() @@ -215,7 +229,7 @@ def battery_history( if row.battery_soc_percent is not None ] 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) + range_days = _range_days(start, end) points = all_points if len(points) > _MAX_BATTERY_CHART_POINTS: @@ -245,7 +259,7 @@ def battery_value( rows = get_usage_series(session, start, end) current_plan = get_current_plan(session) - range_days = max(1, (end - start).days) + range_days = _range_days(start, end) 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, "range_days": range_days}