diff --git a/custom_components/tibber_prices/services/helpers.py b/custom_components/tibber_prices/services/helpers.py index d6036e7..07901ff 100644 --- a/custom_components/tibber_prices/services/helpers.py +++ b/custom_components/tibber_prices/services/helpers.py @@ -408,6 +408,22 @@ def floor_to_quarter_hour(dt_value: datetime) -> datetime: return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0) +def ceil_to_next_quarter_hour(dt_value: datetime) -> datetime: + """Return the start of the next quarter-hour interval after dt_value. + + Always advances past the interval that contains dt_value, even when + dt_value falls exactly on a boundary (in which case that interval is + considered "current" and is skipped). + + This is used when include_current_interval=False to align the search start + with the first interval boundary in the pool index that has not yet started. + Without this, a raw ``now`` timestamp like ``14:47:00.167996`` would not + match any cached key (which are always on :00/:15/:30/:45) and would return + no intervals. + """ + return floor_to_quarter_hour(dt_value) + timedelta(minutes=INTERVAL_MINUTES) + + def _resolve_time_with_day_offset( time_value: dt_time, day_offset: int, @@ -451,7 +467,7 @@ def _resolve_scope( tomorrow_start = today_start + timedelta(days=1) day_after_start = today_start + timedelta(days=2) - rolling_start = floor_to_quarter_hour(now) if include_current else now + rolling_start = floor_to_quarter_hour(now) if include_current else ceil_to_next_quarter_hour(now) if scope == "today": return today_start, tomorrow_start @@ -608,8 +624,10 @@ def resolve_search_range( search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"]) if include_current: search_start = floor_to_quarter_hour(search_start) + else: + search_start = ceil_to_next_quarter_hour(search_start) else: - search_start = floor_to_quarter_hour(now) if include_current else now + search_start = floor_to_quarter_hour(now) if include_current else ceil_to_next_quarter_hour(now) # --- Resolve end --- if "search_end" in call_data: diff --git a/tests/services/test_search_range.py b/tests/services/test_search_range.py index 3a1c3f3..0f5abcb 100644 --- a/tests/services/test_search_range.py +++ b/tests/services/test_search_range.py @@ -150,14 +150,17 @@ class TestResolveSearchRangeNegativeOffsetMinutes: assert start.hour == 23 def test_search_scope_excludes_current_interval_when_disabled(self) -> None: - """Relative search scopes honor include_current_interval=false.""" + """Relative search scopes ceil to next quarter boundary when include_current_interval=False.""" now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN) call_data = { "search_scope": "next_24h", "include_current_interval": False, } start, end = resolve_search_range(call_data, now, BERLIN) - assert start == now + # 14:37 is not on a quarter boundary → should ceil to 14:45 + assert start.hour == 14 + assert start.minute == 45 + assert start.second == 0 assert end == now + timedelta(hours=24) def test_search_scope_includes_current_interval_when_enabled(self) -> None: @@ -172,6 +175,54 @@ class TestResolveSearchRangeNegativeOffsetMinutes: assert start.minute == 30 assert end == now + timedelta(hours=24) + def test_exclude_current_interval_with_sub_second_now(self) -> None: + """Regression: microseconds in now caused no intervals to be returned. + + When include_current_interval=False and now has sub-second precision + (e.g. 14:47:00.167996), the search_start must be ceiled to the next + quarter boundary (15:00) so it aligns with actual interval timestamps + in the pool index. Previously, raw now was used, which matched no + cached interval and returned no data. + """ + # Reproduce the exact scenario from the bug report: 14:47:00.167996+02:00 + now = datetime(2026, 7, 27, 14, 47, 0, 167996, tzinfo=ZoneInfo("Europe/Berlin")) + call_data = { + "search_scope": "next_24h", + "include_current_interval": False, + } + start, end = resolve_search_range(call_data, now, ZoneInfo("Europe/Berlin")) + # 14:47:00.167996 → floor → 14:45 + 15min → 15:00 + assert start.hour == 15 + assert start.minute == 0 + assert start.second == 0 + assert start.microsecond == 0 + + def test_exclude_current_interval_already_on_boundary(self) -> None: + """When now is exactly on a quarter boundary and include_current_interval=False, + the start is advanced to the NEXT boundary (the interval that hasn't started yet). + """ + # 14:45:00 exactly → the 14:45 interval is currently in progress + # → exclude it → ceil to 15:00 + now = datetime(2026, 4, 11, 14, 45, 0, tzinfo=BERLIN) + call_data = { + "search_scope": "next_24h", + "include_current_interval": False, + } + start, end = resolve_search_range(call_data, now, BERLIN) + # 14:45:00 is exactly on a boundary → but the 14:45 interval is "current" + # The correct behaviour: include_current=False should start at 15:00 + assert start.hour == 15 + assert start.minute == 0 + + def test_exclude_current_default_no_scope_ceils_to_next_quarter(self) -> None: + """Default path (no scope) with include_current_interval=False also ceils.""" + now = datetime(2026, 4, 11, 14, 47, 30, tzinfo=BERLIN) + call_data = {"include_current_interval": False} + start, _end = resolve_search_range(call_data, now, BERLIN) + assert start.hour == 15 + assert start.minute == 0 + assert start.second == 0 + # ============================================================================= # Schema validation: day_offset boundaries