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:
@@ -968,6 +968,29 @@ class _Ledger:
|
||||
else:
|
||||
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:
|
||||
"""§9.1: from the 4th birthday onward, the FP pool is hard-capped at
|
||||
min(remaining, `self._input.age4_savable_cap`). Applied once per
|
||||
@@ -1219,13 +1242,22 @@ class _Ledger:
|
||||
both parents currently hold FP (peeked, then committed
|
||||
together — never a lone half-spend).
|
||||
2. Co-leave (PL + vacation) — the default for every other date:
|
||||
the parent with the MOST remaining FP (`sjukpenning_left +
|
||||
lagsta_left`, at the live, mutating balance — ties go to
|
||||
`responsible.parents[0]`, deterministic since that list order
|
||||
is itself deterministic) takes an FP day (sjukpenning
|
||||
preferred); the other parent takes a vacation day, same date.
|
||||
Peeked-then-committed together too: if either side can't pay,
|
||||
NEITHER is spent for that date.
|
||||
which parent PAYS with FP is decided per CHUNK, not per day —
|
||||
§fix (long-chunk co-leave, 2026-09-19). The window's dates are
|
||||
split into two contiguous halves with `_split_dates_by_
|
||||
spendable_cost` (the same balanced-by-cost split `_fund_swap`
|
||||
uses); `responsible.parents[0]` is the designated FP-payer for
|
||||
the first half, `responsible.parents[1]` for the second — so a
|
||||
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
|
||||
(the higher-FP parent has none left, or the other parent has
|
||||
no vacation left) is handed to `_fund_swap` together with the
|
||||
@@ -1271,6 +1303,16 @@ class _Ledger:
|
||||
)
|
||||
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] = []
|
||||
cost = _new_cost()
|
||||
dubbeldag_dates: set[date] = set()
|
||||
@@ -1354,23 +1396,34 @@ class _Ledger:
|
||||
funded = True
|
||||
|
||||
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
|
||||
total_b = pool_b.sjukpenning_left + pool_b.lagsta_left
|
||||
# Ties go to parent_a (`responsible.parents[0]`) — that list
|
||||
# is itself deterministically ordered, so this stays stable
|
||||
# across repeated `propose()` runs (§5 test #11).
|
||||
if total_a >= total_b:
|
||||
fp_parent, fp_pool, other_parent = parent_a, pool_a, parent_b
|
||||
# §fix (long-chunk co-leave, 2026-09-19) — the FP-payer is
|
||||
# decided once per half of the window (`first_half_dates`),
|
||||
# not by comparing live remaining balances on every date —
|
||||
# that per-day comparison is exactly what made the payer
|
||||
# flip almost daily as spending shifted the lead back and
|
||||
# forth. `responsible.parents[0]` pays with FP across the
|
||||
# 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:
|
||||
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)
|
||||
other_budget = self._vacation.get((other_parent.uuid, d.year))
|
||||
other_can_pay_vacation = other_budget is not None and other_budget.available > 0
|
||||
funded_pair = self._try_co_leave_pair(primary, primary_pool, secondary, d)
|
||||
if funded_pair is None:
|
||||
# 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:
|
||||
self._commit_fp_tier(fp_pool, fp_tier)
|
||||
self._spend_vacation(other_parent.uuid, d)
|
||||
if funded_pair is not None:
|
||||
fp_parent, fp_tier, vacation_parent = funded_pair
|
||||
cost[fp_tier] += 1
|
||||
cost["vacation"] += 1
|
||||
fp_reason = (
|
||||
@@ -1382,7 +1435,7 @@ class _Ledger:
|
||||
days.append(
|
||||
ProposedDay(
|
||||
d,
|
||||
other_parent.uuid,
|
||||
vacation_parent.uuid,
|
||||
child.id,
|
||||
"vacation",
|
||||
False,
|
||||
|
||||
@@ -2765,3 +2765,80 @@ class TestCase32TakePlOnRedDaysBothParents:
|
||||
assert len(entries) == 2
|
||||
assert all(e.day_type == "sjukpenning" 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
|
||||
|
||||
@@ -850,6 +850,43 @@ describe('Calendar', () => {
|
||||
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 () => {
|
||||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
|
||||
@@ -719,7 +719,7 @@ export function Calendar(): ReactNode {
|
||||
recurrence: 'yearly',
|
||||
firstYear: new Date().getFullYear(),
|
||||
priority: wishesHook.data.length + 1,
|
||||
parentMode: 'either',
|
||||
parentMode: membersHook.data.length >= 2 ? 'both' : 'either',
|
||||
windowMode: 'flexible',
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user