Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.
project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.
_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.
_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.
Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.
Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.
Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
+4
-1
@@ -265,7 +265,10 @@ class LeaveWish:
|
|||||||
|
|
||||||
# 'fixed' (default): window_start_*/window_end_* are the exact placement. 'flexible':
|
# 'fixed' (default): window_start_*/window_end_* are the exact placement. 'flexible':
|
||||||
# the same fields instead describe the bounding range the allocator searches inside
|
# 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'
|
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||||
# Opt-in dubbeldagar for a parent_mode == 'both' window. Default False
|
# 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).
|
# means the co-leave funding path applies instead (PLANNING_ENGINE_SPEC.md §3.5).
|
||||||
|
|||||||
@@ -624,7 +624,15 @@ class LeaveWish:
|
|||||||
# inside for the best-costing placement (e.g. "vår" = 03-01..05-31) — see §10.2. target_weeks
|
# 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,
|
# 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
|
# 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'
|
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||||
|
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
|||||||
@@ -79,28 +79,37 @@ def project_window(
|
|||||||
Nothing re-derives it at read time — `LeaveWish.window()` just reads the
|
Nothing re-derives it at read time — `LeaveWish.window()` just reads the
|
||||||
dates back off the row.
|
dates back off the row.
|
||||||
|
|
||||||
`window_mode == 'fixed'` wishes describe a week-based habit, not a literal
|
Both `window_mode`s describe a week-based habit, not a literal month/day:
|
||||||
month/day: someone who is off Mon-Sun this year means Mon-Sun again next
|
someone who is off Mon-Sun this year means Mon-Sun again next year, even
|
||||||
year, even though the naive month/day anchor isn't a Monday every year
|
though the naive month/day anchor isn't a Monday every year (the reported
|
||||||
(the reported bug: 2026-10-05..10-11, Mon-Sun, naively recurred to
|
bug: 2026-10-05..10-11, Mon-Sun, naively recurred to 2027-10-05..10-11,
|
||||||
2027-10-05..10-11, which is Tue-Mon). So for any `year` other than
|
which is Tue-Mon). So for any `year` other than `anchor_year`, the naive
|
||||||
`anchor_year`, the naive `date(year, start_month, start_day)` is snapped
|
`date(year, start_month, start_day)` is snapped to the nearest date
|
||||||
to the nearest date carrying the anchor's *start weekday* (ties resolve to
|
carrying the anchor's *start weekday* (ties resolve to the earlier date
|
||||||
the earlier date — see `_shift_to_weekday`), and the end date is
|
— see `_shift_to_weekday`), and the end date is re-derived by holding the
|
||||||
re-derived by holding the anchor window's day-*span* fixed rather than
|
anchor window's day-*span* fixed rather than re-anchoring
|
||||||
re-anchoring end_month/end_day. `anchor_year` itself is unshifted.
|
end_month/end_day. `anchor_year` itself is unshifted.
|
||||||
|
|
||||||
`window_mode == 'flexible'` wishes (§10.2) describe a BOUNDING RANGE the
|
For `window_mode == 'fixed'` the span being held fixed IS the placement:
|
||||||
allocator searches inside, not a placement — they keep the plain month/day
|
the result is the exact dates the family is home. For `window_mode ==
|
||||||
anchor with no weekday shift, every year. Each year's row therefore
|
'flexible'` (§10.2) the result is still a BOUNDING RANGE the allocator
|
||||||
carries that year's bounding range, and the per-year placement search
|
searches inside, not a placement — but the range itself must keep its
|
||||||
inside it still happens at plan time (`_Ledger.resolve_flexible_window`).
|
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)
|
wraps = window_wraps_month_day(start_month, start_day, end_month, end_day)
|
||||||
naive_start = clamped_date(year, start_month, start_day)
|
naive_start = clamped_date(year, start_month, start_day)
|
||||||
naive_end = clamped_date(year + 1 if wraps else year, end_month, end_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
|
return naive_start, naive_end
|
||||||
|
|
||||||
anchor_start = clamped_date(anchor_year, start_month, start_day)
|
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
|
# are the exact placement, as above. 'flexible': the SAME four fields instead
|
||||||
# describe the BOUNDING RANGE the allocator searches inside for the
|
# 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
|
# 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'
|
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||||
|
|
||||||
# Groups the per-year rows a repeating wish was materialised into. `None`
|
# Groups the per-year rows a repeating wish was materialised into. `None`
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ class WindowOutcomeResponse(BaseModel):
|
|||||||
data: dict[str, object]
|
data: dict[str, object]
|
||||||
cost_breakdown: dict[str, int]
|
cost_breakdown: dict[str, int]
|
||||||
remedies: list[ShortfallRemedyResponse]
|
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):
|
class PlanWarningResponse(BaseModel):
|
||||||
@@ -192,6 +196,7 @@ class PlanProposalResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
for r in o.remedies
|
for r in o.remedies
|
||||||
],
|
],
|
||||||
|
shift_gain_days=o.shift_gain_days,
|
||||||
)
|
)
|
||||||
for o in proposal.outcomes
|
for o in proposal.outcomes
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ _FRONT_LOAD_CHILD_AGE_YEARS = 2
|
|||||||
_SHIFT_REMEDY_SEARCH_BOUND_DAYS = 28
|
_SHIFT_REMEDY_SEARCH_BOUND_DAYS = 28
|
||||||
# How many ranked alternative placements the remedy reports at most.
|
# How many ranked alternative placements the remedy reports at most.
|
||||||
_SHIFT_REMEDY_MAX_SUGGESTIONS = 2
|
_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]
|
data: dict[str, object]
|
||||||
cost_breakdown: dict[str, int]
|
cost_breakdown: dict[str, int]
|
||||||
remedies: list[ShortfallRemedy]
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -572,6 +584,15 @@ class _WishWindow:
|
|||||||
anchor_year: int
|
anchor_year: int
|
||||||
start: date
|
start: date
|
||||||
end: 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]:
|
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)
|
"""§10.2 step 5 — every full-length `(start, end)` candidate (inclusive)
|
||||||
|
|
||||||
that fits entirely inside `[bound_start, bound_end]`, sliding one day at
|
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]] = []
|
candidates: list[tuple[date, date]] = []
|
||||||
last_start = bound_end - timedelta(days=length_days - 1)
|
last_start = bound_end - timedelta(days=length_days - 1)
|
||||||
@@ -873,11 +896,20 @@ class _Ledger:
|
|||||||
else alloc_input.existing_planned
|
else alloc_input.existing_planned
|
||||||
)
|
)
|
||||||
self._persisted_by_wish: dict[tuple[UUID, UUID | None], set[date]] = {}
|
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:
|
for p in persisted_source:
|
||||||
if p.wish_id is None:
|
if p.wish_id is None:
|
||||||
continue
|
continue
|
||||||
key = (p.wish_id, p.child_id)
|
key = (p.wish_id, p.child_id)
|
||||||
self._persisted_by_wish.setdefault(key, set()).add(p.date)
|
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_pinned_days()
|
||||||
self._reserve_preschool_days()
|
self._reserve_preschool_days()
|
||||||
@@ -1716,7 +1748,15 @@ class _Ledger:
|
|||||||
responsible.mode, all_dates, days, cost
|
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(
|
outcome = WindowOutcome(
|
||||||
wish_id=wish.id,
|
wish_id=wish.id,
|
||||||
@@ -1743,6 +1783,7 @@ class _Ledger:
|
|||||||
},
|
},
|
||||||
cost_breakdown=cost,
|
cost_breakdown=cost,
|
||||||
remedies=remedies,
|
remedies=remedies,
|
||||||
|
shift_gain_days=w.shift_gain_days,
|
||||||
)
|
)
|
||||||
return days, outcome
|
return days, outcome
|
||||||
|
|
||||||
@@ -1755,17 +1796,20 @@ class _Ledger:
|
|||||||
shortfall_days: int,
|
shortfall_days: int,
|
||||||
include_shift_remedy: bool = True,
|
include_shift_remedy: bool = True,
|
||||||
) -> list[ShortfallRemedy]:
|
) -> list[ShortfallRemedy]:
|
||||||
if shortfall_days <= 0:
|
|
||||||
return []
|
|
||||||
remedies: list[ShortfallRemedy] = []
|
remedies: list[ShortfallRemedy] = []
|
||||||
remedies.extend(self._reduce_preschool_reserve_remedy(wish))
|
if shortfall_days > 0:
|
||||||
remedies.extend(self._carry_vacation_forward_remedy(wish, w))
|
remedies.extend(self._reduce_preschool_reserve_remedy(wish))
|
||||||
remedies.extend(self._lower_priority_wish_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`:
|
# §10.4 — `include_shift_remedy=False` from `build_unplaced_outcome`:
|
||||||
# there `w` is the whole bounding range, not a single placed window,
|
# 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
|
# 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
|
# (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:
|
if include_shift_remedy:
|
||||||
remedies.extend(self._shift_window_into_free_days_remedy(wish, w))
|
remedies.extend(self._shift_window_into_free_days_remedy(wish, w))
|
||||||
return remedies
|
return remedies
|
||||||
@@ -1990,6 +2034,34 @@ class _Ledger:
|
|||||||
dates = self._persisted_by_wish.get((wish.id, child_id), set())
|
dates = self._persisted_by_wish.get((wish.id, child_id), set())
|
||||||
return sorted(d for d in dates if bound_start <= d <= bound_end)
|
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 ----------------------
|
# -- §Decision C — budget-aware candidate ranking ----------------------
|
||||||
|
|
||||||
def _candidate_shortfall(
|
def _candidate_shortfall(
|
||||||
@@ -2072,17 +2144,27 @@ class _Ledger:
|
|||||||
wish: LeaveWish,
|
wish: LeaveWish,
|
||||||
responsible: _Responsible,
|
responsible: _Responsible,
|
||||||
candidate: tuple[date, date],
|
candidate: tuple[date, date],
|
||||||
) -> tuple[int, int, date, int]:
|
) -> tuple[int, int, int, date, int]:
|
||||||
"""Sort key shared by `resolve_flexible_window` and
|
"""Sort key shared by `resolve_flexible_window` and
|
||||||
|
|
||||||
`_reuse_already_funded_window`: fewest unfundable days first (§Decision
|
`_reuse_already_funded_window`: fewest unfundable days first (§Decision
|
||||||
C), THEN calendar cost, THEN start date, THEN wish id — the existing
|
C), THEN calendar cost, THEN Monday-start alignment (§Decision 2),
|
||||||
priority ordering (§10.2 step 6) is otherwise unchanged.
|
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
|
start, end = candidate
|
||||||
shortfall = self._candidate_shortfall(wish, responsible, start, end)
|
shortfall = self._candidate_shortfall(wish, responsible, start, end)
|
||||||
cost = _candidate_cost(start, end, self._input.holidays, self._input.day_overrides)
|
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(
|
def _reuse_already_funded_window(
|
||||||
self,
|
self,
|
||||||
@@ -2119,6 +2201,21 @@ class _Ledger:
|
|||||||
higher-priority fixed wish added since the last apply). That last
|
higher-priority fixed wish added since the last apply). That last
|
||||||
case is what keeps a genuinely-unsatisfied year free to move
|
case is what keeps a genuinely-unsatisfied year free to move
|
||||||
(§10.3) rather than frozen to a window that no longer fits.
|
(§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)
|
persisted_dates = self._persisted_dates_for_wish(wish, child_id, bound_start, bound_end)
|
||||||
if not persisted_dates:
|
if not persisted_dates:
|
||||||
@@ -2139,7 +2236,30 @@ class _Ledger:
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
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(
|
def resolve_flexible_window(
|
||||||
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
|
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
|
||||||
@@ -2187,7 +2307,30 @@ class _Ledger:
|
|||||||
winner_start, winner_end = min(
|
winner_start, winner_end = min(
|
||||||
candidates, key=lambda c: self._rank_candidate(wish, responsible, c)
|
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(
|
def build_unplaced_outcome(
|
||||||
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
|
self, wish: LeaveWish, anchor_year: int, bound_start: date, bound_end: date
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Realign already-materialised flexible series onto their anchor's weekday.
|
||||||
|
|
||||||
|
`project_window` (`app/entities/leave_wish.py`) used to skip the weekday snap
|
||||||
|
for `window_mode == 'flexible'` rows — every year of a flexible series stored
|
||||||
|
the identical naive month/day bound, so a week-shaped bound (e.g. "the week
|
||||||
|
of Oct 5") silently drifted onto a different weekday every year it wasn't the
|
||||||
|
anchor. That bug is fixed at the source (`project_window` now snaps a
|
||||||
|
flexible bound's start to the anchor year's start weekday too, holding the
|
||||||
|
bound's day-span fixed, exactly like `window_mode == 'fixed'` already did),
|
||||||
|
but `project_window` only ever runs ONCE, at materialisation time
|
||||||
|
(`LeaveWishService`) — nothing re-derives it at read time. Every flexible
|
||||||
|
series row materialised before this fix therefore stays unsnapped forever
|
||||||
|
unless something rewrites it once, here.
|
||||||
|
|
||||||
|
This migration:
|
||||||
|
|
||||||
|
1. finds every `leave_wish` row with `window_mode = 'flexible'` and a
|
||||||
|
non-null `series_id`;
|
||||||
|
2. groups them by `series_id`, and for each series treats the row with the
|
||||||
|
lowest `first_year` as the anchor occurrence (unshifted by definition —
|
||||||
|
`project_window` never shifts `year == anchor_year`), the same convention
|
||||||
|
004's `downgrade()` uses to collapse a series back to one row;
|
||||||
|
3. for every OTHER row in that series, re-projects that year's bound from
|
||||||
|
the anchor's own stored month/day fields (which, being the anchor, are
|
||||||
|
already the naive/unshifted pattern) using the anchor's start weekday,
|
||||||
|
and rewrites that row's four month/day columns in place.
|
||||||
|
|
||||||
|
Fixed-mode series are left untouched — `project_window` already snapped them
|
||||||
|
correctly the day they were materialised, and re-running the (identical)
|
||||||
|
projection on them would be a costly no-op at best.
|
||||||
|
|
||||||
|
No schema change: this is a pure data rewrite. The projection helpers below
|
||||||
|
are a **self-contained copy**, not an import of `app.entities.leave_wish`,
|
||||||
|
for the same reason 004 gives at its line 196 — a migration must keep
|
||||||
|
describing the behaviour it was written against, not whatever the entity
|
||||||
|
module does later.
|
||||||
|
|
||||||
|
`downgrade()` restores the naive (unsnapped) anchor month/day for every
|
||||||
|
non-anchor flexible row — i.e. exactly what this migration found — so the
|
||||||
|
round trip is lossless for anchor rows (never touched) and reverses cleanly
|
||||||
|
for every row it did touch.
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import datetime as dt
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: str | None = "007"
|
||||||
|
branch_labels: str | None = None
|
||||||
|
depends_on: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _clamped(year: int, month: int, day: int) -> dt.date:
|
||||||
|
return dt.date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_to_weekday(naive: dt.date, target_weekday: int) -> dt.date:
|
||||||
|
forward = (target_weekday - naive.weekday()) % 7
|
||||||
|
shift = min((forward, forward - 7), key=lambda d: (abs(d), d))
|
||||||
|
return naive + dt.timedelta(days=shift)
|
||||||
|
|
||||||
|
|
||||||
|
def _project(
|
||||||
|
sm: int, sd: int, em: int, ed: int, anchor_year: int, year: int, *, snap: bool
|
||||||
|
) -> tuple[dt.date, dt.date]:
|
||||||
|
"""Verbatim copy of `project_window`'s post-fix (`snap=True`) and
|
||||||
|
|
||||||
|
pre-fix (`snap=False`) behaviour, parametrised on the anchor row's own
|
||||||
|
(start_month, start_day, end_month, end_day) pattern rather than a
|
||||||
|
`LeaveWish` instance.
|
||||||
|
"""
|
||||||
|
wraps = (em, ed) < (sm, sd)
|
||||||
|
naive_start = _clamped(year, sm, sd)
|
||||||
|
naive_end = _clamped(year + 1 if wraps else year, em, ed)
|
||||||
|
if not snap or year == anchor_year:
|
||||||
|
return naive_start, naive_end
|
||||||
|
anchor_start = _clamped(anchor_year, sm, sd)
|
||||||
|
anchor_end = _clamped(anchor_year + 1 if wraps else anchor_year, em, ed)
|
||||||
|
span = anchor_end - anchor_start
|
||||||
|
start = _shift_to_weekday(naive_start, anchor_start.weekday())
|
||||||
|
return start, start + span
|
||||||
|
|
||||||
|
|
||||||
|
def _flexible_series_rows(conn: Any) -> dict[str, list[Any]]:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, series_id, first_year, "
|
||||||
|
"window_start_month, window_start_day, window_end_month, window_end_day "
|
||||||
|
"FROM leave_wish "
|
||||||
|
"WHERE window_mode = 'flexible' AND series_id IS NOT NULL "
|
||||||
|
"ORDER BY series_id, first_year"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
by_series: dict[str, list[Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
by_series.setdefault(str(row.series_id), []).append(row)
|
||||||
|
return by_series
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite(conn: Any, *, snap: bool) -> None:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
for series_rows in _flexible_series_rows(conn).values():
|
||||||
|
anchor = min(series_rows, key=lambda r: int(r.first_year))
|
||||||
|
sm, sd = anchor.window_start_month, anchor.window_start_day
|
||||||
|
em, ed = anchor.window_end_month, anchor.window_end_day
|
||||||
|
anchor_year = int(anchor.first_year)
|
||||||
|
for row in series_rows:
|
||||||
|
year = int(row.first_year)
|
||||||
|
if year == anchor_year:
|
||||||
|
continue
|
||||||
|
start, end = _project(sm, sd, em, ed, anchor_year, year, snap=snap)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE leave_wish SET "
|
||||||
|
"window_start_month = :wsm, window_start_day = :wsd, "
|
||||||
|
"window_end_month = :wem, window_end_day = :wed "
|
||||||
|
"WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"wsm": start.month,
|
||||||
|
"wsd": start.day,
|
||||||
|
"wem": end.month,
|
||||||
|
"wed": end.day,
|
||||||
|
"id": row.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_rewrite(op.get_bind(), snap=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_rewrite(op.get_bind(), snap=False)
|
||||||
@@ -179,7 +179,14 @@ def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_pat
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path: Path) -> None:
|
def test_flexible_yearly_wish_keeps_its_bounding_range_weekday_aligned(tmp_path: Path) -> None:
|
||||||
|
"""`command.upgrade(..., "head")` runs 004's backfill AND 008's
|
||||||
|
|
||||||
|
realignment, so this proves the two migrations agree with each other:
|
||||||
|
004 backfills the pre-fix (unsnapped) flexible bound, and 008 — since
|
||||||
|
"head" now includes it — snaps it the same way `project_window` does
|
||||||
|
post-fix, rather than leaving it at 004's naive output.
|
||||||
|
"""
|
||||||
db = tmp_path / "flexible.db"
|
db = tmp_path / "flexible.db"
|
||||||
command.upgrade(_alembic_config(db), "003")
|
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()),
|
"id": str(uuid.uuid4()),
|
||||||
"name": "Var",
|
"name": "Var",
|
||||||
"wsm": 3,
|
"wsm": 3,
|
||||||
"wsd": 1,
|
"wsd": 1, # 2027-03-01 is a Monday
|
||||||
"wem": 5,
|
"wem": 5,
|
||||||
"wed": 31,
|
"wed": 31,
|
||||||
"target_weeks": 1,
|
"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")
|
rows = _rows(db, "Var")
|
||||||
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
||||||
# A flexible wish's four fields are a bounding range, never a placement —
|
# The bound keeps its weekday alignment (Monday, like the anchor) and
|
||||||
# no weekday snap, so every year's range is the identical month/day pair.
|
# its exact 91-day span, sliding a few days per year rather than staying
|
||||||
assert {
|
# pinned to the identical naive month/day.
|
||||||
(r["window_start_month"], r["window_start_day"], r["window_end_month"], r["window_end_day"])
|
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
|
||||||
for r in rows
|
assert stored == [(3, 1), (2, 28), (2, 26)]
|
||||||
} == {(3, 1, 5, 31)}
|
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:
|
def test_once_wish_is_left_alone_with_no_series(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Migration 008's data rewrite: realign already-materialised flexible series.
|
||||||
|
|
||||||
|
`project_window` (`app/entities/leave_wish.py`) now snaps a `window_mode ==
|
||||||
|
'flexible'` bound's start to the anchor year's start weekday too (Step 1 of
|
||||||
|
the flexible-window realignment fix), holding the bound's day-span fixed —
|
||||||
|
mirroring what `window_mode == 'fixed'` already did. But `project_window`
|
||||||
|
only runs once, at materialisation time (`LeaveWishService`); nothing
|
||||||
|
re-derives it at read time. Every flexible series row materialised before
|
||||||
|
this fix therefore stays unsnapped forever unless something rewrites it
|
||||||
|
once. Migration 008 is that one-time rewrite.
|
||||||
|
|
||||||
|
Driven through the real Alembic chain (upgrade to 007, insert POST-004-shape
|
||||||
|
rows directly — the one-row-per-year shape already exists by 007, so there is
|
||||||
|
no pre-004 fixture to build here — then upgrade to head/008), mirroring
|
||||||
|
`test_migration_004_backfill.py`'s style.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _alembic_config(db_path: Path) -> Config:
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_post_004_fixture(db_path: Path, *, rows: list[dict[str, object]]) -> str:
|
||||||
|
"""Seed a workspace and the given POST-004-shape `leave_wish` rows.
|
||||||
|
|
||||||
|
`rows` are full leave_wish rows (one per year already) — the shape 004
|
||||||
|
produces and 007 leaves untouched — grouped by `series_id`.
|
||||||
|
"""
|
||||||
|
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
|
||||||
|
ws_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
|
||||||
|
(ws_id, "WS", now),
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO leave_wish (id, workspace_id, child_id, name, "
|
||||||
|
"window_start_month, window_start_day, window_end_month, window_end_day, "
|
||||||
|
"target_weeks, first_year, last_year, priority, parent_mode, "
|
||||||
|
"window_mode, series_id, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :workspace_id, NULL, :name, :wsm, :wsd, :wem, :wed, "
|
||||||
|
":target_weeks, :first_year, :last_year, 1, 'either', "
|
||||||
|
":window_mode, :series_id, :now, :now)",
|
||||||
|
{"workspace_id": ws_id, "now": now, **row},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(db_path: Path, name: str) -> list[sqlite3.Row]:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM leave_wish WHERE name = ? ORDER BY first_year", (name,)
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _flexible_series(
|
||||||
|
*, name: str, series_id: str, first_year: int, last_year: int, wsm: int, wsd: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Rows in 004's shape for a flexible series whose bound was NAIVELY
|
||||||
|
|
||||||
|
materialised (every year the identical month/day pair) — exactly what
|
||||||
|
every flexible series looked like before this fix, and what 008 must
|
||||||
|
rewrite.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": name,
|
||||||
|
"wsm": wsm,
|
||||||
|
"wsd": wsd,
|
||||||
|
"wem": 5,
|
||||||
|
"wed": 31,
|
||||||
|
"target_weeks": 1,
|
||||||
|
"first_year": year,
|
||||||
|
"last_year": last_year,
|
||||||
|
"window_mode": "flexible",
|
||||||
|
"series_id": series_id,
|
||||||
|
}
|
||||||
|
for year in range(first_year, last_year + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_realigns_a_flexible_series_onto_its_anchor_weekday(tmp_path: Path) -> None:
|
||||||
|
db = tmp_path / "realign.db"
|
||||||
|
command.upgrade(_alembic_config(db), "007")
|
||||||
|
|
||||||
|
series_id = str(uuid.uuid4())
|
||||||
|
# 2027-03-01 is a Monday; naively every year got the same (3, 1).
|
||||||
|
rows_in = _flexible_series(
|
||||||
|
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
|
||||||
|
)
|
||||||
|
_insert_post_004_fixture(db, rows=rows_in)
|
||||||
|
|
||||||
|
command.upgrade(_alembic_config(db), "head")
|
||||||
|
|
||||||
|
rows = _rows(db, "Var")
|
||||||
|
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
||||||
|
# Anchor (2027) untouched; 2028/2029 snapped to Monday, span held fixed.
|
||||||
|
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
|
||||||
|
assert stored == [(3, 1), (2, 28), (2, 26)]
|
||||||
|
for row in rows:
|
||||||
|
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
|
||||||
|
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
|
||||||
|
assert start.weekday() == 0, row["first_year"]
|
||||||
|
assert (end - start).days == 91, row["first_year"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_restores_the_naive_anchor_month_day(tmp_path: Path) -> None:
|
||||||
|
db = tmp_path / "realign_roundtrip.db"
|
||||||
|
command.upgrade(_alembic_config(db), "007")
|
||||||
|
|
||||||
|
series_id = str(uuid.uuid4())
|
||||||
|
rows_in = _flexible_series(
|
||||||
|
name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1
|
||||||
|
)
|
||||||
|
_insert_post_004_fixture(db, rows=rows_in)
|
||||||
|
|
||||||
|
command.upgrade(_alembic_config(db), "head")
|
||||||
|
assert {(r["window_start_month"], r["window_start_day"]) for r in _rows(db, "Var")} != {(3, 1)}
|
||||||
|
|
||||||
|
command.downgrade(_alembic_config(db), "007")
|
||||||
|
|
||||||
|
rows = _rows(db, "Var")
|
||||||
|
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
|
||||||
|
assert {(r["window_start_month"], r["window_start_day"]) for r in rows} == {(3, 1)}
|
||||||
|
assert {(r["window_end_month"], r["window_end_day"]) for r in rows} == {(5, 31)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_series_is_left_untouched(tmp_path: Path) -> None:
|
||||||
|
"""`project_window` already snapped a fixed series correctly at 004's
|
||||||
|
|
||||||
|
materialisation time — 008 must be a no-op for it, not a costly
|
||||||
|
re-run of an already-correct projection.
|
||||||
|
"""
|
||||||
|
db = tmp_path / "fixed_untouched.db"
|
||||||
|
command.upgrade(_alembic_config(db), "007")
|
||||||
|
|
||||||
|
series_id = str(uuid.uuid4())
|
||||||
|
rows_in = [
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": "Sommar",
|
||||||
|
"wsm": 10,
|
||||||
|
"wsd": 5 if year == 2026 else (4 if year == 2027 else 2),
|
||||||
|
"wem": 10,
|
||||||
|
"wed": 11 if year == 2026 else (10 if year == 2027 else 8),
|
||||||
|
"target_weeks": 1,
|
||||||
|
"first_year": year,
|
||||||
|
"last_year": 2028,
|
||||||
|
"window_mode": "fixed",
|
||||||
|
"series_id": series_id,
|
||||||
|
}
|
||||||
|
for year in (2026, 2027, 2028)
|
||||||
|
]
|
||||||
|
_insert_post_004_fixture(db, rows=rows_in)
|
||||||
|
|
||||||
|
before = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
|
||||||
|
command.upgrade(_alembic_config(db), "head")
|
||||||
|
after = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")}
|
||||||
|
|
||||||
|
for year, row in before.items():
|
||||||
|
assert after[year]["window_start_month"] == row["window_start_month"]
|
||||||
|
assert after[year]["window_start_day"] == row["window_start_day"]
|
||||||
|
assert after[year]["window_end_month"] == row["window_end_month"]
|
||||||
|
assert after[year]["window_end_day"] == row["window_end_day"]
|
||||||
@@ -1433,31 +1433,41 @@ class TestLeaveWishSeriesMaterialisation:
|
|||||||
assert end.weekday() == 6, row.first_year
|
assert end.weekday() == 6, row.first_year
|
||||||
assert (end - start).days == 6
|
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,
|
self,
|
||||||
leave_wish_service: LeaveWishService,
|
leave_wish_service: LeaveWishService,
|
||||||
leave_wish_repo: SQLiteLeaveWishRepository,
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
||||||
seeded_ids: SeededIds,
|
seeded_ids: SeededIds,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A flexible wish's fields are a bounding range, not a placement, so
|
"""A flexible wish's fields are a bounding range, not a placement, but
|
||||||
materialising gives each year the SAME month/day range — the per-year
|
|
||||||
placement search inside it stays the allocator's job.
|
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(
|
_, rows = await self._series(
|
||||||
leave_wish_service,
|
leave_wish_service,
|
||||||
leave_wish_repo,
|
leave_wish_repo,
|
||||||
seeded_ids,
|
seeded_ids,
|
||||||
window_start_month=3,
|
window_start_month=3,
|
||||||
window_start_day=1,
|
window_start_day=1, # 2026-03-01 is a Sunday
|
||||||
window_end_month=5,
|
window_end_month=5,
|
||||||
window_end_day=31,
|
window_end_day=31,
|
||||||
window_mode="flexible",
|
window_mode="flexible",
|
||||||
)
|
)
|
||||||
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
|
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
|
||||||
assert {
|
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
|
||||||
(r.window_start_month, r.window_start_day, r.window_end_month, r.window_end_day)
|
(3, 1),
|
||||||
for r in rows
|
(2, 28),
|
||||||
} == {(3, 1, 5, 31)}
|
(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(
|
async def test_open_ended_series_stops_at_the_childs_fourth_birthday_year(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -140,7 +140,19 @@ class TestExcludedDayIsNeverPlaced:
|
|||||||
|
|
||||||
outcome = proposal.outcomes[0]
|
outcome = proposal.outcomes[0]
|
||||||
assert outcome.shortfall_days == 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
|
# `weeks_funded` still tells the truth about what was actually
|
||||||
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
|
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
|
||||||
assert outcome.weeks_funded == round(6 / 7, 2)
|
assert outcome.weeks_funded == round(6 / 7, 2)
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ def _planned(
|
|||||||
on: date,
|
on: date,
|
||||||
day_type: str = "vacation",
|
day_type: str = "vacation",
|
||||||
wish_id: UUID | None = None,
|
wish_id: UUID | None = None,
|
||||||
|
is_pinned: bool = False,
|
||||||
) -> PlannedLeaveDay:
|
) -> PlannedLeaveDay:
|
||||||
"""A persisted `planned_leave_day` row — the "already applied, before
|
"""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)
|
flexible re-propose; extended for §fix parent-reassignment, decision B)
|
||||||
test cases below. Only the fields `AllocationInput.existing_planned`/
|
test cases below. Only the fields `AllocationInput.existing_planned`/
|
||||||
`all_existing_planned` actually read (`parent_personal_data_uuid`,
|
`all_existing_planned` actually read (`parent_personal_data_uuid`,
|
||||||
`child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what
|
`child_id`, `date`, `day_type`, `wish_id`, `is_pinned`) matter. `wish_id`
|
||||||
`_Ledger._persisted_by_wish` now matches a flexible wish's own prior
|
is what `_Ledger._persisted_by_wish` now matches a flexible wish's own
|
||||||
placement by — pass the wish's `.id` whenever a test relies on that
|
prior placement by — pass the wish's `.id` whenever a test relies on
|
||||||
reuse mechanism finding these rows.
|
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(
|
return PlannedLeaveDay(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
@@ -240,6 +243,7 @@ def _planned(
|
|||||||
date=on,
|
date=on,
|
||||||
day_type=day_type,
|
day_type=day_type,
|
||||||
wish_id=wish_id,
|
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:
|
def test_equal_cost_candidates_break_tie_by_earliest_start(self) -> None:
|
||||||
# A holiday-free bound (Feb 2027): every 7-day slice contains
|
# A holiday-free bound (Feb 2027): every 7-day slice contains
|
||||||
# exactly one Saturday and one Sunday, so every candidate has the
|
# exactly one Saturday and one Sunday, so every candidate has the
|
||||||
# *same* calendar-only cost (5) regardless of start date -- the
|
# *same* calendar-only cost (5) regardless of start date. Since
|
||||||
# only thing that can break the tie is `candidate.start`, never
|
# §Decision 2, the tie is actually broken by Monday-alignment first,
|
||||||
# dict/set iteration order.
|
# 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))
|
child = _child(birth_date=date(2020, 1, 1))
|
||||||
parent = _parent(salary_type="monthly")
|
parent = _parent(salary_type="monthly")
|
||||||
wish = _wish(
|
wish = _wish(
|
||||||
@@ -1956,16 +1963,163 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
|
|||||||
assert flex_outcome.strategy != STRATEGY_UNPLACED
|
assert flex_outcome.strategy != STRATEGY_UNPLACED
|
||||||
assert flex_outcome.shortfall_days == 0
|
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_dates = {
|
||||||
flex_outcome.start_date + timedelta(days=i)
|
flex_outcome.start_date + timedelta(days=i)
|
||||||
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
|
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
|
# The previously-applied day is abandoned along with the rest of the
|
||||||
# window's span) but never re-claims the fixed wish's date.
|
# old window -- cheaper wins over "keep what's already there" -- and
|
||||||
assert date(2027, 3, 23) in flex_dates
|
# 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
|
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
|
# §Task 4 — pinned days: fixed, and debited off the top before any window
|
||||||
# is funded (mirrors `_reserve_preschool_days`'s precedent).
|
# is funded (mirrors `_reserve_preschool_days`'s precedent).
|
||||||
@@ -2587,18 +2741,24 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
|||||||
assert start_2027.weekday() == start_2026.weekday()
|
assert start_2027.weekday() == start_2026.weekday()
|
||||||
assert end_2027.weekday() == end_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
|
"""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.
|
inside, not a placement -- but the range must still get the same
|
||||||
Each year still gets its OWN row (carrying that year's range); the
|
weekday snap as a fixed placement, or a week-shaped bound (the
|
||||||
per-year placement search inside it is the allocator's job.
|
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 = {
|
rows = {
|
||||||
w.first_year: w
|
w.first_year: w
|
||||||
for w in _wish_series(
|
for w in _wish_series(
|
||||||
child_id=None,
|
child_id=None,
|
||||||
start=(3, 1),
|
start=(3, 1), # 2026-03-01 is a Sunday
|
||||||
end=(5, 31),
|
end=(5, 31),
|
||||||
first_year=2026,
|
first_year=2026,
|
||||||
last_year=2028,
|
last_year=2028,
|
||||||
@@ -2606,8 +2766,148 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
assert rows[2026].window() == (date(2026, 3, 1), date(2026, 5, 31))
|
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[2027].window() == (date(2027, 2, 28), date(2027, 5, 30))
|
||||||
assert rows[2028].window() == (date(2028, 3, 1), date(2028, 5, 31))
|
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).
|
# 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_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
|
assert shift_2028.data["wish_name_message_key"] == rows_by_year[2028].name_message_key
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# §todo item (red-day shift toast) — a `window_mode == 'fixed'` window's
|
||||||
|
# placement is unconditional (never auto-moved), so it needs the
|
||||||
|
# shift_window_into_free_days SUGGESTION surfaced even when fully funded
|
||||||
|
# (previously hidden behind `shortfall_days > 0`). A `window_mode ==
|
||||||
|
# 'flexible'` window's own bound search already IS the shift, so it must
|
||||||
|
# NEVER get that remedy kind, and instead reports how much the search
|
||||||
|
# improved on the naive "first slot in the bound" placement via
|
||||||
|
# `WindowOutcome.shift_gain_days`.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCase36ShiftRemedyOnFullyFundedFixedWindow:
|
||||||
|
def test_fully_funded_fixed_window_still_gets_the_shift_suggestion(self) -> None:
|
||||||
|
child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030, past 2027
|
||||||
|
parent = _parent(salary_type="monthly")
|
||||||
|
# Same window TestCase35 uses, but funded generously -- unlike that
|
||||||
|
# test's deliberately starved pool, this window is FULLY funded, so
|
||||||
|
# the only thing that could still hide the shift suggestion is
|
||||||
|
# `_build_remedies`'s shortfall gate (now removed for fixed windows).
|
||||||
|
wish = _wish(
|
||||||
|
child_id=child.id,
|
||||||
|
name="Vårvecka",
|
||||||
|
start=(3, 22),
|
||||||
|
end=(3, 28),
|
||||||
|
weeks=1,
|
||||||
|
first_year=2027,
|
||||||
|
window_mode="fixed",
|
||||||
|
)
|
||||||
|
alloc_input = _build_input(
|
||||||
|
today=date(2027, 1, 1),
|
||||||
|
horizon_end=date(2027, 12, 31),
|
||||||
|
children=[child],
|
||||||
|
parents=[parent],
|
||||||
|
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||||
|
vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)},
|
||||||
|
wishes=[wish],
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal = Allocator().propose(alloc_input)
|
||||||
|
|
||||||
|
outcome = proposal.outcomes[0]
|
||||||
|
assert outcome.shortfall_days == 0
|
||||||
|
shift = next(r for r in outcome.remedies if r.kind == "shift_window_into_free_days")
|
||||||
|
# Same best placement TestCase35 finds for this exact window: shift
|
||||||
|
# 1 day later to pick up Easter Monday (2027-03-29, a real holiday).
|
||||||
|
assert shift.data["new_start_date"] == "2027-03-23"
|
||||||
|
assert shift.data["new_end_date"] == "2027-03-29"
|
||||||
|
assert shift.days_freed == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCase37ShiftRemedyNeverOnFlexibleWindow:
|
||||||
|
def test_flexible_window_never_gets_the_shift_remedy_even_with_a_shortfall(self) -> None:
|
||||||
|
child = _child(birth_date=date(2020, 1, 1))
|
||||||
|
parent = _parent(salary_type="monthly")
|
||||||
|
wish = _wish(
|
||||||
|
child_id=child.id,
|
||||||
|
name="Vårlov",
|
||||||
|
start=VAR_2027[0],
|
||||||
|
end=VAR_2027[1],
|
||||||
|
weeks=1,
|
||||||
|
first_year=2027,
|
||||||
|
window_mode="flexible",
|
||||||
|
)
|
||||||
|
alloc_input = _build_input(
|
||||||
|
today=date(2027, 1, 1),
|
||||||
|
horizon_end=date(2027, 12, 31),
|
||||||
|
children=[child],
|
||||||
|
parents=[parent],
|
||||||
|
# Deliberately starved (same recipe as TestCase35) so the placed
|
||||||
|
# (searched, not the whole bound -- this is not the §10.4
|
||||||
|
# "unplaced" case TestCase20 covers) window carries a genuine
|
||||||
|
# shortfall -- the flexible contract says its own search already
|
||||||
|
# IS the shift, so the remedy must stay absent regardless.
|
||||||
|
pools={(parent.uuid, child.id): DayPool(1, 0, 60)},
|
||||||
|
vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)},
|
||||||
|
wishes=[wish],
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal = Allocator().propose(alloc_input)
|
||||||
|
|
||||||
|
outcome = proposal.outcomes[0]
|
||||||
|
assert outcome.strategy != STRATEGY_UNPLACED
|
||||||
|
assert outcome.shortfall_days > 0
|
||||||
|
assert all(r.kind != "shift_window_into_free_days" for r in outcome.remedies)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCase38FlexibleShiftGainDaysReportsSearchImprovement:
|
||||||
|
def test_winner_beating_naive_bound_start_reports_positive_gain(self) -> None:
|
||||||
|
# Same setup as TestCase15FlexibleWishFindsEasterCluster: the search
|
||||||
|
# picks 2027-03-23..03-29 (3 spendable days -- the Easter cluster)
|
||||||
|
# over the naive bound-start placement 2027-03-01..03-07 (5
|
||||||
|
# spendable days -- Mon-Fri all working, verified against
|
||||||
|
# `get_swedish_holidays(2027)`) -- a 2-day gain.
|
||||||
|
child = _child(birth_date=date(2020, 1, 1))
|
||||||
|
parent = _parent(salary_type="monthly")
|
||||||
|
wish = _wish(
|
||||||
|
child_id=child.id,
|
||||||
|
name="Vårlov",
|
||||||
|
start=VAR_2027[0],
|
||||||
|
end=VAR_2027[1],
|
||||||
|
weeks=1,
|
||||||
|
first_year=2027,
|
||||||
|
window_mode="flexible",
|
||||||
|
)
|
||||||
|
alloc_input = _build_input(
|
||||||
|
today=date(2027, 1, 1),
|
||||||
|
horizon_end=date(2027, 12, 31),
|
||||||
|
children=[child],
|
||||||
|
parents=[parent],
|
||||||
|
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||||
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||||
|
wishes=[wish],
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal = Allocator().propose(alloc_input)
|
||||||
|
|
||||||
|
outcome = proposal.outcomes[0]
|
||||||
|
assert outcome.start_date == date(2027, 3, 23)
|
||||||
|
assert outcome.end_date == date(2027, 3, 29)
|
||||||
|
assert outcome.shift_gain_days == 2
|
||||||
|
|
||||||
|
def test_winner_landing_on_naive_bound_start_reports_zero(self) -> None:
|
||||||
|
# Same setup as TestCase16FlexibleTieBreakByEarliestStart: every
|
||||||
|
# candidate in the holiday-free February bound costs the same, and
|
||||||
|
# 2027-02-01 (the bound's own start) is also the Monday-aligned
|
||||||
|
# tie-break winner -- the winner IS the naive placement, so there is
|
||||||
|
# nothing to report.
|
||||||
|
child = _child(birth_date=date(2020, 1, 1))
|
||||||
|
parent = _parent(salary_type="monthly")
|
||||||
|
wish = _wish(
|
||||||
|
child_id=child.id,
|
||||||
|
name="Sportlov",
|
||||||
|
start=FEB_2027[0],
|
||||||
|
end=FEB_2027[1],
|
||||||
|
weeks=1,
|
||||||
|
first_year=2027,
|
||||||
|
window_mode="flexible",
|
||||||
|
)
|
||||||
|
alloc_input = _build_input(
|
||||||
|
today=date(2027, 1, 1),
|
||||||
|
horizon_end=date(2027, 12, 31),
|
||||||
|
children=[child],
|
||||||
|
parents=[parent],
|
||||||
|
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
|
||||||
|
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
|
||||||
|
wishes=[wish],
|
||||||
|
)
|
||||||
|
|
||||||
|
proposal = Allocator().propose(alloc_input)
|
||||||
|
|
||||||
|
outcome = proposal.outcomes[0]
|
||||||
|
assert outcome.start_date == date(2027, 2, 1)
|
||||||
|
assert outcome.shift_gain_days == 0
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export const calendar = {
|
|||||||
'{name} ({start}–{end}): none of the {daysWished} {daysWished|day|days} requested could be funded — {shortfallDays} {shortfallDays|day|days} short',
|
'{name} ({start}–{end}): none of the {daysWished} {daysWished|day|days} requested could be funded — {shortfallDays} {shortfallDays|day|days} short',
|
||||||
'calendar.toastShortfallPartialDays':
|
'calendar.toastShortfallPartialDays':
|
||||||
'{name} ({start}–{end}): only {daysFunded} of {daysWished} days funded — {shortfallDays} {shortfallDays|day|days} short',
|
'{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.undoButton': 'Undo',
|
||||||
'calendar.undoError': 'Failed to undo the change',
|
'calendar.undoError': 'Failed to undo the change',
|
||||||
'calendar.dismissToastAria': 'Dismiss the message',
|
'calendar.dismissToastAria': 'Dismiss the message',
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export const calendar = {
|
|||||||
'{name} ({start}–{end}): inget av de {daysWished} {daysWished|önskad dag|önskade dagar} kunde finansieras — {shortfallDays} {shortfallDays|dag|dagar} saknas',
|
'{name} ({start}–{end}): inget av de {daysWished} {daysWished|önskad dag|önskade dagar} kunde finansieras — {shortfallDays} {shortfallDays|dag|dagar} saknas',
|
||||||
'calendar.toastShortfallPartialDays':
|
'calendar.toastShortfallPartialDays':
|
||||||
'{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas',
|
'{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.undoButton': 'Ångra',
|
||||||
'calendar.undoError': 'Det gick inte att ångra ändringen',
|
'calendar.undoError': 'Det gick inte att ångra ändringen',
|
||||||
'calendar.dismissToastAria': 'Stäng meddelandet',
|
'calendar.dismissToastAria': 'Stäng meddelandet',
|
||||||
|
|||||||
@@ -770,6 +770,50 @@ describe('Calendar', () => {
|
|||||||
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('saving a period edit pins exact dates: windowMode flips to fixed with a matching target', async () => {
|
||||||
|
// §bug — a preset-born wish is `windowMode: 'flexible'` (a bounding
|
||||||
|
// range the planner searches inside, not a placement). Once the user
|
||||||
|
// has dragged/typed exact dates in the popover, those dates ARE the
|
||||||
|
// placement, so the save must flip the row to 'fixed' with a target
|
||||||
|
// matching the edited span. Leaving it 'flexible' turns the (now
|
||||||
|
// week-long) bound into a single flexible candidate, which repeats the
|
||||||
|
// same calendar date every year on a drifting weekday instead of
|
||||||
|
// preserving the weekday.
|
||||||
|
const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' })
|
||||||
|
const update = vi.fn().mockResolvedValue(wish)
|
||||||
|
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||||
|
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-05'), mockSetSearchParams])
|
||||||
|
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update }))
|
||||||
|
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||||
|
vi.mocked(usePlannedDays).mockReturnValue(
|
||||||
|
makePlannedDaysResult({
|
||||||
|
data: [
|
||||||
|
makePlannedLeaveDay({ id: 'pd-1', date: '2026-10-05', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
makePlannedLeaveDay({ id: 'pd-2', date: '2026-10-06', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
makePlannedLeaveDay({ id: 'pd-3', date: '2026-10-07', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
makePlannedLeaveDay({ id: 'pd-4', date: '2026-10-08', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
makePlannedLeaveDay({ id: 'pd-5', date: '2026-10-09', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
makePlannedLeaveDay({ id: 'pd-6', date: '2026-10-10', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
makePlannedLeaveDay({ id: 'pd-7', date: '2026-10-11', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<Calendar />)
|
||||||
|
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||||
|
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2026-10-05')
|
||||||
|
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-end', '2026-10-11')
|
||||||
|
fireEvent.click(screen.getByTestId('period-save'))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
'lw-1',
|
||||||
|
expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }),
|
||||||
|
'occurrence',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('saving one year of a repeating wish writes that year and keeps the scope narrow', async () => {
|
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
|
// 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
|
// what gets written — the old shape had to keep the wish's anchor year
|
||||||
@@ -1225,6 +1269,60 @@ describe('Calendar', () => {
|
|||||||
expect(toast).toHaveTextContent('saknas')
|
expect(toast).toHaveTextContent('saknas')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows a toast when a flexible window auto-shifted to catch extra red days', async () => {
|
||||||
|
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||||||
|
const add = vi.fn().mockResolvedValue(created)
|
||||||
|
const sync = vi.fn().mockResolvedValue(
|
||||||
|
makePlanSyncResponse({
|
||||||
|
changes: [makePlanChange({ newWishId: 'lw-preset' })],
|
||||||
|
proposal: makePlanProposal({
|
||||||
|
outcomes: [
|
||||||
|
makeWindowOutcome({
|
||||||
|
wishId: 'lw-preset',
|
||||||
|
shiftGainDays: 2,
|
||||||
|
data: { wish_name: 'Sommar', wish_name_message_key: null },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
|
||||||
|
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||||
|
|
||||||
|
render(<Calendar />)
|
||||||
|
fireEvent.click(screen.getByTestId('preset-click'))
|
||||||
|
|
||||||
|
const toast = await screen.findByTestId('calendar-toast')
|
||||||
|
expect(toast).toHaveTextContent('Sommar')
|
||||||
|
expect(toast).toHaveTextContent('flyttad')
|
||||||
|
expect(toast).toHaveTextContent('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => {
|
||||||
|
// A plain re-open (nothing actually touched this wish's dates this
|
||||||
|
// sync) must never re-announce a stable, already-applied shift --
|
||||||
|
// `changes` membership is what distinguishes the two, not
|
||||||
|
// `shiftGainDays > 0` alone.
|
||||||
|
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||||||
|
const add = vi.fn().mockResolvedValue(created)
|
||||||
|
const sync = vi.fn().mockResolvedValue(
|
||||||
|
makePlanSyncResponse({
|
||||||
|
changes: [],
|
||||||
|
proposal: makePlanProposal({
|
||||||
|
outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||||
|
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||||
|
|
||||||
|
render(<Calendar />)
|
||||||
|
fireEvent.click(screen.getByTestId('preset-click'))
|
||||||
|
|
||||||
|
await waitFor(() => expect(add).toHaveBeenCalled())
|
||||||
|
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
|
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
|
||||||
const created = makeLeaveWish({ id: 'lw-preset' })
|
const created = makeLeaveWish({ id: 'lw-preset' })
|
||||||
const add = vi.fn().mockResolvedValue(created)
|
const add = vi.fn().mockResolvedValue(created)
|
||||||
|
|||||||
@@ -216,9 +216,10 @@ function summarizeOutcomeShortfall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the full sync toast message: what changed (existing summary), why
|
/** 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
|
* the wish just touched (if any) fell short of what was wished, which
|
||||||
* rejections the apply step returned — a genuine no-op sync (nothing to
|
* flexible windows auto-shifted to catch extra red days, and any rejections
|
||||||
* report on any of the three) still renders `null`, so it stays silent.
|
* 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
|
* `watchWish`, when given, is used in place of looking `watchWishId` up in
|
||||||
* `wishes` — needed right after creating a wish, where `wishes` is whatever
|
* `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) {
|
for (const rejection of result.applyResult.rejections) {
|
||||||
parts.push(renderRejection(rejection, t, members))
|
parts.push(renderRejection(rejection, t, members))
|
||||||
}
|
}
|
||||||
@@ -722,12 +743,22 @@ export function Calendar(): ReactNode {
|
|||||||
|
|
||||||
const start = splitISODate(payload.startDate)
|
const start = splitISODate(payload.startDate)
|
||||||
const end = splitISODate(payload.endDate)
|
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 —
|
// 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
|
// 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
|
// rewrite the shared month/day pattern for every year, because writing
|
||||||
// the clicked year into `firstYear` would have dropped the earlier
|
// the clicked year into `firstYear` would have dropped the earlier
|
||||||
// occurrences; there is nothing to preserve now.
|
// 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 = {
|
const patch: LeaveWishUpdateRequest = {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
nameMessageKey: null,
|
nameMessageKey: null,
|
||||||
@@ -740,6 +771,9 @@ export function Calendar(): ReactNode {
|
|||||||
parentMode: payload.parentMode,
|
parentMode: payload.parentMode,
|
||||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||||
wantsDubbeldag: payload.wantsDubbeldag,
|
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
|
// `lastYear` belongs to the whole series, so only send it where the
|
||||||
// backend will take it: when this occurrence is becoming a series, or
|
// backend will take it: when this occurrence is becoming a series, or
|
||||||
|
|||||||
@@ -1557,6 +1557,7 @@ describe('endpoint functions (representative coverage)', () => {
|
|||||||
},
|
},
|
||||||
cost_breakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
|
cost_breakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
|
||||||
remedies: [],
|
remedies: [],
|
||||||
|
shift_gain_days: 3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
warnings: [
|
warnings: [
|
||||||
@@ -1637,6 +1638,7 @@ describe('endpoint functions (representative coverage)', () => {
|
|||||||
expect(result.surplusAdvice[0]!.daysInvolved).toBe(10)
|
expect(result.surplusAdvice[0]!.daysInvolved).toBe(10)
|
||||||
expect(result.age4Projections[0]!.expiringAtB4).toBe(10)
|
expect(result.age4Projections[0]!.expiringAtB4).toBe(10)
|
||||||
expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01')
|
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 () => {
|
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`.
|
// must default each one rather than surfacing `undefined`.
|
||||||
cost_breakdown: {},
|
cost_breakdown: {},
|
||||||
remedies: [],
|
remedies: [],
|
||||||
|
shift_gain_days: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
@@ -1680,6 +1683,7 @@ describe('endpoint functions (representative coverage)', () => {
|
|||||||
sjukpenning: 0,
|
sjukpenning: 0,
|
||||||
lagsta: 0,
|
lagsta: 0,
|
||||||
})
|
})
|
||||||
|
expect(result.outcomes[0]!.shiftGainDays).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('syncPlan posts the workspace id and maps the nested proposal/applyResult/changes', async () => {
|
it('syncPlan posts the workspace id and maps the nested proposal/applyResult/changes', async () => {
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ interface RawWindowOutcome {
|
|||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
cost_breakdown: Record<string, number>
|
cost_breakdown: Record<string, number>
|
||||||
remedies: RawShortfallRemedy[]
|
remedies: RawShortfallRemedy[]
|
||||||
|
shift_gain_days: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawPlanWarning {
|
interface RawPlanWarning {
|
||||||
@@ -498,6 +499,7 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
|
|||||||
data: r.data,
|
data: r.data,
|
||||||
daysFreed: r.days_freed,
|
daysFreed: r.days_freed,
|
||||||
})),
|
})),
|
||||||
|
shiftGainDays: o.shift_gain_days,
|
||||||
})),
|
})),
|
||||||
warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })),
|
warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })),
|
||||||
poolBalances: raw.pool_balances.map((p) => ({
|
poolBalances: raw.pool_balances.map((p) => ({
|
||||||
|
|||||||
@@ -400,6 +400,11 @@ export interface WindowOutcome {
|
|||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
costBreakdown: CostBreakdown
|
costBreakdown: CostBreakdown
|
||||||
remedies: ShortfallRemedy[]
|
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 {
|
export interface PlanWarning {
|
||||||
|
|||||||
Vendored
+1
@@ -79,6 +79,7 @@ export function makeWindowOutcome(overrides: Partial<WindowOutcome> = {}): Windo
|
|||||||
data: {},
|
data: {},
|
||||||
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),
|
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),
|
||||||
remedies: [],
|
remedies: [],
|
||||||
|
shiftGainDays: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user