diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 21dd9a4..b9200ab 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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).
diff --git a/PLANNING_ENGINE_SPEC.md b/PLANNING_ENGINE_SPEC.md
index cd128ab..987cd92 100644
--- a/PLANNING_ENGINE_SPEC.md
+++ b/PLANNING_ENGINE_SPEC.md
@@ -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
diff --git a/backend/app/entities/leave_wish.py b/backend/app/entities/leave_wish.py
index fdeae62..7f33c57 100644
--- a/backend/app/entities/leave_wish.py
+++ b/backend/app/entities/leave_wish.py
@@ -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`
diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py
index e7c71a3..a15e496 100644
--- a/backend/app/models/plan.py
+++ b/backend/app/models/plan.py
@@ -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
],
diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py
index 2c1022c..56331ba 100644
--- a/backend/app/services/allocation_service.py
+++ b/backend/app/services/allocation_service.py
@@ -174,6 +174,13 @@ _FRONT_LOAD_CHILD_AGE_YEARS = 2
_SHIFT_REMEDY_SEARCH_BOUND_DAYS = 28
# How many ranked alternative placements the remedy reports at most.
_SHIFT_REMEDY_MAX_SUGGESTIONS = 2
+# §Decision 2 (flexible-window weekday alignment, user-confirmed) — when
+# ranking flexible-window candidates, prefer a Monday start so a repeating
+# wish lands on the same weekday every year (the reported bug). This is only
+# a tie-break: it sits after `shortfall`/`cost` in `_rank_candidate`, so a
+# non-Monday start that absorbs strictly more red days always wins — red
+# days outrank alignment.
+_PREFERRED_START_WEEKDAY = 0 # Monday, per `date.weekday()`
# ---------------------------------------------------------------------------
@@ -428,6 +435,11 @@ class WindowOutcome:
data: dict[str, object]
cost_breakdown: dict[str, int]
remedies: list[ShortfallRemedy]
+ # §todo item (red-day shift toast) — mirrors `_WishWindow.shift_gain_days`
+ # (see its docstring): spendable days the flexible placement search saved
+ # versus the naive bound-start placement. 0 for every fixed window and
+ # for a reused flexible window (see `_WishWindow`).
+ shift_gain_days: int = 0
@dataclass(frozen=True)
@@ -572,6 +584,15 @@ class _WishWindow:
anchor_year: int
start: date
end: date
+ # §todo item (red-day shift toast) — spendable days saved by the flexible
+ # placement search versus the naive "first slot in the bound" placement.
+ # Only ever set by `resolve_flexible_window`'s fresh-search path; the
+ # horizon-clipped early return and the reuse-already-funded path both
+ # leave this at the default 0 (see their call sites for why). Zero for
+ # every `window_mode == 'fixed'` window (`_expand_all_windows` never
+ # passes it) — a fixed window is never auto-shifted, so there is nothing
+ # to report here for it.
+ shift_gain_days: int = 0
def _expand_all_windows(alloc_input: AllocationInput) -> list[_WishWindow]:
@@ -663,7 +684,9 @@ def _candidate_windows(
"""§10.2 step 5 — every full-length `(start, end)` candidate (inclusive)
that fits entirely inside `[bound_start, bound_end]`, sliding one day at
- a time (nothing requires a candidate to start on a Monday).
+ a time (nothing requires a candidate to start on a Monday — the generator
+ stays exhaustive; `_rank_candidate` is what prefers a Monday start among
+ the candidates this returns, per §Decision 2).
"""
candidates: list[tuple[date, date]] = []
last_start = bound_end - timedelta(days=length_days - 1)
@@ -873,11 +896,20 @@ class _Ledger:
else alloc_input.existing_planned
)
self._persisted_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
+ # §fix (Step 3, flexible-window realignment escape hatch) — same
+ # grouping as `_persisted_by_wish`, narrowed to the pinned rows. Lets
+ # `_reuse_already_funded_window` tell "this wish-year's persisted
+ # dates are just where the search last happened to land" (safe to
+ # release for a better-ranked candidate) from "the user pinned one of
+ # these dates on purpose" (must never move).
+ self._pinned_dates_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
for p in persisted_source:
if p.wish_id is None:
continue
key = (p.wish_id, p.child_id)
self._persisted_by_wish.setdefault(key, set()).add(p.date)
+ if p.is_pinned:
+ self._pinned_dates_by_wish.setdefault(key, set()).add(p.date)
self._reserve_pinned_days()
self._reserve_preschool_days()
@@ -1716,7 +1748,15 @@ class _Ledger:
responsible.mode, all_dates, days, cost
)
- remedies = self._build_remedies(wish, w, shortfall_days)
+ # §todo item (red-day shift toast) — a flexible window's own bound
+ # search already IS the shift search (surfaced via
+ # `w.shift_gain_days`/the toast), so suggesting a placement outside
+ # the user's stated bound would contradict the flexible contract.
+ # A fixed window never auto-shifts, so it needs the suggestion
+ # instead — and gets it even when fully funded (shortfall_days == 0).
+ remedies = self._build_remedies(
+ wish, w, shortfall_days, include_shift_remedy=(wish.window_mode == "fixed")
+ )
outcome = WindowOutcome(
wish_id=wish.id,
@@ -1743,6 +1783,7 @@ class _Ledger:
},
cost_breakdown=cost,
remedies=remedies,
+ shift_gain_days=w.shift_gain_days,
)
return days, outcome
@@ -1755,17 +1796,20 @@ class _Ledger:
shortfall_days: int,
include_shift_remedy: bool = True,
) -> list[ShortfallRemedy]:
- if shortfall_days <= 0:
- return []
remedies: list[ShortfallRemedy] = []
- remedies.extend(self._reduce_preschool_reserve_remedy(wish))
- remedies.extend(self._carry_vacation_forward_remedy(wish, w))
- remedies.extend(self._lower_priority_wish_remedy(wish))
+ if shortfall_days > 0:
+ remedies.extend(self._reduce_preschool_reserve_remedy(wish))
+ remedies.extend(self._carry_vacation_forward_remedy(wish, w))
+ remedies.extend(self._lower_priority_wish_remedy(wish))
# §10.4 — `include_shift_remedy=False` from `build_unplaced_outcome`:
# there `w` is the whole bounding range, not a single placed window,
# so there is no one winning edge to shift and no point running the
# (now real, `_SHIFT_REMEDY_SEARCH_BOUND_DAYS`-wide) search only to
- # discard its result.
+ # discard its result. `fund_window` also passes False for a flexible
+ # window (its own bound search already IS the shift), so this branch
+ # is reached only for a fixed window — deliberately NOT gated on
+ # `shortfall_days`, so a fully-funded fixed window still surfaces
+ # "moving this frees N days" instead of hiding it behind a shortfall.
if include_shift_remedy:
remedies.extend(self._shift_window_into_free_days_remedy(wish, w))
return remedies
@@ -1990,6 +2034,34 @@ class _Ledger:
dates = self._persisted_by_wish.get((wish.id, child_id), set())
return sorted(d for d in dates if bound_start <= d <= bound_end)
+ def _reused_window_carries_manual_edit(
+ self,
+ wish: LeaveWish,
+ responsible: _Responsible,
+ child_id: UUID | None,
+ persisted_dates: list[date],
+ ) -> bool:
+ """§fix (Step 3) — whether any of this wish-year's already-persisted
+
+ `persisted_dates` carries a manual edit that must never be silently
+ moved by the realignment escape hatch in `_reuse_already_funded_window`:
+ a pinned day (`is_pinned`, locked in place by the user) or a day the
+ user explicitly removed (§remove-this-day, `_is_excluded`). Both kinds
+ are already respected elsewhere in the ordinary funding path
+ (`_candidate_occupied`/`_persisted_dates_for_wish` keep this wish's own
+ claimed dates out of collision checks, `_is_excluded` keeps a removed
+ date out of a freshly funded window) — this is what keeps the new
+ escape hatch from re-searching its way around either one.
+ """
+ pinned = self._pinned_dates_by_wish.get((wish.id, child_id), frozenset())
+ if any(d in pinned for d in persisted_dates):
+ return True
+ return any(
+ self._is_excluded(p.uuid, child_id, d)
+ for d in persisted_dates
+ for p in responsible.parents
+ )
+
# -- §Decision C — budget-aware candidate ranking ----------------------
def _candidate_shortfall(
@@ -2072,17 +2144,27 @@ class _Ledger:
wish: LeaveWish,
responsible: _Responsible,
candidate: tuple[date, date],
- ) -> tuple[int, int, date, int]:
+ ) -> tuple[int, int, int, date, int]:
"""Sort key shared by `resolve_flexible_window` and
`_reuse_already_funded_window`: fewest unfundable days first (§Decision
- C), THEN calendar cost, THEN start date, THEN wish id — the existing
- priority ordering (§10.2 step 6) is otherwise unchanged.
+ C), THEN calendar cost, THEN Monday-start alignment (§Decision 2),
+ THEN start date, THEN wish id — the existing priority ordering
+ (§10.2 step 6) is otherwise unchanged.
+
+ The alignment term sits strictly after `cost` and strictly before
+ `start`: after `cost` so red days always win a candidate — a
+ cheaper non-Monday start beats a pricier Monday one every time; before
+ `start` so that among equally-cheap candidates (the degenerate case
+ where the bound is exactly `length_days` long, or any other tie) a
+ Monday start wins over "earliest", which is what made flexible
+ placements land on an arbitrary, year-to-year-inconsistent weekday.
"""
start, end = candidate
shortfall = self._candidate_shortfall(wish, responsible, start, end)
cost = _candidate_cost(start, end, self._input.holidays, self._input.day_overrides)
- return (shortfall, cost, start, wish.id.int)
+ misaligned = 0 if start.weekday() == _PREFERRED_START_WEEKDAY else 1
+ return (shortfall, cost, misaligned, start, wish.id.int)
def _reuse_already_funded_window(
self,
@@ -2119,6 +2201,21 @@ class _Ledger:
higher-priority fixed wish added since the last apply). That last
case is what keeps a genuinely-unsatisfied year free to move
(§10.3) rather than frozen to a window that no longer fits.
+
+ §fix (Step 3, flexible-window realignment escape hatch) — also
+ returns `None` when the winner picked below is misaligned (does not
+ start on `_PREFERRED_START_WEEKDAY`) AND a fresh, unoccupied search
+ across the *whole* bound (not just windows containing the persisted
+ dates — exactly what `resolve_flexible_window` runs next when this
+ returns `None`) would find a candidate whose `(shortfall, cost,
+ misaligned)` rank prefix is strictly better. This is what lets a
+ wish-year placed before §Decision 2 existed (or before its bound
+ moved) self-heal onto a Monday on the next `propose()`, instead of
+ being frozen forever to whichever weekday it first landed on. Never
+ fires when any of the persisted dates carry a manual edit — a pinned
+ day or one the user explicitly removed (see
+ `_reused_window_carries_manual_edit`) — so a deliberate per-day
+ override always outlives a realignment.
"""
persisted_dates = self._persisted_dates_for_wish(wish, child_id, bound_start, bound_end)
if not persisted_dates:
@@ -2139,7 +2236,30 @@ class _Ledger:
if not candidates:
return None
- return min(candidates, key=lambda c: self._rank_candidate(wish, responsible, c))
+ winner = min(candidates, key=lambda c: self._rank_candidate(wish, responsible, c))
+
+ winner_start, _winner_end = winner
+ if winner_start.weekday() == _PREFERRED_START_WEEKDAY:
+ return winner
+ if self._reused_window_carries_manual_edit(wish, responsible, child_id, persisted_dates):
+ return winner
+
+ fresh_candidates = [
+ (s, e)
+ for s, e in _candidate_windows(bound_start, bound_end, length_days)
+ if not self._candidate_occupied(s, e, responsible.parents, child_id)
+ ]
+ if fresh_candidates:
+ fresh_winner = min(
+ fresh_candidates, key=lambda c: self._rank_candidate(wish, responsible, c)
+ )
+ if (
+ self._rank_candidate(wish, responsible, fresh_winner)[:3]
+ < self._rank_candidate(wish, responsible, winner)[:3]
+ ):
+ return None
+
+ return winner
def resolve_flexible_window(
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
@@ -2187,7 +2307,30 @@ class _Ledger:
winner_start, winner_end = min(
candidates, key=lambda c: self._rank_candidate(wish, responsible, c)
)
- return _WishWindow(wish=wish, anchor_year=anchor_year, start=winner_start, end=winner_end)
+ # §todo item (red-day shift toast) — how many spendable days the
+ # search saved versus the naive "first slot in the bound" placement,
+ # so the frontend can tell the user their window moved to catch red
+ # days. Compared only against the bound-start placement, not the
+ # previous placement across re-proposes — see `_WishWindow
+ # .shift_gain_days`'s docstring for why the reuse path leaves this 0.
+ naive_start = bound_start
+ naive_end = bound_start + timedelta(days=length_days - 1)
+ shift_gain_days = 0
+ if (winner_start, winner_end) != (naive_start, naive_end):
+ naive_cost = _candidate_cost(
+ naive_start, naive_end, self._input.holidays, self._input.day_overrides
+ )
+ winner_cost = _candidate_cost(
+ winner_start, winner_end, self._input.holidays, self._input.day_overrides
+ )
+ shift_gain_days = max(naive_cost - winner_cost, 0)
+ return _WishWindow(
+ wish=wish,
+ anchor_year=anchor_year,
+ start=winner_start,
+ end=winner_end,
+ shift_gain_days=shift_gain_days,
+ )
def build_unplaced_outcome(
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
diff --git a/backend/migrations/versions/008_realign_flexible_series.py b/backend/migrations/versions/008_realign_flexible_series.py
new file mode 100644
index 0000000..f01e95b
--- /dev/null
+++ b/backend/migrations/versions/008_realign_flexible_series.py
@@ -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)
diff --git a/backend/tests/integration/test_migration_004_backfill.py b/backend/tests/integration/test_migration_004_backfill.py
index 9078a69..59b960f 100644
--- a/backend/tests/integration/test_migration_004_backfill.py
+++ b/backend/tests/integration/test_migration_004_backfill.py
@@ -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:
diff --git a/backend/tests/integration/test_migration_008_realign.py b/backend/tests/integration/test_migration_008_realign.py
new file mode 100644
index 0000000..583c0f2
--- /dev/null
+++ b/backend/tests/integration/test_migration_008_realign.py
@@ -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"]
diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py
index c17cf97..72ee6e5 100644
--- a/backend/tests/unit/test_leave_wish_repository.py
+++ b/backend/tests/unit/test_leave_wish_repository.py
@@ -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,
diff --git a/backend/tests/unit/test_services/test_allocation_excluded_days.py b/backend/tests/unit/test_services/test_allocation_excluded_days.py
index 0529f7a..77b5fa9 100644
--- a/backend/tests/unit/test_services/test_allocation_excluded_days.py
+++ b/backend/tests/unit/test_services/test_allocation_excluded_days.py
@@ -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)
diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py
index f1aa0b4..7eddb92 100644
--- a/backend/tests/unit/test_services/test_allocation_service.py
+++ b/backend/tests/unit/test_services/test_allocation_service.py
@@ -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
diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts
index bcbc2f4..5c3ccb3 100644
--- a/frontend/src/i18n/en/calendar.ts
+++ b/frontend/src/i18n/en/calendar.ts
@@ -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',
diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts
index f4b4f54..7343f44 100644
--- a/frontend/src/i18n/sv/calendar.ts
+++ b/frontend/src/i18n/sv/calendar.ts
@@ -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',
diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx
index 33d0e41..fa065e3 100644
--- a/frontend/src/pages/Calendar.test.tsx
+++ b/frontend/src/pages/Calendar.test.tsx
@@ -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()
+ 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()
+ 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()
+ 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)
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx
index 2c716e2..78a6fe9 100644
--- a/frontend/src/pages/Calendar.tsx
+++ b/frontend/src/pages/Calendar.tsx
@@ -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
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts
index 0c0b436..244d3d0 100644
--- a/frontend/src/services/api.test.ts
+++ b/frontend/src/services/api.test.ts
@@ -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 () => {
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 6d0cb5b..ef974cd 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -407,6 +407,7 @@ interface RawWindowOutcome {
data: Record
cost_breakdown: Record
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) => ({
diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts
index f47b7a5..dfdd581 100644
--- a/frontend/src/types/entities.ts
+++ b/frontend/src/types/entities.ts
@@ -400,6 +400,11 @@ export interface WindowOutcome {
data: Record
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 {
diff --git a/frontend/tests/fixtures/plan.ts b/frontend/tests/fixtures/plan.ts
index e44f516..89105d8 100644
--- a/frontend/tests/fixtures/plan.ts
+++ b/frontend/tests/fixtures/plan.ts
@@ -79,6 +79,7 @@ export function makeWindowOutcome(overrides: Partial = {}): Windo
data: {},
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),
remedies: [],
+ shiftGainDays: 0,
...overrides,
}
}