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: