Skip to content

Commit 597c6d7

Browse files
committed
fix: handle cross-midnight daemon windows, fix time-dependent tests
_in_window() failed for cross-midnight windows (e.g., 22-2) because `start <= hour < end` is always False when start > end. Amazon sync with overnight windows would never execute. Also fixes misleading "All CC payments are scheduled" log when there are no CC payments, and replaces all time-dependent daemon tests with monkeypatched fixed datetimes so they never silently skip.
1 parent 31ec795 commit 597c6d7

4 files changed

Lines changed: 92 additions & 53 deletions

File tree

src/ynab_tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "0.1.3"
1+
__version__ = "0.1.4"
22

33
APP_NAME = "ynab-tools"
44
USER_AGENT = f"ynab-tools/{__version__} (+https://github.com/baker-scripts/ynab-tools)"

src/ynab_tools/daemon/scheduler.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,28 +91,40 @@ def _parse_windows(windows_str: str) -> list[tuple[int, int]]:
9191

9292

9393
def _in_window(windows: list[tuple[int, int]]) -> bool:
94-
"""Check if current time is within any of the specified windows."""
94+
"""Check if current time is within any of the specified windows.
95+
96+
Handles cross-midnight windows (e.g., 22-2 means 22:00-01:59).
97+
"""
9598
if not windows:
9699
return True
97100
hour = datetime.now().hour
98-
return any(start <= hour < end for start, end in windows)
101+
for start, end in windows:
102+
if start <= end:
103+
if start <= hour < end:
104+
return True
105+
else:
106+
# Cross-midnight: e.g., (22, 2) means 22-23 and 0-1
107+
if hour >= start or hour < end:
108+
return True
109+
return False
99110

100111

101112
def _next_window_start(windows: list[tuple[int, int]]) -> datetime:
102113
"""Compute the next datetime when a configured window opens.
103114
104115
Checks remaining windows today first, then wraps to tomorrow's first window.
116+
Uses the start hour of each window (handles cross-midnight windows).
105117
"""
106118
now = datetime.now()
107119
today = now.date()
108120

109121
# Check if any window opens later today
110-
for start, _end in sorted(windows):
122+
for start, _end in sorted(windows, key=lambda w: w[0]):
111123
candidate = datetime.combine(today, datetime.min.time().replace(hour=start))
112124
if candidate > now:
113125
return candidate
114126

115-
# No window opens later today — use first window tomorrow
127+
# No window opens later today — use earliest start hour tomorrow
116128
first_start = min(start for start, _end in windows)
117129
tomorrow = today + timedelta(days=1)
118130
return datetime.combine(tomorrow, datetime.min.time().replace(hour=first_start))

src/ynab_tools/monitor/projection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def project_minimum_balance(
3333
unscheduled_cc_total = sum(p.amount for cc_id, p in cc_payments.items() if cc_id not in covered_cc_ids)
3434
if unscheduled_cc_total > 0:
3535
logger.info(f"Unscheduled CC payments (applied today): ${unscheduled_cc_total:,.2f}")
36-
else:
36+
elif cc_payments:
3737
logger.info("All CC payments are scheduled.")
3838

3939
# Build day-by-day projection

tests/daemon/test_scheduler.py

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@
55
import heapq
66
from datetime import datetime
77

8+
9+
def _fixed_datetime(hour: int, minute: int = 0):
10+
"""Return a datetime class substitute that pins now() to a fixed hour."""
11+
12+
class _FakeDatetime(datetime):
13+
@classmethod
14+
def now(cls, tz=None):
15+
base = datetime(2026, 3, 22, hour, minute, 0)
16+
return base
17+
18+
return _FakeDatetime
19+
20+
821
from ynab_tools.daemon.scheduler import (
922
DaemonConfig,
1023
Feature,
@@ -56,17 +69,29 @@ class TestInWindow:
5669
def test_empty_windows_always_true(self):
5770
assert _in_window([]) is True
5871

59-
def test_in_window(self):
60-
hour = datetime.now().hour
61-
assert _in_window([(hour, hour + 1)]) is True
72+
def test_in_window(self, monkeypatch):
73+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10))
74+
assert _in_window([(10, 12)]) is True
75+
76+
def test_outside_window(self, monkeypatch):
77+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(14))
78+
assert _in_window([(10, 12)]) is False
79+
80+
def test_cross_midnight_inside_before_midnight(self, monkeypatch):
81+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(23))
82+
assert _in_window([(22, 2)]) is True
83+
84+
def test_cross_midnight_inside_after_midnight(self, monkeypatch):
85+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(1))
86+
assert _in_window([(22, 2)]) is True
87+
88+
def test_cross_midnight_outside(self, monkeypatch):
89+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(15))
90+
assert _in_window([(22, 2)]) is False
6291

63-
def test_outside_window(self):
64-
hour = datetime.now().hour
65-
# Use a window that doesn't contain the current hour
66-
start = (hour + 2) % 24
67-
end = (hour + 3) % 24
68-
if start < end:
69-
assert _in_window([(start, end)]) is False
92+
def test_cross_midnight_at_end_hour_excluded(self, monkeypatch):
93+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(2))
94+
assert _in_window([(22, 2)]) is False
7095

