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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
2026-09-20 12:04:20 +02:00
parent fdc6439614
commit ad0e3c0b33
19 changed files with 1217 additions and 72 deletions
@@ -140,7 +140,19 @@ class TestExcludedDayIsNeverPlaced:
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
assert outcome.remedies == []
# None of the three genuinely shortfall-gated remedies fire for a
# zero-shortfall window -- the exclusion did not create a gap to
# remedy. This used to be `remedies == []` outright, but the
# `shift_window_into_free_days` suggestion (§todo item, red-day
# shift toast) is deliberately NOT shortfall-gated for a `fixed`
# window any more: a fully-funded fixed window can still surface
# "moving this frees N days", and this Feb 1-7 window genuinely has
# a cheaper nearby placement. See `_Ledger._build_remedies`.
assert {r.kind for r in outcome.remedies} <= {"shift_window_into_free_days"}
assert not any(
r.kind in {"reduce_preschool_reserve", "carry_vacation_forward", "lower_priority_wish"}
for r in outcome.remedies
)
# `weeks_funded` still tells the truth about what was actually
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
assert outcome.weeks_funded == round(6 / 7, 2)
@@ -220,6 +220,7 @@ def _planned(
on: date,
day_type: str = "vacation",
wish_id: UUID | None = None,
is_pinned: bool = False,
) -> PlannedLeaveDay:
"""A persisted `planned_leave_day` row — the "already applied, before
@@ -227,10 +228,12 @@ def _planned(
flexible re-propose; extended for §fix parent-reassignment, decision B)
test cases below. Only the fields `AllocationInput.existing_planned`/
`all_existing_planned` actually read (`parent_personal_data_uuid`,
`child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what
`_Ledger._persisted_by_wish` now matches a flexible wish's own prior
placement by — pass the wish's `.id` whenever a test relies on that
reuse mechanism finding these rows.
`child_id`, `date`, `day_type`, `wish_id`, `is_pinned`) matter. `wish_id`
is what `_Ledger._persisted_by_wish` now matches a flexible wish's own
prior placement by — pass the wish's `.id` whenever a test relies on
that reuse mechanism finding these rows. `is_pinned=True` marks a date
the user locked in place — the Step 3 realignment escape hatch must
never move a window that contains one.
"""
return PlannedLeaveDay(
id=uuid4(),
@@ -240,6 +243,7 @@ def _planned(
date=on,
day_type=day_type,
wish_id=wish_id,
is_pinned=is_pinned,
)
@@ -1288,9 +1292,12 @@ class TestCase16FlexibleTieBreakByEarliestStart:
def test_equal_cost_candidates_break_tie_by_earliest_start(self) -> None:
# A holiday-free bound (Feb 2027): every 7-day slice contains
# exactly one Saturday and one Sunday, so every candidate has the
# *same* calendar-only cost (5) regardless of start date -- the
# only thing that can break the tie is `candidate.start`, never
# dict/set iteration order.
# *same* calendar-only cost (5) regardless of start date. Since
# §Decision 2, the tie is actually broken by Monday-alignment first,
# THEN earliest start -- 2027-02-01 happens to be a Monday, so this
# still pins down the same winner; `TestFlexibleWishPrefersMonday`
# below exercises the Monday-preference itself against a bound whose
# earliest start is NOT a Monday.
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
@@ -1956,16 +1963,163 @@ class TestCase28FlexibleWishStillMovesWhenItsYearIsNotAlreadySatisfied:
assert flex_outcome.strategy != STRATEGY_UNPLACED
assert flex_outcome.shortfall_days == 0
# Since Step 3's realignment escape hatch, "moves" here means all the
# way to the globally cheapest remaining window, not just the
# nearest one that happens to still contain 2027-03-23: with 03-25
# occupied, the only windows reusable around the applied day cost 5
# (Wed 03-17..03-23 or Thu 03-18..03-24, no red days) -- both
# misaligned (not Monday-start), which is what lets the escape
# hatch compare them against a fresh search. That search finds
# 03-26..04-01 (Fri-Thu), which still catches the Easter cluster
# (Fri-Mon free) at cost 3 -- strictly cheaper, so it wins even
# though it abandons 03-23 entirely and is itself not Monday-start
# (§Decision 2: red days always outrank alignment).
assert (flex_outcome.start_date, flex_outcome.end_date) == (
date(2027, 3, 26),
date(2027, 4, 1),
)
assert flex_outcome.cost_breakdown["free"] == 4
assert flex_outcome.cost_breakdown["vacation"] == 3
flex_dates = {
flex_outcome.start_date + timedelta(days=i)
for i in range((flex_outcome.end_date - flex_outcome.start_date).days + 1)
}
# Still honours the previously-applied day (it's inside the new
# window's span) but never re-claims the fixed wish's date.
assert date(2027, 3, 23) in flex_dates
# The previously-applied day is abandoned along with the rest of the
# old window -- cheaper wins over "keep what's already there" -- and
# the fixed wish's date is (still) never re-claimed.
assert date(2027, 3, 23) not in flex_dates
assert date(2027, 3, 25) not in flex_dates
class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits:
"""§fix (Step 3) — the flexible-window realignment escape hatch.
A flexible wish-year already funded before §Decision 2 existed (or
before its bound moved) is otherwise frozen forever to whichever weekday
it first landed on, by `_reuse_already_funded_window`'s own reuse-the-
prior-window mechanism. It must self-heal onto a Monday the next time
`propose()` runs -- UNLESS one of its persisted dates carries a manual
edit (pinned, or explicitly removed), in which case the user's choice
always outlives realignment.
All three cases share the same bound: August 2027, holiday-free, so
every 7-day candidate costs the same 5 spendable days and the only thing
that can separate them is the Monday-alignment tie-break.
"""
def _alloc_input(
self,
wish: LeaveWish,
child: ChildFacts,
parent: ParentFacts,
existing: list[PlannedLeaveDay],
) -> AllocationInput:
return _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
existing_planned=existing,
)
def test_a_misaligned_year_releases_onto_a_monday(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Augusti",
start=AUG_2027[0],
end=AUG_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
# A prior apply funded the full Tuesday-start week 08-03..08-09.
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id
)
for d in range(3, 10)
]
alloc_input = self._alloc_input(wish, child, parent, existing)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
# Released the Tuesday window for the earliest Monday-start
# candidate that doesn't collide with the abandoned dates.
assert outcome.start_date == date(2027, 8, 16)
assert outcome.start_date.weekday() == 0
def test_a_pinned_day_keeps_the_window_from_moving(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Augusti",
start=AUG_2027[0],
end=AUG_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
existing = [
_planned(
parent_uuid=parent.uuid,
child_id=child.id,
on=date(2027, 8, d),
wish_id=wish.id,
is_pinned=(d == 3), # the user locked the first day in place
)
for d in range(3, 10)
]
alloc_input = self._alloc_input(wish, child, parent, existing)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
# Same misaligned Tuesday window as the unprotected case above --
# but this time it must NOT move, because one of its days is pinned.
assert outcome.start_date == date(2027, 8, 3)
assert outcome.start_date.weekday() == 1 # still Tuesday
def test_an_excluded_day_keeps_the_window_from_moving(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Augusti",
start=AUG_2027[0],
end=AUG_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
existing = [
_planned(
parent_uuid=parent.uuid, child_id=child.id, on=date(2027, 8, d), wish_id=wish.id
)
for d in range(3, 10)
]
alloc_input = replace(
self._alloc_input(wish, child, parent, existing),
# The user explicitly removed one of the window's days
# (§remove-this-day) -- still a manual edit, must not move.
excluded_days=frozenset({(parent.uuid, child.id, date(2027, 8, 4))}),
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2027, 8, 3)
assert outcome.start_date.weekday() == 1
# ---------------------------------------------------------------------------
# §Task 4 — pinned days: fixed, and debited off the top before any window
# is funded (mirrors `_reserve_preschool_days`'s precedent).
@@ -2587,18 +2741,24 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
assert start_2027.weekday() == start_2026.weekday()
assert end_2027.weekday() == end_2026.weekday()
def test_flexible_wish_bounding_range_is_unchanged_across_years(self) -> None:
def test_flexible_wish_bounding_range_stays_weekday_aligned_across_years(self) -> None:
"""A flexible wish's four fields describe a bounding range to search
inside, not a placement -- no weekday shift, ever, including for
Season presets which must stay byte-identical after this change.
Each year still gets its OWN row (carrying that year's range); the
per-year placement search inside it is the allocator's job.
inside, not a placement -- but the range must still get the same
weekday snap as a fixed placement, or a week-shaped bound (the
reported bug, extended: also breaks a Season preset whose bound
happens to be week-shaped) silently drifts onto a different weekday
every non-anchor year. The bound's day-*span* stays exactly fixed
(91 days here) so the allocator's per-year search space never
shrinks; only the start slides by the anchor's own `_shift_to_weekday`
rule. Each year still gets its OWN row (carrying that year's range);
the per-year placement search inside it is the allocator's job.
"""
rows = {
w.first_year: w
for w in _wish_series(
child_id=None,
start=(3, 1),
start=(3, 1), # 2026-03-01 is a Sunday
end=(5, 31),
first_year=2026,
last_year=2028,
@@ -2606,8 +2766,148 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
)
}
assert rows[2026].window() == (date(2026, 3, 1), date(2026, 5, 31))
assert rows[2027].window() == (date(2027, 3, 1), date(2027, 5, 31))
assert rows[2028].window() == (date(2028, 3, 1), date(2028, 5, 31))
assert rows[2027].window() == (date(2027, 2, 28), date(2027, 5, 30))
assert rows[2028].window() == (date(2028, 2, 27), date(2028, 5, 28))
for row in rows.values():
start, end = row.window()
assert start.weekday() == 6 # Sunday, like the anchor
assert (end - start).days == 91
class TestFlexibleWishPrefersMonday:
"""§Decision 2 (user-confirmed) — a flexible window's placement search
prefers a Monday start, but a non-Monday wins whenever it absorbs
strictly more red days; red days always outrank alignment. Covers the
three cases the plan called out: a wide, holiday-free bound (pure
ranking, `_rank_candidate`'s new `misaligned` term); a bound whose
length equals the target (the degenerate single-candidate case only
Step 1's bound snap can fix, since `_rank_candidate` never even runs a
second candidate to prefer); and a strictly-cheaper non-Monday still
winning over a pricier Monday alternative.
"""
def test_wide_holiday_free_bound_places_on_a_monday_every_year(self) -> None:
# August: no Swedish public holidays, so every 7-day candidate in the
# bound costs the same 5 spendable days -- the only thing that can
# separate them is the Monday-alignment tie-break.
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wishes = _wish_series(
child_id=child.id,
name="Augusti",
start=AUG_2027[0],
end=AUG_2027[1],
weeks=1,
first_year=2027,
last_year=2029,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2029, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
vacation={
(parent.uuid, 2027): VacationBudget(50, 0, 0),
(parent.uuid, 2028): VacationBudget(50, 0, 0),
(parent.uuid, 2029): VacationBudget(50, 0, 0),
},
wishes=wishes,
extra_holiday_years=(2027, 2028, 2029),
)
proposal = Allocator().propose(alloc_input)
by_year = {o.year: o for o in proposal.outcomes}
assert set(by_year) == {2027, 2028, 2029}
for o in by_year.values():
assert o.shortfall_days == 0
assert o.start_date.weekday() == 0 # Monday, every year
# The bound itself keeps sliding (§Decision 1, Step 1's bound snap)
# so each year's Monday is a genuinely different date, not one
# window re-proposed three times.
assert by_year[2027].start_date == date(2027, 8, 2)
assert by_year[2028].start_date == date(2028, 7, 31)
assert by_year[2029].start_date == date(2029, 7, 30)
def test_bound_length_equal_to_target_repeats_monday_to_sunday(self) -> None:
# The degenerate case: a 42-day bound for a 42-day (6-week) target
# gives `_candidate_windows` exactly ONE candidate per year, so no
# amount of ranking can align it -- only Step 1's bound snap (the
# bound itself keeping its weekday alignment year over year) can.
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wishes = _wish_series(
child_id=child.id,
name="Sommar",
start=(6, 28), # 2027-06-28 is a Monday
end=(8, 8),
weeks=6,
first_year=2027,
last_year=2029,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2029, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(400, 90, 60)},
vacation={
(parent.uuid, 2027): VacationBudget(50, 0, 0),
(parent.uuid, 2028): VacationBudget(50, 0, 0),
(parent.uuid, 2029): VacationBudget(50, 0, 0),
},
wishes=wishes,
extra_holiday_years=(2027, 2028, 2029),
)
proposal = Allocator().propose(alloc_input)
by_year = {o.year: o for o in proposal.outcomes}
assert set(by_year) == {2027, 2028, 2029}
assert by_year[2027].start_date == date(2027, 6, 28)
assert by_year[2028].start_date == date(2028, 6, 26)
assert by_year[2029].start_date == date(2029, 6, 25)
for o in by_year.values():
assert o.start_date.weekday() == 0
assert (o.end_date - o.start_date).days == 41
def test_strictly_cheaper_non_monday_still_wins(self) -> None:
# Same Easter-cluster bound as TestCase15: the Tuesday-start window
# touching Långfredagen..Annandag påsk costs 3 spendable days.
# Nearby Monday-start candidates (2027-03-22..03-28 and
# 2027-03-29..04-04) each cost 4 -- strictly more -- so red days
# must still win over alignment.
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2027, 3, 23)
assert outcome.start_date.weekday() == 1 # Tuesday, not Monday
assert outcome.cost_breakdown["free"] == 4
# ---------------------------------------------------------------------------
@@ -3108,3 +3408,159 @@ class TestCase35ShiftRemedyFindsMovingEaster:
# it here too since this remedy's `data` shape just grew).
assert shift_2027.data["wish_name_message_key"] == rows_by_year[2027].name_message_key
assert shift_2028.data["wish_name_message_key"] == rows_by_year[2028].name_message_key
# ---------------------------------------------------------------------------
# §todo item (red-day shift toast) — a `window_mode == 'fixed'` window's
# placement is unconditional (never auto-moved), so it needs the
# shift_window_into_free_days SUGGESTION surfaced even when fully funded
# (previously hidden behind `shortfall_days > 0`). A `window_mode ==
# 'flexible'` window's own bound search already IS the shift, so it must
# NEVER get that remedy kind, and instead reports how much the search
# improved on the naive "first slot in the bound" placement via
# `WindowOutcome.shift_gain_days`.
# ---------------------------------------------------------------------------
class TestCase36ShiftRemedyOnFullyFundedFixedWindow:
def test_fully_funded_fixed_window_still_gets_the_shift_suggestion(self) -> None:
child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030, past 2027
parent = _parent(salary_type="monthly")
# Same window TestCase35 uses, but funded generously -- unlike that
# test's deliberately starved pool, this window is FULLY funded, so
# the only thing that could still hide the shift suggestion is
# `_build_remedies`'s shortfall gate (now removed for fixed windows).
wish = _wish(
child_id=child.id,
name="Vårvecka",
start=(3, 22),
end=(3, 28),
weeks=1,
first_year=2027,
window_mode="fixed",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
shift = next(r for r in outcome.remedies if r.kind == "shift_window_into_free_days")
# Same best placement TestCase35 finds for this exact window: shift
# 1 day later to pick up Easter Monday (2027-03-29, a real holiday).
assert shift.data["new_start_date"] == "2027-03-23"
assert shift.data["new_end_date"] == "2027-03-29"
assert shift.days_freed == 1
class TestCase37ShiftRemedyNeverOnFlexibleWindow:
def test_flexible_window_never_gets_the_shift_remedy_even_with_a_shortfall(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
# Deliberately starved (same recipe as TestCase35) so the placed
# (searched, not the whole bound -- this is not the §10.4
# "unplaced" case TestCase20 covers) window carries a genuine
# shortfall -- the flexible contract says its own search already
# IS the shift, so the remedy must stay absent regardless.
pools={(parent.uuid, child.id): DayPool(1, 0, 60)},
vacation={(parent.uuid, 2027): VacationBudget(0, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.strategy != STRATEGY_UNPLACED
assert outcome.shortfall_days > 0
assert all(r.kind != "shift_window_into_free_days" for r in outcome.remedies)
class TestCase38FlexibleShiftGainDaysReportsSearchImprovement:
def test_winner_beating_naive_bound_start_reports_positive_gain(self) -> None:
# Same setup as TestCase15FlexibleWishFindsEasterCluster: the search
# picks 2027-03-23..03-29 (3 spendable days -- the Easter cluster)
# over the naive bound-start placement 2027-03-01..03-07 (5
# spendable days -- Mon-Fri all working, verified against
# `get_swedish_holidays(2027)`) -- a 2-day gain.
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Vårlov",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2027, 3, 23)
assert outcome.end_date == date(2027, 3, 29)
assert outcome.shift_gain_days == 2
def test_winner_landing_on_naive_bound_start_reports_zero(self) -> None:
# Same setup as TestCase16FlexibleTieBreakByEarliestStart: every
# candidate in the holiday-free February bound costs the same, and
# 2027-02-01 (the bound's own start) is also the Monday-aligned
# tie-break winner -- the winner IS the naive placement, so there is
# nothing to report.
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id,
name="Sportlov",
start=FEB_2027[0],
end=FEB_2027[1],
weeks=1,
first_year=2027,
window_mode="flexible",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.start_date == date(2027, 2, 1)
assert outcome.shift_gain_days == 0