Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.
project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.
_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.
_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.
Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.
Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.
Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
@@ -79,28 +79,37 @@ def project_window(
|
||||
Nothing re-derives it at read time — `LeaveWish.window()` just reads the
|
||||
dates back off the row.
|
||||
|
||||
`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` other than
|
||||
`anchor_year`, the naive `date(year, start_month, start_day)` is snapped
|
||||
to the nearest date carrying the anchor'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. `anchor_year` itself is unshifted.
|
||||
Both `window_mode`s 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` other than `anchor_year`, the naive
|
||||
`date(year, start_month, start_day)` is snapped to the nearest date
|
||||
carrying the anchor'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. `anchor_year` itself is 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. Each year's row therefore
|
||||
carries that year's bounding range, and the per-year placement search
|
||||
inside it still happens at plan time (`_Ledger.resolve_flexible_window`).
|
||||
For `window_mode == 'fixed'` the span being held fixed IS the placement:
|
||||
the result is the exact dates the family is home. For `window_mode ==
|
||||
'flexible'` (§10.2) the result is still a BOUNDING RANGE the allocator
|
||||
searches inside, not a placement — but the range itself must keep its
|
||||
weekday alignment year over year, or a week-shaped bound (e.g. Mon-Sun)
|
||||
degenerates into an arbitrary weekday every other year, which is exactly
|
||||
as wrong for a search bound as for a fixed placement (a week-shaped bound
|
||||
should stay week-shaped). The snap never changes the bound's *length* —
|
||||
only `_shift_to_weekday`'s ±3-day slide of the start, with the same
|
||||
day-span held fixed — so the search space `resolve_flexible_window`
|
||||
explores never shrinks. `window_mode` is otherwise unused here; kept as a
|
||||
parameter because both modes now take the identical snap path and every
|
||||
caller already has the value at hand — removing it would just make call
|
||||
sites pass a value nobody reads.
|
||||
"""
|
||||
wraps = window_wraps_month_day(start_month, start_day, end_month, end_day)
|
||||
naive_start = clamped_date(year, start_month, start_day)
|
||||
naive_end = clamped_date(year + 1 if wraps else year, end_month, end_day)
|
||||
|
||||
if window_mode != "fixed" or year == anchor_year:
|
||||
if year == anchor_year:
|
||||
return naive_start, naive_end
|
||||
|
||||
anchor_start = clamped_date(anchor_year, start_month, start_day)
|
||||
@@ -140,7 +149,12 @@ class LeaveWish:
|
||||
# are the exact placement, as above. 'flexible': the SAME four fields instead
|
||||
# describe the BOUNDING RANGE the allocator searches inside for the
|
||||
# best-costing placement (e.g. "vår" = 03-01..05-31) — target_weeks is still
|
||||
# the wanted duration inside that range.
|
||||
# the wanted duration inside that range. Both modes get the same
|
||||
# per-year weekday snap at materialisation (`project_window`): a fixed
|
||||
# window's exact placement keeps its weekday, AND a flexible window's
|
||||
# bounding range keeps its weekday alignment (so a week-shaped bound
|
||||
# stays week-shaped every year) while its span — and so its search
|
||||
# space — stays exactly as wide as the anchor year's.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
|
||||
# Groups the per-year rows a repeating wish was materialised into. `None`
|
||||
|
||||
Reference in New Issue
Block a user