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