Take the time off together, in long stretches

A season preset in a two-parent household now means both parents, not
either one — being off together is the point of the period.

Within such a period the planner used to pick whoever had the most
parental-benefit days left, date by date, so spending kept moving the
lead and the funding flipped almost daily. Six weeks came out as a
patchwork that costs nothing to hold but a great deal to request. The
payer is now chosen once per half of the window, reusing the same split
the swap strategy already uses: one parent on parental benefit while the
other takes vacation, then they change over. Both are still off every
day, and if one runs out mid-stretch the roles swap for the rest rather
than day by day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 14:38:59 +02:00
parent 663d5fcd92
commit f76de7ba04
4 changed files with 190 additions and 23 deletions
+75 -22
View File
@@ -968,6 +968,29 @@ class _Ledger:
else: else:
pool.lagsta_left -= 1 pool.lagsta_left -= 1
def _try_co_leave_pair(
self,
fp_parent: ParentFacts,
fp_pool: _MutablePool,
vacation_parent: ParentFacts,
d: date,
) -> tuple[ParentFacts, str, ParentFacts] | None:
"""§fix (long-chunk co-leave, 2026-09-19) — peek-then-commit a single
co-leave pairing (one parent on FP, the other on vacation) for `d`.
Returns `None` (spending NEITHER side) unless both can actually pay
— used by `_fund_both` to try its chunk-designated pairing first
and the swapped pairing as a same-date fallback.
"""
fp_tier = self._peek_fp_tier(fp_pool)
vacation_budget = self._vacation.get((vacation_parent.uuid, d.year))
vacation_can_pay = vacation_budget is not None and vacation_budget.available > 0
if fp_tier is None or not vacation_can_pay:
return None
self._commit_fp_tier(fp_pool, fp_tier)
self._spend_vacation(vacation_parent.uuid, d)
return fp_parent, fp_tier, vacation_parent
def _maybe_clamp_age4(self, pool_key: tuple[UUID, UUID] | None, window_start: date) -> None: def _maybe_clamp_age4(self, pool_key: tuple[UUID, UUID] | None, window_start: date) -> None:
"""§9.1: from the 4th birthday onward, the FP pool is hard-capped at """§9.1: from the 4th birthday onward, the FP pool is hard-capped at
min(remaining, `self._input.age4_savable_cap`). Applied once per min(remaining, `self._input.age4_savable_cap`). Applied once per
@@ -1219,13 +1242,22 @@ class _Ledger:
both parents currently hold FP (peeked, then committed both parents currently hold FP (peeked, then committed
together — never a lone half-spend). together — never a lone half-spend).
2. Co-leave (PL + vacation) — the default for every other date: 2. Co-leave (PL + vacation) — the default for every other date:
the parent with the MOST remaining FP (`sjukpenning_left + which parent PAYS with FP is decided per CHUNK, not per day —
lagsta_left`, at the live, mutating balance — ties go to §fix (long-chunk co-leave, 2026-09-19). The window's dates are
`responsible.parents[0]`, deterministic since that list order split into two contiguous halves with `_split_dates_by_
is itself deterministic) takes an FP day (sjukpenning spendable_cost` (the same balanced-by-cost split `_fund_swap`
preferred); the other parent takes a vacation day, same date. uses); `responsible.parents[0]` is the designated FP-payer for
Peeked-then-committed together too: if either side can't pay, the first half, `responsible.parents[1]` for the second — so a
NEITHER is spent for that date. long window reads as "parent A on PL while parent B is on
vacation for ~half the window, then they switch", not a
daily-alternating patchwork. Both parents are still off on
EVERY date regardless of chunk. If the chunk's designated
FP-payer has no FP left for a date (or their partner has no
vacation left), the roles are tried swapped for just that date
— since the exhausted resource stays exhausted, this sticks
for the rest of the chunk rather than flip-flopping day to
day. Each attempted pairing is peeked-then-committed together:
if either side can't pay, NEITHER is spent for that date.
3. Swap (last resort) — a date co-leave genuinely cannot fund 3. Swap (last resort) — a date co-leave genuinely cannot fund
(the higher-FP parent has none left, or the other parent has (the higher-FP parent has none left, or the other parent has
no vacation left) is handed to `_fund_swap` together with the no vacation left) is handed to `_fund_swap` together with the
@@ -1271,6 +1303,16 @@ class _Ledger:
) )
want_dubbeldag = wish.wants_dubbeldag want_dubbeldag = wish.wants_dubbeldag
# §fix (long-chunk co-leave, 2026-09-19) — decide the FP-payer per
# CHUNK, not per day, so a long window reads as one switch instead
# of a daily-alternating patchwork (see the tier-2 docstring above).
# Reuses `_fund_swap`'s own balanced-by-spendable-cost split rather
# than a second splitter.
first_half, _second_half = _split_dates_by_spendable_cost(
dates, self._input.holidays, self._input.day_overrides
)
first_half_dates: frozenset[date] = frozenset(first_half)
days: list[ProposedDay] = [] days: list[ProposedDay] = []
cost = _new_cost() cost = _new_cost()
dubbeldag_dates: set[date] = set() dubbeldag_dates: set[date] = set()
@@ -1354,23 +1396,34 @@ class _Ledger:
funded = True funded = True
if not funded and pool_a is not None and pool_b is not None: if not funded and pool_a is not None and pool_b is not None:
total_a = pool_a.sjukpenning_left + pool_a.lagsta_left # §fix (long-chunk co-leave, 2026-09-19) — the FP-payer is
total_b = pool_b.sjukpenning_left + pool_b.lagsta_left # decided once per half of the window (`first_half_dates`),
# Ties go to parent_a (`responsible.parents[0]`) — that list # not by comparing live remaining balances on every date —
# is itself deterministically ordered, so this stays stable # that per-day comparison is exactly what made the payer
# across repeated `propose()` runs (§5 test #11). # flip almost daily as spending shifted the lead back and
if total_a >= total_b: # forth. `responsible.parents[0]` pays with FP across the
fp_parent, fp_pool, other_parent = parent_a, pool_a, parent_b # first half, `responsible.parents[1]` across the second.
if d in first_half_dates:
primary, primary_pool = parent_a, pool_a
secondary, secondary_pool = parent_b, pool_b
else: else:
fp_parent, fp_pool, other_parent = parent_b, pool_b, parent_a primary, primary_pool = parent_b, pool_b
secondary, secondary_pool = parent_a, pool_a
fp_tier = self._peek_fp_tier(fp_pool) funded_pair = self._try_co_leave_pair(primary, primary_pool, secondary, d)
other_budget = self._vacation.get((other_parent.uuid, d.year)) if funded_pair is None:
other_can_pay_vacation = other_budget is not None and other_budget.available > 0 # Graceful fallback: the chunk's designated FP-payer
# can't afford THIS date (or their partner can't pay
# vacation) — try the pairing swapped for just this
# date rather than losing "both off" for it entirely.
# Because the exhausted resource (FP or vacation) stays
# exhausted, once this triggers it keeps triggering for
# the rest of the chunk instead of flip-flopping day to
# day.
funded_pair = self._try_co_leave_pair(secondary, secondary_pool, primary, d)
if fp_tier is not None and other_can_pay_vacation: if funded_pair is not None:
self._commit_fp_tier(fp_pool, fp_tier) fp_parent, fp_tier, vacation_parent = funded_pair
self._spend_vacation(other_parent.uuid, d)
cost[fp_tier] += 1 cost[fp_tier] += 1
cost["vacation"] += 1 cost["vacation"] += 1
fp_reason = ( fp_reason = (
@@ -1382,7 +1435,7 @@ class _Ledger:
days.append( days.append(
ProposedDay( ProposedDay(
d, d,
other_parent.uuid, vacation_parent.uuid,
child.id, child.id,
"vacation", "vacation",
False, False,
@@ -2765,3 +2765,80 @@ class TestCase32TakePlOnRedDaysBothParents:
assert len(entries) == 2 assert len(entries) == 2
assert all(e.day_type == "sjukpenning" for e in entries) assert all(e.day_type == "sjukpenning" for e in entries)
assert all(e.reason == REASON_FP_RED_DAY for e in entries) assert all(e.reason == REASON_FP_RED_DAY for e in entries)
# ---------------------------------------------------------------------------
# §fix (long-chunk co-leave, 2026-09-19) — the FP-payer for a `parent_mode=
# 'both'` window is decided per CHUNK (first half / second half, split by
# `_split_dates_by_spendable_cost`), not per day. The old per-day "whichever
# parent has more remaining FP" comparison flipped the payer almost every
# day once spending started shifting the lead back and forth -- a real
# household reported exactly this as an alternating PL/vacation pattern
# that produced no benefit, just extra admin requesting leave.
# ---------------------------------------------------------------------------
class TestCase33LongChunkCoLeave:
def test_six_week_window_switches_fp_payer_at_most_once(self) -> None:
# Feb 1 -> Mar 14 2027: 42 calendar days, clean of red days (Easter
# 2027 starts Fri 3/26, per TestCase9's own verified figures) so
# every non-weekend date reaches the co-leave tier.
child = _child(birth_date=date(2024, 1, 1)) # 15mo cutoff well past this window
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
start=(2, 1),
end=(3, 14),
weeks=6,
parent_mode="both",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(100, 90, 60),
(parent_b.uuid, child.id): DayPool(100, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(30, 0, 0),
(parent_b.uuid, 2027): VacationBudget(30, 0, 0),
},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.strategy == "co_leave"
assert outcome.shortfall_days == 0
non_free = [d for d in proposal.days if d.day_type != "free"]
by_date: dict[date, list] = {}
for d in non_free:
by_date.setdefault(d.date, []).append(d)
assert by_date, "expected at least one funded date"
# Both parents present (co-leaving) on every spendable date.
for on_date, entries in sorted(by_date.items()):
parents_that_day = {e.parent_personal_data_uuid for e in entries}
assert parents_that_day == {parent_a.uuid, parent_b.uuid}, (on_date, parents_that_day)
# The FP-payer, walked in date order, switches at most once --
# never the old daily alternation.
fp_payer_sequence = [
next(e.parent_personal_data_uuid for e in entries if e.day_type != "vacation")
for _on_date, entries in sorted(by_date.items())
]
switches = sum(
1 for prev, cur in zip(fp_payer_sequence, fp_payer_sequence[1:]) if prev != cur
)
assert switches <= 1, f"expected at most one payer switch, saw {switches}"
# Roughly equal halves -- neither parent gets only a token day.
count_a = fp_payer_sequence.count(parent_a.uuid)
count_b = fp_payer_sequence.count(parent_b.uuid)
assert count_a > 0 and count_b > 0
assert abs(count_a - count_b) <= 2
+37
View File
@@ -850,6 +850,43 @@ describe('Calendar', () => {
await waitFor(() => expect(sync).toHaveBeenCalled()) await waitFor(() => expect(sync).toHaveBeenCalled())
}) })
it('defaults a solo household preset to "either" parent', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }),
)
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).toHaveBeenCalledWith(expect.objectContaining({ parentMode: 'either' })))
})
it('defaults a two-parent household preset to "both" parents', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
vi.mocked(useWorkspaceMembers).mockReturnValue(
makeWorkspaceMembersResult({
data: [
makePersonalData({ uuid: 'pd-a', name: 'Förälder A' }),
makePersonalData({ uuid: 'pd-b', name: 'Förälder B' }),
],
}),
)
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).toHaveBeenCalledWith(expect.objectContaining({ parentMode: 'both' })))
})
it('navigates the month view to the resolved placement and flashes the band', async () => { it('navigates the month view to the resolved placement and flashes the band', 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)
+1 -1
View File
@@ -719,7 +719,7 @@ export function Calendar(): ReactNode {
recurrence: 'yearly', recurrence: 'yearly',
firstYear: new Date().getFullYear(), firstYear: new Date().getFullYear(),
priority: wishesHook.data.length + 1, priority: wishesHook.data.length + 1,
parentMode: 'either', parentMode: membersHook.data.length >= 2 ? 'both' : 'either',
windowMode: 'flexible', windowMode: 'flexible',
}) })