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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user