7196

7297
class TestScheduleEntry:
@@ -131,63 +156,65 @@ def test_both_exclusive_empty(self):
131156

132157

133158
class TestNextWindowStart:
134-
def test_next_window_today(self):
135-
now = datetime.now()
136-
# Window 2 hours from now
137-
future_hour = (now.hour + 2) % 24
138-
if future_hour > now.hour: # only test if doesn't wrap past midnight
139-
result = _next_window_start([(future_hour, future_hour + 1)])
140-
assert result.hour == future_hour
141-
assert result.date() == now.date()
142-
143-
def test_next_window_tomorrow(self):
144-
now = datetime.now()
145-
# Window 1 hour ago (already passed today)
146-
past_hour = (now.hour - 1) % 24
147-
if past_hour < now.hour: # only test if doesn't wrap
148-
result = _next_window_start([(past_hour, past_hour + 1)])
149-
assert result.hour == past_hour
150-
assert result.date() > now.date()
151-
152-
def test_picks_earliest_future_window(self):
153-
now = datetime.now()
154-
h1 = (now.hour + 2) % 24
155-
h2 = (now.hour + 5) % 24
156-
if h1 > now.hour and h2 > h1:
157-
result = _next_window_start([(h2, h2 + 1), (h1, h1 + 1)])
158-
assert result.hour == h1
159+
def test_next_window_today(self, monkeypatch):
160+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10))
161+
result = _next_window_start([(14, 16)])
162+
assert result.hour == 14
163+
assert result.date() == datetime(2026, 3, 22).date()
164+
165+
def test_next_window_tomorrow(self, monkeypatch):
166+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10))
167+
result = _next_window_start([(8, 9)])
168+
assert result.hour == 8
169+
assert result.date() == datetime(2026, 3, 23).date()
170+
171+
def test_picks_earliest_future_window(self, monkeypatch):
172+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10))
173+
result = _next_window_start([(18, 20), (14, 16)])
174+
assert result.hour == 14
175+
176+
def test_cross_midnight_window_next_start(self, monkeypatch):
177+
# At 15:00, next opening for window 22-2 should be 22:00 today
178+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(15))
179+
result = _next_window_start([(22, 2)])
180+
assert result.hour == 22
181+
assert result.date() == datetime(2026, 3, 22).date()
182+
183+
def test_cross_midnight_inside_no_false_next(self, monkeypatch):
184+
# At 23:00, inside window 22-2 — _next_window_start shouldn't be
185+
# called in this case, but if it is, it returns tomorrow's 22:00
186+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(23))
187+
result = _next_window_start([(22, 2)])
188+
assert result.hour == 22
189+
assert result.date() == datetime(2026, 3, 23).date()
159190

160191

161192
class TestExecuteEntry:
162193
def test_monitor_returns_none(self, monkeypatch):
163194
monkeypatch.setattr("ynab_tools.daemon.scheduler._run_monitor", lambda: None)
164-
entry = ScheduleEntry(datetime.now(), Feature.MONITOR, 3600)
195+
entry = ScheduleEntry(datetime(2026, 3, 22, 10), Feature.MONITOR, 3600)
165196
config = DaemonConfig(monitor_interval_seconds=3600)
166197
assert _execute_entry(entry, config) is None
167198

168199
def test_amazon_outside_window_returns_next_open(self, monkeypatch):
169-
now = datetime.now()
170-
# Use a window that's NOT the current hour
171-
future_hour = (now.hour + 3) % 24
172-
if future_hour <= now.hour:
173-
return # skip if wraps (edge case)
200+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10))
174201
config = DaemonConfig(
175202
amazon_interval_seconds=86400,
176-
amazon_windows=[(future_hour, future_hour + 1)],
203+
amazon_windows=[(14, 16)],
177204
)
178-
entry = ScheduleEntry(now, Feature.AMAZON, 86400)
205+
entry = ScheduleEntry(datetime(2026, 3, 22, 10), Feature.AMAZON, 86400)
179206
result = _execute_entry(entry, config)
180207
assert result is not None
181-
assert result.hour == future_hour
208+
assert result.hour == 14
182209

183210
def test_amazon_inside_window_returns_none(self, monkeypatch):
184211
monkeypatch.setattr("ynab_tools.daemon.scheduler._run_amazon", lambda: None)
185-
now = datetime.now()
212+
monkeypatch.setattr("ynab_tools.daemon.scheduler.datetime", _fixed_datetime(10))
186213
config = DaemonConfig(
187214
amazon_interval_seconds=86400,
188-
amazon_windows=[(now.hour, now.hour + 1)],
215+
amazon_windows=[(10, 12)],
189216
)
190-
entry = ScheduleEntry(now, Feature.AMAZON, 86400)
217+
entry = ScheduleEntry(datetime(2026, 3, 22, 10), Feature.AMAZON, 86400)
191218
assert _execute_entry(entry, config) is None
192219

193220

0 commit comments

Comments
 (0)