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:
+4
-1
@@ -265,7 +265,10 @@ class LeaveWish:
|
||||
|
||||
# 'fixed' (default): window_start_*/window_end_* are the exact placement. 'flexible':
|
||||
# the same fields instead describe the bounding range the allocator searches inside
|
||||
# for the best-costing placement (PLANNING_ENGINE_SPEC.md §10.1).
|
||||
# for the best-costing placement (PLANNING_ENGINE_SPEC.md §10.1). Both modes get the
|
||||
# same per-year weekday snap at materialisation: a flexible bound keeps its weekday
|
||||
# alignment year over year (not a literal, unshifted month/day repeat) while its span
|
||||
# stays fixed — see `project_window` in app/entities/leave_wish.py.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
# Opt-in dubbeldagar for a parent_mode == 'both' window. Default False
|
||||
# means the co-leave funding path applies instead (PLANNING_ENGINE_SPEC.md §3.5).
|
||||
|
||||
@@ -624,7 +624,15 @@ class LeaveWish:
|
||||
# inside for the best-costing placement (e.g. "vår" = 03-01..05-31) — see §10.2. target_weeks
|
||||
# is still the wanted duration inside that range; recurrence/first_year/last_year are unchanged,
|
||||
# so a 'yearly' flexible wish still re-resolves its bounding range every anchor year through the
|
||||
# existing window_for_year()/applies_to_year() helpers below, completely unmodified.
|
||||
# existing window_for_year()/applies_to_year() helpers below.
|
||||
#
|
||||
# SUPERSEDED (weekday-shift fix): "unmodified" held only for the helpers'
|
||||
# signatures, not their behavior. `window_for_year` -> `project_window`
|
||||
# now applies the same per-year weekday snap to BOTH modes (previously
|
||||
# `fixed` only) — a flexible bound keeps its weekday alignment year over
|
||||
# year (a week-shaped bound stays week-shaped) with its span, and so its
|
||||
# search space, held exactly as wide as the anchor year's. See
|
||||
# `app/entities/leave_wish.py::project_window`'s docstring.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
|
||||
created_at: datetime | None = None
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -94,6 +94,10 @@ class WindowOutcomeResponse(BaseModel):
|
||||
data: dict[str, object]
|
||||
cost_breakdown: dict[str, int]
|
||||
remedies: list[ShortfallRemedyResponse]
|
||||
# Mirrors `WindowOutcome.shift_gain_days` (app/services/allocation_service.py)
|
||||
# — spendable days the flexible placement search saved versus the naive
|
||||
# bound-start placement. 0 for a fixed window or a reused flexible one.
|
||||
shift_gain_days: int = 0
|
||||
|
||||
|
||||
class PlanWarningResponse(BaseModel):
|
||||
@@ -192,6 +196,7 @@ class PlanProposalResponse(BaseModel):
|
||||
)
|
||||
for r in o.remedies
|
||||
],
|
||||
shift_gain_days=o.shift_gain_days,
|
||||
)
|
||||
for o in proposal.outcomes
|
||||
],
|
||||
|
||||
@@ -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,9 +1796,8 @@ class _Ledger:
|
||||
shortfall_days: int,
|
||||
include_shift_remedy: bool = True,
|
||||
) -> list[ShortfallRemedy]:
|
||||
if shortfall_days <= 0:
|
||||
return []
|
||||
remedies: list[ShortfallRemedy] = []
|
||||
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))
|
||||
@@ -1765,7 +1805,11 @@ class _Ledger:
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Realign already-materialised flexible series onto their anchor's weekday.
|
||||
|
||||
`project_window` (`app/entities/leave_wish.py`) used to skip the weekday snap
|
||||
for `window_mode == 'flexible'` rows — every year of a flexible series stored
|
||||
the identical naive month/day bound, so a week-shaped bound (e.g. "the week
|
||||
of Oct 5") silently drifted onto a different weekday every year it wasn't the
|
||||
anchor. That bug is fixed at the source (`project_window` now snaps a
|
||||
flexible bound's start to the anchor year's start weekday too, holding the
|
||||
bound's day-span fixed, exactly like `window_mode == 'fixed'` already did),
|
||||
but `project_window` only ever runs ONCE, at materialisation time
|
||||
(`LeaveWishService`) — nothing re-derives it at read time. Every flexible
|
||||
series row materialised before this fix therefore stays unsnapped forever
|
||||
unless something rewrites it once, here.
|
||||
|
||||
This migration:
|
||||
|
||||
1. finds every `leave_wish` row with `window_mode = 'flexible'` and a
|
||||
non-null `series_id`;
|
||||
2. groups them by `series_id`, and for each series treats the row with the
|
||||
lowest `first_year` as the anchor occurrence (unshifted by definition —
|
||||
`project_window` never shifts `year == anchor_year`), the same convention
|
||||
004's `downgrade()` uses to collapse a series back to one row;
|
||||
3. for every OTHER row in that series, re-projects that year's bound from
|
||||
the anchor's own stored month/day fields (which, being the anchor, are
|
||||
already the naive/unshifted pattern) using the anchor's start weekday,
|
||||
and rewrites that row's four month/day columns in place.
|
||||
|
||||
Fixed-mode series are left untouched — `project_window` already snapped them
|
||||
correctly the day they were materialised, and re-running the (identical)
|
||||
projection on them would be a costly no-op at best.
|
||||
|
||||
No schema change: this is a pure data rewrite. The projection helpers below
|
||||
are a **self-contained copy**, not an import of `app.entities.leave_wish`,
|
||||
for the same reason 004 gives at its line 196 — a migration must keep
|
||||
describing the behaviour it was written against, not whatever the entity
|
||||
module does later.
|
||||
|
||||
`downgrade()` restores the naive (unsnapped) anchor month/day for every
|
||||
non-anchor flexible row — i.e. exactly what this migration found — so the
|
||||
round trip is lossless for anchor rows (never touched) and reverses cleanly
|
||||
for every row it did touch.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-09-20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "008"
|
||||
down_revision: str | None = "007"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def _clamped(year: int, month: int, day: int) -> dt.date:
|
||||
return dt.date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def _shift_to_weekday(naive: dt.date, target_weekday: int) -> dt.date:
|
||||
forward = (target_weekday - naive.weekday()) % 7
|
||||
shift = min((forward, forward - 7), key=lambda d: (abs(d), d))
|
||||
return naive + dt.timedelta(days=shift)
|
||||
|
||||
|
||||
def _project(
|
||||
sm: int, sd: int, em: int, ed: int, anchor_year: int, year: int, *, snap: bool
|
||||
) -> tuple[dt.date, dt.date]:
|
||||
"""Verbatim copy of `project_window`'s post-fix (`snap=True`) and
|
||||
|
||||
pre-fix (`snap=False`) behaviour, parametrised on the anchor row's own
|
||||
(start_month, start_day, end_month, end_day) pattern rather than a
|
||||
`LeaveWish` instance.
|
||||
"""
|
||||
wraps = (em, ed) < (sm, sd)
|
||||
naive_start = _clamped(year, sm, sd)
|
||||
naive_end = _clamped(year + 1 if wraps else year, em, ed)
|
||||
if not snap or year == anchor_year:
|
||||
return naive_start, naive_end
|
||||
anchor_start = _clamped(anchor_year, sm, sd)
|
||||
anchor_end = _clamped(anchor_year + 1 if wraps else anchor_year, em, ed)
|
||||
span = anchor_end - anchor_start
|
||||
start = _shift_to_weekday(naive_start, anchor_start.weekday())
|
||||
return start, start + span
|
||||
|
||||
|
||||
def _flexible_series_rows(conn: Any) -> dict[str, list[Any]]:
|
||||
from sqlalchemy import text
|
||||
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT id, series_id, first_year, "
|
||||
"window_start_month, window_start_day, window_end_month, window_end_day "
|
||||
"FROM leave_wish "
|
||||
"WHERE window_mode = 'flexible' AND series_id IS NOT NULL "
|
||||
"ORDER BY series_id, first_year"
|
||||
)
|
||||
).fetchall()
|
||||
by_series: dict[str, list[Any]] = {}
|
||||
for row in rows:
|
||||
by_series.setdefault(str(row.series_id), []).append(row)
|
||||
return by_series
|
||||
|
||||
|
||||
def _rewrite(conn: Any, *, snap: bool) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
for series_rows in _flexible_series_rows(conn).values():
|
||||
anchor = min(series_rows, key=lambda r: int(r.first_year))
|
||||
sm, sd = anchor.window_start_month, anchor.window_start_day
|
||||
em, ed = anchor.window_end_month, anchor.window_end_day
|
||||
anchor_year = int(anchor.first_year)
|
||||
for row in series_rows:
|
||||
year = int(row.first_year)
|
||||
if year == anchor_year:
|
||||
continue
|
||||
start, end = _project(sm, sd, em, ed, anchor_year, year, snap=snap)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE leave_wish SET "
|
||||
"window_start_month = :wsm, window_start_day = :wsd, "
|
||||
"window_end_month = :wem, window_end_day = :wed "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"wsm": start.month,
|
||||
"wsd": start.day,
|
||||
"wem": end.month,
|
||||
"wed": end.day,
|
||||
"id": row.id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_rewrite(op.get_bind(), snap=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_rewrite(op.get_bind(), snap=False)
|
||||
@@ -179,7 +179,14 @@ def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_pat
|
||||
)
|
||||
|
||||
|
||||
def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path: Path) -> None:
|
||||
def test_flexible_yearly_wish_keeps_its_bounding_range_weekday_aligned(tmp_path: Path) -> None:
|
||||
"""`command.upgrade(..., "head")` runs 004's backfill AND 008's
|
||||
|
||||
realignment, so this proves the two migrations agree with each other:
|
||||
004 backfills the pre-fix (unsnapped) flexible bound, and 008 — since
|
||||
"head" now includes it — snaps it the same way `project_window` does
|
||||
post-fix, rather than leaving it at 004's naive output.
|
||||
"""
|
||||
db = tmp_path / "flexible.db"
|
||||
command.upgrade(_alembic_config(db), "003")
|
||||
|
||||
@@ -191,7 +198,7 @@ def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path:
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "Var",
|
||||
"wsm": 3,
|
||||
"wsd": 1,
|
||||
"wsd": 1, # 2027-03-01 is a Monday
|
||||
"wem": 5,
|
||||
"wed": 31,
|
||||
"target_weeks": 1,
|
||||
@@ -207,12 +214,17 @@ def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path:
|
||||
|
||||
rows = _rows(db, "Var")
|
||||
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
||||
# A flexible wish's four fields are a bounding range, never a placement —
|
||||
# no weekday snap, so every year's range is the identical month/day pair.
|
||||
assert {
|
||||
(r["window_start_month"], r["window_start_day"], r["window_end_month"], r["window_end_day"])
|
||||
for r in rows
|
||||
} == {(3, 1, 5, 31)}
|
||||
# The bound keeps its weekday alignment (Monday, like the anchor) and
|
||||
# its exact 91-day span, sliding a few days per year rather than staying
|
||||
# pinned to the identical naive month/day.
|
||||
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
|
||||
assert stored == [(3, 1), (2, 28), (2, 26)]
|
||||
for row in rows:
|
||||
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
|
||||
# Never wraps (May > March), so the end date stays in the same year.
|
||||
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
|
||||
assert start.weekday() == 0, row["first_year"]
|
||||
assert (end - start).days == 91, row["first_year"]
|
||||
|
||||
|
||||
def test_once_wish_is_left_alone_with_no_series(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Migration 008's data rewrite: realign already-materialised flexible series.
|
||||
|
||||
`project_window` (`app/entities/leave_wish.py`) now snaps a `window_mode ==
|
||||
'flexible'` bound's start to the anchor year's start weekday too (Step 1 of
|
||||
the flexible-window realignment fix), holding the bound's day-span fixed —
|
||||
mirroring what `window_mode == 'fixed'` already did. But `project_window`
|
||||
only runs once, at materialisation time (`LeaveWishService`); nothing
|
||||
re-derives it at read time. Every flexible series row materialised before
|
||||
this fix therefore stays unsnapped forever unless something rewrites it
|
||||
once. Migration 008 is that one-time rewrite.
|
||||
|
||||
Driven through the real Alembic chain (upgrade to 007, insert POST-004-shape
|
||||
rows directly — the one-row-per-year shape already exists by 007, so there is
|
||||
no pre-004 fixture to build here — then upgrade to head/008), mirroring
|
||||
`test_migration_004_backfill.py`'s style.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
|
||||
def _alembic_config(db_path: Path) -> Config:
|
||||
cfg = Config("alembic.ini")
|
||||
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
||||
return cfg
|
||||
|
||||
|
||||
def _insert_post_004_fixture(db_path: Path, *, rows: list[dict[str, object]]) -> str:
|
||||
"""Seed a workspace and the given POST-004-shape `leave_wish` rows.
|
||||
|
||||
`rows` are full leave_wish rows (one per year already) — the shape 004
|
||||
produces and 007 leaves untouched — grouped by `series_id`.
|
||||
"""
|
||||
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
|
||||
ws_id = str(uuid.uuid4())
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
|
||||
(ws_id, "WS", now),
|
||||
)
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
"INSERT INTO leave_wish (id, workspace_id, child_id, name, "
|
||||
"window_start_month, window_start_day, window_end_month, window_end_day, "
|
||||
"target_weeks, first_year, last_year, priority, parent_mode, "
|
||||
"window_mode, series_id, created_at, updated_at) "
|
||||
"VALUES (:id, :workspace_id, NULL, :name, :wsm, :wsd, :wem, :wed, "
|
||||
":target_weeks, :first_year, :last_year, 1, 'either', "
|
||||
":window_mode, :series_id, :now, :now)",
|
||||
{"workspace_id": ws_id, "now": now, **row},
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return ws_id
|
||||
|
||||
|
||||
def _rows(db_path: Path, name: str) -> list[sqlite3.Row]:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT * FROM leave_wish WHERE name = ? ORDER BY first_year", (name,)
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _flexible_series(
|
||||
*, name: str, series_id: str, first_year: int, last_year: int, wsm: int, wsd: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rows in 004's shape for a flexible series whose bound was NAIVELY
|
||||
|
||||
materialised (every year the identical month/day pair) — exactly what
|
||||
every flexible series looked like before this fix, and what 008 must
|
||||
rewrite.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"wsm": wsm,
|
||||
"wsd": wsd,
|
||||
"wem": 5,
|
||||
"wed": 31,
|
||||
"target_weeks": 1,
|
||||
"first_year": year,
|
||||
"last_year": last_year,
|
||||
"window_mode": "flexible",
|
||||
"series_id": series_id,
|
||||
}
|
||||
for year in range(first_year, last_year + 1)
|
||||
]
|
||||
|
||||
|
||||
def test_upgrade_realigns_a_flexible_series_onto_its_anchor_weekday(tmp_path: Path) -> None:
|
||||
db = tmp_path / "realign.db"
|
||||
command.upgrade(_alembic_config(db), "007")
|
||||
|
||||
series_id = str(uuid.uuid4())
|
||||
# 2027-03-01 is a Monday; naively every year got the same (3, 1).
|
||||
rows_in = _flexible_series(
|
||||
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
|
||||
)
|
||||
_insert_post_004_fixture(db, rows=rows_in)
|
||||
|
||||
command.upgrade(_alembic_config(db), "head")
|
||||
|
||||
rows = _rows(db, "Var")
|
||||
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
||||
# Anchor (2027) untouched; 2028/2029 snapped to Monday, span held fixed.
|
||||
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
|
||||
assert stored == [(3, 1), (2, 28), (2, 26)]
|
||||
for row in rows:
|
||||
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
|
||||
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
|
||||
assert start.weekday() == 0, row["first_year"]
|
||||
assert (end - start).days == 91, row["first_year"]
|
||||
|
||||
|
||||
def test_downgrade_restores_the_naive_anchor_month_day(tmp_path: Path) -> None:
|
||||
db = tmp_path / "realign_roundtrip.db"
|
||||
command.upgrade(_alembic_config(db), "007")
|
||||
|
||||
series_id = str(uuid.uuid4())
|
||||
rows_in = _flexible_series(
|
||||
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
|
||||
)
|
||||
_insert_post_004_fixture(db, rows=rows_in)
|
||||
|
||||
command.upgrade(_alembic_config(db), "head")
|
||||
assert {(r["window_start_month"], r["window_start_day"]) for r in _rows(db, "Var")} != {(3, 1)}
|
||||
|
||||
command.downgrade(_alembic_config(db), "007")
|
||||
|
||||
rows = _rows(db, "Var")
|
||||
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
||||
assert {(r["window_start_month"], r["window_start_day"]) for r in rows} == {(3, 1)}
|
||||
assert {(r["window_end_month"], r["window_end_day"]) for r in rows} == {(5, 31)}
|
||||
|
||||
|
||||
def test_fixed_series_is_left_untouched(tmp_path: Path) -> None:
|
||||
"""`project_window` already snapped a fixed series correctly at 004's
|
||||
|
||||
materialisation time — 008 must be a no-op for it, not a costly
|
||||
re-run of an already-correct projection.
|
||||
"""
|
||||
db = tmp_path / "fixed_untouched.db"
|
||||
command.upgrade(_alembic_config(db), "007")
|
||||
|
||||
series_id = str(uuid.uuid4())
|
||||
rows_in = [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "Sommar",
|
||||
"wsm": 10,
|
||||
"wsd": 5 if year == 2026 else (4 if year == 2027 else 2),
|
||||
"wem": 10,
|
||||
"wed": 11 if year == 2026 else (10 if year == 2027 else 8),
|
||||
"target_weeks": 1,
|
||||
"first_year": year,
|
||||
"last_year": 2028,
|
||||
"window_mode": "fixed",
|
||||
"series_id": series_id,
|
||||
}
|
||||
for year in (2026, 2027, 2028)
|
||||
]
|
||||
_insert_post_004_fixture(db, rows=rows_in)
|
||||
|
||||
before = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
|
||||
command.upgrade(_alembic_config(db), "head")
|
||||
after = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
|
||||
|
||||
for year, row in before.items():
|
||||
assert after[year]["window_start_month"] == row["window_start_month"]
|
||||
assert after[year]["window_start_day"] == row["window_start_day"]
|
||||
assert after[year]["window_end_month"] == row["window_end_month"]
|
||||
assert after[year]["window_end_day"] == row["window_end_day"]
|
||||
@@ -1433,31 +1433,41 @@ class TestLeaveWishSeriesMaterialisation:
|
||||
assert end.weekday() == 6, row.first_year
|
||||
assert (end - start).days == 6
|
||||
|
||||
async def test_flexible_series_keeps_one_bounding_range_per_year_unshifted(
|
||||
async def test_flexible_series_keeps_its_bounding_ranges_weekday_aligned(
|
||||
self,
|
||||
leave_wish_service: LeaveWishService,
|
||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||
seeded_ids: SeededIds,
|
||||
) -> None:
|
||||
"""A flexible wish's fields are a bounding range, not a placement, so
|
||||
materialising gives each year the SAME month/day range — the per-year
|
||||
placement search inside it stays the allocator's job.
|
||||
"""A flexible wish's fields are a bounding range, not a placement, but
|
||||
|
||||
the range must still keep its weekday alignment year over year (the
|
||||
reported bug: a week-shaped bound silently drifting onto a different
|
||||
weekday every non-anchor year) — same snap `window_mode == 'fixed'`
|
||||
gets, with the bound's day-*span* held fixed so the search space
|
||||
`resolve_flexible_window` explores never shrinks.
|
||||
"""
|
||||
_, rows = await self._series(
|
||||
leave_wish_service,
|
||||
leave_wish_repo,
|
||||
seeded_ids,
|
||||
window_start_month=3,
|
||||
window_start_day=1,
|
||||
window_start_day=1, # 2026-03-01 is a Sunday
|
||||
window_end_month=5,
|
||||
window_end_day=31,
|
||||
window_mode="flexible",
|
||||
)
|
||||
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
|
||||
assert {
|
||||
(r.window_start_month, r.window_start_day, r.window_end_month, r.window_end_day)
|
||||
for r in rows
|
||||
} == {(3, 1, 5, 31)}
|
||||
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
|
||||
(3, 1),
|
||||
(2, 28),
|
||||
(2, 27),
|
||||
(3, 4),
|
||||
]
|
||||
for row in rows:
|
||||
start, end = row.window()
|
||||
assert start.weekday() == 6, row.first_year # Sunday, like the anchor
|
||||
assert (end - start).days == 91, row.first_year # span held fixed
|
||||
|
||||
async def test_open_ended_series_stops_at_the_childs_fourth_birthday_year(
|
||||
self,
|
||||
|
||||
@@ -140,7 +140,19 @@ class TestExcludedDayIsNeverPlaced:
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
assert outcome.remedies == []
|
||||
# None of the three genuinely shortfall-gated remedies fire for a
|
||||
# zero-shortfall window -- the exclusion did not create a gap to
|
||||
# remedy. This used to be `remedies == []` outright, but the
|
||||
# `shift_window_into_free_days` suggestion (§todo item, red-day
|
||||
# shift toast) is deliberately NOT shortfall-gated for a `fixed`
|
||||
# window any more: a fully-funded fixed window can still surface
|
||||
# "moving this frees N days", and this Feb 1-7 window genuinely has
|
||||
# a cheaper nearby placement. See `_Ledger._build_remedies`.
|
||||
assert {r.kind for r in outcome.remedies} <= {"shift_window_into_free_days"}
|
||||
assert not any(
|
||||
r.kind in {"reduce_preschool_reserve", "carry_vacation_forward", "lower_priority_wish"}
|
||||
for r in outcome.remedies
|
||||
)
|
||||
# `weeks_funded` still tells the truth about what was actually
|
||||
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
|
||||
assert outcome.weeks_funded == round(6 / 7, 2)
|
||||
|
||||
@@ -220,6 +220,7 @@ def _planned(
|
||||
on: date,
|
||||
day_type: str = "vacation",
|
||||
wish_id: UUID | None = None,
|
||||
is_pinned: bool = False,
|
||||
) -> PlannedLeaveDay:
|
||||
"""A persisted `planned_leave_day` row — the "already applied, before
|
||||
|
||||
@@ -227,10 +228,12 @@ def _planned(
|
||||
flexible re-propose; extended for §fix parent-reassignment, decision B)
|
||||
test cases below. Only the fields `AllocationInput.existing_planned`/
|
||||
`all_existing_planned` actually read (`parent_personal_data_uuid`,
|
||||
`child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what
|
||||
`_Ledger._persisted_by_wish` now matches a flexible wish's own prior
|
||||
placement by — pass the wish's `.id` whenever a test relies on that
|
||||
reuse mechanism finding these rows.
|
||||
`child_id`, `date`, `day_type`, `wish_id`, `is_pinned`) matter. `wish_id`
|
||||
is what `_Ledger._persisted_by_wish` now matches a flexible wish's own
|
||||
prior placement by — pass the wish's `.id` whenever a test relies on
|
||||
that reuse mechanism finding these rows. `is_pinned=True` marks a date
|
||||
the user locked in place — the Step 3 realignment escape hatch must
|
||||
never move a window that contains one.
|
||||
"""
|
||||
return PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
@@ -240,6 +243,7 @@ def _planned(
|
||||
date=on,
|
||||
day_type=day_type,
|
||||
wish_id=wish_id,
|
||||
is_pinned=is_pinned,
|
||||
)
|
||||
|
||||
|
||||
@@ -1288,9 +1292,12 @@ class TestCase16FlexibleTieBreakByEarliestStart:
|
||||
def test_equal_cost_candidates_break_tie_by_earliest_start(self) -> None:
|
||||
# A holiday-free bound (Feb 2027): every 7-day slice contains
|
||||
# exactly one Saturday and one Sunday, so every candidate has the
|
||||
# *same* calendar-only cost (5) regardless of start date -- the
|
||||
# only thing that can break the tie is `candidate.start`, never
|
||||
# dict/set iteration order.
|
||||
# *same* calendar-only cost (5) regardless of start date. Since
|
||||
# §Decision 2, the tie is actually broken by Monday-alignment first,
|
||||
# THEN earliest start -- 2027-02-01 happens to be a Monday, so this
|
||||
# still pins down the same winner; `TestFlexibleWishPrefersMonday`
|
||||
# below exercises the Monday-preference itself against a bound whose
|
||||
# earliest start is NOT a Monday.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
@@ -1956,16 +1963,163 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
|
||||
assert flex_outcome.strategy != STRATEGY_UNPLACED
|
||||
assert flex_outcome.shortfall_days == 0
|
||||
|
||||
# Since Step 3's realignment escape hatch, "moves" here means all the
|
||||
# way to the globally cheapest remaining window, not just the
|
||||
# nearest one that happens to still contain 2027-03-23: with 03-25
|
||||
# occupied, the only windows reusable around the applied day cost 5
|
||||
# (Wed 03-17..03-23 or Thu 03-18..03-24, no red days) -- both
|
||||
# misaligned (not Monday-start), which is what lets the escape
|
||||
# hatch compare them against a fresh search. That search finds
|
||||
# 03-26..04-01 (Fri-Thu), which still catches the Easter cluster
|
||||
# (Fri-Mon free) at cost 3 -- strictly cheaper, so it wins even
|
||||
# though it abandons 03-23 entirely and is itself not Monday-start
|
||||
# (§Decision 2: red days always outrank alignment).
|
||||
assert (flex_outcome.start_date, flex_outcome.end_date) == (
|
||||
date(2027, 3, 26),
|
||||
date(2027, 4, 1),
|
||||
)
|
||||
assert flex_outcome.cost_breakdown["free"] == 4
|
||||
assert flex_outcome.cost_breakdown["vacation"] == 3
|
||||
|
||||
flex_dates = {
|
||||
flex_outcome.start_date + timedelta(days=i)
|
||||
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
|
||||
}
|
||||
# Still honours the previously-applied day (it's inside the new
|
||||
# window's span) but never re-claims the fixed wish's date.
|
||||
assert date(2027, 3, 23) in flex_dates
|
||||
# The previously-applied day is abandoned along with the rest of the
|
||||
# old window -- cheaper wins over "keep what's already there" -- and
|
||||
# the fixed wish's date is (still) never re-claimed.
|
||||
assert date(2027, 3, 23) not in flex_dates
|
||||
assert date(2027, 3, 25) not in flex_dates
|
||||
|
||||
|
||||
class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits:
|
||||
"""§fix (Step 3) — the flexible-window realignment escape hatch.
|
||||
|
||||
A flexible wish-year already funded before §Decision 2 existed (or
|
||||
before its bound moved) is otherwise frozen forever to whichever weekday
|
||||
it first landed on, by `_reuse_already_funded_window`'s own reuse-the-
|
||||
prior-window mechanism. It must self-heal onto a Monday the next time
|
||||
`propose()` runs -- UNLESS one of its persisted dates carries a manual
|
||||
edit (pinned, or explicitly removed), in which case the user's choice
|
||||
always outlives realignment.
|
||||
|
||||
All three cases share the same bound: August 2027, holiday-free, so
|
||||
every 7-day candidate costs the same 5 spendable days and the only thing
|
||||
that can separate them is the Monday-alignment tie-break.
|
||||
"""
|
||||
|
||||
def _alloc_input(
|
||||
self,
|
||||
wish: LeaveWish,
|
||||
child: ChildFacts,
|
||||
parent: ParentFacts,
|
||||
existing: list[PlannedLeaveDay],
|
||||
) -> AllocationInput:
|
||||
return _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
existing_planned=existing,
|
||||
)
|
||||
|
||||
def test_a_misaligned_year_releases_onto_a_monday(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
# A prior apply funded the full Tuesday-start week 08-03..08-09.
|
||||
existing = [
|
||||
_planned(
|
||||
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = self._alloc_input(wish, child, parent, existing)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
# Released the Tuesday window for the earliest Monday-start
|
||||
# candidate that doesn't collide with the abandoned dates.
|
||||
assert outcome.start_date == date(2027, 8, 16)
|
||||
assert outcome.start_date.weekday() == 0
|
||||
|
||||
def test_a_pinned_day_keeps_the_window_from_moving(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
existing = [
|
||||
_planned(
|
||||
parent_uuid=parent.uuid,
|
||||
child_id=child.id,
|
||||
on=date(2027, 8, d),
|
||||
wish_id=wish.id,
|
||||
is_pinned=(d == 3), # the user locked the first day in place
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = self._alloc_input(wish, child, parent, existing)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
# Same misaligned Tuesday window as the unprotected case above --
|
||||
# but this time it must NOT move, because one of its days is pinned.
|
||||
assert outcome.start_date == date(2027, 8, 3)
|
||||
assert outcome.start_date.weekday() == 1 # still Tuesday
|
||||
|
||||
def test_an_excluded_day_keeps_the_window_from_moving(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
existing = [
|
||||
_planned(
|
||||
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id
|
||||
)
|
||||
for d in range(3, 10)
|
||||
]
|
||||
alloc_input = replace(
|
||||
self._alloc_input(wish, child, parent, existing),
|
||||
# The user explicitly removed one of the window's days
|
||||
# (§remove-this-day) -- still a manual edit, must not move.
|
||||
excluded_days=frozenset({(parent.uuid, child.id, date(2027, 8, 4))}),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 8, 3)
|
||||
assert outcome.start_date.weekday() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §Task 4 — pinned days: fixed, and debited off the top before any window
|
||||
# is funded (mirrors `_reserve_preschool_days`'s precedent).
|
||||
@@ -2587,18 +2741,24 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
||||
assert start_2027.weekday() == start_2026.weekday()
|
||||
assert end_2027.weekday() == end_2026.weekday()
|
||||
|
||||
def test_flexible_wish_bounding_range_is_unchanged_across_years(self) -> None:
|
||||
def test_flexible_wish_bounding_range_stays_weekday_aligned_across_years(self) -> None:
|
||||
"""A flexible wish's four fields describe a bounding range to search
|
||||
inside, not a placement -- no weekday shift, ever, including for
|
||||
Season presets which must stay byte-identical after this change.
|
||||
Each year still gets its OWN row (carrying that year's range); the
|
||||
per-year placement search inside it is the allocator's job.
|
||||
|
||||
inside, not a placement -- but the range must still get the same
|
||||
weekday snap as a fixed placement, or a week-shaped bound (the
|
||||
reported bug, extended: also breaks a Season preset whose bound
|
||||
happens to be week-shaped) silently drifts onto a different weekday
|
||||
every non-anchor year. The bound's day-*span* stays exactly fixed
|
||||
(91 days here) so the allocator's per-year search space never
|
||||
shrinks; only the start slides by the anchor's own `_shift_to_weekday`
|
||||
rule. Each year still gets its OWN row (carrying that year's range);
|
||||
the per-year placement search inside it is the allocator's job.
|
||||
"""
|
||||
rows = {
|
||||
w.first_year: w
|
||||
for w in _wish_series(
|
||||
child_id=None,
|
||||
start=(3, 1),
|
||||
start=(3, 1), # 2026-03-01 is a Sunday
|
||||
end=(5, 31),
|
||||
first_year=2026,
|
||||
last_year=2028,
|
||||
@@ -2606,8 +2766,148 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
||||
)
|
||||
}
|
||||
assert rows[2026].window() == (date(2026, 3, 1), date(2026, 5, 31))
|
||||
assert rows[2027].window() == (date(2027, 3, 1), date(2027, 5, 31))
|
||||
assert rows[2028].window() == (date(2028, 3, 1), date(2028, 5, 31))
|
||||
assert rows[2027].window() == (date(2027, 2, 28), date(2027, 5, 30))
|
||||
assert rows[2028].window() == (date(2028, 2, 27), date(2028, 5, 28))
|
||||
for row in rows.values():
|
||||
start, end = row.window()
|
||||
assert start.weekday() == 6 # Sunday, like the anchor
|
||||
assert (end - start).days == 91
|
||||
|
||||
|
||||
class TestFlexibleWishPrefersMonday:
|
||||
"""§Decision 2 (user-confirmed) — a flexible window's placement search
|
||||
|
||||
prefers a Monday start, but a non-Monday wins whenever it absorbs
|
||||
strictly more red days; red days always outrank alignment. Covers the
|
||||
three cases the plan called out: a wide, holiday-free bound (pure
|
||||
ranking, `_rank_candidate`'s new `misaligned` term); a bound whose
|
||||
length equals the target (the degenerate single-candidate case only
|
||||
Step 1's bound snap can fix, since `_rank_candidate` never even runs a
|
||||
second candidate to prefer); and a strictly-cheaper non-Monday still
|
||||
winning over a pricier Monday alternative.
|
||||
"""
|
||||
|
||||
def test_wide_holiday_free_bound_places_on_a_monday_every_year(self) -> None:
|
||||
# August: no Swedish public holidays, so every 7-day candidate in the
|
||||
# bound costs the same 5 spendable days -- the only thing that can
|
||||
# separate them is the Monday-alignment tie-break.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wishes = _wish_series(
|
||||
child_id=child.id,
|
||||
name="Augusti",
|
||||
start=AUG_2027[0],
|
||||
end=AUG_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
last_year=2029,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2029, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
|
||||
vacation={
|
||||
(parent.uuid, 2027): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2028): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2029): VacationBudget(50, 0, 0),
|
||||
},
|
||||
wishes=wishes,
|
||||
extra_holiday_years=(2027, 2028, 2029),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
by_year = {o.year: o for o in proposal.outcomes}
|
||||
assert set(by_year) == {2027, 2028, 2029}
|
||||
for o in by_year.values():
|
||||
assert o.shortfall_days == 0
|
||||
assert o.start_date.weekday() == 0 # Monday, every year
|
||||
# The bound itself keeps sliding (§Decision 1, Step 1's bound snap)
|
||||
# so each year's Monday is a genuinely different date, not one
|
||||
# window re-proposed three times.
|
||||
assert by_year[2027].start_date == date(2027, 8, 2)
|
||||
assert by_year[2028].start_date == date(2028, 7, 31)
|
||||
assert by_year[2029].start_date == date(2029, 7, 30)
|
||||
|
||||
def test_bound_length_equal_to_target_repeats_monday_to_sunday(self) -> None:
|
||||
# The degenerate case: a 42-day bound for a 42-day (6-week) target
|
||||
# gives `_candidate_windows` exactly ONE candidate per year, so no
|
||||
# amount of ranking can align it -- only Step 1's bound snap (the
|
||||
# bound itself keeping its weekday alignment year over year) can.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wishes = _wish_series(
|
||||
child_id=child.id,
|
||||
name="Sommar",
|
||||
start=(6, 28), # 2027-06-28 is a Monday
|
||||
end=(8, 8),
|
||||
weeks=6,
|
||||
first_year=2027,
|
||||
last_year=2029,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2029, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
|
||||
vacation={
|
||||
(parent.uuid, 2027): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2028): VacationBudget(50, 0, 0),
|
||||
(parent.uuid, 2029): VacationBudget(50, 0, 0),
|
||||
},
|
||||
wishes=wishes,
|
||||
extra_holiday_years=(2027, 2028, 2029),
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
by_year = {o.year: o for o in proposal.outcomes}
|
||||
assert set(by_year) == {2027, 2028, 2029}
|
||||
assert by_year[2027].start_date == date(2027, 6, 28)
|
||||
assert by_year[2028].start_date == date(2028, 6, 26)
|
||||
assert by_year[2029].start_date == date(2029, 6, 25)
|
||||
for o in by_year.values():
|
||||
assert o.start_date.weekday() == 0
|
||||
assert (o.end_date - o.start_date).days == 41
|
||||
|
||||
def test_strictly_cheaper_non_monday_still_wins(self) -> None:
|
||||
# Same Easter-cluster bound as TestCase15: the Tuesday-start window
|
||||
# touching Långfredagen..Annandag påsk costs 3 spendable days.
|
||||
# Nearby Monday-start candidates (2027-03-22..03-28 and
|
||||
# 2027-03-29..04-04) each cost 4 -- strictly more -- so red days
|
||||
# must still win over alignment.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 3, 23)
|
||||
assert outcome.start_date.weekday() == 1 # Tuesday, not Monday
|
||||
assert outcome.cost_breakdown["free"] == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3108,3 +3408,159 @@ class TestCase35ShiftRemedyFindsMovingEaster:
|
||||
# it here too since this remedy's `data` shape just grew).
|
||||
assert shift_2027.data["wish_name_message_key"] == rows_by_year[2027].name_message_key
|
||||
assert shift_2028.data["wish_name_message_key"] == rows_by_year[2028].name_message_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §todo item (red-day shift toast) — a `window_mode == 'fixed'` window's
|
||||
# placement is unconditional (never auto-moved), so it needs the
|
||||
# shift_window_into_free_days SUGGESTION surfaced even when fully funded
|
||||
# (previously hidden behind `shortfall_days > 0`). A `window_mode ==
|
||||
# 'flexible'` window's own bound search already IS the shift, so it must
|
||||
# NEVER get that remedy kind, and instead reports how much the search
|
||||
# improved on the naive "first slot in the bound" placement via
|
||||
# `WindowOutcome.shift_gain_days`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCase36ShiftRemedyOnFullyFundedFixedWindow:
|
||||
def test_fully_funded_fixed_window_still_gets_the_shift_suggestion(self) -> None:
|
||||
child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030, past 2027
|
||||
parent = _parent(salary_type="monthly")
|
||||
# Same window TestCase35 uses, but funded generously -- unlike that
|
||||
# test's deliberately starved pool, this window is FULLY funded, so
|
||||
# the only thing that could still hide the shift suggestion is
|
||||
# `_build_remedies`'s shortfall gate (now removed for fixed windows).
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårvecka",
|
||||
start=(3, 22),
|
||||
end=(3, 28),
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="fixed",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.shortfall_days == 0
|
||||
shift = next(r for r in outcome.remedies if r.kind == "shift_window_into_free_days")
|
||||
# Same best placement TestCase35 finds for this exact window: shift
|
||||
# 1 day later to pick up Easter Monday (2027-03-29, a real holiday).
|
||||
assert shift.data["new_start_date"] == "2027-03-23"
|
||||
assert shift.data["new_end_date"] == "2027-03-29"
|
||||
assert shift.days_freed == 1
|
||||
|
||||
|
||||
class TestCase37ShiftRemedyNeverOnFlexibleWindow:
|
||||
def test_flexible_window_never_gets_the_shift_remedy_even_with_a_shortfall(self) -> None:
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
# Deliberately starved (same recipe as TestCase35) so the placed
|
||||
# (searched, not the whole bound -- this is not the §10.4
|
||||
# "unplaced" case TestCase20 covers) window carries a genuine
|
||||
# shortfall -- the flexible contract says its own search already
|
||||
# IS the shift, so the remedy must stay absent regardless.
|
||||
pools={(parent.uuid, child.id): DayPool(1, 0, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.strategy != STRATEGY_UNPLACED
|
||||
assert outcome.shortfall_days > 0
|
||||
assert all(r.kind != "shift_window_into_free_days" for r in outcome.remedies)
|
||||
|
||||
|
||||
class TestCase38FlexibleShiftGainDaysReportsSearchImprovement:
|
||||
def test_winner_beating_naive_bound_start_reports_positive_gain(self) -> None:
|
||||
# Same setup as TestCase15FlexibleWishFindsEasterCluster: the search
|
||||
# picks 2027-03-23..03-29 (3 spendable days -- the Easter cluster)
|
||||
# over the naive bound-start placement 2027-03-01..03-07 (5
|
||||
# spendable days -- Mon-Fri all working, verified against
|
||||
# `get_swedish_holidays(2027)`) -- a 2-day gain.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Vårlov",
|
||||
start=VAR_2027[0],
|
||||
end=VAR_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 3, 23)
|
||||
assert outcome.end_date == date(2027, 3, 29)
|
||||
assert outcome.shift_gain_days == 2
|
||||
|
||||
def test_winner_landing_on_naive_bound_start_reports_zero(self) -> None:
|
||||
# Same setup as TestCase16FlexibleTieBreakByEarliestStart: every
|
||||
# candidate in the holiday-free February bound costs the same, and
|
||||
# 2027-02-01 (the bound's own start) is also the Monday-aligned
|
||||
# tie-break winner -- the winner IS the naive placement, so there is
|
||||
# nothing to report.
|
||||
child = _child(birth_date=date(2020, 1, 1))
|
||||
parent = _parent(salary_type="monthly")
|
||||
wish = _wish(
|
||||
child_id=child.id,
|
||||
name="Sportlov",
|
||||
start=FEB_2027[0],
|
||||
end=FEB_2027[1],
|
||||
weeks=1,
|
||||
first_year=2027,
|
||||
window_mode="flexible",
|
||||
)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[child],
|
||||
parents=[parent],
|
||||
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||
wishes=[wish],
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
outcome = proposal.outcomes[0]
|
||||
assert outcome.start_date == date(2027, 2, 1)
|
||||
assert outcome.shift_gain_days == 0
|
||||
|
||||
@@ -80,6 +80,8 @@ export const calendar = {
|
||||
'{name} ({start}–{end}): none of the {daysWished} {daysWished|day|days} requested could be funded — {shortfallDays} {shortfallDays|day|days} short',
|
||||
'calendar.toastShortfallPartialDays':
|
||||
'{name} ({start}–{end}): only {daysFunded} of {daysWished} days funded — {shortfallDays} {shortfallDays|day|days} short',
|
||||
'calendar.toastRedDayShift':
|
||||
'{name}: moved to {start} – {end} to catch {days} extra free {days|day|days}',
|
||||
'calendar.undoButton': 'Undo',
|
||||
'calendar.undoError': 'Failed to undo the change',
|
||||
'calendar.dismissToastAria': 'Dismiss the message',
|
||||
|
||||
@@ -80,6 +80,8 @@ export const calendar = {
|
||||
'{name} ({start}–{end}): inget av de {daysWished} {daysWished|önskad dag|önskade dagar} kunde finansieras — {shortfallDays} {shortfallDays|dag|dagar} saknas',
|
||||
'calendar.toastShortfallPartialDays':
|
||||
'{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas',
|
||||
'calendar.toastRedDayShift':
|
||||
'{name}: flyttad till {start} – {end} för att fånga {days} extra {days|ledig dag|lediga dagar}',
|
||||
'calendar.undoButton': 'Ångra',
|
||||
'calendar.undoError': 'Det gick inte att ångra ändringen',
|
||||
'calendar.dismissToastAria': 'Stäng meddelandet',
|
||||
|
||||
@@ -770,6 +770,50 @@ describe('Calendar', () => {
|
||||
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saving a period edit pins exact dates: windowMode flips to fixed with a matching target', async () => {
|
||||
// §bug — a preset-born wish is `windowMode: 'flexible'` (a bounding
|
||||
// range the planner searches inside, not a placement). Once the user
|
||||
// has dragged/typed exact dates in the popover, those dates ARE the
|
||||
// placement, so the save must flip the row to 'fixed' with a target
|
||||
// matching the edited span. Leaving it 'flexible' turns the (now
|
||||
// week-long) bound into a single flexible candidate, which repeats the
|
||||
// same calendar date every year on a drifting weekday instead of
|
||||
// preserving the weekday.
|
||||
const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-05'), mockSetSearchParams])
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({ id: 'pd-1', date: '2026-10-05', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-2', date: '2026-10-06', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-3', date: '2026-10-07', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-4', date: '2026-10-08', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-5', date: '2026-10-09', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-6', date: '2026-10-10', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-7', date: '2026-10-11', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2026-10-05')
|
||||
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-end', '2026-10-11')
|
||||
fireEvent.click(screen.getByTestId('period-save'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'lw-1',
|
||||
expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }),
|
||||
'occurrence',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('saving one year of a repeating wish writes that year and keeps the scope narrow', async () => {
|
||||
// The band clicked here is the 2028 row of a series. Its own year IS
|
||||
// what gets written — the old shape had to keep the wish's anchor year
|
||||
@@ -1225,6 +1269,60 @@ describe('Calendar', () => {
|
||||
expect(toast).toHaveTextContent('saknas')
|
||||
})
|
||||
|
||||
it('shows a toast when a flexible window auto-shifted to catch extra red days', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValue(
|
||||
makePlanSyncResponse({
|
||||
changes: [makePlanChange({ newWishId: 'lw-preset' })],
|
||||
proposal: makePlanProposal({
|
||||
outcomes: [
|
||||
makeWindowOutcome({
|
||||
wishId: 'lw-preset',
|
||||
shiftGainDays: 2,
|
||||
data: { wish_name: 'Sommar', wish_name_message_key: null },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('preset-click'))
|
||||
|
||||
const toast = await screen.findByTestId('calendar-toast')
|
||||
expect(toast).toHaveTextContent('Sommar')
|
||||
expect(toast).toHaveTextContent('flyttad')
|
||||
expect(toast).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => {
|
||||
// A plain re-open (nothing actually touched this wish's dates this
|
||||
// sync) must never re-announce a stable, already-applied shift --
|
||||
// `changes` membership is what distinguishes the two, not
|
||||
// `shiftGainDays > 0` alone.
|
||||
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValue(
|
||||
makePlanSyncResponse({
|
||||
changes: [],
|
||||
proposal: makePlanProposal({
|
||||
outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('preset-click'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalled())
|
||||
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
|
||||
@@ -216,9 +216,10 @@ function summarizeOutcomeShortfall(
|
||||
}
|
||||
|
||||
/** Builds the full sync toast message: what changed (existing summary), why
|
||||
* the wish just touched (if any) fell short of what was wished, and any
|
||||
* rejections the apply step returned — a genuine no-op sync (nothing to
|
||||
* report on any of the three) still renders `null`, so it stays silent.
|
||||
* the wish just touched (if any) fell short of what was wished, which
|
||||
* flexible windows auto-shifted to catch extra red days, and any rejections
|
||||
* the apply step returned — a genuine no-op sync (nothing to report on any
|
||||
* of the four) still renders `null`, so it stays silent.
|
||||
*
|
||||
* `watchWish`, when given, is used in place of looking `watchWishId` up in
|
||||
* `wishes` — needed right after creating a wish, where `wishes` is whatever
|
||||
@@ -249,6 +250,26 @@ function buildSyncToastMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// §todo item (red-day shift toast) — a flexible window's own placement
|
||||
// search auto-shifts to the cheapest slot inside the user's bound, so
|
||||
// when it moved to catch extra free days, say so. Gated on `changes`
|
||||
// membership (not just `shiftGainDays > 0`) so a plain re-open of a
|
||||
// stable, already-applied placement never re-announces the same shift —
|
||||
// only a sync that actually touched this wish's dates does.
|
||||
for (const outcome of result.proposal.outcomes) {
|
||||
if (outcome.shiftGainDays <= 0) continue
|
||||
const touched = result.changes.some((c) => c.newWishId === outcome.wishId || c.oldWishId === outcome.wishId)
|
||||
if (!touched) continue
|
||||
parts.push(
|
||||
t('calendar.toastRedDayShift', {
|
||||
name: resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod'),
|
||||
start: formatDateForDisplay(outcome.startDate),
|
||||
end: formatDateForDisplay(outcome.endDate),
|
||||
days: outcome.shiftGainDays,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const rejection of result.applyResult.rejections) {
|
||||
parts.push(renderRejection(rejection, t, members))
|
||||
}
|
||||
@@ -722,12 +743,22 @@ export function Calendar(): ReactNode {
|
||||
|
||||
const start = splitISODate(payload.startDate)
|
||||
const end = splitISODate(payload.endDate)
|
||||
const dayCount = datesInRange(payload.startDate, payload.endDate).length
|
||||
|
||||
// One wish row is one year, so the edited dates ARE this row's window —
|
||||
// year included. The old shape had to keep the wish's anchor year and
|
||||
// rewrite the shared month/day pattern for every year, because writing
|
||||
// the clicked year into `firstYear` would have dropped the earlier
|
||||
// occurrences; there is nothing to preserve now.
|
||||
//
|
||||
// The popover always hands back exact dates — once the user has
|
||||
// typed/dragged them, those dates ARE the placement, not a range for the
|
||||
// planner to search inside. So this save must also flip the row to
|
||||
// `windowMode: 'fixed'` with a target matching the edited span; leaving
|
||||
// it `'flexible'` (its state from a preset chip) turns the now
|
||||
// week-long bound into a single flexible candidate, which repeats the
|
||||
// same calendar dates every year on a drifting weekday instead of
|
||||
// preserving the weekday.
|
||||
const patch: LeaveWishUpdateRequest = {
|
||||
name: payload.name,
|
||||
nameMessageKey: null,
|
||||
@@ -740,6 +771,9 @@ export function Calendar(): ReactNode {
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
wantsDubbeldag: payload.wantsDubbeldag,
|
||||
windowMode: 'fixed',
|
||||
targetDays: dayCount,
|
||||
targetWeeks: Math.max(1, Math.ceil(dayCount / 7)),
|
||||
}
|
||||
// `lastYear` belongs to the whole series, so only send it where the
|
||||
// backend will take it: when this occurrence is becoming a series, or
|
||||
|
||||
@@ -1557,6 +1557,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
},
|
||||
cost_breakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
|
||||
remedies: [],
|
||||
shift_gain_days: 3,
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
@@ -1637,6 +1638,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
expect(result.surplusAdvice[0]!.daysInvolved).toBe(10)
|
||||
expect(result.age4Projections[0]!.expiringAtB4).toBe(10)
|
||||
expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01')
|
||||
expect(result.outcomes[0]!.shiftGainDays).toBe(3)
|
||||
})
|
||||
|
||||
it('proposePlan defaults a missing cost breakdown field to 0', async () => {
|
||||
@@ -1662,6 +1664,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
// must default each one rather than surfacing `undefined`.
|
||||
cost_breakdown: {},
|
||||
remedies: [],
|
||||
shift_gain_days: 0,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
@@ -1680,6 +1683,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
sjukpenning: 0,
|
||||
lagsta: 0,
|
||||
})
|
||||
expect(result.outcomes[0]!.shiftGainDays).toBe(0)
|
||||
})
|
||||
|
||||
it('syncPlan posts the workspace id and maps the nested proposal/applyResult/changes', async () => {
|
||||
|
||||
@@ -407,6 +407,7 @@ interface RawWindowOutcome {
|
||||
data: Record<string, unknown>
|
||||
cost_breakdown: Record<string, number>
|
||||
remedies: RawShortfallRemedy[]
|
||||
shift_gain_days: number
|
||||
}
|
||||
|
||||
interface RawPlanWarning {
|
||||
@@ -498,6 +499,7 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
|
||||
data: r.data,
|
||||
daysFreed: r.days_freed,
|
||||
})),
|
||||
shiftGainDays: o.shift_gain_days,
|
||||
})),
|
||||
warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })),
|
||||
poolBalances: raw.pool_balances.map((p) => ({
|
||||
|
||||
@@ -400,6 +400,11 @@ export interface WindowOutcome {
|
||||
data: Record<string, unknown>
|
||||
costBreakdown: CostBreakdown
|
||||
remedies: ShortfallRemedy[]
|
||||
// Spendable days the flexible placement search saved versus the naive
|
||||
// bound-start placement (see `WindowOutcome.shift_gain_days` in
|
||||
// allocation_service.py). 0 for a fixed window or a reused flexible one —
|
||||
// those never auto-shift, so there is nothing to report.
|
||||
shiftGainDays: number
|
||||
}
|
||||
|
||||
export interface PlanWarning {
|
||||
|
||||
Vendored
+1
@@ -79,6 +79,7 @@ export function makeWindowOutcome(overrides: Partial<WindowOutcome> = {}): Windo
|
||||
data: {},
|
||||
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),
|
||||
remedies: [],
|
||||
shiftGainDays: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user