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:
2026-09-20 12:04:20 +02:00
parent fdc6439614
commit ad0e3c0b33
19 changed files with 1217 additions and 72 deletions
+157 -14
View File
@@ -174,6 +174,13 @@ _FRONT_LOAD_CHILD_AGE_YEARS = 2
_SHIFT_REMEDY_SEARCH_BOUND_DAYS = 28
# How many ranked alternative placements the remedy reports at most.
_SHIFT_REMEDY_MAX_SUGGESTIONS = 2
# §Decision 2 (flexible-window weekday alignment, user-confirmed) — when
# ranking flexible-window candidates, prefer a Monday start so a repeating
# wish lands on the same weekday every year (the reported bug). This is only
# a tie-break: it sits after `shortfall`/`cost` in `_rank_candidate`, so a
# non-Monday start that absorbs strictly more red days always wins — red
# days outrank alignment.
_PREFERRED_START_WEEKDAY = 0 # Monday, per `date.weekday()`
# ---------------------------------------------------------------------------
@@ -428,6 +435,11 @@ class WindowOutcome:
data: dict[str, object]
cost_breakdown: dict[str, int]
remedies: list[ShortfallRemedy]
# §todo item (red-day shift toast) — mirrors `_WishWindow.shift_gain_days`
# (see its docstring): spendable days the flexible placement search saved
# versus the naive bound-start placement. 0 for every fixed window and
# for a reused flexible window (see `_WishWindow`).
shift_gain_days: int = 0
@dataclass(frozen=True)
@@ -572,6 +584,15 @@ class _WishWindow:
anchor_year: int
start: date
end: date
# §todo item (red-day shift toast) — spendable days saved by the flexible
# placement search versus the naive "first slot in the bound" placement.
# Only ever set by `resolve_flexible_window`'s fresh-search path; the
# horizon-clipped early return and the reuse-already-funded path both
# leave this at the default 0 (see their call sites for why). Zero for
# every `window_mode == 'fixed'` window (`_expand_all_windows` never
# passes it) — a fixed window is never auto-shifted, so there is nothing
# to report here for it.
shift_gain_days: int = 0
def _expand_all_windows(alloc_input: AllocationInput) -> list[_WishWindow]:
@@ -663,7 +684,9 @@ def _candidate_windows(
"""§10.2 step 5 — every full-length `(start, end)` candidate (inclusive)
that fits entirely inside `[bound_start, bound_end]`, sliding one day at
a time (nothing requires a candidate to start on a Monday).
a time (nothing requires a candidate to start on a Monday — the generator
stays exhaustive; `_rank_candidate` is what prefers a Monday start among
the candidates this returns, per §Decision 2).
"""
candidates: list[tuple[date, date]] = []
last_start = bound_end - timedelta(days=length_days - 1)
@@ -873,11 +896,20 @@ class _Ledger:
else alloc_input.existing_planned
)
self._persisted_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
# §fix (Step 3, flexible-window realignment escape hatch) — same
# grouping as `_persisted_by_wish`, narrowed to the pinned rows. Lets
# `_reuse_already_funded_window` tell "this wish-year's persisted
# dates are just where the search last happened to land" (safe to
# release for a better-ranked candidate) from "the user pinned one of
# these dates on purpose" (must never move).
self._pinned_dates_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
for p in persisted_source:
if p.wish_id is None:
continue
key = (p.wish_id, p.child_id)
self._persisted_by_wish.setdefault(key, set()).add(p.date)
if p.is_pinned:
self._pinned_dates_by_wish.setdefault(key, set()).add(p.date)
self._reserve_pinned_days()
self._reserve_preschool_days()
@@ -1716,7 +1748,15 @@ class _Ledger:
responsible.mode, all_dates, days, cost
)
remedies = self._build_remedies(wish, w, shortfall_days)
# §todo item (red-day shift toast) — a flexible window's own bound
# search already IS the shift search (surfaced via
# `w.shift_gain_days`/the toast), so suggesting a placement outside
# the user's stated bound would contradict the flexible contract.
# A fixed window never auto-shifts, so it needs the suggestion
# instead — and gets it even when fully funded (shortfall_days == 0).
remedies = self._build_remedies(
wish, w, shortfall_days, include_shift_remedy=(wish.window_mode == "fixed")
)
outcome = WindowOutcome(
wish_id=wish.id,
@@ -1743,6 +1783,7 @@ class _Ledger:
},
cost_breakdown=cost,
remedies=remedies,
shift_gain_days=w.shift_gain_days,
)
return days, outcome
@@ -1755,17 +1796,20 @@ class _Ledger:
shortfall_days: int,
include_shift_remedy: bool = True,
) -> list[ShortfallRemedy]:
if shortfall_days <= 0:
return []
remedies: list[ShortfallRemedy] = []
remedies.extend(self._reduce_preschool_reserve_remedy(wish))
remedies.extend(self._carry_vacation_forward_remedy(wish, w))
remedies.extend(self._lower_priority_wish_remedy(wish))
if shortfall_days > 0:
remedies.extend(self._reduce_preschool_reserve_remedy(wish))
remedies.extend(self._carry_vacation_forward_remedy(wish, w))
remedies.extend(self._lower_priority_wish_remedy(wish))
# §10.4 — `include_shift_remedy=False` from `build_unplaced_outcome`:
# there `w` is the whole bounding range, not a single placed window,
# so there is no one winning edge to shift and no point running the
# (now real, `_SHIFT_REMEDY_SEARCH_BOUND_DAYS`-wide) search only to
# discard its result.
# discard its result. `fund_window` also passes False for a flexible
# window (its own bound search already IS the shift), so this branch
# is reached only for a fixed window — deliberately NOT gated on
# `shortfall_days`, so a fully-funded fixed window still surfaces
# "moving this frees N days" instead of hiding it behind a shortfall.
if include_shift_remedy:
remedies.extend(self._shift_window_into_free_days_remedy(wish, w))
return remedies
@@ -1990,6 +2034,34 @@ class _Ledger:
dates = self._persisted_by_wish.get((wish.id, child_id), set())
return sorted(d for d in dates if bound_start <= d <= bound_end)
def _reused_window_carries_manual_edit(
self,
wish: LeaveWish,
responsible: _Responsible,
child_id: UUID | None,
persisted_dates: list[date],
) -> bool:
"""§fix (Step 3) — whether any of this wish-year's already-persisted
`persisted_dates` carries a manual edit that must never be silently
moved by the realignment escape hatch in `_reuse_already_funded_window`:
a pinned day (`is_pinned`, locked in place by the user) or a day the
user explicitly removed (§remove-this-day, `_is_excluded`). Both kinds
are already respected elsewhere in the ordinary funding path
(`_candidate_occupied`/`_persisted_dates_for_wish` keep this wish's own
claimed dates out of collision checks, `_is_excluded` keeps a removed
date out of a freshly funded window) — this is what keeps the new
escape hatch from re-searching its way around either one.
"""
pinned = self._pinned_dates_by_wish.get((wish.id, child_id), frozenset())
if any(d in pinned for d in persisted_dates):
return True
return any(
self._is_excluded(p.uuid, child_id, d)
for d in persisted_dates
for p in responsible.parents
)
# -- §Decision C — budget-aware candidate ranking ----------------------
def _candidate_shortfall(
@@ -2072,17 +2144,27 @@ class _Ledger:
wish: LeaveWish,
responsible: _Responsible,
candidate: tuple[date, date],
) -> tuple[int, int, date, int]:
) -> tuple[int, int, int, date, int]:
"""Sort key shared by `resolve_flexible_window` and
`_reuse_already_funded_window`: fewest unfundable days first (§Decision
C), THEN calendar cost, THEN start date, THEN wish id — the existing
priority ordering (§10.2 step 6) is otherwise unchanged.
C), THEN calendar cost, THEN Monday-start alignment (§Decision 2),
THEN start date, THEN wish id — the existing priority ordering
(§10.2 step 6) is otherwise unchanged.
The alignment term sits strictly after `cost` and strictly before
`start`: after `cost` so red days always win a candidate — a
cheaper non-Monday start beats a pricier Monday one every time; before
`start` so that among equally-cheap candidates (the degenerate case
where the bound is exactly `length_days` long, or any other tie) a
Monday start wins over "earliest", which is what made flexible
placements land on an arbitrary, year-to-year-inconsistent weekday.
"""
start, end = candidate
shortfall = self._candidate_shortfall(wish, responsible, start, end)
cost = _candidate_cost(start, end, self._input.holidays, self._input.day_overrides)
return (shortfall, cost, start, wish.id.int)
misaligned = 0 if start.weekday() == _PREFERRED_START_WEEKDAY else 1
return (shortfall, cost, misaligned, start, wish.id.int)
def _reuse_already_funded_window(
self,
@@ -2119,6 +2201,21 @@ class _Ledger:
higher-priority fixed wish added since the last apply). That last
case is what keeps a genuinely-unsatisfied year free to move
(§10.3) rather than frozen to a window that no longer fits.
§fix (Step 3, flexible-window realignment escape hatch) — also
returns `None` when the winner picked below is misaligned (does not
start on `_PREFERRED_START_WEEKDAY`) AND a fresh, unoccupied search
across the *whole* bound (not just windows containing the persisted
dates — exactly what `resolve_flexible_window` runs next when this
returns `None`) would find a candidate whose `(shortfall, cost,
misaligned)` rank prefix is strictly better. This is what lets a
wish-year placed before §Decision 2 existed (or before its bound
moved) self-heal onto a Monday on the next `propose()`, instead of
being frozen forever to whichever weekday it first landed on. Never
fires when any of the persisted dates carry a manual edit — a pinned
day or one the user explicitly removed (see
`_reused_window_carries_manual_edit`) — so a deliberate per-day
override always outlives a realignment.
"""
persisted_dates = self._persisted_dates_for_wish(wish, child_id, bound_start, bound_end)
if not persisted_dates:
@@ -2139,7 +2236,30 @@ class _Ledger:
if not candidates:
return None
return min(candidates, key=lambda c: self._rank_candidate(wish, responsible, c))
winner = min(candidates, key=lambda c: self._rank_candidate(wish, responsible, c))
winner_start, _winner_end = winner
if winner_start.weekday() == _PREFERRED_START_WEEKDAY:
return winner
if self._reused_window_carries_manual_edit(wish, responsible, child_id, persisted_dates):
return winner
fresh_candidates = [
(s, e)
for s, e in _candidate_windows(bound_start, bound_end, length_days)
if not self._candidate_occupied(s, e, responsible.parents, child_id)
]
if fresh_candidates:
fresh_winner = min(
fresh_candidates, key=lambda c: self._rank_candidate(wish, responsible, c)
)
if (
self._rank_candidate(wish, responsible, fresh_winner)[:3]
< self._rank_candidate(wish, responsible, winner)[:3]
):
return None
return winner
def resolve_flexible_window(
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
@@ -2187,7 +2307,30 @@ class _Ledger:
winner_start, winner_end = min(
candidates, key=lambda c: self._rank_candidate(wish, responsible, c)
)
return _WishWindow(wish=wish, anchor_year=anchor_year, start=winner_start, end=winner_end)
# §todo item (red-day shift toast) — how many spendable days the
# search saved versus the naive "first slot in the bound" placement,
# so the frontend can tell the user their window moved to catch red
# days. Compared only against the bound-start placement, not the
# previous placement across re-proposes — see `_WishWindow
# .shift_gain_days`'s docstring for why the reuse path leaves this 0.
naive_start = bound_start
naive_end = bound_start + timedelta(days=length_days - 1)
shift_gain_days = 0
if (winner_start, winner_end) != (naive_start, naive_end):
naive_cost = _candidate_cost(
naive_start, naive_end, self._input.holidays, self._input.day_overrides
)
winner_cost = _candidate_cost(
winner_start, winner_end, self._input.holidays, self._input.day_overrides
)
shift_gain_days = max(naive_cost - winner_cost, 0)
return _WishWindow(
wish=wish,
anchor_year=anchor_year,
start=winner_start,
end=winner_end,
shift_gain_days=shift_gain_days,
)
def build_unplaced_outcome(
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date