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