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', })