From 906cf70a987306ccc121b8f5714677f454f1efad Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 22 Sep 2026 23:24:28 +0200 Subject: [PATCH] Keep "both parents" wishes both-off, and warn when they can't be A lower-priority 'both' wish (e.g. a yearly autumn week) quietly fell back to one parent per day once summer/Jul had used both parents' vacation, and still counted as funded, so nothing warned. - Allocator rescue pass: frees a vacation day by switching one parent's vacation to FP on another date (interior v+v, then Mon/Fri bridge days without creating an FP weekend wrap, then solo days) and spends it on the one-parent date as co-leave. Pinned days count as present. - WindowOutcome.unmet_both_dates + new both_parents_unmet plan warning. - Frontend maps the new fields; Dashboard warning card, year-row flag and a Calendar save-toast line list the affected years (sv + en). Co-Authored-By: Claude Opus 5.5 --- backend/app/models/plan.py | 5 + backend/app/services/allocation_service.py | 561 +++++++++++++++++- ..._allocation_service_both_parents_rescue.py | 461 ++++++++++++++ .../dashboard/PlanWarningCard.test.tsx | 51 ++ .../components/dashboard/PlanWarningCard.tsx | 26 +- .../components/dashboard/YearRows.test.tsx | 24 + .../src/components/dashboard/YearRows.tsx | 8 + frontend/src/i18n/en/calendar.ts | 5 + frontend/src/i18n/en/dashboard.ts | 4 + frontend/src/i18n/en/plan.ts | 7 + frontend/src/i18n/sv/calendar.ts | 5 + frontend/src/i18n/sv/dashboard.ts | 4 + frontend/src/i18n/sv/plan.ts | 7 + frontend/src/pages/Calendar.test.tsx | 41 ++ frontend/src/pages/Calendar.tsx | 20 + frontend/src/services/api.test.ts | 15 +- frontend/src/services/api.ts | 6 + frontend/src/styles/components.css | 7 +- frontend/src/types/entities.ts | 19 +- frontend/tests/fixtures/plan.ts | 3 + 20 files changed, 1265 insertions(+), 14 deletions(-) create mode 100644 backend/tests/unit/test_services/test_allocation_service_both_parents_rescue.py diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index d31c64d..0bcee0a 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -116,6 +116,10 @@ class WindowOutcomeResponse(BaseModel): # returned `wish.id`) find every OTHER outcome in this same batch that # belongs to the same repeating wish. series_id: UUID | None = None + # Mirrors `WindowOutcome.unmet_both_dates` — dates in this window still + # funded for only one parent after the backend's own repair pass + # (`_Ledger.rescue_degraded_both_windows`) already tried to fix them. + unmet_both_dates: list[date] = Field(default_factory=list) class PlanWarningResponse(BaseModel): @@ -217,6 +221,7 @@ class PlanProposalResponse(BaseModel): ], shift_gain_days=o.shift_gain_days, series_id=o.series_id, + unmet_both_dates=o.unmet_both_dates, ) for o in proposal.outcomes ], diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index f053fc3..9ab7864 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -124,6 +124,12 @@ REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS = "shift_window_into_free_days" WARNING_DAYS_LOST_AT_AGE_4 = "days_lost_at_age_4" WARNING_SGI_RISK = "sgi_risk" WARNING_VACATION_EXPIRING = "vacation_expiring" +# §both-parents-rescue — a 'both'-mode window (dubbeldagar or co-leave +# requested) that still has one or more dates only ONE parent could be +# funded on after `_Ledger.rescue_degraded_both_windows` already tried to +# fix it by donating a vacation day from elsewhere in the same year. See +# `WindowOutcome.unmet_both_dates`. +WARNING_BOTH_PARENTS_UNMET = "both_parents_unmet" # --------------------------------------------------------------------------- # §10.4 — flexible ("när det passar bäst") wish outcome strategy. Added to @@ -521,11 +527,20 @@ class WindowOutcome: # shortfall on a sibling year (accepted, no rejection, empty band, no # warning — indistinguishable from "nothing changed"). series_id: UUID | None = None + # §both-parents-rescue — dates in THIS window that are still funded for + # only one parent after `_Ledger.rescue_degraded_both_windows` tried to + # fix them by donating a vacation day from elsewhere in the same year. + # Empty for every non-'both' window, and for a 'both' window that funded + # cleanly or was fully rescued. Not the same signal as `shortfall_days` + # (§doesnt-remap-so-both-parents-off) — a swap-funded date still counts + # as "funded" (one parent got a day), so `shortfall_days` stays 0 even + # when this list is non-empty. Drives `WARNING_BOTH_PARENTS_UNMET`. + unmet_both_dates: list[date] = field(default_factory=list) @dataclass(frozen=True) class PlanWarning: - kind: str # 'sgi_risk' | 'days_lost_at_age_4' | 'vacation_expiring' + kind: str # 'sgi_risk' | 'days_lost_at_age_4' | 'vacation_expiring' | 'both_parents_unmet' message_key: str # i18n key for frontend data: dict[str, object] @@ -727,6 +742,86 @@ def _vacation_bridge_reason( return REASON_VACATION_WRAP_RED_DAY if is_red else REASON_VACATION_WRAP_WEEKEND +def _bridge_far_side_dates( + d: date, holidays_map: dict[date, str], overrides: dict[date, bool] | None = None +) -> list[date]: + """§both-parents-rescue — the date(s) on the FAR side of the free-day + + run(s) `d` bridges (per `_vacation_bridge_reason`'s own two cases: an + ordinary Friday/Monday-style adjacent bridge, or the middle of a short + (<=3 day) gap flanked by free runs on both sides). Used to decide + whether converting `d`'s vacation to FP for one parent is safe: if that + parent ALSO has FP on one of these far-side dates, FP would sit on BOTH + edges of the same free run — the run is then "wrapped" by FP instead of + bridged by vacation (a different thing: vacation genuinely extends the + time off; FP on both edges just books two separate paid days around an + ordinary weekend/red-day run that would have been free either way). + `d` is assumed to already be a bridge date (`_vacation_bridge_reason(d, + ...) is not None`) — returns `[]` if it somehow isn't (nothing to + protect). + """ + prev_day = d - timedelta(days=1) + next_day = d + timedelta(days=1) + prev_free = is_free_day(prev_day, holidays_map, overrides) + next_free = is_free_day(next_day, holidays_map, overrides) + + far_sides: list[date] = [] + if next_free: + cursor = next_day + for _ in range(_BRIDGE_SCAN_BOUND_DAYS): + cursor += timedelta(days=1) + if not is_free_day(cursor, holidays_map, overrides): + far_sides.append(cursor) + break + if prev_free: + cursor = prev_day + for _ in range(_BRIDGE_SCAN_BOUND_DAYS): + cursor -= timedelta(days=1) + if not is_free_day(cursor, holidays_map, overrides): + far_sides.append(cursor) + break + if far_sides: + return far_sides + if prev_free or next_free: + return [] # touched a free run but it never ends within the scan bound + + # Rule 2 (see `_vacation_bridge_reason`) — `d` is strictly interior to a + # short non-free run flanked by free runs on both sides. The far side is + # the first non-free date beyond EACH fencing free run. + run_start = d + for _ in range(_BRIDGE_SCAN_BOUND_DAYS): + candidate = run_start - timedelta(days=1) + if is_free_day(candidate, holidays_map, overrides): + break + run_start = candidate + run_end = d + for _ in range(_BRIDGE_SCAN_BOUND_DAYS): + candidate = run_end + timedelta(days=1) + if is_free_day(candidate, holidays_map, overrides): + break + run_end = candidate + if (run_end - run_start).days + 1 > 3: + return [] # not actually a bridge date + + before_fence = run_start - timedelta(days=1) + if is_free_day(before_fence, holidays_map, overrides): + cursor = before_fence + for _ in range(_BRIDGE_SCAN_BOUND_DAYS): + cursor -= timedelta(days=1) + if not is_free_day(cursor, holidays_map, overrides): + far_sides.append(cursor) + break + after_fence = run_end + timedelta(days=1) + if is_free_day(after_fence, holidays_map, overrides): + cursor = after_fence + for _ in range(_BRIDGE_SCAN_BOUND_DAYS): + cursor += timedelta(days=1) + if not is_free_day(cursor, holidays_map, overrides): + far_sides.append(cursor) + break + return far_sides + + def _preschool_reserve_days( child: ChildFacts, today: date, @@ -2162,6 +2257,421 @@ class _Ledger: ) return days, outcome + # -- §both-parents-rescue: vacation-donor repair pass ------------------ + # + # Context (§doesnt-remap-so-both-parents-off): `_fund_both` falls back to + # `_fund_swap` for any date it can't co-leave/dubbeldag — typically + # because BOTH parents' vacation budget for that year is already + # exhausted by a higher-priority 'both' window (e.g. summer + Jul). A + # swap date still counts as "funded" (`_shortfall_and_weeks_funded` + # counts by date, and a swap date has one non-free `ProposedDay`), so + # `shortfall_days` stays 0 and nothing warns — the only trace is + # `WindowOutcome.strategy_degraded`, which the frontend used to drop. + # + # This pass runs once, after BOTH funding passes (`Allocator.propose()`, + # between pass 2 and `reallocate_vacation_by_value`), and repairs what it + # can: for every degraded 'both' window's swap date, it looks for a + # DONOR date elsewhere in the same year where the missing parent already + # holds a vacation day, converts the missing parent's own share of that + # donor date from vacation to FP (freeing exactly one vacation day in + # their (parent, year) budget), and spends the freed day on the swap + # date. Never touches a date the user pinned (§Task 4) or explicitly + # excluded (§remove-this-day). + # + # §both-parents-rescue real-data follow-up: a genuine 'both'-mode + # window's co-leave tier NEVER produces a vacation+vacation date on an + # ordinary interior day — only ONE parent gets vacation there, the other + # FP. Only the bridge tier (`_try_bridge_vacation_pair` — Fri/Mon around + # a weekend, or a klämdag around a red-day run) can put both parents on + # vacation the same date. Excluding bridge donors outright (an earlier + # version of this pass did) therefore left the rescue a no-op on a real + # plan whose every "both" wish is genuinely 'both'-mode: EVERY + # vacation+vacation date it produces IS a bridge date. `_find_donor_date` + # allows a bridge donor, but only when converting it is provably safe + # (`_bridge_conversion_would_wrap`): never leave the SAME parent with FP + # on both edges of the same free run (that's a wrap — FP bracketing an + # ordinary weekend/red-day run — not a bridge, which is what vacation on + # ONE edge does). See `_find_donor_date`'s own docstring for the full + # three-tier preference order (interior v+v > safe bridge v+v > single- + # parent vacation, partner never FP). + # + # A rescue that runs out of FP room (or a `_spend_vacation` that somehow + # still fails right after freeing the unit) rolls back cleanly, leaving + # both the donor date and the swap date exactly as they were — never a + # half-spent budget. + + def rescue_degraded_both_windows( + self, all_days: list[ProposedDay], outcomes: list[WindowOutcome] + ) -> None: + wishes_by_id = {w.id: w for w in self._input.wishes} + + for idx, outcome in enumerate(outcomes): + if outcome.requested_strategy not in ("dubbeldag", STRATEGY_CO_LEAVE): + continue + if not outcome.strategy_degraded: + continue + wish = wishes_by_id.get(outcome.wish_id) + if wish is None: + continue + responsible = self._resolve_parents(wish) + if responsible.mode != "dubbeldag" or len(responsible.parents) < 2: + continue + child = self._resolve_wish_child(wish) + if child is None: + continue + parent_a, parent_b = responsible.parents[0], responsible.parents[1] + + swap_dates = self._find_swap_dates(all_days, wish.id, parent_a, parent_b, child) + + unmet: list[date] = [] + for d, missing_parent, partner in swap_dates: + rescued = self._rescue_one_swap_date( + wish, wishes_by_id, outcomes, all_days, d, missing_parent, partner, child + ) + if not rescued: + unmet.append(d) + + # Always recompute, even when `swap_dates` was empty — a window + # degraded for a reason unrelated to a genuine swap (e.g. a + # 'dubbeldag' request delivered as 'co_leave' everywhere) just + # reproduces the same result; a window whose ONLY "swap" dates + # were really a pinned/asserted parent (§false-positive-pin- + # warning fix, see `_find_swap_dates`) gets its `strategy`/ + # `strategy_degraded` corrected here too, not just its + # (already-empty) `unmet_both_dates`. + outcomes[idx] = self._recompute_window_after_rescue( + outcome, all_days, unmet, parent_a, parent_b, child + ) + + def _parent_present_via_pin(self, parent_uuid: UUID, child_id: UUID, d: date) -> bool: + """Whether `parent_uuid` already has a genuinely PRE-EXISTING + + (pinned, §Task 4, or plain ad-hoc) `planned_leave_day` for + `(child_id, d)` — i.e. `key in self._pre_run_occupied`, the exact + condition `_fund_solo` itself checks before skipping a date without + emitting a fresh `ProposedDay` for it. Such a day never + appears in `all_days` (that skip is the whole point — the row is + already correct and `apply_proposed_days` leaves it standing), but + it is a real commitment, not a gap: `_find_swap_dates` and + `_recompute_window_after_rescue` both need this to avoid reporting a + parent as "missing" on a date they're actually off via day_assertion + rather than this run's own funding (§false-positive-pin-warning). + """ + return (parent_uuid, child_id, d) in self._pre_run_occupied + + def _find_swap_dates( + self, + all_days: list[ProposedDay], + wish_id: UUID, + parent_a: ParentFacts, + parent_b: ParentFacts, + child: ChildFacts, + ) -> list[tuple[date, ParentFacts, ParentFacts]]: + """Dates in `wish_id`'s own window where exactly one of the two + + responsible parents has a non-free `ProposedDay` and the other has + none at all for this wish — i.e. a date `_fund_swap` funded for only + one parent. Returns `(date, missing_parent, present_parent)` triples, + chronologically sorted. A missing parent explicitly excluded from + `d` (§remove-this-day), or already covered by a pinned/pre-existing + row (§false-positive-pin-warning — see `_parent_present_via_pin`), + is never reported — neither is a gap this pass could or should fix. + """ + by_date: dict[date, dict[UUID, ProposedDay]] = {} + for day in all_days: + if day.wish_id != wish_id: + continue + by_date.setdefault(day.date, {})[day.parent_personal_data_uuid] = day + + result: list[tuple[date, ParentFacts, ParentFacts]] = [] + for d, entries in sorted(by_date.items()): + present = {uid: day for uid, day in entries.items() if day.day_type != "free"} + if len(present) != 1: + continue + (present_uuid,) = present.keys() + if present_uuid == parent_a.uuid: + missing_parent, present_parent = parent_b, parent_a + elif present_uuid == parent_b.uuid: + missing_parent, present_parent = parent_a, parent_b + else: + continue + if missing_parent.uuid in entries: + continue # missing parent has SOME entry (e.g. 'free') — not a gap + if self._parent_present_via_pin(missing_parent.uuid, child.id, d): + continue # already covered by a pinned/pre-existing row + if self._is_excluded(missing_parent.uuid, child.id, d): + continue + result.append((d, missing_parent, present_parent)) + return result + + def _bridge_conversion_would_wrap( + self, parent_uuid: UUID, d: date, all_days: list[ProposedDay] + ) -> bool: + """Whether converting `parent_uuid`'s bridge-vacation day on `d` to + + FP would leave that same parent with FP on BOTH edges of the free + run `d` bridges — see `_bridge_far_side_dates`'s docstring. Re-scans + `all_days` fresh on every call (not cached), so a conversion made + earlier in the SAME rescue pass is already reflected — this is what + stops the rescue from converting both the Friday and the Monday of + the same weekend for one parent across two separate donor picks. + """ + far_sides = _bridge_far_side_dates(d, self._input.holidays, self._input.day_overrides) + if not far_sides: + return False + far_side_set = set(far_sides) + return any( + day.parent_personal_data_uuid == parent_uuid + and day.date in far_side_set + and day.day_type in ("sjukpenning", "lagsta") + for day in all_days + ) + + def _find_donor_date( + self, + exclude_wish_id: UUID, + all_days: list[ProposedDay], + year: int, + missing_parent: ParentFacts, + partner: ParentFacts, + wishes_by_id: dict[UUID, LeaveWish], + ) -> tuple[int, LeaveWish] | None: + """The best donor date for `missing_parent`: a date, in a DIFFERENT + + wish and the same calendar year, where `missing_parent` already + holds a vacation day — converting their own entry there to FP frees + exactly one vacation day in their (parent, year) budget. Returns the + winning entry's index into `all_days` plus the wish it belongs to, + or `None`. + + Three tiers, tried in order (§both-parents-rescue, real-data + follow-up: every "both" wish's own vacation+vacation dates are + typically bridge days — Fri/Mon around a weekend, or a klämdag + around a red-day cluster — so excluding bridge donors outright, as + an earlier version of this method did, left the rescue a no-op on + real plans): + + 1. Interior (non-bridge) vacation+vacation — `partner` is ALSO on + vacation the same date, and it's an ordinary interior day for + `missing_parent`. Safest and cheapest: no wrap risk, and the + date stays "both off" via vacation either way. + 2. Bridge vacation+vacation — same as above but `missing_parent`'s + own entry is a bridge day (Fri/Mon/klämdag). Only used when + `_bridge_conversion_would_wrap` says converting it is safe — + i.e. `missing_parent` doesn't already hold FP on the OTHER edge + of the same free run (which would otherwise leave that parent + with FP wrapping both sides of an ordinary weekend/red-day run + instead of vacation bridging it). + 3. Single-parent vacation — `partner` has NO entry at all for the + date (not off that day in any wish). Converting `missing_parent` + alone still leaves the date exactly as covered as it was + (nobody's presence there depended on it being vacation + specifically), and creates no same-day FP/FP pair. Still + subject to the same bridge-wrap check when it applies. A date + where `partner` already holds FP is never used at all (that + would leave both parents on FP the same day). + + Preference within EACH tier: lower-value donor wish first (higher + `priority` number), then latest date in the year. + """ + by_date: dict[date, dict[UUID, int]] = {} + for i, day in enumerate(all_days): + if day.wish_id == exclude_wish_id or day.date.year != year: + continue + if day.parent_personal_data_uuid not in (missing_parent.uuid, partner.uuid): + continue + by_date.setdefault(day.date, {})[day.parent_personal_data_uuid] = i + + interior_v_v: list[tuple[date, int, LeaveWish]] = [] + bridge_v_v: list[tuple[date, int, LeaveWish]] = [] + solo: list[tuple[date, int, LeaveWish]] = [] + + for dd, by_parent in by_date.items(): + missing_idx = by_parent.get(missing_parent.uuid) + if missing_idx is None: + continue + missing_entry = all_days[missing_idx] + if missing_entry.day_type != "vacation": + continue + if (missing_parent.uuid, missing_entry.child_id, dd) in self._input.pinned_days: + continue + donor_wish = wishes_by_id.get(missing_entry.wish_id) + if donor_wish is None: + continue + + is_bridge = missing_entry.reason in ( + REASON_VACATION_WRAP_RED_DAY, + REASON_VACATION_WRAP_WEEKEND, + ) + if is_bridge and self._bridge_conversion_would_wrap(missing_parent.uuid, dd, all_days): + continue + + partner_idx = by_parent.get(partner.uuid) + partner_entry = all_days[partner_idx] if partner_idx is not None else None + candidate = (dd, missing_idx, donor_wish) + if partner_entry is None: + solo.append(candidate) + elif partner_entry.day_type == "vacation": + (bridge_v_v if is_bridge else interior_v_v).append(candidate) + # else: `partner` holds something else here (FP, most likely) -- + # never a safe donor (a same-day FP/FP pair) -- skip entirely. + + for candidates in (interior_v_v, bridge_v_v, solo): + if not candidates: + continue + candidates.sort(key=lambda c: (-c[2].priority, -c[0].toordinal())) + _best_date, best_idx, best_wish = candidates[0] + return best_idx, best_wish + return None + + def _rescue_one_swap_date( + self, + wish: LeaveWish, + wishes_by_id: dict[UUID, LeaveWish], + outcomes: list[WindowOutcome], + all_days: list[ProposedDay], + d: date, + missing_parent: ParentFacts, + partner: ParentFacts, + child: ChildFacts, + ) -> bool: + """Try to fund `missing_parent` on `d` via a donated vacation day. + + Returns whether it worked; never leaves a half-spent budget behind + (see the section docstring above `rescue_degraded_both_windows`). + """ + if any( + day.parent_personal_data_uuid == missing_parent.uuid and day.date == d + for day in all_days + ): + # `missing_parent` already has an entry for `d` under some OTHER + # wish — never double-book them. + return False + if self._parent_present_via_pin(missing_parent.uuid, child.id, d): + # Defense in depth — `_find_swap_dates` already excludes a + # pinned/pre-existing date from ever reaching here, but a + # pinned row is never in `all_days` (the check above can't see + # it), so this call would otherwise double-book on top of it. + return False + + donor = self._find_donor_date( + wish.id, all_days, d.year, missing_parent, partner, wishes_by_id + ) + if donor is None: + return False + donor_idx, donor_wish = donor + donor_entry = all_days[donor_idx] + + donor_pool_key = (missing_parent.uuid, donor_entry.child_id) + donor_pool = self._pools.get(donor_pool_key) + if donor_pool is None: + return False + fp_tier = self._peek_fp_tier(donor_pool) + if fp_tier is None: + return False + + # -- commit: convert the donor's vacation day to FP ----------------- + self._commit_fp_tier(donor_pool, fp_tier) + self._vacation[(missing_parent.uuid, donor_entry.date.year)].already_used -= 1 + fp_reason = REASON_FP_INTERIOR if fp_tier == "sjukpenning" else REASON_FP_LAGSTA_STRETCH + all_days[donor_idx] = replace(donor_entry, day_type=fp_tier, reason=fp_reason) + donor_outcome_idx = next( + (i for i, o in enumerate(outcomes) if o.wish_id == donor_wish.id), None + ) + if donor_outcome_idx is not None: + donor_cost = outcomes[donor_outcome_idx].cost_breakdown + donor_cost["vacation"] = donor_cost.get("vacation", 0) - 1 + donor_cost[fp_tier] = donor_cost.get(fp_tier, 0) + 1 + + # -- spend the freed vacation day on `d` ----------------------------- + if not self._spend_vacation(missing_parent.uuid, d): + # Roll back the donor conversion entirely -- never a unit spent + # twice or dropped on the floor. + self._vacation[(missing_parent.uuid, donor_entry.date.year)].already_used += 1 + if fp_tier == "sjukpenning": + donor_pool.sjukpenning_left += 1 + else: + donor_pool.lagsta_left += 1 + all_days[donor_idx] = donor_entry + if donor_outcome_idx is not None: + donor_cost = outcomes[donor_outcome_idx].cost_breakdown + donor_cost["vacation"] = donor_cost.get("vacation", 0) + 1 + donor_cost[fp_tier] = donor_cost.get(fp_tier, 0) - 1 + return False + + new_day = ProposedDay( + d, missing_parent.uuid, child.id, "vacation", False, wish.id, REASON_VACATION_INTERIOR + ) + all_days.append(new_day) + window_outcome_idx = next((i for i, o in enumerate(outcomes) if o.wish_id == wish.id), None) + if window_outcome_idx is not None: + window_cost = outcomes[window_outcome_idx].cost_breakdown + window_cost["vacation"] = window_cost.get("vacation", 0) + 1 + return True + + def _recompute_window_after_rescue( + self, + outcome: WindowOutcome, + all_days: list[ProposedDay], + unmet: list[date], + parent_a: ParentFacts, + parent_b: ParentFacts, + child: ChildFacts, + ) -> WindowOutcome: + """Recompute `strategy`/`strategy_degraded` from this window's FINAL + + days after `rescue_degraded_both_windows` has done whatever it + could — mirrors the date-count/dominant-category logic `fund_window` + itself uses (see its 'dubbeldag' branch), so the two never disagree + about what the same (dubbeldag/co_leave/swap) date counts mean. + + §false-positive-pin-warning — a date with only ONE `ProposedDay` + here is "swap" ONLY when the other responsible parent is genuinely + missing. If they instead have a pinned/pre-existing row for this + exact date (`_parent_present_via_pin` — never in `all_days`, see its + docstring), both parents ARE off, just via two different + mechanisms; counted as `co_leave`, matching what `_find_swap_dates` + already excludes from `unmet_both_dates` for the same reason. + """ + by_date: dict[date, list[ProposedDay]] = {} + for day in all_days: + if day.wish_id != outcome.wish_id: + continue + by_date.setdefault(day.date, []).append(day) + + date_counts = {"dubbeldag": 0, "co_leave": 0, "swap": 0} + for d, entries in by_date.items(): + non_free = [e for e in entries if e.day_type != "free"] + if len(non_free) < 2: + if non_free: + present_uuids = {e.parent_personal_data_uuid for e in non_free} + other = parent_a if parent_a.uuid not in present_uuids else parent_b + if self._parent_present_via_pin(other.uuid, child.id, d): + date_counts["co_leave"] += 1 + else: + date_counts["swap"] += 1 + continue + if all(e.is_dubbeldag for e in non_free): + date_counts["dubbeldag"] += 1 + else: + date_counts["co_leave"] += 1 + + if any(date_counts.values()): + _tier_rank = {"dubbeldag": 2, "co_leave": 1, "swap": 0} + strategy = max(date_counts, key=lambda k: (date_counts[k], _tier_rank[k])) + else: + strategy = outcome.requested_strategy + + strategy_degraded = bool(date_counts["swap"]) or ( + outcome.requested_strategy == "dubbeldag" and date_counts["co_leave"] > 0 + ) + return replace( + outcome, + strategy=strategy, + strategy_degraded=strategy_degraded, + unmet_both_dates=sorted(unmet), + ) + # -- shortfall remedies (§3.6, §9.1) ------------------------------------ def _build_remedies( @@ -3072,12 +3582,50 @@ class _Ledger: # -- warnings ------------------------------------------------------------ def build_warnings( - self, all_days: list[ProposedDay], rescue_days: list[RescueDay] + self, + all_days: list[ProposedDay], + rescue_days: list[RescueDay], + outcomes: list[WindowOutcome], ) -> list[PlanWarning]: warnings: list[PlanWarning] = [] warnings.extend(self._age4_warnings(all_days)) warnings.extend(self._sgi_warnings(all_days)) warnings.extend(self._vacation_expiry_warnings(all_days, rescue_days)) + warnings.extend(self._both_parents_unmet_warnings(outcomes)) + return warnings + + def _both_parents_unmet_warnings(self, outcomes: list[WindowOutcome]) -> list[PlanWarning]: + """§both-parents-rescue — one warning per 'both'-mode window that + + still has a date only one parent could be funded on after + `rescue_degraded_both_windows` already tried to fix it. See + `WindowOutcome.unmet_both_dates`. + """ + warnings: list[PlanWarning] = [] + for outcome in outcomes: + if not outcome.unmet_both_dates: + continue + warnings.append( + PlanWarning( + kind=WARNING_BOTH_PARENTS_UNMET, + message_key=f"planWarnings.{WARNING_BOTH_PARENTS_UNMET}", + data={ + "wish_id": outcome.wish_id, + "series_id": outcome.series_id, + "wish_name": outcome.wish_name, + "wish_name_message_key": outcome.wish_name_message_key, + "year": outcome.year, + "count": len(outcome.unmet_both_dates), + "total_days": sum( + 1 + for d in _dates_in(outcome.start_date, outcome.end_date) + if not is_free_day(d, self._input.holidays, self._input.day_overrides) + ), + "first_date": outcome.unmet_both_dates[0].isoformat(), + "last_date": outcome.unmet_both_dates[-1].isoformat(), + }, + ) + ) return warnings def _age4_projections( @@ -3551,6 +4099,13 @@ class Allocator: day_cost_dict[day] = outcome.cost_breakdown ledger.record_days(days) + # §both-parents-rescue — repairs a 'both'-mode window that quietly + # degraded to a sequential swap by donating a vacation day from + # elsewhere in the same year (see the method's own docstring). Runs + # BEFORE `reallocate_vacation_by_value` so any vacation days it + # moves are still eligible for that pass's own best-date re-timing. + ledger.rescue_degraded_both_windows(all_days, outcomes) + # §vacation-never-withhold — every window is funded greedily above # (nothing was ever refused for the sake of a date that might come # later); this is the "re-order, never withhold" half of the user's @@ -3565,7 +4120,7 @@ class Allocator: # `build_warnings`, which folds the count into `WARNING_VACATION_ # EXPIRING.data["rescue_days_available"]`. rescue_days = ledger.build_rescue_days(all_days) - warnings = ledger.build_warnings(all_days, rescue_days) + warnings = ledger.build_warnings(all_days, rescue_days, outcomes) surplus_advice = ledger.build_surplus_advice(all_days) age4_projections = ledger.build_age4_projections(all_days) balances = ledger.snapshot_balances() diff --git a/backend/tests/unit/test_services/test_allocation_service_both_parents_rescue.py b/backend/tests/unit/test_services/test_allocation_service_both_parents_rescue.py new file mode 100644 index 0000000..fa56d3f --- /dev/null +++ b/backend/tests/unit/test_services/test_allocation_service_both_parents_rescue.py @@ -0,0 +1,461 @@ +"""Tests for §both-parents-rescue: `_Ledger.rescue_degraded_both_windows`. + +Context: `_fund_both` falls back to `_fund_swap` for any 'both'-mode date it +can't co-leave/dubbeldag on — typically because BOTH parents' vacation +budget for that year is already exhausted by a higher-priority 'both' +window (summer, Jul, ...). A swap date still counts as "funded" (one parent +got a day), so `shortfall_days` stays 0 and nothing warns — the only trace +used to be `WindowOutcome.strategy_degraded`, which the frontend dropped. + +This module's real-world motivating case: two monthly-salaried parents, each +with a full vacation budget consumed by fixed, high-priority "both" wishes, +then a lower-priority "both" wish (here fixed, for a deterministic test — +the production case is flexible, but the rescue pass itself doesn't care +which pass placed the window) that would otherwise degrade to a sequential +swap for every one of its dates. + +Builders (`_build_input`, `_child`, `_parent`, `_wish`) and `FEB_2027` are +imported from `test_allocation_service.py` rather than reinvented — see that +module's docstring for the harness convention. +""" + +from datetime import date, timedelta +from uuid import uuid4 + +from app.entities.planned_leave import PlannedLeaveDay +from app.services.allocation_service import ( + Allocator, + DayPool, + VacationBudget, + _bridge_far_side_dates, +) +from tests.unit.test_services.test_allocation_service import ( + FEB_2027, + _build_input, + _child, + _parent, + _wish, +) + +# Mon 2027-09-06 .. Fri 2027-09-10: five clean spendable weekdays, no red +# days nearby (verified against `get_swedish_holidays(2027)`). +_AUTUMN_WEEK = ((9, 6), (9, 10)) +_AUTUMN_DATES = ( + date(2027, 9, 6), + date(2027, 9, 7), + date(2027, 9, 8), + date(2027, 9, 9), + date(2027, 9, 10), +) + + +class TestBothParentsRescueRepairsDegradedWindow: + def test_donates_vacation_from_summer_to_fund_autumn_as_co_leave(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + + # Two 'specific'-mode wishes covering the SAME dates (Feb 2027 — 20 + # spendable weekdays, no red days) — each parent independently and + # entirely exhausts their own 20-day vacation budget for 2027, + # producing a genuine "vacation+vacation" date for every spendable + # Feb weekday (the donor pool this test's rescue draws from). + summer_a = _wish( + child_id=child.id, + name="Summer A", + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + priority=1, + parent_mode="specific", + parent_uuid=parent_a.uuid, + ) + summer_b = _wish( + child_id=child.id, + name="Summer B", + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + priority=1, + parent_mode="specific", + parent_uuid=parent_b.uuid, + ) + autumn = _wish( + child_id=child.id, + name="Autumn", + start=_AUTUMN_WEEK[0], + end=_AUTUMN_WEEK[1], + weeks=1, + priority=9, + 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(300, 90, 60), + (parent_b.uuid, child.id): DayPool(300, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(20, 0, 0), + (parent_b.uuid, 2027): VacationBudget(20, 0, 0), + }, + wishes=[summer_a, summer_b, autumn], + ) + + proposal = Allocator().propose(alloc_input) + + outcome = next(o for o in proposal.outcomes if o.wish_name == "Autumn") + assert outcome.requested_strategy == "co_leave" + assert outcome.strategy == "co_leave" + assert outcome.strategy_degraded is False + assert outcome.unmet_both_dates == [] + assert outcome.shortfall_days == 0 + + by_date: dict[date, list] = {} + for d in proposal.days: + if d.wish_id == autumn.id and d.day_type != "free": + by_date.setdefault(d.date, []).append(d) + + for autumn_date in _AUTUMN_DATES: + entries = by_date.get(autumn_date, []) + parents_present = {e.parent_personal_data_uuid for e in entries} + assert parents_present == {parent_a.uuid, parent_b.uuid}, ( + autumn_date, + entries, + ) + + # Summer dates are still both-off — the rescue only ever changes a + # donor date's TYPE (vacation -> FP), never removes it. + summer_days: dict[date, set] = {} + for d in proposal.days: + if d.wish_id in (summer_a.id, summer_b.id) and d.day_type != "free": + summer_days.setdefault(d.date, set()).add(d.parent_personal_data_uuid) + spendable_feb_dates = [ + date(2027, 2, 1) + timedelta(days=i) + for i in range(28) + if (date(2027, 2, 1) + timedelta(days=i)).weekday() < 5 + ] + assert len(spendable_feb_dates) == 20 + for feb_date in spendable_feb_dates: + assert summer_days.get(feb_date) == {parent_a.uuid, parent_b.uuid}, feb_date + + # Every donated vacation day was exactly replaced by a spent one on + # the autumn window -- net vacation usage per parent is unchanged + # (still fully exhausted), just re-timed. + vacation_after = proposal.balances_after.vacation_after + assert vacation_after[(parent_a.uuid, 2027)].available == 0 + assert vacation_after[(parent_b.uuid, 2027)].available == 0 + + assert not any(w.kind == "both_parents_unmet" for w in proposal.warnings) + + +class TestBothParentsRescueLeavesUnmetWhenNoDonorExists: + def test_no_vacation_anywhere_reports_unmet_and_warns(self) -> None: + """No vacation budget at all for either parent -- the rescue pass has + + nothing to donate, so every autumn date stays swap-funded, + `unmet_both_dates` reports all of them, and a `both_parents_unmet` + warning is emitted. + """ + child = _child(birth_date=date(2020, 1, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + autumn = _wish( + child_id=child.id, + name="Autumn", + start=_AUTUMN_WEEK[0], + end=_AUTUMN_WEEK[1], + weeks=1, + priority=1, + 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(300, 90, 60), + (parent_b.uuid, child.id): DayPool(300, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(0, 0, 0), + (parent_b.uuid, 2027): VacationBudget(0, 0, 0), + }, + wishes=[autumn], + ) + + proposal = Allocator().propose(alloc_input) + + outcome = next(o for o in proposal.outcomes if o.wish_name == "Autumn") + assert outcome.requested_strategy == "co_leave" + assert outcome.strategy_degraded is True + assert outcome.shortfall_days == 0 # still the silent-success bug's own signature + assert sorted(outcome.unmet_both_dates) == list(_AUTUMN_DATES) + + warning = next(w for w in proposal.warnings if w.kind == "both_parents_unmet") + assert warning.data["year"] == 2027 + assert warning.data["count"] == 5 + assert warning.data["total_days"] == 5 + assert warning.data["wish_id"] == autumn.id + + +class TestBothParentsRescueUsesBridgeDonors: + """Real-data follow-up: a genuine 'both'-mode window's co-leave tier + + NEVER produces a vacation+vacation date on an ordinary interior day (it + always splits one parent FP / the other vacation) — only its bridge tier + (`_try_bridge_vacation_pair`, Fri/Mon around a weekend or a klämdag + around a red-day run) can. So on a real plan where the high-priority + 'both' wishes are genuinely 'both'-mode (not two solo per-parent wishes, + unlike `TestBothParentsRescueRepairsDegradedWindow` above), EVERY + donor candidate is a bridge date, and an earlier version of the rescue + that excluded bridge donors outright was a complete no-op. + """ + + def test_donates_from_bridge_only_vacation_and_never_wraps_a_weekend(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + + # An 8-week 'both' window (Feb 1 - Mar 21 2027, clear of Easter, + # which lands 2027-03-26) -- long enough that a 24-day vacation + # budget is EXACTLY its natural (unconstrained) vacation appetite: + # confirmed empirically to fund with `strategy_degraded == False` + # (no swap fallback inside Summer itself, so nothing here is + # confounded by `reallocate_vacation_by_value`'s own, unrelated, + # pre-existing handling of swap-fallback bridge days) while leaving + # both parents at exactly 0 available afterwards. + summer = _wish( + child_id=child.id, + name="Summer", + start=(2, 1), + end=(3, 21), + weeks=8, + priority=1, + parent_mode="both", + ) + autumn = _wish( + child_id=child.id, + name="Autumn", + start=_AUTUMN_WEEK[0], + end=_AUTUMN_WEEK[1], + weeks=1, + priority=9, + 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(300, 90, 60), + (parent_b.uuid, child.id): DayPool(300, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(24, 0, 0), + (parent_b.uuid, 2027): VacationBudget(24, 0, 0), + }, + wishes=[summer, autumn], + ) + + proposal = Allocator().propose(alloc_input) + + summer_outcome = next(o for o in proposal.outcomes if o.wish_name == "Summer") + assert summer_outcome.strategy_degraded is False, "fixture assumption broken" + + # Fixture assumption: Summer's own vacation+vacation dates are ALL + # bridge days -- never an ordinary interior day (co-leave always + # splits FP/vacation on those). If this ever stops holding the test + # itself is no longer exercising the bridge-donor path. + by_date: dict[date, list] = {} + for d in proposal.days: + if d.wish_id == summer.id and d.day_type != "free": + by_date.setdefault(d.date, []).append(d) + for dt, entries in by_date.items(): + if len(entries) == 2 and all(e.day_type == "vacation" for e in entries): + assert entries[0].reason in ( + "vacation_wrap_weekend", + "vacation_wrap_red_day", + ), (dt, entries) + + outcome = next(o for o in proposal.outcomes if o.wish_name == "Autumn") + assert outcome.requested_strategy == "co_leave" + assert outcome.strategy == "co_leave" + assert outcome.strategy_degraded is False + assert outcome.unmet_both_dates == [] + assert outcome.shortfall_days == 0 + + autumn_days: dict[date, set] = {} + for d in proposal.days: + if d.wish_id == autumn.id and d.day_type != "free": + autumn_days.setdefault(d.date, set()).add(d.parent_personal_data_uuid) + for autumn_date in _AUTUMN_DATES: + assert autumn_days.get(autumn_date) == {parent_a.uuid, parent_b.uuid}, autumn_date + + # Vacation really was the binding constraint -- fully exhausted for + # both parents, exactly as the real plan's summer + Jul do. + vacation_after = proposal.balances_after.vacation_after + assert vacation_after[(parent_a.uuid, 2027)].available == 0 + assert vacation_after[(parent_b.uuid, 2027)].available == 0 + + # No weekend/red-day run ever ends up "wrapped" by FP on both edges + # for the same parent -- the one thing bridge-donor conversion must + # never create (§both-parents-rescue real-data follow-up). + fp_dates_by_parent: dict[str, set] = {parent_a.uuid: set(), parent_b.uuid: set()} + for d in proposal.days: + if d.day_type in ("sjukpenning", "lagsta"): + fp_dates_by_parent.setdefault(d.parent_personal_data_uuid, set()).add(d.date) + for parent_uuid, fp_dates in fp_dates_by_parent.items(): + for fp_date in fp_dates: + for far_side in _bridge_far_side_dates( + fp_date, alloc_input.holidays, alloc_input.day_overrides + ): + assert far_side not in fp_dates, ( + "weekend wrapped by FP on both edges", + parent_uuid, + fp_date, + far_side, + ) + + +class TestBothParentsRescueDeterminism: + def test_repeated_runs_are_identical(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + summer_a = _wish( + child_id=child.id, + name="Summer A", + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + priority=1, + parent_mode="specific", + parent_uuid=parent_a.uuid, + ) + summer_b = _wish( + child_id=child.id, + name="Summer B", + start=FEB_2027[0], + end=FEB_2027[1], + weeks=4, + priority=1, + parent_mode="specific", + parent_uuid=parent_b.uuid, + ) + autumn = _wish( + child_id=child.id, + name="Autumn", + start=_AUTUMN_WEEK[0], + end=_AUTUMN_WEEK[1], + weeks=1, + priority=9, + 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(300, 90, 60), + (parent_b.uuid, child.id): DayPool(300, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(20, 0, 0), + (parent_b.uuid, 2027): VacationBudget(20, 0, 0), + }, + wishes=[summer_a, summer_b, autumn], + ) + + allocator = Allocator() + proposal_1 = allocator.propose(alloc_input) + proposal_2 = allocator.propose(alloc_input) + + assert proposal_1 == proposal_2 + + +class TestBothParentsRescueIgnoresPinnedDays: + """§false-positive-pin-warning — real-data regression. + + A 'both'-mode wish where one parent is pinned (`day_assertion` vacation) + on every date and the other is funded normally. `_fund_both` sends every + such date straight to `_fund_swap` (`existing_a is not None`, checked + before dubbeldag/bridge/co-leave even run), and the pinned parent's row + is deliberately never re-proposed as a `ProposedDay` (`_fund_solo` skips + it — the row already exists and is correct). Before this fix, + `_find_swap_dates` only ever looked at `all_days`, so it saw the pinned + parent as flat-out missing and reported every one of these dates in + `unmet_both_dates` plus a `both_parents_unmet` warning — even though + `apply_proposed_days` leaves the pinned rows standing and the persisted + plan genuinely has both parents off every day. + """ + + def test_pinned_parent_never_counts_as_missing(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + jacob = _parent(name="Jacob") + amelie = _parent(name="Amelie") + dates = [date(2026, 10, d) for d in (5, 6, 7, 8, 9)] # Mon-Fri, clean + wish = _wish( + child_id=child.id, + name="Manuellt tillagd period", + start=(10, 5), + end=(10, 9), + weeks=1, + priority=1, + parent_mode="both", + first_year=2026, + ) + pinned = [ + PlannedLeaveDay( + id=uuid4(), + workspace_id=uuid4(), + child_id=child.id, + parent_personal_data_uuid=jacob.uuid, + date=d, + day_type="vacation", + wish_id=wish.id, + ) + for d in dates + ] + alloc_input = _build_input( + today=date(2026, 1, 1), + horizon_end=date(2026, 12, 31), + children=[child], + parents=[jacob, amelie], + pools={ + (jacob.uuid, child.id): DayPool(300, 90, 60), + (amelie.uuid, child.id): DayPool(300, 90, 60), + }, + vacation={ + (jacob.uuid, 2026): VacationBudget(0, 0, 5), # all 5 already spent (pinned) + (amelie.uuid, 2026): VacationBudget(25, 0, 0), + }, + wishes=[wish], + existing_planned=pinned, + pinned_days=frozenset((jacob.uuid, child.id, d) for d in dates), + ) + + proposal = Allocator().propose(alloc_input) + + outcome = next(o for o in proposal.outcomes if o.wish_name == "Manuellt tillagd period") + assert outcome.unmet_both_dates == [], outcome.unmet_both_dates + assert outcome.strategy_degraded is False + assert outcome.strategy == "co_leave" + assert not any(w.kind == "both_parents_unmet" for w in proposal.warnings) + + # Jacob's pinned rows are never re-proposed (they're already correct + # and `apply_proposed_days` leaves them standing)... + assert not any( + d.parent_personal_data_uuid == jacob.uuid and d.date in dates for d in proposal.days + ) + # ...while Amelie genuinely gets funded on every one of those dates + # from this run's own placement -- the persisted result IS both-off. + amelie_dates = {d.date for d in proposal.days if d.parent_personal_data_uuid == amelie.uuid} + assert set(dates) <= amelie_dates, amelie_dates diff --git a/frontend/src/components/dashboard/PlanWarningCard.test.tsx b/frontend/src/components/dashboard/PlanWarningCard.test.tsx index 1216970..092db1f 100644 --- a/frontend/src/components/dashboard/PlanWarningCard.test.tsx +++ b/frontend/src/components/dashboard/PlanWarningCard.test.tsx @@ -119,6 +119,57 @@ describe('PlanWarningCard', () => { expect(warning).not.toHaveTextContent('förslag på') }) + it('renders a both_parents_unmet warning with the (re-translated) wish name and its figures', () => { + render( + , + ) + const warning = screen.getByTestId('dashboard-plan-warning-2') + expect(warning).toHaveTextContent("Föräldrarna kunde inte vara lediga tillsammans varje dag") + expect(warning).toHaveTextContent('Höst 2029: föräldrarna kunde inte vara lediga tillsammans 3 av 5 dagar') + expect(warning).not.toHaveTextContent('2029-09-10') + }) + + it('re-translates a preset wish name in a both_parents_unmet warning via its message key', () => { + render( + , + ) + const warning = screen.getByTestId('dashboard-plan-warning-2') + expect(warning).toHaveTextContent('Sommar 2029') + expect(warning).not.toHaveTextContent('Summer 2029') + }) + it('falls back to the raw kind and messageKey for an unrecognised warning', () => { render( title, `messageKey` -> the body -// template. The backend's own `message_key` is already `f"planWarnings. -// {kind}"`, i.e. a real translation key — routed through a lookup anyway -// (like `SurplusAdviceTip`'s `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised -// kind (a newer backend rule this catalogue hasn't caught up with) falls -// back to the raw value instead of rendering a missing-key placeholder. +// `_vacation_expiry_warnings`, `_both_parents_unmet_warnings`). `kind` -> +// title, `messageKey` -> the body template. The backend's own `message_key` +// is already `f"planWarnings.{kind}"`, i.e. a real translation key — routed +// through a lookup anyway (like `SurplusAdviceTip`'s +// `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised kind (a newer backend rule +// this catalogue hasn't caught up with) falls back to the raw value instead +// of rendering a missing-key placeholder. const PLAN_WARNING_TITLE_KEYS: Record = { sgi_risk: 'planWarnings.sgiRiskTitle', days_lost_at_age_4: 'planWarnings.daysLostAtAge4Title', vacation_expiring: 'planWarnings.vacationExpiringTitle', + both_parents_unmet: 'planWarnings.bothParentsUnmetTitle', } const PLAN_WARNING_BODY_KEYS: Record = { 'planWarnings.sgi_risk': 'planWarnings.sgi_risk', 'planWarnings.days_lost_at_age_4': 'planWarnings.days_lost_at_age_4', 'planWarnings.vacation_expiring': 'planWarnings.vacation_expiring', + 'planWarnings.both_parents_unmet': 'planWarnings.both_parents_unmet', } // `data` fields that carry an ISO-8601 date and must go through @@ -30,6 +34,7 @@ const DATE_FIELDS_BY_KIND: Record = { sgi_risk: ['week_start'], days_lost_at_age_4: ['fourth_birthday'], vacation_expiring: [], + both_parents_unmet: [], } export interface PlanWarningCardProps { @@ -61,6 +66,13 @@ export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProp if (typeof uuid === 'string') { params.parent_name = nameFor(uuid) } + if (warning.kind === 'both_parents_unmet') { + // §both-parents-rescue — re-translate a preset wish's name into the + // UI's current locale, same rule `resolveWishName` applies everywhere + // else a wish name is shown (see `resolveWishNameFromData`'s doc + // comment). + params.wish_name = resolveWishNameFromData(warning.data, t) + } for (const field of DATE_FIELDS_BY_KIND[warning.kind] ?? []) { const raw = warning.data[field] if (typeof raw === 'string') { diff --git a/frontend/src/components/dashboard/YearRows.test.tsx b/frontend/src/components/dashboard/YearRows.test.tsx index 259b87f..786c8b8 100644 --- a/frontend/src/components/dashboard/YearRows.test.tsx +++ b/frontend/src/components/dashboard/YearRows.test.tsx @@ -439,5 +439,29 @@ describe('YearRows', () => { expect(screen.getByTestId('year-row-shortfall-lw-1-2026')).toBeInTheDocument() expect(screen.queryByTestId('year-row-remedy-lw-1-2026-0')).not.toBeInTheDocument() }) + + // §both-parents-rescue + it('flags a wish row whose window still has unmet both-parents dates', () => { + renderRows({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-1', + wishName: 'Höst', + year: 2027, + unmetBothDates: ['2027-09-10', '2027-09-11', '2027-09-12'], + }), + ], + }) + expect(screen.getByTestId('year-row-both-unmet-lw-1-2027')).toHaveTextContent( + '3 dagar kunde inte täckas av båda föräldrarna', + ) + }) + + it('shows no both-parents-unmet flag when the list is empty', () => { + renderRows({ + outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, unmetBothDates: [] })], + }) + expect(screen.queryByTestId('year-row-both-unmet-lw-1-2026')).not.toBeInTheDocument() + }) }) }) diff --git a/frontend/src/components/dashboard/YearRows.tsx b/frontend/src/components/dashboard/YearRows.tsx index f840b89..4c04a05 100644 --- a/frontend/src/components/dashboard/YearRows.tsx +++ b/frontend/src/components/dashboard/YearRows.tsx @@ -475,6 +475,14 @@ export function YearRows({ ))} )} + {outcome.unmetBothDates.length > 0 && ( + + {t('yearRow.bothParentsUnmet', { count: outcome.unmetBothDates.length })} + + )} ))} diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 67922db..cd56384 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -95,6 +95,11 @@ export const calendar = { '{name} ({start}–{end}): only {daysFunded} of {daysWished} days funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.toastRedDayShift': '{name}: moved to {start} – {end} to catch {days} extra free {days|day|days}', + // §both-parents-rescue — a line in the sync toast when at least one wish + // outcome has `unmetBothDates.length > 0` for that year, despite the + // backend's own repair pass (donating a vacation day from another wish in + // the same year). `{years}` is a comma-separated list, e.g. "2027, 2029". + 'calendar.toastBothParentsUnmet': "Both parents couldn't be off together for {years}", // Reallocation pass — `reallocate_vacation_by_value` swaps vacation onto // better dates 1-for-1 with parental leave. `{from}`/`{to}` already carry // the period name plus its dates (e.g. "Summer (Jun 20, Jun 21)"), or the diff --git a/frontend/src/i18n/en/dashboard.ts b/frontend/src/i18n/en/dashboard.ts index 0544489..e21bf3b 100644 --- a/frontend/src/i18n/en/dashboard.ts +++ b/frontend/src/i18n/en/dashboard.ts @@ -66,4 +66,8 @@ export const dashboard = { 'yearRow.vacationTitle': 'Vacation', 'yearRow.weeksFunded': '{funded} of {wished} weeks', 'yearRow.wishesTitle': 'Leave periods', + // §both-parents-rescue — shown under a wish when + // `WindowOutcome.unmetBothDates` isn't empty, i.e. despite the backend's + // own repair pass, both parents couldn't be off together every day. + 'yearRow.bothParentsUnmet': "{count} {count|day|days} couldn't be covered by both parents", } as const diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts index 90b5097..acb9e37 100644 --- a/frontend/src/i18n/en/plan.ts +++ b/frontend/src/i18n/en/plan.ts @@ -77,6 +77,13 @@ export const plan = { // `data.rescue_days_available` is > 0 (`PlanWarningCard.tsx`). Never shown // on its own, so no separate title. 'planWarnings.vacationExpiringRescueHint': 'There are {count} suggested dates to rescue them — see the suggestions below.', + // §both-parents-rescue — `_both_parents_unmet_warnings` (allocation_service.py): + // a 'both'-mode period that still has dates only one parent could be off + // on, despite the backend's own repair pass (donating a vacation day from + // another wish in the same year). + 'planWarnings.bothParentsUnmetTitle': "Both parents couldn't be off together every day", + 'planWarnings.both_parents_unmet': + "{wish_name} {year}: both parents couldn't be off together on {count} of {total_days} days — not enough vacation or parental-leave days.", // §vacation-rescue — `VacationRescueTip.tsx`, one card per (parent, year) // group of `PlanProposal.rescueDays`. 'planResults.vacationRescueTitle': 'Rescue vacation days {year}', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index da05014..7d5e7ff 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -92,6 +92,11 @@ export const calendar = { '{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.toastRedDayShift': '{name}: flyttad till {start} – {end} för att fånga {days} extra {days|ledig dag|lediga dagar}', + // §both-parents-rescue — en rad i synk-toasten när minst ett önskemåls + // utfall har `unmetBothDates.length > 0` för det året, trots backendens + // egen reparation (donerar en semesterdag från ett annat önskemål samma + // år). `{years}` är en kommaseparerad lista, t.ex. "2027, 2029". + 'calendar.toastBothParentsUnmet': 'Båda föräldrarna kunde inte vara lediga samtidigt för {years}', // Reallocation pass — `reallocate_vacation_by_value` swaps vacation onto // better dates 1-for-1 with föräldrapenning. `{from}`/`{to}` already carry // the period name plus its dates (e.g. "Sommar (20 jun, 21 jun)"), or the diff --git a/frontend/src/i18n/sv/dashboard.ts b/frontend/src/i18n/sv/dashboard.ts index b4c67a2..8c01991 100644 --- a/frontend/src/i18n/sv/dashboard.ts +++ b/frontend/src/i18n/sv/dashboard.ts @@ -66,4 +66,8 @@ export const dashboard = { 'yearRow.vacationTitle': 'Semester', 'yearRow.weeksFunded': '{funded} av {wished} veckor', 'yearRow.wishesTitle': 'Ledigheter', + // §both-parents-rescue — visas under ett önskemål när + // `WindowOutcome.unmetBothDates` inte är tom, dvs. trots backendens egen + // reparation kunde inte båda föräldrarna vara lediga varje dag. + 'yearRow.bothParentsUnmet': '{count} {count|dag|dagar} kunde inte täckas av båda föräldrarna', } as const diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts index 9b3e59d..2daf2e7 100644 --- a/frontend/src/i18n/sv/plan.ts +++ b/frontend/src/i18n/sv/plan.ts @@ -77,6 +77,13 @@ export const plan = { // `data.rescue_days_available` is > 0 (`PlanWarningCard.tsx`). Never shown // on its own, so no separate title. 'planWarnings.vacationExpiringRescueHint': 'Det finns förslag på {count} datum för att rädda dem — se förslagen nedan.', + // §both-parents-rescue — `_both_parents_unmet_warnings` (allocation_service.py): + // a 'both'-läge-period som fortfarande har datum där bara en förälder + // kunde vara ledig, trots backendens egen reparation (donerar en + // semesterdag från ett annat önskemål samma år). + 'planWarnings.bothParentsUnmetTitle': 'Föräldrarna kunde inte vara lediga tillsammans varje dag', + 'planWarnings.both_parents_unmet': + '{wish_name} {year}: föräldrarna kunde inte vara lediga tillsammans {count} av {total_days} dagar — inte tillräckligt med semester eller föräldrapenningdagar.', // §vacation-rescue — `VacationRescueTip.tsx`, one card per (parent, year) // group of `PlanProposal.rescueDays`. 'planResults.vacationRescueTitle': 'Rädda semesterdagar {year}', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index bfdc4d5..7787aaf 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -2051,6 +2051,47 @@ describe('Calendar', () => { expect(toast).toHaveTextContent('2') }) + it('shows a toast when a sibling year could not keep both parents off despite the rescue pass', async () => { + // §both-parents-rescue — unlike the shortfall summary above, this is + // NOT gated on `changes` membership: a rescue can ripple into a + // sibling year this save never directly touched, so every outcome in + // the batch is scanned (mirrors the shift-gain loop). + const created = makeLeaveWish({ id: 'lw-preset', name: 'Höst' }) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-preset', + year: 2027, + unmetBothDates: ['2027-09-10'], + }), + makeWindowOutcome({ + wishId: 'lw-other', + year: 2029, + unmetBothDates: ['2029-09-10', '2029-09-11'], + }), + makeWindowOutcome({ wishId: 'lw-clean', year: 2028, unmetBothDates: [] }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [created] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + fireEvent.click(screen.getByTestId('preset-click')) + + const toast = await screen.findByTestId('calendar-toast') + expect(toast).toHaveTextContent("kunde inte vara lediga samtidigt") + expect(toast).toHaveTextContent('2027, 2029') + expect(toast).not.toHaveTextContent('2028') + }) + it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => { // A plain re-open (nothing actually touched this wish's dates this // sync) must never re-announce a stable, already-applied shift -- diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index ffdb987..f0a0cfc 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -374,6 +374,26 @@ function buildSyncToastMessage( ) } + // §both-parents-rescue — the backend's own repair pass + // (`_Ledger.rescue_degraded_both_windows`) already tried to fix a 'both' + // window that fell back to a sequential swap by donating a vacation day + // from elsewhere in the same year; this reports what's left over — every + // wish year that still has a date only one parent could be off on, no + // matter which wish was actually touched by this save (mirrors the + // shift-gain loop above, not gated on `watchWishId`/`changes` membership, + // since a rescue can ripple into a sibling year this save never directly + // touched). + const bothParentsUnmetYears = Array.from( + new Set( + result.proposal.outcomes + .filter((o) => o.unmetBothDates.length > 0) + .map((o) => o.year), + ), + ).sort((a, b) => a - b) + if (bothParentsUnmetYears.length > 0) { + parts.push(t('calendar.toastBothParentsUnmet', { years: bothParentsUnmetYears.join(', ') })) + } + for (const rejection of result.applyResult.rejections) { parts.push(renderRejection(rejection, t, members)) } diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index a1aeab8..3361195 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -1571,7 +1571,10 @@ describe('endpoint functions (representative coverage)', () => { weeks_wished: 6, weeks_funded: 6, shortfall_days: 0, - strategy: 'parent_swap', + strategy: 'co_leave', + requested_strategy: 'dubbeldag', + strategy_degraded: true, + unmet_both_dates: ['2026-09-10', '2026-09-11'], message_key: 'planOutcomes.windowExplanation', data: { wish_name: 'Sommar', @@ -1592,6 +1595,11 @@ describe('endpoint functions (representative coverage)', () => { message_key: 'planWarnings.days_lost_at_age_4', data: { expiring_days: 10 }, }, + { + kind: 'both_parents_unmet', + message_key: 'planWarnings.both_parents_unmet', + data: { wish_name: 'Höst', year: 2026, count: 2, total_days: 5 }, + }, ], pool_balances: [ { @@ -1665,6 +1673,11 @@ describe('endpoint functions (representative coverage)', () => { }) expect(result.warnings[0]!.kind).toBe('days_lost_at_age_4') expect(result.warnings[0]!.messageKey).toBe('planWarnings.days_lost_at_age_4') + expect(result.warnings[1]!.kind).toBe('both_parents_unmet') + expect(result.warnings[1]!.data).toEqual({ wish_name: 'Höst', year: 2026, count: 2, total_days: 5 }) + expect(result.outcomes[0]!.requestedStrategy).toBe('dubbeldag') + expect(result.outcomes[0]!.strategyDegraded).toBe(true) + expect(result.outcomes[0]!.unmetBothDates).toEqual(['2026-09-10', '2026-09-11']) expect(result.poolBalances[0]!.sjukpenningLeft).toBe(100) expect(result.vacationBalances[0]!.alreadyUsed).toBe(19) expect(result.vacationBalances[0]!.isAssumed).toBe(true) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 16c508c..4e78cd4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -407,6 +407,9 @@ interface RawWindowOutcome { weeks_funded: number shortfall_days: number strategy: string + requested_strategy: string + strategy_degraded: boolean + unmet_both_dates: string[] message_key: string data: Record cost_breakdown: Record @@ -504,6 +507,9 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal { weeksFunded: o.weeks_funded, shortfallDays: o.shortfall_days, strategy: o.strategy, + requestedStrategy: o.requested_strategy, + strategyDegraded: o.strategy_degraded, + unmetBothDates: o.unmet_both_dates, messageKey: o.message_key, data: o.data, costBreakdown: toCostBreakdown(o.cost_breakdown), diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 94f502e..dc040be 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -466,10 +466,15 @@ } .year-row__wish-weeks--short, -.year-row__shortfall { +.year-row__shortfall, +.year-row__both-unmet { color: var(--c-danger); } +.year-row__both-unmet { + flex-basis: 100%; +} + .year-row__remedies { flex-basis: 100%; padding-left: var(--sp-4); diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index 0459df9..8d93b29 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -423,7 +423,22 @@ export interface WindowOutcome { weeksWished: number weeksFunded: number shortfallDays: number - strategy: string // 'dubbeldag' | 'parent_swap' | 'solo' — kept open-ended, see allocation_service.py + strategy: string // 'dubbeldag' | 'parent_swap' | 'solo' | 'co_leave' | 'swap' | 'unplaced' — kept open-ended, see allocation_service.py + // What `parent_mode` (+ wants-dubbeldag) actually asked for, independent + // of whether it was achieved — see `WindowOutcome.requested_strategy` + // (allocation_service.py). Compare against `strategy` to tell "as + // requested" from "fell back". + requestedStrategy: string + // True when `strategy !== requestedStrategy` for a 'both'-mode window — + // see `WindowOutcome.strategy_degraded`. + strategyDegraded: boolean + // §both-parents-rescue — dates in this window still funded for only one + // parent after the backend's own repair pass tried to fix them by + // donating a vacation day from elsewhere in the same year. Empty for + // every non-'both' window and for a window that funded cleanly or was + // fully rescued. See `WindowOutcome.unmet_both_dates` + // (allocation_service.py) and `PlanWarning` kind 'both_parents_unmet'. + unmetBothDates: string[] // ISO-8601 dates messageKey: string data: Record costBreakdown: CostBreakdown @@ -443,7 +458,7 @@ export interface WindowOutcome { } export interface PlanWarning { - kind: string // 'sgi_risk' | 'days_lost_at_age_4' | 'vacation_expiring' + kind: string // 'sgi_risk' | 'days_lost_at_age_4' | 'vacation_expiring' | 'both_parents_unmet' messageKey: string data: Record } diff --git a/frontend/tests/fixtures/plan.ts b/frontend/tests/fixtures/plan.ts index ddebfa3..2776a64 100644 --- a/frontend/tests/fixtures/plan.ts +++ b/frontend/tests/fixtures/plan.ts @@ -63,6 +63,9 @@ export function makeWindowOutcome(overrides: Partial = {}): Windo weeksFunded: 6, shortfallDays: 0, strategy: 'parent_swap', + requestedStrategy: 'parent_swap', + strategyDegraded: false, + unmetBothDates: [], messageKey: 'planOutcomes.windowExplanation', data: {}, costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),