diff --git a/backend/app/entities/leave_wish.py b/backend/app/entities/leave_wish.py index d12f577..84b6f88 100644 --- a/backend/app/entities/leave_wish.py +++ b/backend/app/entities/leave_wish.py @@ -13,10 +13,25 @@ its start, it wraps into the next year — e.g. Jullov 12-20 -> 01-07. """ from dataclasses import dataclass -from datetime import date, datetime +from datetime import date, datetime, timedelta from uuid import UUID +def _shift_to_weekday(naive: date, target_weekday: int) -> date: + """Nearest date to `naive` whose `weekday()` is `target_weekday`. + + Weekdays repeat every 7 days, so the shift is always in [-3, +3] days — + built with `timedelta`, never by mutating month/day ints (Feb 29 and + month-end would misbehave otherwise). Ties can't actually arise (7 is + odd, so no naive date is equidistant between two candidates), but the + comparison key resolves one in favour of the earlier date regardless, to + stay defensive. + """ + forward = (target_weekday - naive.weekday()) % 7 # 0..6, shift going forward + shift = min((forward, forward - 7), key=lambda d: (abs(d), d)) + return naive + timedelta(days=shift) + + @dataclass class LeaveWish: """A family's recurring intent to be home for N weeks inside a yearly window.""" @@ -107,10 +122,37 @@ class LeaveWish: end date lands in `year + 1` — this also means a wrapping window spans two different vacation-year budgets (see PLANNING_ENGINE_SPEC.md §3.2); callers that debit vacation must account for both years separately. + + `window_mode == 'fixed'` wishes describe a week-based habit, not a + literal month/day: someone who is off Mon-Sun this year means Mon-Sun + again next year, even though the naive month/day anchor isn't a + Monday every year (the reported bug: 2026-10-05..10-11, Mon-Sun, + naively recurred to 2027-10-05..10-11, which is Tue-Mon). So for any + `year` after `first_year`, the naive `date(year, start_month, + start_day)` is snapped to the nearest date carrying `first_year`'s + *start weekday* (ties resolve to the earlier date — see + `_shift_to_weekday`), and the end date is re-derived by holding the + anchor window's day-*span* fixed rather than re-anchoring + end_month/end_day. `first_year` itself is always returned unshifted. + + `window_mode == 'flexible'` wishes (§10.2) describe a BOUNDING RANGE + the allocator searches inside, not a placement — they keep the plain + month/day anchor with no weekday shift, every year. """ - start = date(year, self.window_start_month, self.window_start_day) - end_year = year + 1 if self.window_wraps() else year - end = date(end_year, self.window_end_month, self.window_end_day) + naive_start = date(year, self.window_start_month, self.window_start_day) + naive_end_year = year + 1 if self.window_wraps() else year + naive_end = date(naive_end_year, self.window_end_month, self.window_end_day) + + if self.window_mode != "fixed" or year == self.first_year: + return naive_start, naive_end + + anchor_start = date(self.first_year, self.window_start_month, self.window_start_day) + anchor_end_year = self.first_year + 1 if self.window_wraps() else self.first_year + anchor_end = date(anchor_end_year, self.window_end_month, self.window_end_day) + span = anchor_end - anchor_start + + start = _shift_to_weekday(naive_start, anchor_start.weekday()) + end = start + span return start, end def applies_to_year(self, year: int) -> bool: diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 968982e..762d1e0 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -2420,3 +2420,103 @@ class TestStrategyReportsDegradation: if d.parent_personal_data_uuid == parent_b.uuid and d.day_type != "free" ] assert non_free_b == [], "parent B cannot fund anything -- must get nothing, not a lie" + + +# --------------------------------------------------------------------------- +# todo.md #1 -- a `window_mode == 'fixed'` wish's recurrence is a week-based +# habit ("off Mon-Sun"), not a literal month/day. `window_for_year` must snap +# later years' naive month/day anchor onto the nearest date sharing +# `first_year`'s start weekday, and hold the window's day-span fixed instead +# of re-anchoring end_month/end_day. Flexible wishes are unaffected -- their +# four fields are a bounding range to search inside, never a placement. +# These are pure entity-level tests (no Allocator needed) since the rule +# lives entirely in `LeaveWish.window_for_year`. +# --------------------------------------------------------------------------- + + +class TestCase29FixedYearlyWishPreservesWeekdayAlignment: + def test_reported_user_case_shifts_to_the_nearest_matching_weekday(self) -> None: + """2026-10-05..10-11 is Mon-Sun. Naively recurring the same month/day + would land 2027 on Tue-Mon -- the exact bug report. The nearest date + to 2027-10-05 sharing Monday is 2027-10-04, and holding the 6-day + span gives 2027-10-04..10-10 (also Mon-Sun); 2028 lands on + 10-02..10-08 for the same reason (verified with date.weekday() + directly, not by re-deriving the arithmetic in this docstring). + """ + wish = _wish( + child_id=None, + start=(10, 5), + end=(10, 11), + recurrence="yearly", + first_year=2026, + window_mode="fixed", + ) + + start_2026, end_2026 = wish.window_for_year(2026) + assert (start_2026, end_2026) == (date(2026, 10, 5), date(2026, 10, 11)) + assert start_2026.weekday() == 0 # Monday + assert end_2026.weekday() == 6 # Sunday + + start_2027, end_2027 = wish.window_for_year(2027) + assert (start_2027, end_2027) == (date(2027, 10, 4), date(2027, 10, 10)) + assert start_2027.weekday() == 0 + assert end_2027.weekday() == 6 + + start_2028, end_2028 = wish.window_for_year(2028) + assert (start_2028, end_2028) == (date(2028, 10, 2), date(2028, 10, 8)) + assert start_2028.weekday() == 0 + assert end_2028.weekday() == 6 + + def test_first_year_is_returned_unshifted(self) -> None: + wish = _wish( + child_id=None, + start=(10, 5), + end=(10, 11), + recurrence="yearly", + first_year=2026, + window_mode="fixed", + ) + assert wish.window_for_year(2026) == (date(2026, 10, 5), date(2026, 10, 11)) + + def test_new_year_wrapping_fixed_wish_still_wraps(self) -> None: + """Jullov 12-20..01-06 (Sun -> Wed, a 17-day span) must keep landing + its end in `year + 1` relative to its own anchor year after the + weekday shift, exactly like the unshifted (first_year) case. + """ + wish = _wish( + child_id=None, + start=(12, 20), + end=(1, 6), + recurrence="yearly", + first_year=2026, + window_mode="fixed", + ) + assert wish.window_wraps() is True + + start_2026, end_2026 = wish.window_for_year(2026) + assert start_2026 == date(2026, 12, 20) + assert end_2026 == date(2027, 1, 6) + + start_2027, end_2027 = wish.window_for_year(2027) + assert start_2027 == date(2027, 12, 19) + assert end_2027 == date(2028, 1, 5) + assert (end_2027 - start_2027) == (end_2026 - start_2026) + assert start_2027.weekday() == start_2026.weekday() + assert end_2027.weekday() == end_2026.weekday() + + def test_flexible_wish_bounding_range_is_unchanged_across_years(self) -> None: + """A flexible wish's four fields describe a bounding range to search + inside, not a placement -- no weekday shift, ever, including for + Season presets which must stay byte-identical after this change. + """ + wish = _wish( + child_id=None, + start=(3, 1), + end=(5, 31), + recurrence="yearly", + first_year=2026, + window_mode="flexible", + ) + assert wish.window_for_year(2026) == (date(2026, 3, 1), date(2026, 5, 31)) + assert wish.window_for_year(2027) == (date(2027, 3, 1), date(2027, 5, 31)) + assert wish.window_for_year(2028) == (date(2028, 3, 1), date(2028, 5, 31))