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, } }