diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py
index 6dd14b7..2bef20d 100644
--- a/backend/app/services/allocation_service.py
+++ b/backend/app/services/allocation_service.py
@@ -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,
diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py
index f6da46a..712010f 100644
--- a/backend/tests/unit/test_services/test_allocation_service.py
+++ b/backend/tests/unit/test_services/test_allocation_service.py
@@ -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
diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx
index 025343d..f64f8e5 100644
--- a/frontend/src/pages/Calendar.test.tsx
+++ b/frontend/src/pages/Calendar.test.tsx
@@ -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()
+ 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()
+ 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)
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx
index 38c0f15..43338b7 100644
--- a/frontend/src/pages/Calendar.tsx
+++ b/frontend/src/pages/Calendar.tsx
@@ -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',
})