Domain: recurring-event scheduling across timezone transitions

Issue:
Since the clocks went forward on 30 March, our daily 09:00 stand-up appears at 10:00 in every agenda — it was correct all winter, and the first days of the series still show 09:00. Colleagues report the mirror problem in the other direction after the October change. Nothing about the meetings themselves was edited. Expected behavior: every occurrence of a recurring meeting falls at the meeting's configured wall-clock time in its own timezone, for any series and any dates, regardless of daylight-saving transitions.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: python-dateutil==2.9.0.post0
six==1.17.0

path: requirements.lock

path: src/__init__.py

content: """Rendering occurrences as a human-readable agenda."""

from .schedule import occurrences
from .tzutil import to_local


def agenda_lines(meeting, start_date, days):
    """One 'DD Mon HH:MM name' line per occurrence, in the meeting's zone."""
    lines = []
    for moment in occurrences(meeting, start_date, days):
        local = to_local(moment, meeting.tz_name)
        lines.append(
            "{:%d %b %H:%M} {}".format(local, meeting.name)
        )
    return lines


def agenda_text(meeting, start_date, days):
    """The full agenda as one newline-joined document."""
    return "\n".join(agenda_lines(meeting, start_date, days))

path: src/agenda.py

content: """Detection of overlapping meetings."""

from datetime import timedelta

from .schedule import occurrences

DEFAULT_LENGTH = timedelta(minutes=30)


def clashes(meeting_a, meeting_b, start_date, days, length=DEFAULT_LENGTH):
    """Dates on which the two meetings' occurrences overlap."""
    hits = []
    for first, second in zip(
        occurrences(meeting_a, start_date, days),
        occurrences(meeting_b, start_date, days),
    ):
        if first < second + length and second < first + length:
            hits.append(first.date())
    return hits

path: src/conflicts.py

content: """Data model for recurring meetings."""

from dataclasses import dataclass


@dataclass(frozen=True)
class Meeting:
    """A meeting that recurs daily at a fixed local wall-clock time."""

    name: str
    hour: int
    minute: int
    tz_name: str = "Europe/London"

    def __post_init__(self):
        if not 0 <= self.hour <= 23:
            raise ValueError("hour must be 0-23")
        if not 0 <= self.minute <= 59:
            raise ValueError("minute must be 0-59")

path: src/models.py

content: """Expansion of recurring meetings into concrete occurrences."""

from datetime import datetime, timedelta

from .tzutil import UTC, zone


def occurrences(meeting, start_date, days):
    """Aware UTC datetimes for the meeting on `days` consecutive dates.

    Every occurrence falls at the meeting's configured wall-clock time in
    the meeting's own timezone, whatever that instant is in UTC.
    """
    meeting_zone = zone(meeting.tz_name)
    first = datetime(
        start_date.year,
        start_date.month,
        start_date.day,
        meeting.hour,
        meeting.minute,
        tzinfo=meeting_zone,
    )
    first_utc = first.astimezone(UTC)
    return [first_utc + timedelta(days=index) for index in range(days)]


def next_occurrence(meeting, after):
    """The first occurrence strictly after the given aware datetime."""
    day = after.astimezone(zone(meeting.tz_name)).date()
    for candidate in occurrences(meeting, day, 2):
        if candidate > after:
            return candidate
    raise RuntimeError("no occurrence found in window")

path: src/schedule.py

content: """Timezone helpers."""

from dateutil import tz

UTC = tz.UTC


def zone(name):
    """The tzinfo for an IANA zone name."""
    info = tz.gettz(name)
    if info is None:
        raise ValueError("unknown timezone: {}".format(name))
    return info


def to_local(moment, zone_name):
    """Convert an aware datetime to the given zone."""
    return moment.astimezone(zone(zone_name))

path: src/tzutil.py

content: """Tests for meeting expansion, agenda rendering, and conflicts."""

from datetime import date, datetime, timedelta

import pytest

from src.agenda import agenda_lines, agenda_text
from src.conflicts import clashes
from src.models import Meeting
from src.schedule import next_occurrence, occurrences
from src.tzutil import UTC, to_local, zone


def test_winter_occurrences_utc_and_local():
    standup = Meeting("standup", 9, 0)
    moments = occurrences(standup, date(2025, 1, 10), 3)
    assert [m.astimezone(UTC).hour for m in moments] == [9, 9, 9]
    assert [to_local(m, standup.tz_name).hour for m in moments] == [9, 9, 9]
    assert [m.date() for m in moments] == [
        date(2025, 1, 10),
        date(2025, 1, 11),
        date(2025, 1, 12),
    ]


