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:
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user