Keep a repeating week on its weekday when the planner picks the dates

A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
2026-09-20 12:04:20 +02:00
parent fdc6439614
commit ad0e3c0b33
19 changed files with 1217 additions and 72 deletions
+4 -1
View File
@@ -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).
+9 -1
View File
@@ -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
+31 -17
View File
@@ -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`
+5
View File
@@ -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
],
+154 -11
View File
@@ -174,6 +174,13 @@ _FRONT_LOAD_CHILD_AGE_YEARS = 2
_SHIFT_REMEDY_SEARCH_BOUND_DAYS = 28
# How many ranked alternative placements the remedy reports at most.
_SHIFT_REMEDY_MAX_SUGGESTIONS = 2
# §Decision 2 (flexible-window weekday alignment, user-confirmed) — when
# ranking flexible-window candidates, prefer a Monday start so a repeating
# wish lands on the same weekday every year (the reported bug). This is only
# a tie-break: it sits after `shortfall`/`cost` in `_rank_candidate`, so a
# non-Monday start that absorbs strictly more red days always wins — red
# days outrank alignment.
_PREFERRED_START_WEEKDAY = 0 # Monday, per `date.weekday()`
# ---------------------------------------------------------------------------
@@ -428,6 +435,11 @@ class WindowOutcome:
data: dict[str, object]
cost_breakdown: dict[str, int]
remedies: list[ShortfallRemedy]
# §todo item (red-day shift toast) — mirrors `_WishWindow.shift_gain_days`
# (see its docstring): spendable days the flexible placement search saved
# versus the naive bound-start placement. 0 for every fixed window and
# for a reused flexible window (see `_WishWindow`).
shift_gain_days: int = 0
@dataclass(frozen=True)
@@ -572,6 +584,15 @@ class _WishWindow:
anchor_year: int
start: date
end: date
# §todo item (red-day shift toast) — spendable days saved by the flexible
# placement search versus the naive "first slot in the bound" placement.
# Only ever set by `resolve_flexible_window`'s fresh-search path; the
# horizon-clipped early return and the reuse-already-funded path both
# leave this at the default 0 (see their call sites for why). Zero for
# every `window_mode == 'fixed'` window (`_expand_all_windows` never
# passes it) — a fixed window is never auto-shifted, so there is nothing
# to report here for it.
shift_gain_days: int = 0
def _expand_all_windows(alloc_input: AllocationInput) -> list[_WishWindow]:
@@ -663,7 +684,9 @@ def _candidate_windows(
"""§10.2 step 5 — every full-length `(start, end)` candidate (inclusive)
that fits entirely inside `[bound_start, bound_end]`, sliding one day at
a time (nothing requires a candidate to start on a Monday).
a time (nothing requires a candidate to start on a Monday — the generator
stays exhaustive; `_rank_candidate` is what prefers a Monday start among
the candidates this returns, per §Decision 2).
"""
candidates: list[tuple[date, date]] = []
last_start = bound_end - timedelta(days=length_days - 1)
@@ -873,11 +896,20 @@ class _Ledger:
else alloc_input.existing_planned
)
self._persisted_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
# §fix (Step 3, flexible-window realignment escape hatch) — same
# grouping as `_persisted_by_wish`, narrowed to the pinned rows. Lets
# `_reuse_already_funded_window` tell "this wish-year's persisted
# dates are just where the search last happened to land" (safe to
# release for a better-ranked candidate) from "the user pinned one of
# these dates on purpose" (must never move).
self._pinned_dates_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
for p in persisted_source:
if p.wish_id is None:
continue
key = (p.wish_id, p.child_id)
self._persisted_by_wish.setdefault(key, set()).add(p.date)
if p.is_pinned:
self._pinned_dates_by_wish.setdefault(key, set()).add(p.date)
self._reserve_pinned_days()
self._reserve_preschool_days()
@@ -1716,7 +1748,15 @@ class _Ledger:
responsible.mode, all_dates, days, cost
)
remedies = self._build_remedies(wish, w, shortfall_days)
# §todo item (red-day shift toast) — a flexible window's own bound
# search already IS the shift search (surfaced via
# `w.shift_gain_days`/the toast), so suggesting a placement outside
# the user's stated bound would contradict the flexible contract.
# A fixed window never auto-shifts, so it needs the suggestion
# instead — and gets it even when fully funded (shortfall_days == 0).
remedies = self._build_remedies(
wish, w, shortfall_days, include_shift_remedy=(wish.window_mode == "fixed")
)
outcome = WindowOutcome(
wish_id=wish.id,
@@ -1743,6 +1783,7 @@ class _Ledger:
},
cost_breakdown=cost,
remedies=remedies,
shift_gain_days=w.shift_gain_days,
)
return days, outcome
@@ -1755,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
+2
View File
@@ -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',
+2
View File
@@ -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',
+98
View File
@@ -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)
+37 -3
View File
@@ -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
+4
View File
@@ -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 () => {
+2
View File
@@ -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) => ({
+5
View File
@@ -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 {
+1
View File
@@ -79,6 +79,7 @@ export function makeWindowOutcome(overrides: Partial<WindowOutcome> = {}): Windo
data: {},
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),
remedies: [],
shiftGainDays: 0,
...overrides,
}
}