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