def test_summer_occurrences_hold_local_time():
    standup = Meeting("standup", 9, 0)
    moments = occurrences(standup, date(2025, 7, 7), 3)
    assert [m.astimezone(UTC).hour for m in moments] == [8, 8, 8]
    assert [to_local(m, standup.tz_name).hour for m in moments] == [9, 9, 9]


def test_agenda_formatting():
    review = Meeting("weekly review", 14, 30)
    lines = agenda_lines(review, date(2025, 1, 20), 2)
    assert lines == ["20 Jan 14:30 weekly review", "21 Jan 14:30 weekly review"]
    assert agenda_text(review, date(2025, 1, 20), 2) == "\n".join(lines)


def test_next_occurrence_moves_to_the_following_day():
    standup = Meeting("standup", 9, 0)
    after = datetime(2025, 1, 10, 10, 0, tzinfo=UTC)
    nxt = next_occurrence(standup, after)
    assert nxt == datetime(2025, 1, 11, 9, 0, tzinfo=UTC)


def test_validation():
    with pytest.raises(ValueError):
        zone("Neverland/Nowhere")
    with pytest.raises(ValueError):
        Meeting("bad", 24, 0)
    with pytest.raises(ValueError):
        Meeting("bad", 9, 60)


def test_conflict_detection():
    standup = Meeting("standup", 9, 0)
    overlap = Meeting("triage", 9, 15)
    clear = Meeting("lunch", 12, 0)
    start = date(2025, 1, 6)
    assert clashes(standup, overlap, start, 3) == [
        date(2025, 1, 6),
        date(2025, 1, 7),
        date(2025, 1, 8),
    ]
    assert clashes(standup, clear, start, 3) == []

path: tests/public/test_public.py

Reference patch:
--- a/src/schedule.py
+++ b/src/schedule.py
@@ -12,16 +12,19 @@ def occurrences(meeting, start_date, days):
     the meeting's own timezone, whatever that instant is in UTC.
     """
     meeting_zone = zone(meeting.tz_name)
-    first = datetime(
-        start_date.year,
-        start_date.month,
-        start_date.day,
-        meeting.hour,
-        meeting.minute,
-        tzinfo=meeting_zone,
-    )
-    first_utc = first.astimezone(UTC)
-    return [first_utc + timedelta(days=index) for index in range(days)]
+    result = []
+    for index in range(days):
+        day = start_date + timedelta(days=index)
+        local = datetime(
+            day.year,
+            day.month,
+            day.day,
+            meeting.hour,
+            meeting.minute,
+            tzinfo=meeting_zone,
+        )
+        result.append(local.astimezone(UTC))
+    return result
 
 
 def next_occurrence(meeting, after):


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,49 @@
+"""Hidden evaluation tests. Never agent-visible (DEC-015).
+
+FAIL_TO_PASS: occurrences() steps the series by fixed 24-hour UTC
+increments from the first instant, so every occurrence after a DST
+transition drifts an hour off the meeting's local wall-clock time; all
+three tests fail at baseline. Generality-probing per DEC-036: the first
+test is the spring case cited in the issue, the others use a different
+meeting time across the autumn transition and a longer spring-spanning
+series, so a fix special-cased to the cited series passes at most 1 of 3.
+"""
+
+from datetime import date
+
+from src.agenda import agenda_lines
+from src.models import Meeting
+from src.schedule import occurrences
+from src.tzutil import UTC, to_local
+
+
+def test_spring_transition_keeps_wall_clock():
+    standup = Meeting("standup", 9, 0)
+    moments = occurrences(standup, date(2025, 3, 28), 4)
+    locals_ = [to_local(m, standup.tz_name) for m in moments]
+    assert [(l.hour, l.minute) for l in locals_] == [(9, 0)] * 4
+    assert moments[-1].astimezone(UTC).hour == 8  # BST occurrence is 08:00 UTC
+
+
+def test_autumn_transition_keeps_wall_clock():
+    sync = Meeting("euro sync", 7, 30)
+    lines = agenda_lines(sync, date(2025, 10, 24), 4)
+    assert lines == [
+        "24 Oct 07:30 euro sync",
+        "25 Oct 07:30 euro sync",
+        "26 Oct 07:30 euro sync",
+        "27 Oct 07:30 euro sync",
+    ]
+
+
+def test_long_series_across_spring_stays_stable():
+    standup = Meeting("standup", 9, 0)
+    moments = occurrences(standup, date(2025, 3, 27), 8)
+    assert len(moments) == 8
+    assert sorted(moments) == list(moments)
+    locals_ = [to_local(m, standup.tz_name) for m in moments]
+    assert {(l.hour, l.minute) for l in locals_} == {(9, 0)}
+    assert [l.date() for l in locals_] == [
+        date(2025, 3, 27 + i) if i <= 4 else date(2025, 4, i - 4)
+        for i in range(8)
+    ]