From 4318671c28ef77a2d478b6c8f0ad525fe51780f8 Mon Sep 17 00:00:00 2001 From: Mario Schlicht <196292603+mschlicht-tt@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:12:09 +0200 Subject: [PATCH] Fix statistics hang on DST fall-back day split_into_days advanced by a fixed 24 hours and then rounded down to the start of the day. On a 25-hour day (end of daylight saving time, e.g. 25 October 2026 in Europe/Berlin) that landed on the same day again, so the loop never ended and kept growing the result list. Any range containing such a day hung: Year always, Month in October. Step by calendar date instead, like get_range does for LAST. Fixes #21 Co-Authored-By: Claude --- tests/test_domain.py | 24 ++++++++++++++++++++++++ timetracker/domain/ranges.py | 7 ++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/test_domain.py b/tests/test_domain.py index 97a8f8a..35f8e81 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -268,6 +268,30 @@ def test_split_into_days_keeps_shifted_boundaries_after_dst(monkeypatch): time.tzset() +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset is unavailable") +@pytest.mark.parametrize("shift", [0, 2 * HOUR_MS]) +def test_split_into_days_handles_dst_fall_back(monkeypatch, shift): + # 25 October 2026 lasts 25 hours in Berlin; 24 hours on never left it (#21). + previous = os.environ.get("TZ") + monkeypatch.setenv("TZ", "Europe/Berlin") + time.tzset() + try: + now = ms(2026, 10, 8, 12) + october = split_into_days(get_range(RangeLength.MONTH, now=now, start_of_day_shift=shift), shift) + year = split_into_days(get_range(RangeLength.YEAR, now=now, start_of_day_shift=shift), shift) + + assert len(october) == 31 + assert october[24] == day_range(date(2026, 10, 25), shift) + assert october[24].duration == 25 * HOUR_MS + assert len(year) == 365 + finally: + if previous is None: + monkeypatch.delenv("TZ", raising=False) + else: + monkeypatch.setenv("TZ", previous) + time.tzset() + + # --------------------------------------------------------------------------- # # Statistics # --------------------------------------------------------------------------- # diff --git a/timetracker/domain/ranges.py b/timetracker/domain/ranges.py index 58452e8..91d9842 100644 --- a/timetracker/domain/ranges.py +++ b/timetracker/domain/ranges.py @@ -6,7 +6,6 @@ from enum import Enum from ..models import Range -from .duration import DAY_MS class RangeLength(str, Enum): @@ -143,9 +142,11 @@ def split_into_days(time_range: Range, start_of_day_shift: int = 0) -> list[Rang return [] result: list[Range] = [] cursor = start_of_day(time_range.time_started, start_of_day_shift) + day = to_datetime(shift_timestamp(cursor, -start_of_day_shift)).date() while cursor < time_range.time_ended: - # The next boundary is simply the start of the day 24 hours on. - next_day = start_of_day(cursor + DAY_MS, start_of_day_shift) + # Step by calendar date: a fixed 24 hours never leaves a 25-hour DST day. + day += timedelta(days=1) + next_day = day_range(day, start_of_day_shift).time_started result.append(Range(max(cursor, time_range.time_started), min(next_day, time_range.time_ended))) cursor = next_day return result