Keep a repeating week on the weekday it was taken on

A week off is a week off — Monday to Sunday this year means Monday to
Sunday next year, not the same two calendar dates landing mid-week. A
fixed yearly wish now snaps each later year's start to the nearest date
carrying the first year's weekday and holds the span, so 5-11 October
2026 recurs as 4-10 October 2027.

Flexible wishes are untouched: their four date fields are a bounding
range the allocator searches inside, not a placement, so a weekday shift
there would mean nothing. The first year is always returned unshifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 14:03:21 +02:00
parent 2d40e4caa6
commit 224fe7ae3e
2 changed files with 146 additions and 4 deletions
+46 -4
View File
@@ -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:
@@ -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))