diff --git a/PLANNING_ENGINE_SPEC.md b/PLANNING_ENGINE_SPEC.md
index 987cd92..f9e1cf9 100644
--- a/PLANNING_ENGINE_SPEC.md
+++ b/PLANNING_ENGINE_SPEC.md
@@ -236,27 +236,39 @@ choice is genuinely ambiguous:
**Tier 1 — free days.** Saturday or in the holiday set. Cost nothing. Never spend a resource here.
-**Tier 2 — dominant plays.** Days where one resource wins on *every* axis, regardless of goal. The
-main case is a **klämdag inside a red-day cluster** → spend **semester**:
+**Tier 2 — dominant plays (§weekend-wrap-bridge, revised 2026-09-20).** Days where vacation is
+spent to **bridge or extend a free stretch**, regardless of goal *or* `salary_type`. This tier used
+to fire only for a **klämdag inside a real red-day cluster**; it now also covers the far more common
+case of a bare weekend wrap (Friday before / Monday after a plain Sat+Sun) and the middle day of any
+short (≤3-day) non-working gap, whether or not a real red day is involved. See
+`_vacation_bridge_reason` in `allocation_service.py` for the exact rule (adjacency first, then
+short-run-middle) and its reason codes (`vacation_wrap_red_day` when the adjacent/fencing free run
+contains a genuine holiday, `vacation_wrap_weekend` otherwise):
| Benefit | Monthly salary | Hourly salary |
|---|---|---|
| Preserves an FP day for later (time) | ✅ | ✅ |
| Protects pay you'd lose on an FP day (money) | ✅ | ❌ |
-Note the asymmetry, which §6.3 hints at but does not spell out: the **FP-preservation** half is
-salary-type independent, so red-day-cluster vacation is favourable for hourly parents too. Only the
-**pay-protection** half is monthly-only. This is why the §6.1 suppression for hourly applies to
-*weekend wrapping* specifically — for an hourly parent there is no weekend salary to protect, so
-bracketing a bare Sat/Sun with Fri+Mon vacation buys nothing — but it does **not** justify suppressing
-red-day-cluster vacation.
+**Superseded reasoning (kept for history):** this tier used to be suppressed for weekend-only
+wraps on an hourly parent, on the theory that "there is no weekend salary to protect, so bracketing
+a bare Sat/Sun with Fri+Mon vacation buys nothing." That argument only weighed the pay-protection
+half of the benefit table and ignored the FP-preservation half, which — as the row above already
+shows — is salary-type independent. **Decision (2026-09-20):** the tier now fires ungated, for every
+`salary_type` and regardless of `vacation_forfeiting`. For an hourly parent whose vacation is not
+otherwise expiring, this is a genuine behaviour change: vacation now pays the Friday/Monday instead
+of an FP day (or, previously, instead of nothing at all — a hollow "buys nothing" framing that
+missed the FP saved for later), trading a day that costs the household nothing extra for a day kept
+in the bank. The old `REASON_VACATION_WRAP_WEEKEND` reason code, retired when this suppression was
+introduced, is reinstated for exactly this case.
> **SUPERSEDED (2026-09-15).** Tier 3 no longer consults a goal at all — there is a single policy,
> branching only on `salary_type`, that maximises value extracted from FP + vacation combined. The
> table below has been replaced to describe it; see `_assign_tier3` in `allocation_service.py`.
-**Tier 3 — salary-type interior.** Ordinary working days with no red-day adjacency, where semester
-vs FP is a real tradeoff:
+**Tier 3 — salary-type interior.** Ordinary working days that tier 2 does not claim as a bridge
+(see `_vacation_bridge_reason`: not adjacent to a free day, and not the middle day of a ≤3-day
+gap) — genuinely interior weekdays where semester vs FP is a real tradeoff:
| `salary_type` | Interior choice | Why |
|---|---|---|
@@ -348,7 +360,7 @@ Constraints — all hard unless marked:
`employment_status`, never a block.
Every assigned day carries a machine-readable `reason` so the UI can explain itself:
-`free_weekend`, `free_red_day`, `vacation_wrap_weekend`, `vacation_wrap_red_day`, `fp_interior`,
+`free_weekend`, `free_red_day`, `vacation_interior`, `vacation_wrap_red_day`, `fp_interior`,
`fp_lagsta_stretch`, `swap_first_half`, `swap_second_half`, `dubbeldag`.
### 3.6 Shortfall, not failure
@@ -422,7 +434,7 @@ Against the **pure** `Allocator`, with a frozen `today`. Target ≥90% on new ba
| 1 | solo, `max_time`, 6-week summer, child < 12 months | **zero** FP days assigned (SGI protected) |
| 2 | couple, `balanced`, 6-week summer | `parent_swap`, 3+3, ≈6 weeks together |
| 3 | `max_money` + monthly salary | vacation lands on Fri/Mon around weekends |
-| 4 | `max_money` + **hourly** salary | wrapping **suppressed**; no vacation-wrap reasons emitted |
+| 4 | `max_money` + **hourly** salary | weekend/red-day **wrapping still fires** (vacation on Fri/Mon and short fenced gaps, `vacation_wrap_*` reasons emitted, §weekend-wrap-bridge below) — only genuinely **interior** mid-week days still prefer FP |
| 5 | wishes exceed budget | lower-priority window shows a shortfall; higher ones are full |
| 6 | Jullov 12-20 → 01-07 | window wraps years; vacation debited from **both** years |
| 7 | two children, overlapping windows | pools kept separate per `(parent, child)` |
diff --git a/PROPOSE_VS_SYNC_BUG.md b/PROPOSE_VS_SYNC_BUG.md
new file mode 100644
index 0000000..493f7dc
--- /dev/null
+++ b/PROPOSE_VS_SYNC_BUG.md
@@ -0,0 +1,77 @@
+# Bug: Dashboard figures swing on the first plan sync
+
+**Status:** open, not fixed. Found 2026-09-20 during browser verification of the vacation-rescue
+feature. **Pre-existing** — not introduced by that work.
+
+## Symptom
+
+The vacation figures on the Dashboard change substantially the first time
+`POST /api/plan/sync` runs, with **zero user interaction** — an ordinary page reload is enough.
+
+Measured on a freshly seeded demo workspace (`backend/scripts/seed_demo.py`), 2026-09-20:
+
+| Figure | Before sync | After sync |
+|---|---|---|
+| Total "vacation at risk of being lost" | 178 | 24 |
+| Sam 2026 — vacation left | 23 | 17 |
+| Sam 2026 — expiring | 18 | 12 |
+| Sam 2027 — expiring | 20 | 2 |
+| Sam 2028 — expiring | 20 | 3 |
+
+## Cause
+
+Two different numbers are both correct, for different questions, and the UI shows them in
+sequence without saying which is which:
+
+1. `usePlanProposal` (`frontend/src/hooks/usePlanProposal.ts`) initially renders the result of
+ `POST /api/plan/propose` — a **read-only** projection that writes nothing.
+2. `PlanSyncProvider` (`frontend/src/context/PlanSyncProvider.tsx`) then runs
+ `POST /api/plan/sync`, which **persists** the proposed days via
+ `PlanningService.sync_plan` (`backend/app/services/planning_service.py`).
+3. Once those days exist in `planned_leave_day`, `AllocationInputLoader` counts them as committed
+ usage when it builds `vacation_forfeiting`, so the next forecast is much smaller.
+4. `usePlanProposal` permanently prefers `PlanSyncProvider.latestProposal` once any sync has
+ completed, so the post-sync figure is what sticks.
+
+So the pre-sync figure answers *"what would be lost if nothing were planned?"* and the post-sync
+figure answers *"what will be lost given the plan we just wrote?"*. Only the second is what the
+user means by "days I'm about to lose".
+
+## Why it matters
+
+It reads as a bug in whatever the user just did. During verification, the vacation-rescue
+**dismiss** action was initially blamed for it — dismiss was the first Dashboard control to call
+`bumpPlanVersion()`, so it triggered the sync and appeared to move the numbers.
+
+Dismiss was **exonerated** by capturing figures immediately before and after the click: with
+dismiss no longer bumping the plan version, the figures are byte-identical across a dismiss
+(178 / 23 / 18 before and after). The swing only ever came from the sync.
+
+## Suggested direction (not decided)
+
+Options, roughly in increasing order of effort:
+
+1. Don't render vacation figures until the first sync has completed — show the existing loading
+ skeleton instead. Smallest change; makes the transient state invisible rather than explained.
+2. Have the Dashboard sync before first paint rather than propose-then-sync, so only one number is
+ ever shown.
+3. Reconsider whether `sync_plan` should write on a plain page load at all. It is a persisting
+ write triggered by navigation, which is what makes a read of the Dashboard change stored data.
+
+Option 3 is the real question and is the largest; 1 is a stopgap that would stop the user seeing a
+figure that is about to change by 7x.
+
+## Reproduction
+
+1. Seed a fresh demo workspace and log in as `demo@example.com`.
+2. Open the Dashboard and read the "vacation at risk" total **immediately**, before the background
+ sync completes (it is quick — capture it fast, or throttle the network).
+3. Reload the page and read it again.
+
+## Related, probably not a real defect
+
+Chrome's network panel reports `POST /api/plan/rescue-days/dismiss` as **503** while uvicorn's own
+access log shows **204 No Content** for the same call, with one log line per click and no retries
+reaching the server. Server behaviour is verifiably correct. Looks like an artifact of the
+browser extension's request tracking, possibly around React 18 dev-mode effect double-invocation.
+Recorded only so it isn't re-investigated from scratch.
diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py
index a6d8c84..8872270 100644
--- a/backend/app/dependencies.py
+++ b/backend/app/dependencies.py
@@ -425,8 +425,11 @@ def get_allocation_input_loader(
AllocationInputLoaderDep = Annotated[AllocationInputLoader, Depends(get_allocation_input_loader)]
-def get_planning_engine_service(loader: AllocationInputLoaderDep) -> PlanningEngineService:
- return PlanningEngineService(loader)
+def get_planning_engine_service(
+ loader: AllocationInputLoaderDep,
+ dismissal_repo: TipDismissalRepositoryDep,
+) -> PlanningEngineService:
+ return PlanningEngineService(loader, dismissal_repo=dismissal_repo)
PlanningEngineServiceDep = Annotated[PlanningEngineService, Depends(get_planning_engine_service)]
diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py
index a15e496..2c08636 100644
--- a/backend/app/models/plan.py
+++ b/backend/app/models/plan.py
@@ -73,6 +73,23 @@ class SurplusAdviceResponse(BaseModel):
data: dict[str, object]
+class RescueDayResponse(BaseModel):
+ """§vacation-rescue — a suggest-only concrete date to spend a vacation
+
+ day that would otherwise expire this year — see `RescueDay`
+ (app/services/allocation_service.py) for why this rides on its own
+ `PlanProposalResponse.rescue_days` field rather than joining `days`: it
+ has no wish behind it and must never be picked up by anything that
+ iterates the proposal's `days` before the user explicitly accepts it.
+ """
+
+ date: date
+ parent_personal_data_uuid: UUID
+ child_id: UUID
+ year: int
+ reason: str
+
+
class WindowOutcomeResponse(BaseModel):
wish_id: UUID
wish_name: str
@@ -153,6 +170,7 @@ class PlanProposalResponse(BaseModel):
vacation_balances: list[VacationBalanceResponse]
surplus_advice: list[SurplusAdviceResponse]
age4_projections: list[Age4ProjectionResponse]
+ rescue_days: list[RescueDayResponse]
@classmethod
def from_domain(cls, proposal: PlanProposal) -> "PlanProposalResponse":
@@ -246,6 +264,16 @@ class PlanProposalResponse(BaseModel):
)
for p in proposal.age4_projections
],
+ rescue_days=[
+ RescueDayResponse(
+ date=r.date,
+ parent_personal_data_uuid=r.parent_personal_data_uuid,
+ child_id=r.child_id,
+ year=r.year,
+ reason=r.reason,
+ )
+ for r in proposal.rescue_days
+ ],
)
@@ -486,6 +514,25 @@ class EvictionPreviewResponse(BaseModel):
)
+# ---------------------------------------------------------------------------
+# POST /api/plan/rescue-days/dismiss
+# ---------------------------------------------------------------------------
+
+
+class RescueDayDismissRequest(BaseModel):
+ """Dismiss one (parent, year) group of suggested vacation-rescue days —
+
+ see `PlanningEngineService.dismiss_rescue_days`. No `date`/`reason` list
+ here on purpose: the service recomputes the currently-suggested group
+ itself rather than trusting a client-supplied date list (same shape as
+ `TipDismissalCreate`/`TipsService.dismiss`).
+ """
+
+ workspace_id: UUID
+ parent_personal_data_uuid: UUID
+ year: int
+
+
class PlanSyncResponse(BaseModel):
proposal: PlanProposalResponse
apply_result: PlanApplyResponse
diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py
index 7172788..73eee8b 100644
--- a/backend/app/routers/plan.py
+++ b/backend/app/routers/plan.py
@@ -17,6 +17,7 @@ from app.models.plan import (
PlanProposeRequest,
PlanSyncRequest,
PlanSyncResponse,
+ RescueDayDismissRequest,
)
from app.utils.errors import NotFoundError, ValidationError
@@ -103,3 +104,27 @@ async def sync_plan(
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return PlanSyncResponse.from_domain(result)
+
+
+@router.post("/rescue-days/dismiss", status_code=status.HTTP_204_NO_CONTENT)
+async def dismiss_rescue_days(
+ payload: RescueDayDismissRequest,
+ service: PlanningEngineServiceDep,
+ scope: WorkspaceScopeDep,
+) -> None:
+ """Dismiss a suggested vacation-rescue group (§vacation-rescue) so it
+ stops appearing in `/propose`/`/sync` — until the underlying dates
+ change, at which point it resurfaces (see
+ `PlanningEngineService.dismiss_rescue_days`).
+ """
+ scope.check_workspace(payload.workspace_id)
+ # The parent must belong to the caller's own workspace too — otherwise a
+ # foreign parent_personal_data_uuid could dismiss (or probe for) another
+ # tenant's rescue-day suggestion even though workspace_id itself checks out.
+ await scope.check_member(payload.parent_personal_data_uuid)
+ try:
+ await service.dismiss_rescue_days(
+ payload.workspace_id, payload.parent_personal_data_uuid, payload.year
+ )
+ except NotFoundError as exc:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py
index 3e98393..69d6bdd 100644
--- a/backend/app/services/allocation_service.py
+++ b/backend/app/services/allocation_service.py
@@ -17,9 +17,9 @@ router (`POST /api/plan/propose`).
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from datetime import date, timedelta
-from uuid import UUID
+from uuid import UUID, uuid4
from dateutil.relativedelta import relativedelta
@@ -27,6 +27,7 @@ from app.config import settings
from app.entities.child import Child
from app.entities.leave_wish import LeaveWish
from app.entities.planned_leave import PlannedLeaveDay
+from app.entities.tip_dismissal import TipDismissal
from app.repositories.child_repository import ChildRepository
from app.repositories.day_override_repository import DayOverrideRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
@@ -35,6 +36,7 @@ from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.preschool_planning_days_repository import PreschoolPlanningDaysRepository
+from app.repositories.tip_dismissal_repository import TipDismissalRepository
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.repositories.workspace_repository import WorkspaceRepository
@@ -56,6 +58,7 @@ from app.utils.holidays import (
from app.utils.tip_predicates import (
days_left_at_age_4_projection,
default_tier_pool_share,
+ hash_tip_data,
resolve_age4_savable_cap,
)
@@ -66,7 +69,14 @@ from app.utils.tip_predicates import (
REASON_FREE_WEEKEND = "free_weekend"
REASON_FREE_RED_DAY = "free_red_day"
REASON_VACATION_WRAP_RED_DAY = "vacation_wrap_red_day"
+# §weekend-wrap-bridge — a vacation day spent to bridge/extend a free stretch
+# that does NOT touch a genuine public holiday (a bare Friday-before/Monday-
+# after a plain weekend, or the middle day of a short non-working gap fenced
+# by two plain weekends). See `_vacation_bridge_reason` for the full rule.
+# Sibling of `REASON_VACATION_WRAP_RED_DAY`, which fires instead whenever the
+# adjacent/fencing free run contains a real red day (Jul, Påsk, ...).
REASON_VACATION_WRAP_WEEKEND = "vacation_wrap_weekend"
+REASON_VACATION_INTERIOR = "vacation_interior"
REASON_FP_INTERIOR = "fp_interior"
REASON_FP_LAGSTA_STRETCH = "fp_lagsta_stretch"
REASON_SWAP_FIRST_HALF = "swap_first_half"
@@ -81,6 +91,13 @@ REASON_DUBBELDAG = "dubbeldag"
# red day to replace the (unpaid) day off with a paid one — at the cost of
# spending a day the household would otherwise have kept.
REASON_FP_RED_DAY = "fp_red_day"
+# §vacation-rescue — a SUGGESTED (never applied by `sync_plan`) vacation date
+# generated for a residual forfeiture: a (parent, year) still forecast to
+# lose vacation days at year end even after this run's own placements are
+# netted out (see `_Ledger._vacation_residuals`). Emitted only on `RescueDay`,
+# never on `ProposedDay` — see that dataclass's docstring for why the two are
+# deliberately not unified.
+REASON_VACATION_RESCUE = "vacation_rescue"
# ---------------------------------------------------------------------------
# Surplus advice kinds (§9.2) — the mirror of a shortfall remedy: emitted
@@ -316,8 +333,9 @@ class AllocationInput:
# tab shows), for every year from `today.year` through `horizon_end.year`
# inclusive. Computed by `AllocationInputLoader` from already-committed
# usage, BEFORE this run's own placements are added — see
- # `_Ledger._vacation_expiry_warnings`'s docstring for what that means for
- # the warning it drives. Never contains a year before `today.year`: a
+ # `_Ledger._vacation_residuals`'s docstring for what that means for the
+ # warning (and, for the current year, the `RescueDay`s) it drives. Never
+ # contains a year before `today.year`: a
# past year's loss already happened and must never resurface here or bias
# `_assign_tier3`. Empty by default, so every hand-built `AllocationInput`
# in the test suite keeps producing byte-identical output absent an
@@ -341,6 +359,34 @@ class ProposedDay:
reason: str
+@dataclass(frozen=True)
+class RescueDay:
+ """§vacation-rescue — a concrete, suggest-only date to spend a vacation
+
+ day that would otherwise expire, generated by `_Ledger.build_rescue_days`.
+ Deliberately NOT a `ProposedDay`: a rescue day has no wish behind it
+ (`ProposedDay.wish_id` is non-optional and has many consumers that assume
+ a real wish produced the day), and it must never be picked up by anything
+ that iterates `PlanProposal.days` — balance netting, calendar rendering,
+ exports — before the user explicitly accepts it (a later task adds that
+ accept/dismiss UI). Riding on its own field (`PlanProposal.rescue_days`)
+ is what keeps it invisible to all of that until then.
+
+ `child_id` is required, unlike `ProposedDay.child_id` (`UUID | None`),
+ because it must survive persistence: `PlannedLeaveDay.child_id` and
+ `ProposedDayApplyItem.child_id` are both non-nullable, so a rescue day
+ that could carry `None` would be a suggestion nobody could ever accept.
+ Vacation itself is not per-child — see `_Ledger._rescue_child_id` for how
+ a child is picked purely to satisfy that persistence requirement.
+ """
+
+ date: date
+ parent_personal_data_uuid: UUID
+ child_id: UUID
+ year: int
+ reason: str
+
+
@dataclass(frozen=True)
class ShortfallRemedy:
kind: str
@@ -467,6 +513,12 @@ class PlanProposal:
# §3.3 part 2 — plan-aware age-4 projection per (parent, child), always
# present (unlike `surplus_advice`, which is empty absent a surplus).
age4_projections: list[Age4Projection] = field(default_factory=list)
+ # §vacation-rescue — suggest-only concrete dates to spend a vacation day
+ # that would otherwise expire this year (see `RescueDay`'s docstring for
+ # why these ride on their own field rather than joining `days`). Empty
+ # when nothing is forecast to expire this year, or when there are no
+ # children to attribute a rescue day's required `child_id` to.
+ rescue_days: list[RescueDay] = field(default_factory=list)
# ---------------------------------------------------------------------------
@@ -543,6 +595,103 @@ def _red_day_cluster_dates(
return result
+# §weekend-wrap-bridge — how far `_vacation_bridge_reason` walks outward when
+# tracing a free-day run or a short interior gap. An ordinary Mon-Fri week is
+# already a 5-day non-free run, and Sweden's longest realistic red-day
+# corridor (Jul/Nyår) is well under this bound, so 10 keeps both scans
+# O(1)-ish per date while never mis-measuring a real case.
+_BRIDGE_SCAN_BOUND_DAYS = 10
+
+
+def _free_run_contains_real_red_day(
+ edge: date,
+ holidays_map: dict[date, str],
+ overrides: dict[date, bool] | None,
+ step: int,
+) -> bool:
+ """Whether the free-day run starting at `edge` (already known free) and
+
+ extending in direction `step` (+1 or -1) contains >=1 real red day.
+ `edge` is the day immediately touching the non-free date being examined,
+ so walking outward from it in `step` direction traverses the whole
+ contiguous free run on that side. Bounded to `_BRIDGE_SCAN_BOUND_DAYS`
+ steps — see that constant's docstring.
+ """
+ d = edge
+ for _ in range(_BRIDGE_SCAN_BOUND_DAYS):
+ if not is_free_day(d, holidays_map, overrides):
+ return False
+ if _is_real_red_day(d, holidays_map):
+ return True
+ d += timedelta(days=step)
+ return False
+
+
+def _vacation_bridge_reason(
+ d: date, holidays_map: dict[date, str], overrides: dict[date, bool] | None = None
+) -> str | None:
+ """§weekend-wrap-bridge — the reason code for spending vacation on `d` to
+
+ bridge/extend a free stretch, or `None` if `d` is an ordinary interior
+ workday that should fall through to `_assign_tier3`. `d` is assumed
+ non-free — every caller already branched on `is_free_day` first.
+
+ Replaces the old, narrower tier-2 rule (klämdag directly touching a
+ real-red-day cluster only) with a broader one that also covers a bare
+ weekend wrap (Friday before / Monday after a plain Sat+Sun) and the
+ middle day of a short (<=3 day) non-working gap, regardless of whether a
+ real red day is involved:
+
+ 1. Adjacent — `d-1` or `d+1` is free: `d` bridges into that free day.
+ The reason is `REASON_VACATION_WRAP_RED_DAY` when the adjacent free
+ run contains a genuine holiday, else `REASON_VACATION_WRAP_WEEKEND`.
+ 2. Middle of a short run — else, `d` sits inside a maximal run of
+ consecutive non-free days. If that run is `<= 3` days long, `d` is
+ its middle day (lengths 1 and 2 are already caught by rule 1) and is
+ still a bridge; the reason depends on whether either FENCING free run
+ (immediately before/after the whole gap) contains a real red day.
+ 3. Otherwise `None` — an ordinary interior workday (e.g. Tue/Wed/Thu of
+ a normal week, or any day of a run longer than 3).
+ """
+ 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)
+
+ if prev_free or next_free:
+ is_red = (
+ prev_free and _free_run_contains_real_red_day(prev_day, holidays_map, overrides, -1)
+ ) or (next_free and _free_run_contains_real_red_day(next_day, holidays_map, overrides, 1))
+ return REASON_VACATION_WRAP_RED_DAY if is_red else REASON_VACATION_WRAP_WEEKEND
+
+ # Neither neighbour is free -> `d` is strictly interior to a non-free
+ # run of length >= 3. Find that run's bounds (bounded walk both ways).
+ 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
+
+ run_length = (run_end - run_start).days + 1
+ if run_length > 3:
+ return None
+
+ before_fence = run_start - timedelta(days=1)
+ after_fence = run_end + timedelta(days=1)
+ is_red = _free_run_contains_real_red_day(
+ before_fence, holidays_map, overrides, -1
+ ) or _free_run_contains_real_red_day(after_fence, holidays_map, overrides, 1)
+ return REASON_VACATION_WRAP_RED_DAY if is_red else REASON_VACATION_WRAP_WEEKEND
+
+
def _preschool_reserve_days(
child: ChildFacts,
today: date,
@@ -844,6 +993,29 @@ class _Ledger:
}
self._dubbeldagar_used: dict[UUID, int] = {}
self._preschool_reserved: dict[UUID, int] = {}
+ # §vacation-never-withhold — every `ProposedDay` `_fund_solo` places
+ # via its tier-2 (bridge) or tier-3 (goal-dependent interior)
+ # resolution, i.e. every day a per-(parent, year) value-ordered
+ # reassignment is allowed to touch afterwards
+ # (`reallocate_vacation_by_value`). Deliberately does NOT include a
+ # day `_fund_both` places directly (dubbeldagar, the bridge-both
+ # pairing, or the ordinary co-leave FP+vacation pairing) — those are
+ # routing decisions between the two parents on a shared date, not a
+ # single parent's tier-3 choice, and reassigning them after the fact
+ # would fight that routing rather than merely re-time which DATE
+ # gets vacation. A `_fund_both` window's own leftover dates that fall
+ # through to `_fund_swap` (and from there to `_fund_solo`) ARE
+ # collected here, same as any other solo/swap-mode day.
+ #
+ # Membership is checked by VALUE (this is a `set[ProposedDay]`, and
+ # `ProposedDay` is a frozen, hashable dataclass), never by object
+ # identity: a discarded flexible-window probe's `ProposedDay`s (see
+ # `_candidate_shortfall`) get added here too, but since a probe's
+ # days are never placed into the real `all_days` list, they simply
+ # never match anything the reassignment pass iterates over — inert,
+ # not a leak (and safer than `id()`, which CPython can and does
+ # reuse once a probe object is garbage-collected).
+ self._tier3_eligible: set[ProposedDay] = set()
self._existing_lookup: dict[tuple[UUID, UUID | None, date], str] = {
(p.parent_personal_data_uuid, p.child_id, p.date): p.day_type
@@ -933,6 +1105,11 @@ class _Ledger:
if not p.is_pinned:
continue
if p.day_type == "vacation":
+ # A pinned day is a hard, already-spent fact (see the
+ # docstring above); it must debit the budget unconditionally
+ # — `_spend_vacation` never refuses anyway (§vacation-never-
+ # withhold), but a pinned day isn't even an interior-day
+ # *choice* the value-ordered reassignment should ever revisit.
self._spend_vacation(p.parent_personal_data_uuid, p.date)
continue
pool = self._pools.get((p.parent_personal_data_uuid, p.child_id))
@@ -1105,12 +1282,53 @@ class _Ledger:
return all_dates
return [d for d in all_dates if d not in excluded]
+ def _peek_vacation(self, parent_uuid: UUID, d: date) -> bool:
+ """Whether `_spend_vacation(parent_uuid, d)` would succeed, WITHOUT
+
+ spending it. Split out from `_spend_vacation` (which is now
+ `_peek_vacation` + `_commit_vacation`) so a caller that must fund two
+ parents all-or-nothing — `_try_bridge_vacation_pair`, mirroring
+ `_try_co_leave_pair`'s FP-side peek/commit split — can peek both
+ sides before committing either, exactly the discipline that keeps a
+ failed pairing from ever leaving a half-spent budget behind.
+
+ §vacation-never-withhold — the product rule ("spend vacation
+ greedily; never refuse a day because a better one might come along")
+ means this is now the WHOLE eligibility rule: available budget,
+ full stop. There used to be a second, `high_value`-gated branch here
+ that refused an ordinary interior-day request once the budget ran
+ low enough to threaten a not-yet-reached bridge/klämdag date for
+ this (parent, year) — see git history (§vacation-reservation) for
+ that version. It was a genuine bug: a date the planner withheld
+ budget FOR could still fail to materialise (excluded, the window
+ never gets placed, ...), permanently losing a vacation day to FP for
+ nothing. `reallocate_vacation_by_value` (called once, after all
+ funding, from `Allocator.propose()`) replaces it: fund every request
+ immediately, then re-time WHICH dates ended up with vacation, using
+ the exact same red-day-bridge > weekend-bridge > interior ranking
+ the old reservation used to protect ahead of time.
+ """
+ budget = self._vacation.get((parent_uuid, d.year))
+ return budget is not None and budget.available > 0
+
+ def _commit_vacation(self, parent_uuid: UUID, d: date) -> None:
+ """Actually spend a vacation day already confirmed payable by a prior
+
+ `_peek_vacation(parent_uuid, d)` call — see that method's docstring
+ for why the two are split.
+ """
+ self._vacation[(parent_uuid, d.year)].already_used += 1
+
def _spend_vacation(self, parent_uuid: UUID, d: date) -> bool:
- key = (parent_uuid, d.year)
- budget = self._vacation.get(key)
- if budget is None or budget.available <= 0:
+ """A thin peek-then-commit wrapper (see `_peek_vacation`/
+
+ `_commit_vacation`) for the common single-parent case. See
+ `_peek_vacation`'s docstring for the (now unconditional-on-budget)
+ eligibility rule.
+ """
+ if not self._peek_vacation(parent_uuid, d):
return False
- budget.already_used += 1
+ self._commit_vacation(parent_uuid, d)
return True
def _spend_fp(self, pool_key: tuple[UUID, UUID], prefer: str) -> str | None:
@@ -1162,9 +1380,30 @@ class _Ledger:
if fp_tier is None or not vacation_can_pay:
return None
self._commit_fp_tier(fp_pool, fp_tier)
+ # Co-leave's vacation side is a routing decision between the two
+ # parents on a shared date, not `_assign_tier3`'s single-parent
+ # interior choice — `_spend_vacation` no longer distinguishes the two
+ # (§vacation-never-withhold: it only ever checks available budget),
+ # so this is just an ordinary commit.
self._spend_vacation(vacation_parent.uuid, d)
return fp_parent, fp_tier, vacation_parent
+ def _try_bridge_vacation_pair(
+ self, parent_a: ParentFacts, parent_b: ParentFacts, d: date
+ ) -> bool:
+ """§bridge-both-vacation — peek-then-commit BOTH parents' vacation for
+
+ a bridge date (`_vacation_bridge_reason(d, ...) is not None`,
+ checked by the caller), all-or-nothing exactly like
+ `_try_co_leave_pair`: if either parent's vacation can't pay, NEITHER
+ is spent, so a half-spent pair is never left behind.
+ """
+ if not (self._peek_vacation(parent_a.uuid, d) and self._peek_vacation(parent_b.uuid, d)):
+ return False
+ self._commit_vacation(parent_a.uuid, d)
+ self._commit_vacation(parent_b.uuid, d)
+ return True
+
def _maybe_clamp_age4(self, pool_key: tuple[UUID, UUID] | None, window_start: date) -> None:
"""§9.1: from the 4th birthday onward, the FP pool is hard-capped at
min(remaining, `self._input.age4_savable_cap`). Applied once per
@@ -1226,24 +1465,30 @@ class _Ledger:
docstring). Never applies to a past year: every date reaching this
method already comes from a window clipped to `>= today`, and
`vacation_forfeiting` itself never carries a year before
- `today.year`. Only ever spends MORE vacation than the hourly default
- would, never less, so it can't push a parent below their
- `min_vacation_days_per_year` floor — that floor bounds how little
- vacation may be taken, not how much, and `_spend_vacation` still
- refuses once a year's budget is exhausted regardless of this bias.
+ `today.year`.
+
+ §vacation-never-withhold — `_spend_vacation` never refuses for lack
+ of a "better date might come along" reason any more (it only ever
+ checks the (parent, year) budget), so there is no longer a second,
+ reservation-bypassing retry here: the first attempt already IS the
+ unconditional one. `reallocate_vacation_by_value` (run once, after
+ every window is funded) is what re-times WHICH dates end up with
+ vacation vs. FP when the budget can't cover every request — this
+ method just spends greedily in whichever order the caller reaches
+ each date.
"""
if child is None or pool_key is None:
# No resolvable child (genuinely ambiguous family-wide wish, or a
# stale child_id) -> no FP pool to draw from. Vacation or nothing.
if self._spend_vacation(parent.uuid, d):
- return "vacation", REASON_VACATION_WRAP_WEEKEND
+ return "vacation", REASON_VACATION_INTERIOR
return None
vacation_forfeiting = self._input.vacation_forfeiting.get((parent.uuid, d.year), 0) > 0
if parent.salary_type == "monthly" or vacation_forfeiting:
if self._spend_vacation(parent.uuid, d):
- return "vacation", REASON_VACATION_WRAP_WEEKEND
+ return "vacation", REASON_VACATION_INTERIOR
fp = self._spend_fp(pool_key, prefer="sjukpenning")
if fp:
return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH)
@@ -1253,7 +1498,7 @@ class _Ledger:
if fp:
return fp, (REASON_FP_INTERIOR if fp == "sjukpenning" else REASON_FP_LAGSTA_STRETCH)
if self._spend_vacation(parent.uuid, d):
- return "vacation", REASON_VACATION_WRAP_WEEKEND
+ return "vacation", REASON_VACATION_INTERIOR
return None
# -- per-parent day resolution (tiers 1-3) -----------------------------
@@ -1330,15 +1575,20 @@ class _Ledger:
continue
resolved: tuple[str, str] | None = None
- if (
- d in red_cluster
- or (d - timedelta(days=1)) in red_cluster
- or (d + timedelta(days=1)) in red_cluster
- ):
- # Tier 2 — dominant play: klämdag inside a red-day cluster.
- # Fires regardless of goal or salary_type (§3.3).
- if self._spend_vacation(parent.uuid, d):
- resolved = ("vacation", REASON_VACATION_WRAP_RED_DAY)
+ # §weekend-wrap-bridge — tier 2: broader than the old red-day-
+ # only klämdag rule (superseded; `red_cluster` is no longer
+ # consulted here — see `_vacation_bridge_reason`'s docstring).
+ # Fires regardless of goal, salary_type or `vacation_forfeiting`
+ # (deliberate product decision, PLANNING_ENGINE_SPEC.md §6.1):
+ # bridging a Friday/Monday (or a short gap) with vacation
+ # preserves FP days for later even for an hourly parent, who
+ # would otherwise burn an FP day (or an unpaid free day) right
+ # next to a stretch they're already off for.
+ bridge_reason = _vacation_bridge_reason(
+ d, self._input.holidays, self._input.day_overrides
+ )
+ if bridge_reason is not None and self._spend_vacation(parent.uuid, d):
+ resolved = ("vacation", bridge_reason)
if resolved is None:
resolved = self._assign_tier3(wish, d, parent, child, pool_key)
@@ -1348,7 +1598,14 @@ class _Ledger:
day_type, reason = resolved
cost[day_type] += 1
- days.append(ProposedDay(d, parent.uuid, child_id, day_type, False, wish.id, reason))
+ day = ProposedDay(d, parent.uuid, child_id, day_type, False, wish.id, reason)
+ days.append(day)
+ # §vacation-never-withhold — this IS a tier-2/tier-3 decision
+ # `reallocate_vacation_by_value` is allowed to re-time later (see
+ # `_tier3_eligible`'s docstring). Registered here, at the single
+ # place both tiers actually produce a day, rather than duplicated
+ # at each of `_assign_tier3`'s return sites.
+ self._tier3_eligible.add(day)
return days, cost
@@ -1437,14 +1694,22 @@ class _Ledger:
) -> tuple[list[ProposedDay], dict[str, int], dict[str, int | bool]]:
"""§Decision A (co-leave, 2026-09-18 correction) — both parents off
- on the SAME dates. Three tiers, tried in order for every date:
+ on the SAME dates. Four tiers, tried in order for every date:
1. Dubbeldagar — ONLY when `wish.wants_dubbeldag` is set (opt-in,
never automatic — see `LeaveWish.wants_dubbeldag`) AND the date
is inside the child's 15-month window AND quota remains AND
both parents currently hold FP (peeked, then committed
together — never a lone half-spend).
- 2. Co-leave (PL + vacation) — the default for every other date:
+ 2. §bridge-both-vacation — a date where `_vacation_bridge_reason`
+ fires (a Friday/Monday touching a weekend, or any other bridge
+ date): BOTH parents funded from vacation, no FP spent at all —
+ vacation pays better than FP on a bridge date, so the FP day
+ stays banked for later. Peeked-then-committed together for the
+ same all-or-nothing reason as dubbeldagar/co-leave
+ (`_try_bridge_vacation_pair`). Falls through to tier 3 when
+ either parent's vacation can't pay.
+ 3. Co-leave (PL + vacation) — the default for every other date:
which parent PAYS with FP is decided per CHUNK, not per day —
§fix (long-chunk co-leave, 2026-09-19). The window's dates are
split into two contiguous halves with `_split_dates_by_
@@ -1461,7 +1726,7 @@ class _Ledger:
for the rest of the chunk rather than flip-flopping day to
day. Each attempted pairing is peeked-then-committed together:
if either side can't pay, NEITHER is spent for that date.
- 3. Swap (last resort) — a date co-leave genuinely cannot fund
+ 4. Swap (last resort) — a date co-leave genuinely cannot fund
(the higher-FP parent has none left, or the other parent has
no vacation left) is handed to `_fund_swap` together with the
rest of that leftover run, which splits it into sequential
@@ -1615,6 +1880,46 @@ class _Ledger:
dubbeldag_dates.add(d)
funded = True
+ if not funded:
+ # §bridge-both-vacation — a Friday/Monday touching a weekend
+ # (or any other `_vacation_bridge_reason` date) is worth more
+ # spent as vacation than as FP for EITHER parent: vacation
+ # pays better, and the FP day stays banked for later. Tried
+ # before the ordinary co-leave (FP+vacation) pairing below,
+ # but after dubbeldagar — an explicit user request beats this
+ # optimisation. Peek-then-commit BOTH parents together
+ # (`_try_bridge_vacation_pair`): if either can't pay, NEITHER
+ # is spent, so a half-spent pair is never left behind. Unlike
+ # the co-leave/dubbeldag tiers, this doesn't need `pool_a`/
+ # `pool_b` — it never touches an FP pool at all.
+ bridge_reason = _vacation_bridge_reason(
+ d, self._input.holidays, self._input.day_overrides
+ )
+ if bridge_reason is not None and self._try_bridge_vacation_pair(
+ parent_a, parent_b, d
+ ):
+ cost["vacation"] += 2
+ days.append(
+ ProposedDay(
+ d, parent_a.uuid, child.id, "vacation", False, wish.id, bridge_reason
+ )
+ )
+ days.append(
+ ProposedDay(
+ d, parent_b.uuid, child.id, "vacation", False, wish.id, bridge_reason
+ )
+ )
+ # No dedicated `strategy` value exists for "both parents
+ # on vacation together" (see the value-set comment on
+ # `WindowOutcome.strategy`) — it is still both parents
+ # off on the SAME date, so it folds into `co_leave_dates`
+ # for meta/strategy purposes exactly like the ordinary
+ # FP+vacation pairing right below. `strategy` must never
+ # lie by claiming 'dubbeldag'/'solo'/'swap', and
+ # `co_leave` is the closest truthful label available.
+ co_leave_dates.add(d)
+ funded = True
+
if not funded and pool_a is not None and pool_b is not None:
# §fix (long-chunk co-leave, 2026-09-19) — the FP-payer is
# decided once per half of the window (`first_half_dates`),
@@ -1660,7 +1965,7 @@ class _Ledger:
"vacation",
False,
wish.id,
- REASON_VACATION_WRAP_WEEKEND,
+ REASON_VACATION_INTERIOR,
)
)
co_leave_dates.add(d)
@@ -1986,6 +2291,195 @@ class _Ledger:
for d in days:
self._existing_lookup[(d.parent_personal_data_uuid, d.child_id, d.date)] = d.day_type
+ # -- §vacation-never-withhold: value-ordered vacation placement --------
+ #
+ # Replaces the old "reservation" (a `high_value` flag on `_spend_vacation`
+ # that REFUSED a low-value request once the budget got low enough to
+ # threaten a not-yet-reached bridge date — see git history for that
+ # version). The product rule is: spend vacation greedily, never refuse a
+ # day for the sake of a better one that might not even materialise, but
+ # when the (parent, year) budget can't cover every date that wants it,
+ # make sure it's the BEST dates that get it. Refusing up front couldn't
+ # do that reliably (a "protected" date could itself turn out excluded, or
+ # its window never placed, permanently wasting the reservation); doing it
+ # as a single pass AFTER every window is funded can, because by then
+ # every (parent, year)'s actual candidate set is fully known.
+ #
+ # `reallocate_vacation_by_value` never lowers the number of vacation days
+ # placed for a (parent, year): every move is a strict 1-for-1 swap (a
+ # worse-ranked vacation day is demoted to FP ONLY to free the unit a
+ # better-ranked FP day is promoted with) — never a day given up for
+ # nothing, and never a NEW vacation spend invented beyond what the
+ # ordinary greedy pass already committed (see `_reallocate_vacation_
+ # group`'s docstring for why a "spend whatever's left over" backfill
+ # step would overreach here).
+
+ def reallocate_vacation_by_value(
+ self,
+ all_days: list[ProposedDay],
+ day_cost_dict: dict[ProposedDay, dict[str, int]],
+ ) -> None:
+ """Re-time which dates get vacation, grouped by (parent, year).
+
+ `all_days` is every window's placed days, already funded by the
+ ordinary greedy pass; mutated in place (`ProposedDay` is frozen, so
+ entries are replaced via `dataclasses.replace`, never mutated
+ in-place). `day_cost_dict` maps each day to the (mutable)
+ `WindowOutcome.cost_breakdown` dict it counted toward, so a swap can
+ keep that window's own breakdown in sync — see the module comment
+ above `propose()`'s call site.
+
+ Only touches days in `self._tier3_eligible` (§bridge-both-vacation's
+ pairing, co-leave's FP+vacation pairing and dubbeldagar are never
+ reassigned — see `_tier3_eligible`'s docstring for why). A no-op
+ whenever nothing was ever tier-2/tier-3-eligible (every hand-built
+ `AllocationInput` a test builds without any wish reaching that path).
+ """
+ if not self._tier3_eligible:
+ return
+ groups: dict[tuple[UUID, int], list[int]] = {}
+ for idx, day in enumerate(all_days):
+ if day not in self._tier3_eligible:
+ continue
+ groups.setdefault((day.parent_personal_data_uuid, day.date.year), []).append(idx)
+
+ # Sorted so a dict's (insertion-order-only) iteration can never
+ # influence the byte-identical-output contract (§Allocator
+ # docstring) — insertion order here already tracks `all_days`, which
+ # is itself deterministic, but sorting makes that explicit rather
+ # than relying on it.
+ for key in sorted(groups, key=lambda k: (k[0].int, k[1])):
+ self._reallocate_vacation_group(all_days, day_cost_dict, groups[key])
+
+ def _vacation_rank(self, d: date) -> tuple[int, date]:
+ """Best-first ranking key: red-day bridge > weekend bridge > ordinary
+
+ interior (`_vacation_bridge_reason`'s own class order — see its
+ docstring), earlier dates first within a class (an arbitrary but
+ deterministic tie-break; chronological is the natural default for
+ "which of two equally-valuable dates to prefer").
+ """
+ reason = _vacation_bridge_reason(d, self._input.holidays, self._input.day_overrides)
+ value = {
+ REASON_VACATION_WRAP_RED_DAY: 0,
+ REASON_VACATION_WRAP_WEEKEND: 1,
+ }.get(reason, 2)
+ return value, d
+
+ def _vacation_reason_for(self, d: date) -> str:
+ reason = _vacation_bridge_reason(d, self._input.holidays, self._input.day_overrides)
+ return reason if reason is not None else REASON_VACATION_INTERIOR
+
+ @staticmethod
+ def _day_pool_key(day: ProposedDay) -> tuple[UUID, UUID] | None:
+ if day.child_id is None:
+ return None
+ return day.parent_personal_data_uuid, day.child_id
+
+ def _reallocate_vacation_group(
+ self,
+ all_days: list[ProposedDay],
+ day_cost_dict: dict[ProposedDay, dict[str, int]],
+ indices: list[int],
+ ) -> None:
+ """Re-time vacation within one (parent, year) group via a pure swap.
+
+ Deliberately NOT also a "backfill" step that spends any budget still
+ unclaimed after the swap on the best remaining FP day, regardless of
+ why it is FP (the second shape this module's header comment
+ mentions, and the more obvious-looking one) — that would overreach:
+ an FP day here can be FP for two entirely different reasons, and only
+ one of them is this rule's business.
+
+ - Scarcity: `_assign_tier3` genuinely wanted vacation for this date
+ and the budget was already at 0 when it got there. For a monthly
+ (or forfeiting-year) parent this is the ONLY way an FP day ends up
+ in this group at all, and it means the group's budget is already
+ fully spent — there is nothing left to backfill WITH.
+ - Salary-type preference: an hourly, non-forfeiting parent's
+ `_assign_tier3` branch tries FP FIRST on purpose (§3.3 — vacation
+ would cost them lost pay) and only falls back to vacation once FP
+ itself is exhausted. A day funded this way with budget still
+ sitting unspent is not a date "the budget couldn't cover" — it is
+ working exactly as designed, and force-spending leftover vacation
+ on it would silently override that salary-type ordering for no
+ gain (there is no better-ranked date being starved by it, or the
+ swap below would already have claimed it).
+
+ Only the swap below can tell the two apart (it only ever moves a
+ unit that is currently BLOCKING a better-ranked date), so it is the
+ only move `reallocate_vacation_by_value` makes.
+ """
+ ordered = sorted(indices, key=lambda idx: self._vacation_rank(all_days[idx].date))
+
+ # Two-pointer swap: `i` walks best-first looking for a date that
+ # WANTS vacation but doesn't have it; `j` walks worst-first looking
+ # for a date that HAS vacation but ranks worse than `i`. Every
+ # successful swap is a strict exchange (one unit moves from a
+ # worse-ranked date to a better-ranked one, total spend unchanged);
+ # a demotion that fails (the FP pool it would need has nothing left
+ # either) just means that unit is stuck — `j` moves on without
+ # advancing `i`, so `i`'s date is retried against the next worst
+ # vacation-holder instead of being silently given up on.
+ i, j = 0, len(ordered) - 1
+ while i < j:
+ promote_idx = ordered[i]
+ if all_days[promote_idx].day_type == "vacation":
+ i += 1
+ continue
+ demote_idx = ordered[j]
+ if all_days[demote_idx].day_type != "vacation":
+ j -= 1
+ continue
+ if self._swap_vacation(all_days, day_cost_dict, promote_idx, demote_idx):
+ i += 1
+ j -= 1
+ else:
+ j -= 1
+
+ def _swap_vacation(
+ self,
+ all_days: list[ProposedDay],
+ day_cost_dict: dict[ProposedDay, dict[str, int]],
+ promote_idx: int,
+ demote_idx: int,
+ ) -> bool:
+ """Try to hand `demote_idx`'s vacation unit to `promote_idx`.
+
+ Demotes first (frees the unit) and only then promotes, so a demotion
+ that turns out infeasible (the pool it would need is itself
+ exhausted) leaves BOTH days untouched — never a unit spent twice or
+ dropped on the floor.
+ """
+ demote_day = all_days[demote_idx]
+ pool_key = self._day_pool_key(demote_day)
+ if pool_key is None:
+ return False
+ fp_tier = self._spend_fp(pool_key, prefer="sjukpenning")
+ if fp_tier is None:
+ return False
+
+ self._vacation[
+ (demote_day.parent_personal_data_uuid, demote_day.date.year)
+ ].already_used -= 1
+ fp_reason = REASON_FP_INTERIOR if fp_tier == "sjukpenning" else REASON_FP_LAGSTA_STRETCH
+ demote_cost = day_cost_dict.get(demote_day)
+ if demote_cost is not None:
+ demote_cost["vacation"] -= 1
+ demote_cost[fp_tier] = demote_cost.get(fp_tier, 0) + 1
+ all_days[demote_idx] = replace(demote_day, day_type=fp_tier, reason=fp_reason)
+
+ promote_day = all_days[promote_idx]
+ self._commit_vacation(promote_day.parent_personal_data_uuid, promote_day.date)
+ promote_cost = day_cost_dict.get(promote_day)
+ if promote_cost is not None:
+ promote_cost[promote_day.day_type] -= 1
+ promote_cost["vacation"] = promote_cost.get("vacation", 0) + 1
+ all_days[promote_idx] = replace(
+ promote_day, day_type="vacation", reason=self._vacation_reason_for(promote_day.date)
+ )
+ return True
+
def _candidate_occupied(
self,
start: date,
@@ -2093,6 +2587,15 @@ class _Ledger:
probe may leave a trace on the real ledger, win or lose. The winning
candidate is funded for real afterwards through the ordinary
`fund_window()` path, exactly as before this fix.
+
+ Deliberately does NOT also save/restore `self._tier3_eligible`: a
+ losing (or even winning, pre-real-funding) candidate's probe DOES
+ add its own `ProposedDay`s to it, but that set is read later only by
+ intersecting it against the real `all_days` list
+ (`reallocate_vacation_by_value`), and a probe's `days` are never
+ placed into `all_days` — so the extra membership is inert, not a
+ leak. See `_tier3_eligible`'s docstring for why value-based (not
+ `id()`-based) membership makes this safe.
"""
dates = self._window_dates(wish, responsible, candidate_start, candidate_end)
red_cluster = _red_day_cluster_dates(
@@ -2386,13 +2889,124 @@ class _Ledger:
remedies=remedies,
)
+ # -- vacation rescue (§vacation-rescue) --------------------------------
+
+ def _rescue_child_id(self) -> UUID | None:
+ """Which child a rescue day's (required) `child_id` is attributed to.
+
+ Vacation is not per-child, so this is purely to satisfy persistence
+ (`PlannedLeaveDay.child_id`/`ProposedDayApplyItem.child_id` are both
+ non-nullable — see `RescueDay`'s docstring). No children at all means
+ no rescue days can ever be offered (`None` here always short-circuits
+ `build_rescue_days` to `[]`). Exactly one child is the unambiguous
+ case. Two or more: the youngest by `birth_date` (the child whose
+ preschool-reserve/age-4 pressure is furthest away, so attributing a
+ vacation suggestion to them is the least likely to mislead), tied by
+ `str(id)` so the pick stays deterministic.
+ """
+ children = self._input.children
+ if not children:
+ return None
+ youngest = max(children, key=lambda c: (c.birth_date, str(c.id)))
+ return youngest.id
+
+ def _rescue_rank_bucket(self, d: date) -> int:
+ """§vacation-rescue ranking — reuses `_vacation_bridge_reason` rather
+
+ than reinventing adjacency: 0 = extends a genuine red-day cluster,
+ 1 = extends a plain weekend, 2 = an ordinary interior workday.
+ """
+ reason = _vacation_bridge_reason(d, self._input.holidays, self._input.day_overrides)
+ if reason == REASON_VACATION_WRAP_RED_DAY:
+ return 0
+ if reason == REASON_VACATION_WRAP_WEEKEND:
+ return 1
+ return 2
+
+ def build_rescue_days(self, all_days: list[ProposedDay]) -> list[RescueDay]:
+ """§vacation-rescue — concrete dates to spend a vacation day that
+
+ would otherwise expire THIS calendar year (`self._input.today.year`
+ only — a future year's forecast is still provisional, since
+ `sync_plan` re-runs on every edit and the loss hasn't crystallised
+ yet; it keeps getting the existing `WARNING_VACATION_EXPIRING` with
+ no dates named). Suggest-only: this method reads ledger state
+ (`_existing_lookup`, which already includes this run's own
+ placements via `record_days`) but never mutates it — no
+ `_spend_vacation`, no `record_days` — so `snapshot_balances()` keeps
+ reporting the true, un-rescued balance.
+
+ Per (parent, year) residual (see `_vacation_residuals`), candidates
+ are every date strictly after `today` through Dec 31 of that year
+ that is not a free day, not user-excluded, and not already occupied
+ — checked by (parent, date) ONLY, never narrowed to one `child_id`:
+ a parent can only be off once per date regardless of which child a
+ competing commitment is filed under, so a naive per-child lookup
+ would miss a collision recorded under a different child. Ranked by
+ `_rescue_rank_bucket` (red-day bridge, then weekend bridge, then
+ interior), later dates first within a bucket — a date nearer the
+ deadline leaves more runway for the user's own wishes to still claim
+ the earlier ones on a later sync — and capped at the residual count;
+ fewer eligible candidates than the residual just means fewer days
+ back, never an error or a fabricated date.
+ """
+ current_year = self._input.today.year
+ residuals = {
+ (parent_uuid, year): count
+ for (parent_uuid, year), count in self._vacation_residuals(all_days).items()
+ if year == current_year
+ }
+ if not residuals:
+ return []
+
+ child_id = self._rescue_child_id()
+ if child_id is None:
+ return []
+
+ # (parent, date) only — see the docstring above for why the
+ # `child_id` component of these keys must be dropped.
+ occupied_parent_dates = {(p, d) for (p, _child_id, d) in self._existing_lookup}
+ excluded_parent_dates = {(p, d) for (p, _child_id, d) in self._input.excluded_days}
+
+ year_end = date(current_year, 12, 31)
+ rescue_days: list[RescueDay] = []
+ for (parent_uuid, year), count in sorted(residuals.items(), key=lambda kv: str(kv[0][0])):
+ candidates: list[date] = []
+ d = self._input.today + timedelta(days=1)
+ while d <= year_end:
+ if (
+ not is_free_day(d, self._input.holidays, self._input.day_overrides)
+ and (parent_uuid, d) not in excluded_parent_dates
+ and (parent_uuid, d) not in occupied_parent_dates
+ ):
+ candidates.append(d)
+ d += timedelta(days=1)
+
+ candidates.sort(key=lambda c: (self._rescue_rank_bucket(c), -c.toordinal()))
+
+ for chosen in candidates[:count]:
+ rescue_days.append(
+ RescueDay(
+ date=chosen,
+ parent_personal_data_uuid=parent_uuid,
+ child_id=child_id,
+ year=year,
+ reason=REASON_VACATION_RESCUE,
+ )
+ )
+
+ rescue_days.sort(key=lambda r: (r.date, str(r.parent_personal_data_uuid)))
+ return rescue_days
+
# -- warnings ------------------------------------------------------------
- def build_warnings(self, all_days: list[ProposedDay]) -> list[PlanWarning]:
+ def build_warnings(
+ self, all_days: list[ProposedDay], rescue_days: list[RescueDay]
+ ) -> 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))
+ warnings.extend(self._vacation_expiry_warnings(all_days, rescue_days))
return warnings
def _age4_projections(
@@ -2554,7 +3168,7 @@ class _Ledger:
if d.parent_personal_data_uuid == parent.uuid
and d.child_id == child.id
and d.day_type == "vacation"
- and d.reason == REASON_VACATION_WRAP_WEEKEND
+ and d.reason == REASON_VACATION_INTERIOR
and d.date < child.fourth_birthday
)
days_involved = min(swappable, expiring)
@@ -2683,7 +3297,7 @@ class _Ledger:
)
return warnings
- def _vacation_expiry_warnings(self, all_days: list[ProposedDay]) -> list[PlanWarning]:
+ def _vacation_residuals(self, all_days: list[ProposedDay]) -> dict[tuple[UUID, int], int]:
"""§Task 2 — the real forfeiture figure (`AllocationInput.
vacation_forfeiting`, sourced from the same FIFO carry-forward
simulation the Analytics tab uses) replaces the old
@@ -2714,6 +3328,13 @@ class _Ledger:
forecast itself; the clamp stops the count going negative once
placements cover (or exceed) the forecast, and any placements
beyond that draw from newer, non-expiring entries and are ignored.
+
+ Shared by `_vacation_expiry_warnings` (§Task 2) and
+ `build_rescue_days` (§vacation-rescue) so a residual is computed
+ exactly once and the warning and the concrete dates it names can
+ never quietly disagree on the count. Only ever returns POSITIVE
+ residuals — a (parent, year) fully covered by this run's own
+ placements is simply absent, not present at 0.
"""
placed_vacation_by_parent_year: dict[tuple[UUID, int], int] = {}
for d in all_days:
@@ -2722,14 +3343,34 @@ class _Ledger:
key = (d.parent_personal_data_uuid, d.date.year)
placed_vacation_by_parent_year[key] = placed_vacation_by_parent_year.get(key, 0) + 1
- warnings: list[PlanWarning] = []
- for (parent_uuid, year), forecast in sorted(
- self._input.vacation_forfeiting.items(), key=lambda kv: (str(kv[0][0]), kv[0][1])
- ):
+ residuals: dict[tuple[UUID, int], int] = {}
+ for (parent_uuid, year), forecast in self._input.vacation_forfeiting.items():
placed = placed_vacation_by_parent_year.get((parent_uuid, year), 0)
expiring = max(0, forecast - placed)
- if expiring <= 0:
- continue
+ if expiring > 0:
+ residuals[(parent_uuid, year)] = expiring
+ return residuals
+
+ def _vacation_expiry_warnings(
+ self, all_days: list[ProposedDay], rescue_days: list[RescueDay]
+ ) -> list[PlanWarning]:
+ """§Task 2 + §vacation-rescue — one `WARNING_VACATION_EXPIRING` per
+
+ residual forfeiture (see `_vacation_residuals`), now also carrying
+ `rescue_days_available`: how many concrete `RescueDay` dates
+ `build_rescue_days` managed to name for that same (parent, year) —
+ 0 when none were eligible, never absent. No new warning kind and no
+ schema break: `PlanWarning.data` is already `dict[str, object]`.
+ """
+ rescue_counts: dict[tuple[UUID, int], int] = {}
+ for r in rescue_days:
+ key = (r.parent_personal_data_uuid, r.year)
+ rescue_counts[key] = rescue_counts.get(key, 0) + 1
+
+ warnings: list[PlanWarning] = []
+ for (parent_uuid, year), expiring in sorted(
+ self._vacation_residuals(all_days).items(), key=lambda kv: (str(kv[0][0]), kv[0][1])
+ ):
warnings.append(
PlanWarning(
kind=WARNING_VACATION_EXPIRING,
@@ -2738,6 +3379,7 @@ class _Ledger:
"parent_personal_data_uuid": parent_uuid,
"year": year,
"expiring_days": expiring,
+ "rescue_days_available": rescue_counts.get((parent_uuid, year), 0),
},
)
)
@@ -2775,16 +3417,35 @@ class Allocator:
all_days: list[ProposedDay] = []
outcomes: list[WindowOutcome] = []
+ # §vacation-never-withhold — which `WindowOutcome.cost_breakdown`
+ # dict a given `ProposedDay` counted toward, so
+ # `reallocate_vacation_by_value` (below) can keep a window's own
+ # cost breakdown in sync after it re-times a day between "vacation"
+ # and an FP tier post-hoc. `cost_breakdown` is a plain (mutable)
+ # dict even though `WindowOutcome` itself is frozen — mutating it in
+ # place is what keeps `outcomes` and `all_days` agreeing about what
+ # actually funded each day. Keyed by VALUE (`ProposedDay` is a
+ # frozen, hashable dataclass) — see `_tier3_eligible`'s docstring on
+ # `_Ledger` for why that is safe here too.
+ day_cost_dict: dict[ProposedDay, dict[str, int]] = {}
# Pass 1 (§10.2, unchanged from §3.2/§3.3): every fixed-wish window is
# placed first, unconditionally — a flexible wish can never displace
- # a fixed one.
+ # a fixed one. No priority-tier batching any more (§vacation-never-
+ # withhold removed the only reason it existed — protecting a
+ # priority tier's reservation from a lower tier — so a flat pass in
+ # the existing (priority, start, wish.id) order is equivalent):
+ # priority still settles WHICH wish gets a contested resource first,
+ # simply by which one is funded first, exactly as plain funding
+ # order always has.
fixed_windows = _expand_all_windows(alloc_input)
fixed_windows.sort(key=lambda w: (w.wish.priority, w.start, w.wish.id.int))
for w in fixed_windows:
days, outcome = ledger.fund_window(w)
all_days.extend(days)
outcomes.append(outcome)
+ for day in days:
+ day_cost_dict[day] = outcome.cost_breakdown
ledger.record_days(days)
# Pass 2 (§10.2, new): flexible wish-years, processed in
@@ -2806,9 +3467,25 @@ class Allocator:
days, outcome = ledger.fund_window(window)
all_days.extend(days)
outcomes.append(outcome)
+ for day in days:
+ day_cost_dict[day] = outcome.cost_breakdown
ledger.record_days(days)
- warnings = ledger.build_warnings(all_days)
+ # §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
+ # rule: for each (parent, year), move the vacation days already
+ # spent onto the best-ranked dates (red-day bridge > weekend bridge
+ # > ordinary interior). Never lowers the number of vacation days
+ # placed for a (parent, year) — see its own docstring.
+ ledger.reallocate_vacation_by_value(all_days, day_cost_dict)
+
+ # §vacation-rescue — generated after every window is placed (so
+ # `_existing_lookup` reflects this run's own occupancy) and before
+ # `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)
surplus_advice = ledger.build_surplus_advice(all_days)
age4_projections = ledger.build_age4_projections(all_days)
balances = ledger.snapshot_balances()
@@ -2820,6 +3497,7 @@ class Allocator:
warnings=warnings,
surplus_advice=surplus_advice,
age4_projections=age4_projections,
+ rescue_days=rescue_days,
)
@@ -3168,6 +3846,72 @@ class AllocationInputLoader:
)
+def _rescue_dismissal_tip_id(parent_personal_data_uuid: UUID, year: int) -> str:
+ """§vacation-rescue dismissal — the synthetic `tip_id` for one (parent,
+ year) group of suggested rescue days.
+
+ Rescue days never appear in `TipsService.RULE_REGISTRY` (they come from
+ the allocator, not a tips rule), so `TipsService.dismiss` can't be reused
+ directly — but the `tip_dismissal` table itself (UNIQUE on
+ `(workspace_id, tip_id)`) doesn't care where a `tip_id` came from, so a
+ synthetic, namespaced id lets `PlanningEngineService` reuse that table
+ and `hash_tip_data` without a second dismissal mechanism.
+ """
+ return f"vacation_rescue:{parent_personal_data_uuid}:{year}"
+
+
+def _rescue_group_hash_data(group: list[RescueDay]) -> dict[str, object]:
+ """Stable, JSON-serialisable fingerprint payload for one rescue-day group.
+
+ Deliberately the group's IDENTITY — `parent_personal_data_uuid` and
+ `year` (every entry in `group` shares both, by construction — see
+ `_group_rescue_days`), the same two fields `_rescue_dismissal_tip_id`
+ already encodes in the `tip_id` — never the group's own date list.
+
+ This deliberately differs from `TipsService`'s ordinary tips, which hash
+ over their own *volatile* `data` (see `hash_tip_data`'s docstring) so a
+ changed fact gives an old dismissal free auto-expiry — a genuine feature
+ there, because an ordinary tip's `data` only changes when the specific
+ fact it warns about changes. A rescue-day group's date list has no such
+ property: `build_rescue_days` recomputes it from the residual
+ forfeiture across the *entire* plan, so the dates shift on almost any
+ unrelated write — a different rescue group being accepted, a wish edited
+ elsewhere, a background sync — not just on a change to what THIS
+ dismissal was actually about. Hashing on dates made a dismissal
+ self-defeating: dismissing used to re-sync the plan itself
+ (`useRescueDayActions.ts` called `bumpPlanVersion()` after every action,
+ dismiss included — since fixed, Defect 2), which recomputed this very
+ date list, so the stored hash almost never matched again and the group
+ always came back (observed 12 dates -> 6 -> 5, never gone). Even with
+ that particular trigger fixed, the underlying date list is still
+ downstream of the whole plan and still shifts on unrelated writes, so
+ identity-based hashing remains the correct fix.
+
+ Hashing on identity instead suppresses the group until it genuinely
+ stops existing — the residual reaches 0, so the allocator stops
+ producing this (parent, year) group at all — rather than until its
+ dates happen to still match a stale snapshot. Safe to scope this loosely
+ because rescue days are current-year-only (`_Ledger.build_rescue_days`):
+ next year is a different `year`, hence a different `tip_id`
+ (`_rescue_dismissal_tip_id`), so it is unaffected and resurfaces on its
+ own.
+ """
+ first = group[0]
+ return {
+ "parent_personal_data_uuid": str(first.parent_personal_data_uuid),
+ "year": first.year,
+ }
+
+
+def _group_rescue_days(
+ rescue_days: list[RescueDay],
+) -> dict[tuple[UUID, int], list[RescueDay]]:
+ groups: dict[tuple[UUID, int], list[RescueDay]] = {}
+ for r in rescue_days:
+ groups.setdefault((r.parent_personal_data_uuid, r.year), []).append(r)
+ return groups
+
+
class PlanningEngineService:
"""Thin orchestrator gluing the loader and the pure allocator together.
@@ -3176,9 +3920,22 @@ class PlanningEngineService:
testable.
"""
- def __init__(self, loader: AllocationInputLoader, allocator: Allocator | None = None) -> None:
+ def __init__(
+ self,
+ loader: AllocationInputLoader,
+ allocator: Allocator | None = None,
+ # §rescue-day dismissal — optional, like `AllocationInputLoader`'s
+ # `day_override_repo`/`exclusion_repo`: `None` (every direct-
+ # construction call site not yet updated, e.g. scripts/seed_demo.py
+ # and most of the test suite) means "skip dismissal filtering
+ # entirely" — `propose()` returns the allocator's rescue_days
+ # unchanged and `dismiss_rescue_days` is simply unusable, so every
+ # existing caller keeps byte-identical behaviour.
+ dismissal_repo: TipDismissalRepository | None = None,
+ ) -> None:
self._loader = loader
self._allocator = allocator or Allocator()
+ self._dismissal_repo = dismissal_repo
async def propose(
self,
@@ -3199,8 +3956,108 @@ class PlanningEngineService:
(`wish_id is None`) days are unaffected either way: always visible,
always immovable. `POST /api/plan/propose` never sets this — it
keeps its historical, unconditional behaviour.
+
+ §rescue-day dismissal — after the pure allocator runs, any dismissed
+ (parent, year) rescue-day group is filtered out of
+ `PlanProposal.rescue_days` (see `_drop_dismissed_rescue_days`). This
+ happens here, in the async wrapper, never inside `Allocator`/
+ `_Ledger` — those stay pure and DB-free.
"""
alloc_input = await self._loader.load(
workspace_id, today=today, only_pinned_existing=only_pinned_existing
)
- return self._allocator.propose(alloc_input)
+ proposal = self._allocator.propose(alloc_input)
+ if self._dismissal_repo is None or not proposal.rescue_days:
+ return proposal
+ rescue_days = await self._drop_dismissed_rescue_days(workspace_id, proposal.rescue_days)
+ if len(rescue_days) == len(proposal.rescue_days):
+ return proposal
+ return replace(proposal, rescue_days=rescue_days)
+
+ async def _drop_dismissed_rescue_days(
+ self, workspace_id: UUID, rescue_days: list[RescueDay]
+ ) -> list[RescueDay]:
+ """Filter out any (parent, year) rescue-day group whose dismissal is
+ still fresh — the `RescueDay` mirror of `TipsService.
+ _drop_stale_dismissed`.
+ """
+ assert self._dismissal_repo is not None # guarded by the caller
+ dismissals = await self._dismissal_repo.list_by_workspace(workspace_id)
+ if not dismissals:
+ return rescue_days
+ dismissed_hash_by_tip_id = {d.tip_id: d.data_hash for d in dismissals}
+
+ dismissed_keys = {
+ key
+ for key, group in _group_rescue_days(rescue_days).items()
+ if dismissed_hash_by_tip_id.get(_rescue_dismissal_tip_id(*key))
+ == hash_tip_data(_rescue_group_hash_data(group))
+ }
+ if not dismissed_keys:
+ return rescue_days
+ return [
+ r for r in rescue_days if (r.parent_personal_data_uuid, r.year) not in dismissed_keys
+ ]
+
+ async def dismiss_rescue_days(
+ self,
+ workspace_id: UUID,
+ parent_personal_data_uuid: UUID,
+ year: int,
+ today: date | None = None,
+ ) -> None:
+ """Dismiss the currently-suggested vacation-rescue group for one
+ (parent, year), fingerprinted to that group's identity — see
+ `_rescue_group_hash_data`'s docstring for why this is identity, not
+ dates. Still recomputes the current proposal itself and requires a
+ real, currently-suggested group before recording anything — see
+ `TipsService.dismiss`, whose recompute-don't-trust-the-client shape
+ this mirrors (never trusts a client-supplied date list).
+
+ `today` mirrors `propose()`'s own override (tests should always pass
+ it explicitly; the router never does, so production always uses the
+ real wall clock) — needed here because rescue days only ever exist
+ for `today`'s own calendar year (see `_Ledger.build_rescue_days`), so
+ recomputing against the wrong "today" would never find the group.
+
+ Raises `NotFoundError` if that (parent, year) group is not currently
+ being suggested — either because the residual forfeiture no longer
+ exists, or because it's already dismissed with a matching hash (in
+ which case `propose()` already filtered it out of `rescue_days`
+ above, so there is nothing new to record). No dismissal repo wired
+ (`self._dismissal_repo is None`) is treated the same way: without
+ one, a dismissal can never actually take effect, so there is nothing
+ meaningful to dismiss either.
+ """
+ if self._dismissal_repo is None:
+ raise NotFoundError(
+ f"No vacation-rescue suggestion for parent {parent_personal_data_uuid} in {year}"
+ )
+
+ proposal = await self.propose(workspace_id, today=today)
+ group = [
+ r
+ for r in proposal.rescue_days
+ if r.parent_personal_data_uuid == parent_personal_data_uuid and r.year == year
+ ]
+ if not group:
+ raise NotFoundError(
+ f"No vacation-rescue suggestion for parent {parent_personal_data_uuid} in {year}"
+ )
+
+ tip_id = _rescue_dismissal_tip_id(parent_personal_data_uuid, year)
+ data_hash = hash_tip_data(_rescue_group_hash_data(group))
+ existing = await self._dismissal_repo.get_by_workspace_tip(workspace_id, tip_id)
+ if existing is not None:
+ existing.data_hash = data_hash
+ await self._dismissal_repo.update(existing.id, existing)
+ return
+
+ await self._dismissal_repo.create(
+ TipDismissal(
+ id=uuid4(),
+ workspace_id=workspace_id,
+ tip_id=tip_id,
+ data_hash=data_hash,
+ )
+ )
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 8f48e50..9401e77 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -6,12 +6,13 @@ Follow the `vacation_repo` / `vacation_service` pattern to add fixtures for
other entities.
"""
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass
from datetime import date, datetime
from uuid import UUID, uuid4
import aiosqlite
+import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
@@ -22,6 +23,7 @@ from app.entities.personal_data import PersonalData
from app.entities.user import User
from app.entities.workspace import Workspace
from app.main import app
+from app.rate_limit import limiter
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
@@ -58,6 +60,51 @@ from app.services.vacation_days_service import VacationDaysService
from app.services.workspace_service import WorkspaceService
+# ---------------------------------------------------------------------------
+# Rate limiting — OFF by default for the whole test session.
+#
+# `limiter` (app/rate_limit.py) keys on remote address via slowapi's
+# `get_remote_address`, which assumes one IP == one client. Every test in a
+# pytest run shares the same `app.main.app` instance and drives it through
+# `httpx.ASGITransport`, so the whole session looks like one client to the
+# limiter — the same problem `frontend/playwright.config.ts` already works
+# around for the E2E suite via `APP_RATE_LIMIT_ENABLED=false`. Left on here,
+# `tests/integration/test_user_journey.py` alone makes exactly ten
+# `/api/auth/register` calls, spending the entire 10/minute budget, so any
+# other integration test that registers a user 429s if it happens to run
+# afterwards in the same process — a suite that was only green by accident
+# of file ordering.
+#
+# We flip the already-constructed `limiter` object directly rather than
+# `settings.rate_limit_enabled`: `Limiter(..., enabled=...)` reads that
+# setting once at import time in app/rate_limit.py, so mutating `settings`
+# afterwards would no longer reach the singleton the app actually calls.
+# Production is unaffected — `settings.rate_limit_enabled` still defaults to
+# True, so a deployed process still builds its limiter enabled; only this
+# pytest process ever runs the line below.
+limiter.enabled = False
+
+
+@pytest.fixture
+def rate_limiting_enabled() -> Iterator[None]:
+ """Opt-in fixture for a test that specifically exercises rate limiting.
+
+ Turns the shared limiter back on and clears its in-memory counters
+ first — in case some earlier test's traffic ever slipped through while
+ it was "enabled" — then restores both the disabled default and a clean
+ counter set afterwards. `limiter` is a process-global in-memory
+ limiter, so without this a test that enables it would leak state into
+ whichever test runs next.
+ """
+ limiter.reset()
+ limiter.enabled = True
+ try:
+ yield
+ finally:
+ limiter.enabled = False
+ limiter.reset()
+
+
@dataclass
class SeededIds:
workspace_id: UUID
diff --git a/backend/tests/integration/test_rate_limiting.py b/backend/tests/integration/test_rate_limiting.py
new file mode 100644
index 0000000..ade5412
--- /dev/null
+++ b/backend/tests/integration/test_rate_limiting.py
@@ -0,0 +1,55 @@
+"""Real 429s from the shared slowapi limiter.
+
+`tests/conftest.py` turns rate limiting OFF by default for the whole test
+session (see the comment there) — every ASGI test client in a pytest run
+would otherwise share one process-global in-memory limiter keyed by remote
+address, and look like a single client to it. This file is the one place
+that turns the limiter back on (via the `rate_limiting_enabled` fixture) to
+prove the feature itself still works end-to-end: hit `/api/auth/login`
+past its declared 10/minute budget and confirm slowapi actually returns a
+429, not just that our own code assumes it would.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+
+import aiosqlite
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+
+from app.dependencies import get_connection
+from app.main import app
+
+
+@pytest_asyncio.fixture
+async def client(connection: aiosqlite.Connection) -> AsyncIterator[AsyncClient]:
+ """Same technique as `tests/integration/test_user_journey.py`'s `client`
+ fixture: a real app over HTTP, with only the DB connection swapped for
+ the test's in-memory one.
+ """
+ app.dependency_overrides[get_connection] = lambda: connection
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
+ yield ac
+ app.dependency_overrides.pop(get_connection, None)
+
+
+@pytest.mark.usefixtures("rate_limiting_enabled")
+async def test_login_returns_429_once_the_per_minute_budget_is_spent(
+ client: AsyncClient,
+) -> None:
+ payload = {"email": "nobody@example.com", "password": "wrong password entirely"}
+
+ # /api/auth/login is limited to 10/minute (app/routers/auth.py,
+ # `@limiter.limit("10/minute")`). The credentials are deliberately
+ # wrong — slowapi counts the call before the handler body runs, and a
+ # 401 has no side effects to clean up afterwards, unlike registering
+ # ten real users would.
+ for _ in range(10):
+ resp = await client.post("/api/auth/login", json=payload)
+ assert resp.status_code == 401, resp.text
+
+ resp = await client.post("/api/auth/login", json=payload)
+ assert resp.status_code == 429, resp.text
+ assert "Rate limit exceeded" in resp.json()["error"]
diff --git a/backend/tests/integration/test_vacation_rescue_api_flow.py b/backend/tests/integration/test_vacation_rescue_api_flow.py
new file mode 100644
index 0000000..a388240
--- /dev/null
+++ b/backend/tests/integration/test_vacation_rescue_api_flow.py
@@ -0,0 +1,298 @@
+"""API-level flow tests for §vacation-rescue — the accept/dismiss/re-sync
+
+sequence a real browser session drives, which no unit test walks (a unit
+test mocks the seam between layers where all three defects below lived).
+
+Three defects shipped in the vacation-rescue feature and were only caught by
+manual browser testing, while 2273 unit tests stayed green:
+
+1. Dismissing a rescue group did not stick — the dismissal fingerprinted the
+ group's volatile date list, and dismissing triggered a plan sync that
+ changed those dates, so the group always returned on the next propose.
+ Fixed by hashing the group's IDENTITY (`parent_personal_data_uuid`,
+ `year`) instead — see `allocation_service._rescue_group_hash_data`.
+2. A dismiss triggered `POST /api/plan/sync`, which *writes* the plan, from
+ an action that should change no data.
+3. The same parent-year vacation expiry was reported by three different
+ cards with three different numbers.
+
+This file drives the real FastAPI app over HTTP (`ASGITransport`), reusing
+`test_user_journey.py`'s `client` fixture and setup helpers — see that
+file's own module docstring for why (every existing test below the API
+layer exercises one layer in isolation and so never walked this sequence).
+
+Rather than hand-building an `AllocationInput` with `vacation_forfeiting`
+pre-populated (as the allocator's own unit tests do), every scenario here
+gets a REAL forfeiture forecast for free: a workspace with a child and no
+`vacation_days` row entered for the current year defaults to the statutory
+entitlement (`settings.rules.statutory_vacation_days_per_year`, 25) with
+`is_assumed=True`, and with zero vacation used so far this year, the
+FIFO carry-forward simulation (`AnalyticsService._vacation_year_series`)
+forfeits `earned - min_vacation_days_per_year` (25 - 20 = 20) days for
+*every* parent with no explicit override — see
+`AllocationInputLoader.load`'s `vacation_forfeiting` computation and
+`_Ledger.build_rescue_days`. No `_set_vacation` call for the current year is
+needed (or wanted) anywhere in this file for that reason.
+
+Two independent (parent, year) rescue groups are needed — one to accept
+down to zero (proving "shrinks then disappears" via a live, never-dismissed
+propose response) and one to dismiss (proving persistence-across-recompute
+and the no-write guarantee) without the two actions masking each other's
+observable state. A single-parent workspace can't do that (there's only one
+group), so the main scenario adds a placeholder partner (`Sam`) — the same
+`POST /api/workspace/{id}/parents` endpoint `test_user_journey.py`'s
+`TestWorkspaceParentsRenameAndRemove` exercises — giving two independent
+forfeiture forecasts (Alex's and Sam's) off ONE registration.
+`/api/auth/register` is rate-limited to 10/minute and shares its in-memory
+counter with every other test in the process (see `test_user_journey.py`'s
+own docstrings on this) — that file alone already uses all 10 registrations
+in a single run, leaving no budget for a second file to register anyone
+else, so this file makes exactly ONE registration total (the workspace-
+isolation guard below uses fresh random uuids instead of a second tenant —
+see that section's comment for why that's equally valid).
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+from uuid import UUID, uuid4
+
+import aiosqlite
+from dateutil.relativedelta import relativedelta
+from httpx import AsyncClient
+
+from tests.integration.test_user_journey import ( # noqa: F401 (client is a fixture)
+ _add_child,
+ _list_planned_days,
+ _propose,
+ _register,
+ _sync,
+ client,
+)
+
+
+async def _dismiss(
+ http_client: AsyncClient,
+ headers: dict[str, str],
+ workspace_id: UUID,
+ parent_personal_data_uuid: UUID,
+ year: int,
+) -> Any:
+ return await http_client.post(
+ "/api/plan/rescue-days/dismiss",
+ headers=headers,
+ json={
+ "workspace_id": str(workspace_id),
+ "parent_personal_data_uuid": str(parent_personal_data_uuid),
+ "year": year,
+ },
+ )
+
+
+async def _apply_rescue_days(
+ http_client: AsyncClient,
+ headers: dict[str, str],
+ workspace_id: UUID,
+ rescue_days: list[dict[str, Any]],
+) -> dict[str, Any]:
+ """Accept a batch of `RescueDayResponse` entries as real vacation days —
+
+ `wish_id: None` (no wish produced these) and `is_pinned: True` (the
+ allocator must never retype a hand-accepted rescue day on a later sync).
+ """
+ days = [
+ {
+ "date": r["date"],
+ "parent_personal_data_uuid": r["parent_personal_data_uuid"],
+ "child_id": r["child_id"],
+ "day_type": "vacation",
+ "is_dubbeldag": False,
+ "wish_id": None,
+ "is_pinned": True,
+ }
+ for r in rescue_days
+ ]
+ resp = await http_client.post(
+ "/api/plan/apply",
+ headers=headers,
+ json={"workspace_id": str(workspace_id), "days": days, "replace": False},
+ )
+ assert resp.status_code == 200, resp.text
+ return resp.json()
+
+
+def _rescue_group(proposal: dict[str, Any], parent_uuid: UUID, year: int) -> list[dict[str, Any]]:
+ return [
+ r
+ for r in proposal["rescue_days"]
+ if r["parent_personal_data_uuid"] == str(parent_uuid) and r["year"] == year
+ ]
+
+
+async def _planned_leave_rows(connection: aiosqlite.Connection) -> list[tuple[Any, ...]]:
+ """Every `planned_leave_day` row, as a stable, comparable tuple list —
+
+ used to assert a dismiss call is byte-identical no-op at the DB layer
+ (defect 2), not just "the response looked like a no-op".
+ """
+ cursor = await connection.execute("SELECT * FROM planned_leave_day ORDER BY id")
+ rows = await cursor.fetchall()
+ return [tuple(row) for row in rows]
+
+
+class TestVacationRescueApiFlow:
+ """One registration (Alex + placeholder Sam + one child) walks every
+
+ rescue-day sequence a real browser session would: two independent
+ (parent, year) forfeiture groups let the accept-to-zero flow (Sam) and
+ the dismiss flow (Alex) run without either masking the other's
+ observable state — see this module's docstring for why both are needed
+ and why neither `_set_vacation` call for the current year appears.
+ """
+
+ async def test_accept_dismiss_and_isolation_sequence(
+ self,
+ client: AsyncClient, # noqa: F811 (pytest fixture injection needs this exact name)
+ connection: aiosqlite.Connection,
+ ) -> None:
+ headers, ws_id, pd_alex = await _register(client, "vacation-rescue@example.com", "Alex")
+ birth_date = date.today() - relativedelta(months=6)
+ child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
+
+ add_resp = await client.post(
+ f"/api/workspace/{ws_id}/parents", headers=headers, json={"display_name": "Sam"}
+ )
+ assert add_resp.status_code == 201, add_resp.text
+ pd_sam = UUID(add_resp.json()["uuid"])
+
+ current_year = date.today().year
+
+ proposal = await _propose(client, headers, ws_id)
+ group_alex = _rescue_group(proposal, pd_alex, current_year)
+ group_sam = _rescue_group(proposal, pd_sam, current_year)
+ assert group_alex, "expected a real current-year vacation-rescue group for Alex"
+ assert group_sam, "expected a real current-year vacation-rescue group for Sam"
+ # Vacation is taken per-parent, not a shared family resource, so the
+ # two groups' dates may legitimately coincide (same ranking, same
+ # eligible calendar) — only the (parent, year) IDENTITY need be
+ # independent, which `_rescue_group`'s own filtering already proves.
+
+ # -----------------------------------------------------------------
+ # 1) Accept, batched; the group shrinks; it disappears at residual 0;
+ # an accepted day survives a later sync. Uses Sam's group, which
+ # is NEVER dismissed, so its live propose count directly reflects
+ # the real residual throughout (a dismissed group reads empty
+ # regardless of residual — see the dismiss flow below — so it
+ # can't be used to observe shrinking).
+ # -----------------------------------------------------------------
+ sam_total = len(group_sam)
+ assert sam_total >= 2, (
+ f"need at least 2 eligible rescue-day candidates for Sam to exercise a partial "
+ f"accept before full acceptance, got {sam_total} "
+ f"(too few weekdays left in {current_year}?)"
+ )
+ first_batch = group_sam[: sam_total // 2]
+ assert first_batch
+
+ apply_1 = await _apply_rescue_days(client, headers, ws_id, first_batch)
+ assert apply_1["accepted"] is True, apply_1
+ assert apply_1["created_count"] == len(first_batch)
+
+ proposal_2 = await _propose(client, headers, ws_id)
+ group_sam_2 = _rescue_group(proposal_2, pd_sam, current_year)
+ assert len(group_sam_2) == sam_total - len(first_batch), (
+ "the group must shrink by exactly the number of accepted days"
+ )
+ assert {r["date"] for r in group_sam_2}.isdisjoint({r["date"] for r in first_batch})
+ # Alex's group is untouched by anything done to Sam's.
+ assert len(_rescue_group(proposal_2, pd_alex, current_year)) == len(group_alex)
+
+ apply_2 = await _apply_rescue_days(client, headers, ws_id, group_sam_2)
+ assert apply_2["accepted"] is True, apply_2
+ assert apply_2["created_count"] == len(group_sam_2)
+
+ proposal_3 = await _propose(client, headers, ws_id)
+ assert _rescue_group(proposal_3, pd_sam, current_year) == [], (
+ "the group must disappear entirely once its residual reaches 0"
+ )
+
+ all_sam_dates = {r["date"] for r in group_sam}
+ planned = await _list_planned_days(client, headers, child_id)
+ sam_planned = {
+ d["date"]: d for d in planned if d["parent_personal_data_uuid"] == str(pd_sam)
+ }
+ assert set(sam_planned) == all_sam_dates, "every accepted rescue date must be persisted"
+ for row in sam_planned.values():
+ assert row["day_type"] == "vacation"
+ assert row["wish_id"] is None
+ assert row["is_pinned"] is True
+
+ # A following sync must leave the accepted (pinned) days untouched.
+ await _sync(client, headers, ws_id)
+ planned_after_sync = await _list_planned_days(client, headers, child_id)
+ sam_after_sync = {
+ d["date"]: d
+ for d in planned_after_sync
+ if d["parent_personal_data_uuid"] == str(pd_sam)
+ }
+ assert set(sam_after_sync) == all_sam_dates
+ for row in sam_after_sync.values():
+ assert row["day_type"] == "vacation"
+ assert row["is_pinned"] is True
+
+ # -----------------------------------------------------------------
+ # 2) Workspace isolation: a foreign workspace id or parent uuid is
+ # rejected and never touches the real (still fully intact,
+ # never-dismissed) Alex group. `check_workspace`/`check_member`
+ # (app/dependencies.py) 404 identically on a missing OR a
+ # genuinely foreign id BY DESIGN ("a foreign id can never be
+ # distinguished from a nonexistent one" — see their docstrings),
+ # so a fresh random uuid exercises exactly the same guard a real
+ # second tenant's id would, without a second `/api/auth/register`
+ # call: that endpoint is rate-limited to 10/minute and shares its
+ # in-memory counter with every other test in the process —
+ # `test_user_journey.py` alone already uses all 10 in one run, so
+ # this file keeps its own total to the ONE registration above.
+ # -----------------------------------------------------------------
+ foreign_workspace_resp = await _dismiss(client, headers, uuid4(), pd_alex, current_year)
+ assert foreign_workspace_resp.status_code == 404, foreign_workspace_resp.text
+
+ foreign_parent_resp = await _dismiss(client, headers, ws_id, uuid4(), current_year)
+ assert foreign_parent_resp.status_code == 404, foreign_parent_resp.text
+
+ proposal_after_guard = await _propose(client, headers, ws_id)
+ group_alex_after_guard = _rescue_group(proposal_after_guard, pd_alex, current_year)
+ assert {r["date"] for r in group_alex_after_guard} == {r["date"] for r in group_alex}, (
+ "a rejected dismiss attempt (foreign workspace or parent) must have no effect on "
+ "the real group"
+ )
+
+ # -----------------------------------------------------------------
+ # 3) A dismiss writes nothing at the DB layer (defect 2), and it
+ # persists across a recompute — including the exact repro step,
+ # a second propose after a sync (defect 1). Uses Alex's group.
+ # -----------------------------------------------------------------
+ before_rows = await _planned_leave_rows(connection)
+
+ legit_dismiss_resp = await _dismiss(client, headers, ws_id, pd_alex, current_year)
+ assert legit_dismiss_resp.status_code == 204, legit_dismiss_resp.text
+ assert legit_dismiss_resp.content == b""
+
+ after_rows = await _planned_leave_rows(connection)
+ assert after_rows == before_rows, (
+ "dismissing a rescue-day group must write NOTHING to planned_leave_day"
+ )
+
+ proposal_4 = await _propose(client, headers, ws_id)
+ assert _rescue_group(proposal_4, pd_alex, current_year) == [], (
+ "the dismissed group must not reappear on the very next propose"
+ )
+
+ await _sync(client, headers, ws_id)
+
+ proposal_5 = await _propose(client, headers, ws_id)
+ assert _rescue_group(proposal_5, pd_alex, current_year) == [], (
+ "Defect 1 repro: the dismissed group must stay gone through a propose -> sync -> "
+ "propose cycle, not resurface once the plan is recomputed"
+ )
diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py
index ed5334c..4e3bb10 100644
--- a/backend/tests/unit/test_plan_router.py
+++ b/backend/tests/unit/test_plan_router.py
@@ -1258,3 +1258,156 @@ class TestPlanProposalFieldParityWithDomain:
# the one carrying it).
shortfall_outcome = next(o for o in body["outcomes"] if o["shortfall_days"] > 0)
assert shortfall_outcome["remedies"], "expected the carry_vacation_forward remedy"
+
+
+class TestRescueDaysDismissRoute:
+ """§vacation-rescue dismissal — `POST /api/plan/rescue-days/dismiss`.
+
+ A parent with no `vacation_days` row at all still gets a genuine
+ forfeiture forecast (see `AnalyticsService._vacation_year_series`: with
+ zero carry-in and zero usage so far, the assumed statutory entitlement
+ forecasts losing `min(earned, floor)` days — 20 with every default left
+ untouched), so every test below can rely on the seed alone to produce a
+ real `rescue_days` group, exactly like `TestProposeSurplusAdviceOverTheWire`
+ relies on an untouched FP pool for a guaranteed surplus.
+ """
+
+ async def _seed_workspace_with_rescue_group(
+ self, connection: aiosqlite.Connection
+ ) -> tuple[Workspace, PersonalData, Child]:
+ now = datetime.now()
+ workspace_repo = SQLiteWorkspaceRepository(connection)
+ personal_data_repo = SQLitePersonalDataRepository(connection)
+ child_repo = SQLiteChildRepository(connection)
+
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ parent = await personal_data_repo.create(
+ PersonalData(
+ uuid=_CURRENT_USER_PD_UUID,
+ workspace_id=ws.id,
+ name="Alex",
+ salary_type="monthly",
+ created_at=now,
+ )
+ )
+ child = await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date.today() - relativedelta(months=6),
+ created_at=now,
+ )
+ )
+ return ws, parent, child
+
+ async def test_dismiss_returns_204_and_group_disappears_from_a_later_propose(
+ self,
+ client: AsyncClient,
+ connection: aiosqlite.Connection,
+ ) -> None:
+ ws, parent, _child = await self._seed_workspace_with_rescue_group(connection)
+
+ propose_response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
+ assert propose_response.status_code == 200
+ rescue_days = propose_response.json()["rescue_days"]
+ assert rescue_days, "seed must produce a real rescue-day group to dismiss"
+ group = next(r for r in rescue_days if r["parent_personal_data_uuid"] == str(parent.uuid))
+
+ dismiss_response = await client.post(
+ "/api/plan/rescue-days/dismiss",
+ json={
+ "workspace_id": str(ws.id),
+ "parent_personal_data_uuid": str(parent.uuid),
+ "year": group["year"],
+ },
+ )
+ assert dismiss_response.status_code == 204
+ assert dismiss_response.content == b""
+
+ after_response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
+ assert after_response.status_code == 200
+ remaining = after_response.json()["rescue_days"]
+ assert not any(
+ r["parent_personal_data_uuid"] == str(parent.uuid) and r["year"] == group["year"]
+ for r in remaining
+ ), "dismissed group must not reappear on the next propose"
+
+ async def test_dismiss_a_group_not_currently_suggested_returns_404(
+ self,
+ client: AsyncClient,
+ connection: aiosqlite.Connection,
+ ) -> None:
+ ws, parent, _child = await self._seed_workspace_with_rescue_group(connection)
+ # Rescue days only ever exist for `today`'s own calendar year (see
+ # `_Ledger.build_rescue_days`) -- next year's group can never be
+ # currently suggested.
+ not_suggested_year = date.today().year + 1
+
+ response = await client.post(
+ "/api/plan/rescue-days/dismiss",
+ json={
+ "workspace_id": str(ws.id),
+ "parent_personal_data_uuid": str(parent.uuid),
+ "year": not_suggested_year,
+ },
+ )
+ assert response.status_code == 404
+
+ async def test_dismiss_rejects_a_parent_from_another_workspace(
+ self,
+ client: AsyncClient,
+ connection: aiosqlite.Connection,
+ ) -> None:
+ """The caller's own workspace_id passes `scope.check_workspace`, but
+ the named parent belongs to a different tenant entirely -- must 404
+ via `scope.check_member`, never silently dismiss (or confirm) a
+ foreign tenant's suggestion."""
+ ws, _parent, _child = await self._seed_workspace_with_rescue_group(connection)
+
+ workspace_repo = SQLiteWorkspaceRepository(connection)
+ personal_data_repo = SQLitePersonalDataRepository(connection)
+ other_ws = await workspace_repo.create(
+ Workspace(id=uuid4(), name="Other Familj", created_at=datetime.now())
+ )
+ other_parent = await personal_data_repo.create(
+ PersonalData(
+ uuid=uuid4(), workspace_id=other_ws.id, name="Victim", created_at=datetime.now()
+ )
+ )
+
+ response = await client.post(
+ "/api/plan/rescue-days/dismiss",
+ json={
+ "workspace_id": str(ws.id),
+ "parent_personal_data_uuid": str(other_parent.uuid),
+ "year": date.today().year,
+ },
+ )
+ assert response.status_code == 404
+
+ async def test_dismiss_rejects_a_foreign_workspace(
+ self,
+ client: AsyncClient,
+ connection: aiosqlite.Connection,
+ ) -> None:
+ """The caller's own JWT resolves to their own workspace, so naming a
+ DIFFERENT (still-existing, real) workspace_id must 404 via
+ `scope.check_workspace` -- never confirm that the foreign workspace
+ even exists."""
+ await self._seed_workspace_with_rescue_group(connection)
+
+ workspace_repo = SQLiteWorkspaceRepository(connection)
+ other_ws = await workspace_repo.create(
+ Workspace(id=uuid4(), name="Other Familj", created_at=datetime.now())
+ )
+
+ response = await client.post(
+ "/api/plan/rescue-days/dismiss",
+ json={
+ "workspace_id": str(other_ws.id),
+ "parent_personal_data_uuid": str(_CURRENT_USER_PD_UUID),
+ "year": date.today().year,
+ },
+ )
+ assert response.status_code == 404
diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py
index 7eddb92..9f4ddb3 100644
--- a/backend/tests/unit/test_services/test_allocation_service.py
+++ b/backend/tests/unit/test_services/test_allocation_service.py
@@ -30,6 +30,7 @@ from app.services.allocation_service import (
REASON_FP_RED_DAY,
REASON_VACATION_WRAP_RED_DAY,
REASON_VACATION_WRAP_WEEKEND,
+ REASON_VACATION_INTERIOR,
STRATEGY_UNPLACED,
AllocationInput,
Allocator,
@@ -37,6 +38,7 @@ from app.services.allocation_service import (
DayPool,
ParentFacts,
VacationBudget,
+ _vacation_bridge_reason,
)
from app.utils.holidays import get_swedish_holidays
from app.utils.tip_predicates import resolve_age4_savable_cap
@@ -373,18 +375,34 @@ class TestCase3MonthlySalaryVacationFirst:
friday = by_date[date(2027, 2, 5)]
monday = by_date[date(2027, 2, 8)]
assert friday.day_type == "vacation"
+ # §weekend-wrap-bridge (2026-09-20): Fri/Mon bracketing a bare
+ # weekend are now claimed by the broader tier-2 bridge rule before
+ # tier 3 ever runs, so they carry `REASON_VACATION_WRAP_WEEKEND`
+ # rather than the tier-3 `REASON_VACATION_INTERIOR` this test
+ # originally asserted (see `test_allocation_service_vacation_wrap.py`
+ # for the test that actually discriminates bridge days from
+ # genuinely interior ones — this window happened to make every day
+ # vacation regardless of which tier claimed it, which is why this
+ # assertion alone never caught the missing weekend-bridge tier).
assert friday.reason == REASON_VACATION_WRAP_WEEKEND
assert monday.day_type == "vacation"
assert monday.reason == REASON_VACATION_WRAP_WEEKEND
# ---------------------------------------------------------------------------
-# #4 — hourly salary -> wrapping suppressed
+# #4 — hourly salary -> FP first for INTERIOR days only; weekend/red-day
+# bridging still fires (§weekend-wrap-bridge, 2026-09-20 policy change: the
+# old "wrapping suppressed for hourly" rule only weighed the pay-protection
+# half of the tier-2 benefit (moot for hourly) and ignored the FP-preservation
+# half (salary-type independent) — see PLANNING_ENGINE_SPEC.md §3.3. This test
+# used to assert the OPPOSITE (`cost_breakdown["vacation"] == 0`, no vacation
+# days at all) — that assertion now fails by design and has been rewritten
+# below to match the intended new behaviour rather than deleted.
# ---------------------------------------------------------------------------
class TestCase4HourlySalaryFPFirst:
- def test_wrapping_suppressed_for_hourly(self) -> None:
+ def test_bridge_days_still_vacation_for_hourly_but_interior_days_stay_fp(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="hourly")
wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
@@ -401,10 +419,22 @@ class TestCase4HourlySalaryFPFirst:
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
- assert outcome.cost_breakdown["vacation"] == 0
- assert outcome.cost_breakdown["sjukpenning"] == 20
- assert not any(d.reason == REASON_VACATION_WRAP_WEEKEND for d in proposal.days)
- assert not any(d.day_type == "vacation" for d in proposal.days)
+ # 20 spendable weekdays in FEB_2027: 4 Mondays + 4 Fridays bracket a
+ # bare weekend each (bridge tier, vacation) -> 8; the remaining 12
+ # mid-week days (Tue/Wed/Thu x4) are genuinely interior -> FP
+ # (sjukpenning, hourly's tier-3 preference).
+ assert outcome.cost_breakdown["vacation"] == 8
+ assert outcome.cost_breakdown["sjukpenning"] == 12
+ assert not any(d.reason == REASON_VACATION_INTERIOR for d in proposal.days)
+
+ by_date = {d.date: d for d in proposal.days}
+ # 2027-02-01 is a Monday: Fri 2027-02-05 / Mon 2027-02-08 bracket the
+ # first full weekend; Wed 2027-02-03 is genuinely interior.
+ friday = by_date[date(2027, 2, 5)]
+ wednesday = by_date[date(2027, 2, 3)]
+ assert friday.day_type == "vacation"
+ assert friday.reason == REASON_VACATION_WRAP_WEEKEND
+ assert wednesday.day_type == "sjukpenning"
# ---------------------------------------------------------------------------
@@ -643,7 +673,15 @@ class TestCase10DubbeldagOutsideWindow:
# on the SAME date (not parent_a taking the first half of the month
# and parent_b the second, which is what the old sequential-swap
# fallback would have produced), one funded from FP, the other from
- # vacation.
+ # vacation -- EXCEPT a bridge date (§bridge-both-vacation,
+ # 2026-09-20), where both are now funded from vacation instead. Every
+ # Monday/Friday in FEB_2027 is such a date (an ordinary weekend
+ # bridge; see `test_allocation_service_vacation_wrap.py`'s
+ # `TestOrdinaryWeekBridgesOnlyMondayAndFriday` for the same window),
+ # so this test's old blanket "one FP + one vacation" assertion no
+ # longer holds for every date -- split by `_vacation_bridge_reason`
+ # instead of asserting a fixed day count that would silently rot the
+ # next time the calendar or the bridge rule changes.
non_free = [d for d in proposal.days if d.day_type != "free"]
by_date: dict[date, list] = {}
for d in non_free:
@@ -654,12 +692,18 @@ class TestCase10DubbeldagOutsideWindow:
assert parents_that_day == {parent_a.uuid, parent_b.uuid}, (
f"{on_date}: expected both parents, got {parents_that_day}"
)
- # Co-leave's PL+vacation split: exactly one FP day + one vacation
- # day per date -- ample pools here mean it never has to fall back
- # further, so this holds for every date, not just "on average".
types_that_day = sorted(e.day_type for e in entries)
- assert types_that_day[1] == "vacation", (on_date, types_that_day)
- assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day)
+ if _vacation_bridge_reason(on_date, alloc_input.holidays, alloc_input.day_overrides):
+ # Bridge tier: both parents on vacation, no FP spent.
+ assert types_that_day == ["vacation", "vacation"], (on_date, types_that_day)
+ else:
+ # Genuinely interior day: co-leave's PL+vacation split is
+ # unchanged -- exactly one FP day + one vacation day per
+ # date -- ample pools here mean it never has to fall back
+ # further, so this holds for every such date, not just "on
+ # average".
+ assert types_that_day[1] == "vacation", (on_date, types_that_day)
+ assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day)
# ---------------------------------------------------------------------------
@@ -670,8 +714,13 @@ class TestCase10DubbeldagOutsideWindow:
class TestCase11Determinism:
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")
+ # §vacation-rescue — hourly salary so this wish funds entirely from
+ # FP (never touching vacation): otherwise the swap window's own
+ # vacation-first (monthly) tier-3 spend would itself retire the
+ # small forecast below, leaving nothing for `rescue_days` to name
+ # and making the new assertion below vacuous.
+ parent_a = _parent(name="A", salary_type="hourly")
+ parent_b = _parent(name="B", salary_type="hourly")
wish = _wish(child_id=child.id, start=(6, 1), end=(7, 12), weeks=6, parent_mode="either")
alloc_input = _build_input(
today=date(2027, 1, 1),
@@ -687,12 +736,25 @@ class TestCase11Determinism:
(parent_b.uuid, 2027): VacationBudget(42, 0, 0),
},
wishes=[wish],
+ # §vacation-rescue — a residual forfeiture forecast so this run's
+ # `rescue_days` is actually non-empty, not just vacuously equal
+ # across both calls. Large enough to survive the swap window's
+ # own bridge-tier vacation spend (weekend/red-day bridge dates
+ # always draw vacation regardless of salary_type — see
+ # `_fund_solo`'s tier-2 branch), which this window cannot come
+ # close to spending in full.
+ vacation_forfeiting={
+ (parent_a.uuid, 2027): 40,
+ (parent_b.uuid, 2027): 40,
+ },
)
allocator = Allocator()
proposal_1 = allocator.propose(alloc_input)
proposal_2 = allocator.propose(alloc_input)
+ assert proposal_1.rescue_days, "expected a non-empty rescue_days to make this a real test"
+ assert proposal_1.rescue_days == proposal_2.rescue_days
assert proposal_1 == proposal_2
@@ -3212,10 +3274,16 @@ class TestCase33LongChunkCoLeave:
assert parents_that_day == {parent_a.uuid, parent_b.uuid}, (on_date, parents_that_day)
# The FP-payer, walked in date order, switches at most once --
- # never the old daily alternation.
+ # never the old daily alternation. §bridge-both-vacation (2026-09-20)
+ # funds every Monday/Friday bridge date as BOTH parents on vacation
+ # with no FP payer at all -- this 6-week window contains several --
+ # so those dates carry no signal for "which parent pays FP" and are
+ # excluded from the sequence rather than raising on `next()`'s empty
+ # iterator.
fp_payer_sequence = [
next(e.parent_personal_data_uuid for e in entries if e.day_type != "vacation")
for _on_date, entries in sorted(by_date.items())
+ if any(e.day_type != "vacation" for e in entries)
]
switches = sum(
1 for prev, cur in zip(fp_payer_sequence, fp_payer_sequence[1:]) if prev != cur
diff --git a/backend/tests/unit/test_services/test_allocation_service_branches.py b/backend/tests/unit/test_services/test_allocation_service_branches.py
index 3b199f8..708f809 100644
--- a/backend/tests/unit/test_services/test_allocation_service_branches.py
+++ b/backend/tests/unit/test_services/test_allocation_service_branches.py
@@ -12,6 +12,7 @@ from app.services.allocation_service import (
REASON_DUBBELDAG,
REASON_VACATION_WRAP_RED_DAY,
REASON_VACATION_WRAP_WEEKEND,
+ REASON_VACATION_INTERIOR,
SURPLUS_FRONT_LOAD_EARLY_YEARS,
SURPLUS_SHIFT_MIX_TO_FP,
SURPLUS_USE_LESS_VACATION,
@@ -84,7 +85,7 @@ class TestFamilyWideWish:
proposal = Allocator().propose(alloc_input)
assert all(d.day_type in ("free", "vacation") for d in proposal.days)
- assert any(d.reason == REASON_VACATION_WRAP_WEEKEND for d in proposal.days)
+ assert any(d.reason == REASON_VACATION_INTERIOR for d in proposal.days)
def test_family_wide_wish_unfunded_without_vacation(self) -> None:
parent = _parent(salary_type="monthly")
@@ -483,11 +484,24 @@ class TestVacationForfeitureBias:
assert balances_after.sjukpenning_left == 90
assert balances_after.lagsta_left == 90
- def test_non_forfeiting_year_is_unchanged_fp_first_for_hourly_parent(self) -> None:
- """Regression guard: absent a forecast forfeiture, an hourly parent's
+ def test_non_forfeiting_year_still_fp_first_for_genuinely_interior_hourly_days(self) -> None:
+ """Regression guard, UPDATED for §weekend-wrap-bridge (2026-09-20):
- tier-3 order must stay exactly what it was before §Task 2 — FP
- first, vacation only backstopping what FP can't cover.
+ absent a forecast forfeiture, an hourly parent's TIER-3 order must
+ stay exactly what it was before §Task 2 — FP first, vacation only
+ backstopping what FP can't cover. That guarantee only ever covered
+ tier 3 (genuinely interior days); it never applied to tier 2, which
+ by definition fires before tier 3 is even consulted.
+
+ This test used to assert `all(d.day_type == "sjukpenning" ...)` over
+ every non-free day in the FEB_2027 window, which happened to hold
+ only because tier 2 used to be narrow (real-red-day klämdagar only,
+ and February has none). Now that tier 2 also claims a plain
+ Friday/Monday weekend wrap (ungated by `salary_type`, see
+ PLANNING_ENGINE_SPEC.md §3.3), that blanket assertion no longer
+ holds — FEB_2027's 4 Mondays + 4 Fridays are bridge days, not
+ interior ones. Rewritten to check the two day-classes separately
+ instead of deleting the guard.
"""
child = _child(birth_date=date(2025, 1, 1))
parent = _parent(salary_type="hourly")
@@ -504,13 +518,19 @@ class TestVacationForfeitureBias:
proposal = Allocator().propose(alloc_input)
- interior_days = [
- d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta")
- ]
- assert interior_days, "expected some funded interior (non-free) days"
+ funded_days = [d for d in proposal.days if d.day_type != "free"]
+ bridge_days = [d for d in funded_days if d.reason == REASON_VACATION_WRAP_WEEKEND]
+ interior_days = [d for d in funded_days if d.reason != REASON_VACATION_WRAP_WEEKEND]
+ assert interior_days, "expected some genuinely interior (tier-3) days"
+ assert bridge_days, "expected some bridge (tier-2 weekend-wrap) days"
+ # Tier 3 is unchanged: every genuinely interior day still goes FP
+ # first for an hourly, non-forfeiting parent.
assert all(d.day_type == "sjukpenning" for d in interior_days)
+ # Tier 2 is the new behaviour: every bridge day is vacation, ungated
+ # by salary_type.
+ assert all(d.day_type == "vacation" for d in bridge_days)
balances_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
- assert balances_after.already_used == 0
+ assert balances_after.already_used == len(bridge_days)
class TestRemedies:
@@ -777,19 +797,30 @@ class TestDayOverrides:
def _big_surplus_input(*, min_vacation_days_per_year: int = 10):
- """A parent who funds a whole 20-day interior window entirely from
- vacation (monthly salary, no red days nearby -> tier-3 picks vacation
- first) while sitting on a huge, untouched FP pool. By the child's
- 4th birthday the untouched pool massively exceeds the 96-day ceiling, so
- every surplus-advice kind should have something to say:
+ """A parent who funds a whole 20-day window entirely from vacation
+ (monthly salary -> tier 3 picks vacation first for every day it reaches)
+ while sitting on a huge, untouched FP pool. FEB_2027 has no real red days
+ nearby, but per §weekend-wrap-bridge (2026-09-20) its 4 Mondays + 4
+ Fridays are still tier-2 weekend-wrap bridge days (vacation, ungated by
+ salary_type) — only the remaining 12 Tue/Wed/Thu days are genuinely
+ tier-3 interior. By the child's 4th birthday the untouched FP pool
+ massively exceeds the 96-day ceiling, so every surplus-advice kind should
+ have something to say:
- wish_more_leave: 384 expiring FP days -> 76 affordable weeks
- - shift_mix_to_fp: 20 vacation days spent in the interior could
- have been FP instead (min(20, 384) == 20)
+ - shift_mix_to_fp: only the 12 genuinely tier-3 (REASON_
+ VACATION_INTERIOR) days count as "could have
+ been FP instead" — the other 8 are tier-2
+ bridge days, which (like the older red-day-
+ cluster wrap) stay vacation regardless of
+ goal/salary_type, so they were never a real
+ tradeoff to begin with (min(12, 384) == 12)
- front_load_early_years: 384 >= the "large surplus" threshold, and the
child is < 2 years old at `today`
- use_less_vacation: already_used (20) - min (10) == 10 days could
have stayed banked instead of being spent
+ (counts ALL vacation usage regardless of
+ tier — see `_use_less_vacation_advice`)
"""
child = _child(birth_date=date(2026, 3, 1)) # B4 = 2030-03-01
parent = _parent(salary_type="monthly", min_vacation_days_per_year=min_vacation_days_per_year)
@@ -836,7 +867,10 @@ class TestSurplusAdvice:
}
assert by_kind[SURPLUS_WISH_MORE_LEAVE].days_involved == 384
- assert by_kind[SURPLUS_SHIFT_MIX_TO_FP].days_involved == 20
+ # §weekend-wrap-bridge (2026-09-20): was 20 before the bridge tier
+ # existed to (correctly) exclude the 8 Fri/Mon weekend-wrap days from
+ # "could have been FP instead" — see `_big_surplus_input`'s docstring.
+ assert by_kind[SURPLUS_SHIFT_MIX_TO_FP].days_involved == 12
assert by_kind[SURPLUS_FRONT_LOAD_EARLY_YEARS].days_involved == 384
assert by_kind[SURPLUS_USE_LESS_VACATION].days_involved == 10
diff --git a/backend/tests/unit/test_services/test_allocation_service_coleave_bridge.py b/backend/tests/unit/test_services/test_allocation_service_coleave_bridge.py
new file mode 100644
index 0000000..b231b41
--- /dev/null
+++ b/backend/tests/unit/test_services/test_allocation_service_coleave_bridge.py
@@ -0,0 +1,305 @@
+"""Tests for §bridge-both-vacation: on a bridge date, both co-leave parents
+
+take vacation together instead of the ordinary co-leave FP+vacation split.
+
+Context: `_Ledger._fund_both` funds a `parent_mode == "both"` wish window with
+dubbeldagar (tier 1, opt-in) then co-leave (tier 3, one parent on FP, the
+other on vacation, per §fix long-chunk co-leave) then a sequential swap
+(tier 4, last resort). §bridge-both-vacation inserts a new tier 2 between
+them: on a date where `_vacation_bridge_reason` fires (a Friday/Monday
+touching a weekend, or any other bridge date), BOTH parents are funded from
+vacation instead -- vacation pays better than FP, and the FP day stays
+banked for later. All-or-nothing (`_Ledger._try_bridge_vacation_pair`): if
+either parent's vacation can't pay, neither is spent and the date falls
+through to the ordinary co-leave pairing unchanged.
+
+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
+
+from app.services.allocation_service import (
+ REASON_DUBBELDAG,
+ REASON_VACATION_INTERIOR,
+ REASON_VACATION_WRAP_WEEKEND,
+ Allocator,
+ DayPool,
+ VacationBudget,
+ _vacation_bridge_reason,
+)
+from tests.unit.test_services.test_allocation_service import (
+ FEB_2027,
+ _build_input,
+ _child,
+ _parent,
+ _wish,
+)
+
+# FEB_2027 (Mon 2027-02-01 -> Sun 2027-02-28): exactly 4 full weeks, no real
+# holidays nearby (see test_allocation_service.py's module docstring and
+# TestOrdinaryWeekBridgesOnlyMondayAndFriday in
+# test_allocation_service_vacation_wrap.py) -- every Monday and Friday in it
+# bridges a plain weekend; every Tue/Wed/Thu is genuinely interior.
+_FEB_2027_MONDAYS_AND_FRIDAYS = (
+ date(2027, 2, 1),
+ date(2027, 2, 5),
+ date(2027, 2, 8),
+ date(2027, 2, 12),
+ date(2027, 2, 15),
+ date(2027, 2, 19),
+ date(2027, 2, 22),
+ date(2027, 2, 26),
+)
+
+
+class TestBridgeBothVacationOrdinaryWeekends:
+ def test_monday_and_friday_are_both_parents_on_vacation_no_fp_spent(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent_a = _parent(name="A")
+ parent_b = _parent(name="B")
+ wish = _wish(
+ child_id=child.id,
+ start=FEB_2027[0],
+ end=FEB_2027[1],
+ weeks=4,
+ parent_mode="both",
+ # wants_dubbeldag defaults False -- co-leave is the strategy this
+ # test exercises.
+ )
+ 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(200, 90, 60),
+ (parent_b.uuid, child.id): DayPool(200, 90, 60),
+ },
+ vacation={
+ (parent_a.uuid, 2027): VacationBudget(30, 0, 0),
+ (parent_b.uuid, 2027): VacationBudget(30, 0, 0),
+ },
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ outcome = proposal.outcomes[0]
+ # No dedicated `strategy` label exists for "both parents on vacation
+ # together" -- it still reports 'co_leave' (see `_Ledger._fund_both`'s
+ # tier-2 docstring and the comment at its call site).
+ assert outcome.strategy == "co_leave"
+ assert outcome.requested_strategy == "co_leave"
+ assert outcome.shortfall_days == 0
+
+ by_date: dict[date, list] = {}
+ for d in proposal.days:
+ if d.day_type != "free":
+ by_date.setdefault(d.date, []).append(d)
+
+ for bridge_date in _FEB_2027_MONDAYS_AND_FRIDAYS:
+ assert _vacation_bridge_reason(
+ bridge_date, alloc_input.holidays, alloc_input.day_overrides
+ ), f"{bridge_date} was expected to be a bridge date -- fixture assumption broken"
+ entries = by_date[bridge_date]
+ assert {e.parent_personal_data_uuid for e in entries} == {
+ parent_a.uuid,
+ parent_b.uuid,
+ }
+ assert all(e.day_type == "vacation" for e in entries), (bridge_date, entries)
+ assert all(e.reason == REASON_VACATION_WRAP_WEEKEND for e in entries), (
+ bridge_date,
+ entries,
+ )
+
+ # Tue/Wed/Thu: the existing co-leave pairing is unchanged -- exactly
+ # one FP day + one vacation day, never two vacation days.
+ for on_date, entries in by_date.items():
+ if on_date in _FEB_2027_MONDAYS_AND_FRIDAYS:
+ continue
+ types_that_day = sorted(e.day_type for e in entries)
+ assert types_that_day[1] == "vacation", (on_date, types_that_day)
+ assert types_that_day[0] in ("sjukpenning", "lagsta"), (on_date, types_that_day)
+
+ # No FP at all was spent on any bridge date -- it stayed banked.
+ fp_dates = {d.date for d in proposal.days if d.day_type in ("sjukpenning", "lagsta")}
+ assert fp_dates.isdisjoint(_FEB_2027_MONDAYS_AND_FRIDAYS)
+
+
+class TestBridgeBothVacationAllOrNothingFallback:
+ def test_one_parent_out_of_vacation_falls_back_to_ordinary_co_leave(self) -> None:
+ """Parent B has zero vacation for 2027 -- the bridge tier's
+
+ all-or-nothing peek must fail for every bridge date (neither parent
+ gets a vacation day from THIS rule), and the date must fall through
+ to the normal co-leave pairing (one FP, one vacation) rather than
+ being left unfunded or half-spent. A single Mon-Sun week (2027-02-01
+ .. 02-07) contains exactly one of each bridge kind (Monday, Friday)
+ plus three genuinely interior days (Tue/Wed/Thu), so both are
+ covered without a longer window's chunk-switch behaviour muddying
+ which parent pays FP.
+ """
+ child = _child(birth_date=date(2020, 1, 1))
+ parent_a = _parent(name="A")
+ parent_b = _parent(name="B")
+ wish = _wish(
+ child_id=child.id,
+ start=(2, 1),
+ end=(2, 7),
+ weeks=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(200, 90, 60),
+ (parent_b.uuid, child.id): DayPool(200, 90, 60),
+ },
+ vacation={
+ (parent_a.uuid, 2027): VacationBudget(20, 0, 0),
+ (parent_b.uuid, 2027): VacationBudget(0, 0, 0), # <- the point of this test
+ },
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ outcome = proposal.outcomes[0]
+ assert outcome.shortfall_days == 0
+
+ by_date: dict[date, list] = {}
+ for d in proposal.days:
+ if d.day_type != "free":
+ by_date.setdefault(d.date, []).append(d)
+
+ monday, friday = date(2027, 2, 1), date(2027, 2, 5)
+ assert _vacation_bridge_reason(monday, alloc_input.holidays, alloc_input.day_overrides)
+ assert _vacation_bridge_reason(friday, alloc_input.holidays, alloc_input.day_overrides)
+
+ # Every spendable date -- bridge or interior -- falls back to the
+ # normal co-leave pairing: one FP day, one vacation day. Parent B can
+ # never pay vacation, so parent A is always the vacation side and
+ # parent B is always the FP side (the swapped-pairing fallback
+ # `_try_co_leave_pair` already handles).
+ for on_date, entries in by_date.items():
+ by_parent = {e.parent_personal_data_uuid: e for e in entries}
+ assert set(by_parent) == {parent_a.uuid, parent_b.uuid}, (on_date, entries)
+ assert by_parent[parent_a.uuid].day_type == "vacation", (on_date, entries)
+ assert by_parent[parent_b.uuid].day_type in ("sjukpenning", "lagsta"), (
+ on_date,
+ entries,
+ )
+ # The bridge dates specifically must carry the ORDINARY co-leave
+ # reason, not the bridge reason -- proof the bridge tier was
+ # tried and genuinely declined, not silently skipped.
+ if on_date in (monday, friday):
+ assert by_parent[parent_a.uuid].reason == REASON_VACATION_INTERIOR, (
+ on_date,
+ by_parent[parent_a.uuid],
+ )
+
+ # No half-spent state: parent B's budget (0 available) was never
+ # touched, and parent A's vacation was spent exactly once per
+ # spendable date -- never twice (which a botched bridge attempt that
+ # committed parent A's side before checking parent B's would cause).
+ vacation_after = proposal.balances_after.vacation_after
+ assert vacation_after[(parent_b.uuid, 2027)].already_used == 0
+ spendable_dates = 5 # Mon-Fri of a clean week
+ assert vacation_after[(parent_a.uuid, 2027)].already_used == spendable_dates
+
+
+class TestBridgeBothVacationYieldsToDubbeldag:
+ def test_dubbeldag_still_wins_on_a_bridge_date_when_requested_and_available(self) -> None:
+ """`wants_dubbeldag=True` with dubbeldagar legally available (child
+
+ inside the 15-month window, quota free, both parents hold FP) must
+ still win tier 1 on a bridge date -- an explicit user request beats
+ this optimisation. §bridge-both-vacation's tier 2 must never even be
+ reached for such a date.
+ """
+ child = _child(birth_date=date(2027, 1, 1)) # 15mo cutoff: 2028-04-01
+ parent_a = _parent(name="A")
+ parent_b = _parent(name="B")
+ wish = _wish(
+ child_id=child.id,
+ start=(2, 1),
+ end=(2, 7), # well inside the 15-month window
+ weeks=1,
+ parent_mode="both",
+ wants_dubbeldag=True,
+ )
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent_a, parent_b],
+ pools={
+ (parent_a.uuid, child.id): DayPool(100, 90, 60),
+ (parent_b.uuid, child.id): DayPool(100, 90, 60),
+ },
+ vacation={
+ (parent_a.uuid, 2027): VacationBudget(20, 0, 0),
+ (parent_b.uuid, 2027): VacationBudget(20, 0, 0),
+ },
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ monday, friday = date(2027, 2, 1), date(2027, 2, 5)
+ assert _vacation_bridge_reason(monday, alloc_input.holidays, alloc_input.day_overrides)
+ assert _vacation_bridge_reason(friday, alloc_input.holidays, alloc_input.day_overrides)
+
+ for bridge_date in (monday, friday):
+ entries = [d for d in proposal.days if d.date == bridge_date]
+ assert len(entries) == 2
+ assert all(e.is_dubbeldag for e in entries), (bridge_date, entries)
+ assert all(e.reason == REASON_DUBBELDAG for e in entries), (bridge_date, entries)
+ assert all(e.day_type in ("sjukpenning", "lagsta") for e in entries), (
+ bridge_date,
+ entries,
+ )
+
+ outcome = proposal.outcomes[0]
+ assert outcome.strategy == "dubbeldag"
+ assert outcome.requested_strategy == "dubbeldag"
+
+
+class TestBridgeBothVacationDeterminism:
+ 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")
+ wish = _wish(
+ child_id=child.id,
+ start=FEB_2027[0],
+ end=FEB_2027[1],
+ weeks=4,
+ 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(200, 90, 60),
+ (parent_b.uuid, child.id): DayPool(200, 90, 60),
+ },
+ vacation={
+ (parent_a.uuid, 2027): VacationBudget(30, 0, 0),
+ (parent_b.uuid, 2027): VacationBudget(30, 0, 0),
+ },
+ wishes=[wish],
+ )
+
+ allocator = Allocator()
+ proposal_1 = allocator.propose(alloc_input)
+ proposal_2 = allocator.propose(alloc_input)
+
+ assert proposal_1 == proposal_2
diff --git a/backend/tests/unit/test_services/test_allocation_service_rescue_dismissal.py b/backend/tests/unit/test_services/test_allocation_service_rescue_dismissal.py
new file mode 100644
index 0000000..5cb07dc
--- /dev/null
+++ b/backend/tests/unit/test_services/test_allocation_service_rescue_dismissal.py
@@ -0,0 +1,563 @@
+"""Tests for §vacation-rescue dismissal — letting the user dismiss a
+
+suggested vacation-rescue group (`PlanProposal.rescue_days`), reusing the
+`tip_dismissal` table/`hash_tip_data` machinery `TipsService` already uses
+(see `PlanningEngineService.dismiss_rescue_days` /
+`_drop_dismissed_rescue_days` in `app/services/allocation_service.py`).
+
+Two layers:
+ - Pure unit tests for the module-level helpers (`_rescue_dismissal_tip_id`,
+ `_rescue_group_hash_data`, `_group_rescue_days`) — no DB, no loader.
+ - DB-backed integration tests that seed a genuine vacation-forfeiture
+ scenario (via `AllocationInputLoader`, exactly like
+ `test_allocation_input_loader.py`) so `Allocator().propose()` actually
+ produces `rescue_days`, then exercise `PlanningEngineService.propose`/
+ `dismiss_rescue_days` end to end.
+"""
+
+from datetime import date, datetime
+from uuid import uuid4
+
+import pytest
+
+from app.entities.child import Child
+from app.entities.personal_data import PersonalData
+from app.entities.planned_leave import PlannedLeaveDay
+from app.entities.vacation_days import VacationDays
+from app.entities.workspace import Workspace
+from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
+from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
+from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
+from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
+from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
+from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
+from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
+from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
+from app.services.allocation_service import (
+ AllocationInputLoader,
+ PlanningEngineService,
+ RescueDay,
+ _group_rescue_days,
+ _rescue_dismissal_tip_id,
+ _rescue_group_hash_data,
+)
+from app.utils.errors import NotFoundError
+
+# ---------------------------------------------------------------------------
+# Pure helpers — no DB
+# ---------------------------------------------------------------------------
+
+
+class TestRescueDismissalTipId:
+ def test_tip_id_namespaces_by_parent_and_year(self) -> None:
+ parent = uuid4()
+ assert _rescue_dismissal_tip_id(parent, 2027) == f"vacation_rescue:{parent}:2027"
+
+ def test_tip_id_differs_across_years_for_same_parent(self) -> None:
+ parent = uuid4()
+ assert _rescue_dismissal_tip_id(parent, 2027) != _rescue_dismissal_tip_id(parent, 2028)
+
+ def test_tip_id_differs_across_parents_for_same_year(self) -> None:
+ assert _rescue_dismissal_tip_id(uuid4(), 2027) != _rescue_dismissal_tip_id(uuid4(), 2027)
+
+
+class TestRescueGroupHashData:
+ def test_hash_data_is_parent_and_year_identity(self) -> None:
+ parent = uuid4()
+ child = uuid4()
+ group = [
+ RescueDay(date(2027, 12, 20), parent, child, 2027, "vacation_rescue"),
+ RescueDay(date(2027, 12, 10), parent, child, 2027, "vacation_rescue"),
+ ]
+ assert _rescue_group_hash_data(group) == {
+ "parent_personal_data_uuid": str(parent),
+ "year": 2027,
+ }
+
+ def test_hash_is_unaffected_by_the_group_s_date_list(self) -> None:
+ """The whole point of Defect 1's fix: a group's date list is
+ volatile (recomputed from the entire plan's residual on every
+ propose), so the dismissal fingerprint must NOT depend on it — only
+ on (parent, year) identity. Otherwise dismissing (which itself used
+ to trigger a re-sync, and even without that, any other plan write)
+ changes the dates and the dismissal never sticks."""
+ from app.utils.tip_predicates import hash_tip_data
+
+ parent = uuid4()
+ child = uuid4()
+ five = [
+ RescueDay(date(2027, 12, d), parent, child, 2027, "vacation_rescue")
+ for d in range(10, 15)
+ ]
+ four = five[:-1]
+ assert hash_tip_data(_rescue_group_hash_data(five)) == hash_tip_data(
+ _rescue_group_hash_data(four)
+ )
+
+ def test_hash_differs_across_parents_and_years(self) -> None:
+ from app.utils.tip_predicates import hash_tip_data
+
+ child = uuid4()
+ a = [RescueDay(date(2027, 12, 10), uuid4(), child, 2027, "vacation_rescue")]
+ b = [RescueDay(date(2027, 12, 10), uuid4(), child, 2027, "vacation_rescue")]
+ assert hash_tip_data(_rescue_group_hash_data(a)) != hash_tip_data(
+ _rescue_group_hash_data(b)
+ )
+
+ parent = uuid4()
+ same_parent_2027 = [RescueDay(date(2027, 12, 10), parent, child, 2027, "vacation_rescue")]
+ same_parent_2028 = [RescueDay(date(2028, 12, 10), parent, child, 2028, "vacation_rescue")]
+ assert hash_tip_data(_rescue_group_hash_data(same_parent_2027)) != hash_tip_data(
+ _rescue_group_hash_data(same_parent_2028)
+ )
+
+
+class TestGroupRescueDays:
+ def test_groups_by_parent_and_year_not_child(self) -> None:
+ parent_a, parent_b = uuid4(), uuid4()
+ child_1, child_2 = uuid4(), uuid4()
+ days = [
+ RescueDay(date(2027, 12, 1), parent_a, child_1, 2027, "vacation_rescue"),
+ RescueDay(date(2027, 12, 2), parent_a, child_2, 2027, "vacation_rescue"),
+ RescueDay(date(2027, 12, 3), parent_b, child_1, 2027, "vacation_rescue"),
+ RescueDay(date(2028, 1, 1), parent_a, child_1, 2028, "vacation_rescue"),
+ ]
+ groups = _group_rescue_days(days)
+ assert set(groups.keys()) == {(parent_a, 2027), (parent_b, 2027), (parent_a, 2028)}
+ assert len(groups[(parent_a, 2027)]) == 2
+
+
+# ---------------------------------------------------------------------------
+# DB-backed integration tests
+# ---------------------------------------------------------------------------
+
+
+def _make_loader(
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ parental_leave_repo: SQLiteParentalLeaveRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ planned_leave_repo: SQLitePlannedLeaveRepository,
+ leave_wish_repo: SQLiteLeaveWishRepository,
+) -> AllocationInputLoader:
+ return AllocationInputLoader(
+ workspace_repo=workspace_repo,
+ child_repo=child_repo,
+ personal_data_repo=personal_data_repo,
+ parental_leave_repo=parental_leave_repo,
+ vacation_repo=vacation_repo,
+ planned_leave_repo=planned_leave_repo,
+ leave_wish_repo=leave_wish_repo,
+ )
+
+
+async def _seed_forfeiting_parent(
+ workspace_repo: SQLiteWorkspaceRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ workspace_id,
+ year: int,
+ *,
+ total_days: int = 10,
+ floor: int = 5,
+) -> PersonalData:
+ """A parent who, with no vacation planned yet, is forecast to forfeit
+ exactly `total_days - floor` days at `year`'s end (see
+ `AnalyticsService._vacation_year_series`'s docstring/algorithm: with
+ `saved_days_in=0` and no planned use, only `total_days - floor` days can
+ be carried out — the rest is lost)."""
+ now = datetime.now()
+ parent = await personal_data_repo.create(
+ PersonalData(
+ uuid=uuid4(),
+ workspace_id=workspace_id,
+ name=f"Parent-{uuid4().hex[:6]}",
+ min_vacation_days_per_year=floor,
+ created_at=now,
+ )
+ )
+ await vacation_repo.create(
+ VacationDays(
+ id=uuid4(),
+ personal_data_uuid=parent.uuid,
+ workspace_id=workspace_id,
+ year=year,
+ total_days=total_days,
+ saved_days_in=0,
+ )
+ )
+ return parent
+
+
+@pytest.fixture
+def rescue_today() -> date:
+ # Early in the year so plenty of candidate dates remain through Dec 31.
+ return date(2027, 1, 1)
+
+
+class TestDismissRescueDaysNotFound:
+ async def test_dismissing_a_group_that_is_not_currently_suggested_raises_not_found(
+ self,
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ parental_leave_repo: SQLiteParentalLeaveRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ planned_leave_repo: SQLitePlannedLeaveRepository,
+ leave_wish_repo: SQLiteLeaveWishRepository,
+ tip_dismissal_repo: SQLiteTipDismissalRepository,
+ rescue_today: date,
+ ) -> None:
+ now = datetime.now()
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ # `min_vacation_days_per_year=0` -> the statutory floor is 0, so
+ # `AnalyticsService._vacation_year_series` never forecasts a loss
+ # even with zero vacation planned so far this year (see
+ # `_seed_forfeiting_parent`'s docstring for the general formula:
+ # with no carry-in/usage, forfeiture == min(earned, floor)). No
+ # forfeiture at all -> no rescue days are ever suggested.
+ parent = await personal_data_repo.create(
+ PersonalData(
+ uuid=uuid4(),
+ workspace_id=ws.id,
+ name="Alex",
+ min_vacation_days_per_year=0,
+ created_at=now,
+ )
+ )
+ await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date(2026, 1, 1),
+ created_at=now,
+ )
+ )
+
+ loader = _make_loader(
+ workspace_repo,
+ child_repo,
+ personal_data_repo,
+ parental_leave_repo,
+ vacation_repo,
+ planned_leave_repo,
+ leave_wish_repo,
+ )
+ service = PlanningEngineService(loader, dismissal_repo=tip_dismissal_repo)
+
+ with pytest.raises(NotFoundError):
+ await service.dismiss_rescue_days(
+ ws.id, parent.uuid, rescue_today.year, today=rescue_today
+ )
+
+
+class TestDismissThenRepropose:
+ async def test_dismissed_group_gone_other_parent_unaffected(
+ self,
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ parental_leave_repo: SQLiteParentalLeaveRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ planned_leave_repo: SQLitePlannedLeaveRepository,
+ leave_wish_repo: SQLiteLeaveWishRepository,
+ tip_dismissal_repo: SQLiteTipDismissalRepository,
+ rescue_today: date,
+ ) -> None:
+ now = datetime.now()
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date(2026, 1, 1),
+ created_at=now,
+ )
+ )
+ parent_a = await _seed_forfeiting_parent(
+ workspace_repo, personal_data_repo, vacation_repo, ws.id, rescue_today.year
+ )
+ parent_b = await _seed_forfeiting_parent(
+ workspace_repo, personal_data_repo, vacation_repo, ws.id, rescue_today.year
+ )
+
+ loader = _make_loader(
+ workspace_repo,
+ child_repo,
+ personal_data_repo,
+ parental_leave_repo,
+ vacation_repo,
+ planned_leave_repo,
+ leave_wish_repo,
+ )
+ service = PlanningEngineService(loader, dismissal_repo=tip_dismissal_repo)
+
+ proposal = await service.propose(ws.id, today=rescue_today)
+ by_parent = {r.parent_personal_data_uuid for r in proposal.rescue_days}
+ assert parent_a.uuid in by_parent and parent_b.uuid in by_parent, (
+ "expected both parents to have a suggested rescue group before any dismissal"
+ )
+
+ await service.dismiss_rescue_days(
+ ws.id, parent_a.uuid, rescue_today.year, today=rescue_today
+ )
+
+ proposal_after = await service.propose(ws.id, today=rescue_today)
+ remaining_parents = {r.parent_personal_data_uuid for r in proposal_after.rescue_days}
+ assert parent_a.uuid not in remaining_parents, "dismissed group must be filtered out"
+ assert parent_b.uuid in remaining_parents, "the other parent's group must be unaffected"
+ # Parent B's own dates are untouched by A's dismissal.
+ before = [r for r in proposal.rescue_days if r.parent_personal_data_uuid == parent_b.uuid]
+ after = [
+ r for r in proposal_after.rescue_days if r.parent_personal_data_uuid == parent_b.uuid
+ ]
+ assert before == after
+
+ async def test_redismissing_refreshes_the_stored_hash(
+ self,
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ parental_leave_repo: SQLiteParentalLeaveRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ planned_leave_repo: SQLitePlannedLeaveRepository,
+ leave_wish_repo: SQLiteLeaveWishRepository,
+ tip_dismissal_repo: SQLiteTipDismissalRepository,
+ rescue_today: date,
+ ) -> None:
+ """Dismissing the SAME group twice must update the one dismissal row
+ (UNIQUE on workspace_id, tip_id) rather than erroring or duplicating —
+ matches `TipsService.dismiss`'s re-dismiss behaviour."""
+ now = datetime.now()
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date(2026, 1, 1),
+ created_at=now,
+ )
+ )
+ parent = await _seed_forfeiting_parent(
+ workspace_repo, personal_data_repo, vacation_repo, ws.id, rescue_today.year
+ )
+ loader = _make_loader(
+ workspace_repo,
+ child_repo,
+ personal_data_repo,
+ parental_leave_repo,
+ vacation_repo,
+ planned_leave_repo,
+ leave_wish_repo,
+ )
+ service = PlanningEngineService(loader, dismissal_repo=tip_dismissal_repo)
+
+ await service.propose(ws.id, today=rescue_today)
+ await service.dismiss_rescue_days(ws.id, parent.uuid, rescue_today.year, today=rescue_today)
+
+ proposal = await service.propose(ws.id, today=rescue_today)
+ assert proposal.rescue_days == [], "still dismissed, nothing changed yet"
+
+ # Re-dismissing a group that's no longer being suggested (already
+ # filtered out above) is itself a "not currently suggested" 404 —
+ # exactly like re-dismissing an already-dismissed tip in TipsService
+ # would be if the tip stopped firing.
+ with pytest.raises(NotFoundError):
+ await service.dismiss_rescue_days(
+ ws.id, parent.uuid, rescue_today.year, today=rescue_today
+ )
+
+ all_dismissals = await tip_dismissal_repo.list_by_workspace(ws.id)
+ assert len(all_dismissals) == 1, "must update the one row, never duplicate"
+
+
+class TestDismissalIsIdentityScoped:
+ """Defect 1's fix: dismissal is keyed on (parent, year) IDENTITY, not the
+ group's date list — so it must survive a date change and only clear once
+ the group's residual genuinely reaches 0 (see `_rescue_group_hash_data`'s
+ docstring). Replaces the old
+ `test_group_resurfaces_once_a_planned_day_shrinks_the_residual`, which
+ asserted the now-intentionally-removed opposite behaviour."""
+
+ async def test_dismissal_survives_a_date_change(
+ self,
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ parental_leave_repo: SQLiteParentalLeaveRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ planned_leave_repo: SQLitePlannedLeaveRepository,
+ leave_wish_repo: SQLiteLeaveWishRepository,
+ tip_dismissal_repo: SQLiteTipDismissalRepository,
+ rescue_today: date,
+ ) -> None:
+ now = datetime.now()
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ child = await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date(2026, 1, 1),
+ created_at=now,
+ )
+ )
+ parent = await _seed_forfeiting_parent(
+ workspace_repo, personal_data_repo, vacation_repo, ws.id, rescue_today.year
+ )
+ loader = _make_loader(
+ workspace_repo,
+ child_repo,
+ personal_data_repo,
+ parental_leave_repo,
+ vacation_repo,
+ planned_leave_repo,
+ leave_wish_repo,
+ )
+ service = PlanningEngineService(loader, dismissal_repo=tip_dismissal_repo)
+
+ proposal = await service.propose(ws.id, today=rescue_today)
+ assert proposal.rescue_days, "need a real residual to dismiss"
+
+ await service.dismiss_rescue_days(ws.id, parent.uuid, rescue_today.year, today=rescue_today)
+ dismissed_proposal = await service.propose(ws.id, today=rescue_today)
+ assert dismissed_proposal.rescue_days == []
+
+ # Plan (persist) an actual vacation day early in the year -- this
+ # counts toward `use_by_year` in `AnalyticsService._vacation_year_series`
+ # regardless of pinned status, shrinking the forecast forfeiture by
+ # one day (but not to 0) and, with it, the rescue-day date list
+ # `build_rescue_days` would now produce for this (parent, year).
+ await planned_leave_repo.create(
+ PlannedLeaveDay(
+ id=uuid4(),
+ workspace_id=ws.id,
+ child_id=child.id,
+ parent_personal_data_uuid=parent.uuid,
+ date=date(rescue_today.year, 1, 15),
+ day_type="vacation",
+ )
+ )
+
+ after_date_change = await service.propose(ws.id, today=rescue_today)
+ still_dismissed = [
+ r for r in after_date_change.rescue_days if r.parent_personal_data_uuid == parent.uuid
+ ]
+ assert still_dismissed == [], (
+ "a date change alone must never resurrect a dismissal — only "
+ "(parent, year) identity is fingerprinted, so the group stays "
+ "suppressed even though its date list is now different"
+ )
+
+ async def test_group_disappears_once_the_residual_reaches_zero(
+ self,
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ parental_leave_repo: SQLiteParentalLeaveRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ planned_leave_repo: SQLitePlannedLeaveRepository,
+ leave_wish_repo: SQLiteLeaveWishRepository,
+ tip_dismissal_repo: SQLiteTipDismissalRepository,
+ rescue_today: date,
+ ) -> None:
+ now = datetime.now()
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ child = await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date(2026, 1, 1),
+ created_at=now,
+ )
+ )
+ # total_days=10, floor=5 -> an initial residual of 5 rescue days (see
+ # `_seed_forfeiting_parent`'s docstring), so planning exactly 5
+ # vacation days fully absorbs it: `left == current_year_saveable`,
+ # nothing left to forfeit.
+ parent = await _seed_forfeiting_parent(
+ workspace_repo, personal_data_repo, vacation_repo, ws.id, rescue_today.year
+ )
+ loader = _make_loader(
+ workspace_repo,
+ child_repo,
+ personal_data_repo,
+ parental_leave_repo,
+ vacation_repo,
+ planned_leave_repo,
+ leave_wish_repo,
+ )
+ service = PlanningEngineService(loader, dismissal_repo=tip_dismissal_repo)
+
+ proposal = await service.propose(ws.id, today=rescue_today)
+ assert len(proposal.rescue_days) == 5, "expected the full residual up front"
+
+ await service.dismiss_rescue_days(ws.id, parent.uuid, rescue_today.year, today=rescue_today)
+
+ for day in range(15, 20):
+ await planned_leave_repo.create(
+ PlannedLeaveDay(
+ id=uuid4(),
+ workspace_id=ws.id,
+ child_id=child.id,
+ parent_personal_data_uuid=parent.uuid,
+ date=date(rescue_today.year, 1, day),
+ day_type="vacation",
+ )
+ )
+
+ resolved = await service.propose(ws.id, today=rescue_today)
+ resolved_group = [
+ r for r in resolved.rescue_days if r.parent_personal_data_uuid == parent.uuid
+ ]
+ assert resolved_group == [], (
+ "the group must genuinely stop existing once the residual is 0 — "
+ "the allocator itself no longer produces it, independent of the "
+ "(now-irrelevant) stored dismissal"
+ )
+
+
+class TestPlanningEngineServiceWithoutDismissalRepo:
+ async def test_propose_is_unfiltered_and_dismiss_is_unusable(
+ self,
+ allocation_input_loader: AllocationInputLoader,
+ workspace_repo: SQLiteWorkspaceRepository,
+ child_repo: SQLiteChildRepository,
+ personal_data_repo: SQLitePersonalDataRepository,
+ vacation_repo: SQLiteVacationDaysRepository,
+ rescue_today: date,
+ ) -> None:
+ """A `PlanningEngineService` built without a `dismissal_repo` (every
+ pre-existing direct-construction call site, e.g. scripts/seed_demo.py)
+ must behave exactly as before this feature: `propose()` returns the
+ allocator's `rescue_days` unfiltered, and `dismiss_rescue_days` has
+ nothing to record against, so it 404s."""
+ now = datetime.now()
+ ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
+ await child_repo.create(
+ Child(
+ id=uuid4(),
+ workspace_id=ws.id,
+ name="Barn",
+ birth_date=date(2026, 1, 1),
+ created_at=now,
+ )
+ )
+ parent = await _seed_forfeiting_parent(
+ workspace_repo, personal_data_repo, vacation_repo, ws.id, rescue_today.year
+ )
+
+ service = PlanningEngineService(allocation_input_loader) # no dismissal_repo
+
+ proposal = await service.propose(ws.id, today=rescue_today)
+ assert proposal.rescue_days, "unfiltered — same as before this feature existed"
+
+ with pytest.raises(NotFoundError):
+ await service.dismiss_rescue_days(
+ ws.id, parent.uuid, rescue_today.year, today=rescue_today
+ )
diff --git a/backend/tests/unit/test_services/test_allocation_service_vacation_rescue.py b/backend/tests/unit/test_services/test_allocation_service_vacation_rescue.py
new file mode 100644
index 0000000..675ad8d
--- /dev/null
+++ b/backend/tests/unit/test_services/test_allocation_service_vacation_rescue.py
@@ -0,0 +1,355 @@
+"""Tests for §vacation-rescue — `RescueDay` generation.
+
+`_Ledger.build_rescue_days` turns a residual vacation-forfeiture forecast
+(`AllocationInput.vacation_forfeiting`, netted against this run's own placed
+vacation days — the same arithmetic `_vacation_expiry_warnings` uses, see
+`_Ledger._vacation_residuals`) into concrete, suggest-only dates. These ride
+on `PlanProposal.rescue_days`, never `PlanProposal.days` — see `RescueDay`'s
+docstring in `allocation_service.py` for why the two are deliberately not
+unified.
+
+Reuses the builders in `test_allocation_service.py` (`_build_input`, `_child`,
+`_parent`, `_planned`) rather than inventing a new harness.
+"""
+
+from datetime import date
+
+from app.services.allocation_service import (
+ REASON_VACATION_RESCUE,
+ Allocator,
+ DayPool,
+ VacationBudget,
+ is_free_day,
+)
+from tests.unit.test_services.test_allocation_service import (
+ _build_input,
+ _child,
+ _parent,
+ _planned,
+)
+
+# December 2027, verified against `get_swedish_holidays(2027)`:
+# free (excluded from candidacy): Dec 4/5, 11/12, 18/19, 24/25/26, 31
+# red-day bridge (bucket 0): Dec 23, 27, 30
+# weekend bridge (bucket 1): Dec 3, 6, 10, 13, 17, 20
+# plain interior (bucket 2): Dec 2, 7, 8, 9, 14, 15, 16, 21, 22, 28, 29
+_DEC_RED_DAY_BRIDGE_DATES = [date(2027, 12, 23), date(2027, 12, 27), date(2027, 12, 30)]
+_DEC_WEEKEND_BRIDGE_DATES = [
+ date(2027, 12, 3),
+ date(2027, 12, 6),
+ date(2027, 12, 10),
+ date(2027, 12, 13),
+ date(2027, 12, 17),
+ date(2027, 12, 20),
+]
+
+
+class TestNoForfeitureNoRescue:
+ def test_no_forfeiture_forecast_produces_no_rescue_days(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ wishes=[],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert proposal.rescue_days == []
+
+ def test_no_children_produces_no_rescue_days_even_with_forfeiture(self) -> None:
+ # §vacation-rescue: a rescue day's `child_id` is required for
+ # persistence, so a workspace with no children at all can never
+ # produce one, however large the residual.
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[],
+ parents=[parent],
+ pools={},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert proposal.rescue_days == []
+
+
+class TestRescueDayEligibility:
+ def test_residual_with_ample_candidates_yields_exactly_residual_count(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert len(proposal.rescue_days) == 5
+ for r in proposal.rescue_days:
+ assert r.date > alloc_input.today
+ assert r.date <= date(2027, 12, 31)
+ assert not is_free_day(r.date, alloc_input.holidays, alloc_input.day_overrides)
+ assert r.parent_personal_data_uuid == parent.uuid
+ assert r.child_id == child.id
+ assert r.year == 2027
+ assert r.reason == REASON_VACATION_RESCUE
+
+ def test_fewer_eligible_candidates_than_residual_returns_fewer_no_error(self) -> None:
+ # Only Dec 30 remains eligible after `today` (Dec 31 is Nyårsafton,
+ # already a free day) -- residual asks for 5, only 1 exists.
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 12, 29),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert len(proposal.rescue_days) == 1
+ assert proposal.rescue_days[0].date == date(2027, 12, 30)
+
+ def test_future_year_residual_produces_no_rescue_days(self) -> None:
+ # Only `today.year` gets concrete dates -- a future year's forecast
+ # is still provisional (`sync_plan` re-runs on every edit).
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2028, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={
+ (parent.uuid, 2027): VacationBudget(25, 0, 0),
+ (parent.uuid, 2028): VacationBudget(25, 8, 0),
+ },
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2028): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert proposal.rescue_days == []
+
+
+class TestCrossChildAndExclusion:
+ def test_date_occupied_under_a_different_child_is_not_offered(self) -> None:
+ # The sharpest pitfall: a parent can only be off once per date
+ # regardless of which child a competing commitment is filed under.
+ older_child = _child(birth_date=date(2020, 1, 1))
+ younger_child = _child(birth_date=date(2022, 1, 1)) # the rescue child
+ parent = _parent()
+ # Without this row, residual=1 would pick Dec 30 (latest red-day
+ # bridge date). It is booked under `older_child`, NOT the rescue
+ # child (`younger_child`) -- a naive per-child lookup would miss it.
+ existing = _planned(
+ parent_uuid=parent.uuid,
+ child_id=older_child.id,
+ on=date(2027, 12, 30),
+ day_type="sjukpenning",
+ )
+ alloc_input = _build_input(
+ today=date(2027, 12, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[older_child, younger_child],
+ parents=[parent],
+ pools={},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ existing_planned=[existing],
+ vacation_forfeiting={(parent.uuid, 2027): 1},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert len(proposal.rescue_days) == 1
+ rescue = proposal.rescue_days[0]
+ assert rescue.date != date(2027, 12, 30)
+ assert rescue.date == date(2027, 12, 27) # next-best red-day bridge date
+ assert rescue.child_id == younger_child.id # youngest child, per the rule
+
+ def test_excluded_date_is_not_offered(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 12, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ excluded_days=frozenset({(parent.uuid, child.id, date(2027, 12, 30))}),
+ vacation_forfeiting={(parent.uuid, 2027): 1},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert len(proposal.rescue_days) == 1
+ assert proposal.rescue_days[0].date == date(2027, 12, 27)
+
+
+class TestRanking:
+ def test_red_day_bridge_outranks_everything_and_later_wins_within_bucket(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 12, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 1},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ # 3 red-day-bridge dates exist in range (23, 27, 30); the latest wins.
+ assert [r.date for r in proposal.rescue_days] == [date(2027, 12, 30)]
+
+ def test_full_hierarchy_red_then_weekend_then_interior(self) -> None:
+ # residual == (# red-day-bridge) + (# weekend-bridge) + 1: every
+ # bucket-0 and bucket-1 candidate is exhausted, so exactly one
+ # bucket-2 (plain interior) date is pulled in -- the latest one.
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ residual = len(_DEC_RED_DAY_BRIDGE_DATES) + len(_DEC_WEEKEND_BRIDGE_DATES) + 1
+ alloc_input = _build_input(
+ today=date(2027, 12, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): residual},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ selected = {r.date for r in proposal.rescue_days}
+ assert len(selected) == residual
+ assert set(_DEC_RED_DAY_BRIDGE_DATES) <= selected
+ assert set(_DEC_WEEKEND_BRIDGE_DATES) <= selected
+ # The lone interior pick is the latest eligible interior date left
+ # (Dec 30/31 are already claimed by bucket 0 / free).
+ assert date(2027, 12, 29) in selected
+
+
+class TestWarningIntegration:
+ def test_warning_reports_rescue_days_available_count(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
+ assert len(expiry_warnings) == 1
+ assert expiry_warnings[0].data["rescue_days_available"] == len(proposal.rescue_days)
+ assert expiry_warnings[0].data["rescue_days_available"] == 5
+
+ def test_warning_reports_actual_rescued_count_not_the_forecast(self) -> None:
+ # Only 1 eligible candidate exists even though 5 are forecast to
+ # expire -- the warning's `rescue_days_available` must reflect what
+ # was actually named (1), never the unmet forecast (5, still
+ # reported unchanged as `expiring_days`).
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 12, 29),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
+ assert len(expiry_warnings) == 1
+ assert expiry_warnings[0].data["expiring_days"] == 5
+ assert expiry_warnings[0].data["rescue_days_available"] == 1
+ assert len(proposal.rescue_days) == 1
+
+
+class TestPurity:
+ def test_snapshot_balances_unchanged_by_rescue_pass(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ proposal = Allocator().propose(alloc_input)
+
+ assert len(proposal.rescue_days) == 5 # the pass did produce suggestions
+ vacation_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
+ assert vacation_after.total_days == 25
+ assert vacation_after.saved_days_in == 8
+ assert vacation_after.already_used == 0
+
+ def test_determinism_repeated_runs_produce_equal_rescue_days(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent()
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
+ wishes=[],
+ vacation_forfeiting={(parent.uuid, 2027): 5},
+ )
+
+ allocator = Allocator()
+ proposal_1 = allocator.propose(alloc_input)
+ proposal_2 = allocator.propose(alloc_input)
+
+ assert proposal_1.rescue_days == proposal_2.rescue_days
+ assert proposal_1 == proposal_2
diff --git a/backend/tests/unit/test_services/test_allocation_service_vacation_reservation.py b/backend/tests/unit/test_services/test_allocation_service_vacation_reservation.py
new file mode 100644
index 0000000..d4f9068
--- /dev/null
+++ b/backend/tests/unit/test_services/test_allocation_service_vacation_reservation.py
@@ -0,0 +1,582 @@
+"""Tests for §vacation-never-withhold: "spend vacation greedily, never refuse
+
+a day for the sake of a better one that might not even materialise — but
+when the (parent, year) budget can't cover every date that wants it, put it
+on the best ones" — `_Ledger.reallocate_vacation_by_value` /
+`_Ledger._tier3_eligible` / `Allocator.propose()`'s single post-funding call.
+
+This REPLACES the old "reservation" (a `high_value` flag on
+`_spend_vacation` that REFUSED a low-value request once the budget got low
+enough to threaten a not-yet-reached bridge date — see git history, tag
+§vacation-reservation, for that version and this file's own prior contents).
+The reservation could permanently waste a vacation day protecting a bridge
+date that then never materialised (excluded, its window never placed, ...);
+the replacement funds every request immediately and re-times WHICH dates end
+up with vacation in a single pass after every window is funded, once the
+whole (parent, year) candidate set is actually known.
+
+Builders (`_build_input`, `_child`, `_parent`, `_wish`) are imported from
+`test_allocation_service.py` rather than reinvented — see that module's
+docstring for the harness convention. FEB_2027 (Mon 1 Feb -> Sun 28 Feb, 20
+spendable days, no real holidays nearby) is used throughout so every
+Mon/Fri in the window is a bare-weekend bridge day (§weekend-wrap-bridge,
+`REASON_VACATION_WRAP_WEEKEND`) and every Tue/Wed/Thu is an ordinary interior
+day — a clean, calendar-only split between "high-value" and "low-value"
+dates with nothing else (real red days, exclusions, ...) in play.
+
+Every scenario below was cross-checked against the actual `Allocator().
+propose()` output before being written down (not hand-derived arithmetic
+alone) — so the exact per-date assertions are the real, current behaviour of
+the engine, not a guess at what it "should" do.
+"""
+
+from collections import Counter
+from datetime import date
+from unittest import mock
+from uuid import UUID
+
+from app.services import allocation_service
+from app.services.allocation_service import (
+ REASON_FP_INTERIOR,
+ REASON_VACATION_INTERIOR,
+ REASON_VACATION_WRAP_WEEKEND,
+ AllocationInput,
+ Allocator,
+ DayPool,
+ PlanProposal,
+ ProposedDay,
+ VacationBudget,
+)
+from tests.unit.test_services.test_allocation_service import (
+ _build_input,
+ _child,
+ _parent,
+ _wish,
+)
+
+# FEB_2027: Mon 1 Feb -> Sun 28 Feb, 4 full weeks. Every week contributes
+# exactly 2 high-value (bridge) dates -- its Monday and Friday, each wrapping
+# the surrounding bare weekend -- and 3 low-value interior dates (Tue/Wed/
+# Thu). Whole month: 8 high-value, 12 low-value, 20 spendable total.
+_FEB_START, _FEB_END = (2, 1), (2, 28)
+
+
+def _by_date(proposal: PlanProposal) -> dict[date, ProposedDay]:
+ return {d.date: d for d in proposal.days}
+
+
+def _vacation_counts_by_parent_year(proposal: PlanProposal) -> Counter[tuple[UUID, int]]:
+ """§5 invariant helper — vacation-day COUNT per (parent, year), the
+
+ quantity `reallocate_vacation_by_value` must never lower relative to a
+ plain-greedy (no bridge tier) run — see `TestNeverWithholdInvariant`.
+ """
+ counts: Counter[tuple[UUID, int]] = Counter()
+ for d in proposal.days:
+ if d.day_type == "vacation":
+ counts[(d.parent_personal_data_uuid, d.date.year)] += 1
+ return counts
+
+
+class TestAmpleBudgetStaysGreedy:
+ """Plenty of vacation left -> unchanged, pre-existing behaviour: a
+
+ monthly parent's tier-3 policy (vacation first) still claims every
+ interior day too, not just the bridge days. Budget never gets anywhere
+ near scarce enough for `reallocate_vacation_by_value`'s swap to have
+ anything to do (every candidate is already vacation).
+ """
+
+ def test_monthly_parent_with_ample_vacation_takes_every_day_as_vacation(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = _by_date(proposal)
+
+ monday = by_date[date(2027, 2, 1)]
+ tuesday = by_date[date(2027, 2, 2)]
+ friday = by_date[date(2027, 2, 5)]
+
+ assert monday.day_type == "vacation"
+ assert monday.reason == REASON_VACATION_WRAP_WEEKEND
+ assert friday.day_type == "vacation"
+ assert friday.reason == REASON_VACATION_WRAP_WEEKEND
+ # An interior day a scarce budget would have to ration -- here it
+ # still draws vacation, proving ample budget never triggers rationing.
+ assert tuesday.day_type == "vacation"
+ assert tuesday.reason == REASON_VACATION_INTERIOR
+
+ vacation_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
+ assert vacation_after.already_used == 20 # every spendable day, greedy
+
+
+class TestScarceBudgetProtectsHighValueDates:
+ """The core test: `budget.available` set to EXACTLY the month's
+
+ high-value date count (8 -- 2 per week x 4 weeks). Nothing is refused
+ during funding any more -- every date, high-value or not, greedily draws
+ vacation as `_fund_solo` reaches it in date order, which on its own would
+ exhaust the budget on the first 8 chronological days (Feb 1-10) and leave
+ the month's later bridge days (Feb 12 onward) funded from FP instead.
+ `reallocate_vacation_by_value` runs once every window is funded and swaps
+ those low-ranked (interior) vacation days for the higher-ranked (bridge)
+ FP days -- a strict 1-for-1 exchange, so the total spent (8) never
+ changes, only WHICH 8 dates hold it. Net result: vacation lands on
+ exactly the 8 Mondays/Fridays, FP on exactly the 12 interior days --
+ same end state the old refusal-based reservation used to produce, now
+ reached by re-ordering after the fact instead of refusing up front.
+ """
+
+ def test_interior_days_go_to_fp_bridge_days_keep_vacation(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(8, 0, 0)},
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = _by_date(proposal)
+
+ bridge_dates = [
+ date(2027, 2, 1),
+ date(2027, 2, 5),
+ date(2027, 2, 8),
+ date(2027, 2, 12),
+ date(2027, 2, 15),
+ date(2027, 2, 19),
+ date(2027, 2, 22),
+ date(2027, 2, 26),
+ ]
+ interior_dates = [
+ date(2027, 2, 2),
+ date(2027, 2, 3),
+ date(2027, 2, 4),
+ date(2027, 2, 9),
+ date(2027, 2, 10),
+ date(2027, 2, 11),
+ date(2027, 2, 16),
+ date(2027, 2, 17),
+ date(2027, 2, 18),
+ date(2027, 2, 23),
+ date(2027, 2, 24),
+ date(2027, 2, 25),
+ ]
+
+ for d in bridge_dates:
+ day = by_date[d]
+ assert day.day_type == "vacation", f"{d} should be vacation (bridge)"
+ assert day.reason == REASON_VACATION_WRAP_WEEKEND
+
+ for d in interior_dates:
+ day = by_date[d]
+ assert day.day_type == "sjukpenning", f"{d} should have fallen through to FP"
+ assert day.reason == REASON_FP_INTERIOR
+
+ vacation_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
+ assert vacation_after.already_used == 8 # exactly the bridge days, budget fully spent
+
+
+class TestForfeitingNoLongerBypassesBridgeProtection:
+ """§the old "forfeiting lifts the reservation" carve-out is GONE.
+
+ Nothing is ever refused any more, so there is no reservation left for a
+ forfeiting year to lift -- `vacation_forfeiting` still biases
+ `_assign_tier3`'s hourly-vs-monthly resource ORDER (unrelated, kept, see
+ that method), but it no longer has any special effect on whether a
+ bridge day keeps its vacation. Same scarce budget (8) and same wish as
+ the core test above, but with `vacation_forfeiting` set for this
+ (parent, year) too: the outcome is BYTE-IDENTICAL to the core test's --
+ proof the carve-out was genuinely redundant, not just untested.
+ """
+
+ def test_scarce_budget_plus_forfeiting_matches_scarce_budget_alone(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+
+ without_forfeiting = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(8, 0, 0)},
+ wishes=[wish],
+ )
+ with_forfeiting = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(8, 0, 0)},
+ wishes=[wish],
+ vacation_forfeiting={(parent.uuid, 2027): 1},
+ )
+
+ proposal_plain = Allocator().propose(without_forfeiting)
+ proposal_forfeiting = Allocator().propose(with_forfeiting)
+
+ by_date_forfeiting = _by_date(proposal_forfeiting)
+ friday_bridge = by_date_forfeiting[date(2027, 2, 12)]
+ # The date the OLD carve-out used to sacrifice (chronological spend
+ # exhausts the budget by Feb 10, so a naive "no reservation at all"
+ # read would expect this Friday bridge day to fall through to FP) --
+ # it does not: the value-ordered swap protects it exactly like the
+ # no-forfeiting case.
+ assert friday_bridge.day_type == "vacation"
+ assert friday_bridge.reason == REASON_VACATION_WRAP_WEEKEND
+
+ assert proposal_plain.days == proposal_forfeiting.days
+ vacation_after = proposal_forfeiting.balances_after.vacation_after[(parent.uuid, 2027)]
+ assert vacation_after.already_used == 8
+
+
+class TestFlexibleWindowSharesReallocationAcrossWindows:
+ """A fixed window (Feb, pass 1) and a flexible window (bound: all of
+
+ August, resolved in pass 2) share one (parent, year) vacation budget.
+ `reallocate_vacation_by_value` runs ONCE, after both passes, over every
+ tier-3/bridge-eligible day from BOTH windows together -- there is no
+ per-priority-tier or per-pass scoping left to reason about (the old
+ reservation's tier-batching existed only to protect its seeded counts
+ from a lower priority tier; nothing is seeded any more, so it was deleted
+ along with the reservation itself, see `Allocator.propose()`).
+
+ Budget is set to exactly (fixed window's full 5-day cost) + (flexible
+ window's own 2 high-value dates) = 7:
+
+ - The fixed window (Feb 1-5) is cheap enough on its own that greedy
+ funding takes it entirely in vacation, spending 5 -- nothing here for
+ the reallocation pass to touch.
+ - The flexible window resolves to Aug 2-8 (the cheapest, Monday-aligned
+ full week in the bound). Greedy funding spends the remaining 2 units
+ chronologically (Aug 2-3), leaving Aug 4-6 as FP; the reallocation
+ pass then swaps Aug 6 (a Friday bridge day) back in for Aug 3 (a
+ lower-ranked interior day), so the window's OWN bridge days (Aug 2,
+ Aug 6) end up protected exactly like the fixed window's did, using a
+ budget a whole OTHER window already spent most of.
+ """
+
+ def test_flexible_windows_own_bridge_days_survive_a_fixed_windows_earlier_spend(
+ self,
+ ) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ fixed_wish = _wish(
+ child_id=child.id, name="Fixed", start=(2, 1), end=(2, 5), weeks=1, priority=1
+ )
+ flex_wish = _wish(
+ child_id=child.id,
+ name="Flex",
+ start=(8, 1),
+ end=(8, 31),
+ weeks=1,
+ window_mode="flexible",
+ priority=1,
+ )
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(7, 0, 0)},
+ wishes=[fixed_wish, flex_wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = _by_date(proposal)
+
+ # Fixed window: fully greedy, all 5 days vacation.
+ for d in (
+ date(2027, 2, 1),
+ date(2027, 2, 2),
+ date(2027, 2, 3),
+ date(2027, 2, 4),
+ date(2027, 2, 5),
+ ):
+ assert by_date[d].day_type == "vacation"
+
+ # Flexible window resolved into the first Monday-aligned week of
+ # August (no other wish competes for it, so the cheapest/aligned
+ # candidate wins).
+ flex_outcome = next(o for o in proposal.outcomes if o.wish_name == "Flex")
+ assert (flex_outcome.start_date, flex_outcome.end_date) == (
+ date(2027, 8, 2),
+ date(2027, 8, 8),
+ )
+ assert flex_outcome.shortfall_days == 0
+
+ monday, friday = by_date[date(2027, 8, 2)], by_date[date(2027, 8, 6)]
+ assert monday.day_type == "vacation"
+ assert monday.reason == REASON_VACATION_WRAP_WEEKEND
+ assert friday.day_type == "vacation"
+ assert friday.reason == REASON_VACATION_WRAP_WEEKEND
+
+ for d in (date(2027, 8, 3), date(2027, 8, 4), date(2027, 8, 5)):
+ day = by_date[d]
+ assert day.day_type == "sjukpenning", f"{d} should have fallen through to FP"
+ assert day.reason == REASON_FP_INTERIOR
+
+ vacation_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
+ assert vacation_after.already_used == 7 # 5 (fixed) + 2 (flexible's own bridge days)
+
+
+class TestGreedyNeverLeavesADayUnfundedWhenVacationCouldPay:
+ """An empty FP pool used to need a special retry (§the old "fix 2") to
+
+ stop a low-value refusal from leaving a date unfunded outright while
+ vacation still had room. There is no refusal left to retry around any
+ more -- `_assign_tier3`'s first (and only) vacation attempt already
+ spends unconditionally, so a date only ever goes unfunded once BOTH
+ resources are genuinely exhausted. One week (Feb 1-5), budget=3, FP pool
+ completely empty (`DayPool(0, 0, 0)`).
+
+ Vacation is spent in date order until the budget of 3 runs out (Mon-Wed);
+ Thu/Fri then have nothing left to fund them with (FP is empty too) and
+ are genuinely unfunded -- not a reservation artifact, real exhaustion.
+ """
+
+ def test_interior_day_gets_vacation_instead_of_being_left_unfunded(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, name="OneWeek", start=(2, 1), end=(2, 5), weeks=1)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(0, 0, 0)}, # no FP at all
+ vacation={(parent.uuid, 2027): VacationBudget(3, 0, 0)},
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = _by_date(proposal)
+
+ monday = by_date[date(2027, 2, 1)]
+ tuesday = by_date[date(2027, 2, 2)]
+ wednesday = by_date[date(2027, 2, 3)]
+
+ assert monday.day_type == "vacation"
+ assert monday.reason == REASON_VACATION_WRAP_WEEKEND
+ assert tuesday.day_type == "vacation"
+ assert tuesday.reason == REASON_VACATION_INTERIOR
+ assert wednesday.day_type == "vacation"
+ assert wednesday.reason == REASON_VACATION_INTERIOR
+
+ # Thu/Fri are absent entirely: the budget is genuinely exhausted by
+ # then (3 spent, 0 left) and FP has nothing either -- real
+ # exhaustion, not a reservation withholding a day it could have paid.
+ assert date(2027, 2, 4) not in by_date
+ assert date(2027, 2, 5) not in by_date
+
+ outcome = next(o for o in proposal.outcomes if o.wish_name == "OneWeek")
+ assert outcome.shortfall_days == 2
+
+ vacation_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
+ assert vacation_after.already_used == 3
+
+
+class TestReallocationDeterminism:
+ """§5 test #11 — `_Ledger._tier3_eligible` and `reallocate_vacation_by_
+
+ value` are pure per-run state/computation (fresh `set()` in `_Ledger.
+ __init__`, no wall-clock or repository access); two identical
+ `propose()` calls on the same scarce-budget input must still produce
+ byte-identical output.
+ """
+
+ def test_repeated_runs_with_a_scarce_budget_are_identical(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(8, 0, 0)},
+ wishes=[wish],
+ )
+
+ allocator = Allocator()
+ proposal_1 = allocator.propose(alloc_input)
+ proposal_2 = allocator.propose(alloc_input)
+
+ assert proposal_1 == proposal_2
+
+
+class TestNeverWithholdInvariant:
+ """The invariant the whole rewrite exists to guarantee: for every
+
+ (parent, year), the number of vacation days PLACED must never be lower
+ than a plain-greedy run would place -- the value logic may change WHICH
+ dates get vacation, never HOW MANY. Verified by monkeypatching
+ `_vacation_bridge_reason` to always return `None`: this is a complete
+ kill-switch for the value logic (it disables `_fund_solo`'s tier-2
+ bridge branch AND makes every date rank as plain "interior" in
+ `reallocate_vacation_by_value`'s ranking, so the swap has no
+ distinctions left to act on) and reduces the engine to the same
+ plain-greedy, budget-only spending `_assign_tier3` always did.
+
+ Each scenario runs `propose()` twice on the SAME `AllocationInput` --
+ once with the value logic live, once with the kill-switch active -- and
+ compares the per-(parent, year) vacation-day counts.
+ """
+
+ def _counts_with_and_without_value_logic(
+ self, alloc_input: AllocationInput
+ ) -> tuple[Counter[tuple[UUID, int]], Counter[tuple[UUID, int]]]:
+ with_value_logic = _vacation_counts_by_parent_year(Allocator().propose(alloc_input))
+ with mock.patch.object(allocation_service, "_vacation_bridge_reason", return_value=None):
+ plain_greedy = _vacation_counts_by_parent_year(Allocator().propose(alloc_input))
+ return with_value_logic, plain_greedy
+
+ def test_ample_budget(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ wishes=[wish],
+ )
+
+ with_value_logic, plain_greedy = self._counts_with_and_without_value_logic(alloc_input)
+
+ key = (parent.uuid, 2027)
+ assert with_value_logic[key] >= plain_greedy[key]
+ # Ample budget: both should max out identically (20 spendable days).
+ assert with_value_logic[key] == plain_greedy[key] == 20
+
+ def test_scarce_budget(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(8, 0, 0)},
+ wishes=[wish],
+ )
+
+ with_value_logic, plain_greedy = self._counts_with_and_without_value_logic(alloc_input)
+
+ key = (parent.uuid, 2027)
+ assert with_value_logic[key] >= plain_greedy[key]
+ # Scarce budget: both spend the SAME total (8) -- the value logic
+ # only changes WHICH 8 dates, per `TestScarceBudgetProtectsHighValue
+ # Dates` above -- never more, never fewer.
+ assert with_value_logic[key] == plain_greedy[key] == 8
+
+ def test_forfeiting_year(self) -> None:
+ # Hourly + forfeiting is the scenario where forfeiting actually
+ # changes `_assign_tier3`'s resource order (an hourly parent
+ # normally tries FP first) -- both sides of the comparison see that
+ # same bias, so this still isolates the value logic's OWN effect.
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="hourly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(8, 0, 0)},
+ wishes=[wish],
+ vacation_forfeiting={(parent.uuid, 2027): 1},
+ )
+
+ with_value_logic, plain_greedy = self._counts_with_and_without_value_logic(alloc_input)
+
+ key = (parent.uuid, 2027)
+ assert with_value_logic[key] >= plain_greedy[key]
+ assert with_value_logic[key] == plain_greedy[key] == 8
+
+ def test_no_high_value_dates_in_the_window(self) -> None:
+ # Tue 3 Aug -> Thu 5 Aug 2027: bounded by a genuine Mon/Fri workday
+ # on both sides (not a free day), so the surrounding non-free run is
+ # 5 days long (> 3) -- every date in this window is an ordinary
+ # interior day, never a bridge date. The value logic has nothing to
+ # rank differently here; it must still never do WORSE than plain
+ # greedy.
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, start=(8, 3), end=(8, 5), weeks=1)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(1, 0, 0)},
+ wishes=[wish],
+ )
+
+ with_value_logic, plain_greedy = self._counts_with_and_without_value_logic(alloc_input)
+
+ key = (parent.uuid, 2027)
+ assert with_value_logic[key] >= plain_greedy[key]
+ assert with_value_logic[key] == plain_greedy[key] == 1
+
+ def test_hourly_parent_bridge_days_strictly_beat_plain_greedy(self) -> None:
+ """The one scenario in this class with a REAL strict inequality.
+
+ An hourly, non-forfeiting parent's `_assign_tier3` prefers FP over
+ vacation for ordinary interior days (§3.3 — vacation would cost them
+ lost pay) — with ample FP and ample vacation budget, plain greedy
+ (bridge tier disabled) spends FP for literally everything and never
+ touches vacation at all. With the value logic live, `_fund_solo`'s
+ tier-2 bridge branch still spends the month's 8 bridge dates as
+ vacation UNCONDITIONALLY (that tier never consulted salary_type —
+ see its own docstring), so this (parent, year) ends up with 8
+ vacation days under the real engine against 0 under the kill-switch.
+ """
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="hourly")
+ wish = _wish(child_id=child.id, start=_FEB_START, end=_FEB_END, weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ wishes=[wish],
+ )
+
+ with_value_logic, plain_greedy = self._counts_with_and_without_value_logic(alloc_input)
+
+ key = (parent.uuid, 2027)
+ assert with_value_logic[key] > plain_greedy[key]
+ assert with_value_logic[key] == 8
+ assert plain_greedy[key] == 0
diff --git a/backend/tests/unit/test_services/test_allocation_service_vacation_wrap.py b/backend/tests/unit/test_services/test_allocation_service_vacation_wrap.py
new file mode 100644
index 0000000..4389070
--- /dev/null
+++ b/backend/tests/unit/test_services/test_allocation_service_vacation_wrap.py
@@ -0,0 +1,292 @@
+"""Tests for the §weekend-wrap-bridge tier-2 rule (`_vacation_bridge_reason`).
+
+Complements the acceptance matrix in `test_allocation_service.py`: that file's
+own #3/#9 cases exercise the OLD, narrower "klämdag inside a real red-day
+cluster" rule and continue to pass unchanged (see `TestCase9MovableEaster`,
+still asserting `REASON_VACATION_WRAP_RED_DAY` around Easter 2027). This file
+targets the broader replacement: every ordinary weekend now gets a
+Friday/Monday bridge too, ungated by `salary_type` or `vacation_forfeiting`,
+and a short (<=3 day) interior gap fenced by free days on both sides is
+bridged in full, including its middle day.
+
+Builders (`_build_input`, `_child`, `_parent`, `_wish`) 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 app.services.allocation_service import (
+ REASON_VACATION_INTERIOR,
+ REASON_VACATION_WRAP_RED_DAY,
+ REASON_VACATION_WRAP_WEEKEND,
+ AllocationInput,
+ Allocator,
+ DayPool,
+ VacationBudget,
+)
+from tests.unit.test_services.test_allocation_service import (
+ FEB_2027,
+ _build_input,
+ _child,
+ _parent,
+ _wish,
+)
+
+
+def _dates_in(start: date, end: date) -> list[date]:
+ n = (end - start).days + 1
+ return [start + timedelta(days=i) for i in range(n)]
+
+
+def _forced_workdays_except(
+ range_start: date, range_end: date, free_dates: set[date]
+) -> dict[date, bool]:
+ """A `day_overrides` map forcing every date in `[range_start, range_end]`
+
+ to be a working day (`True`), except `free_dates` (forced free, `False`).
+ Used to build fully controlled, calendar-independent free/non-free
+ patterns for the gap-length tests below, so they never depend on which
+ weekday a given date happens to fall on nor on which real holidays a
+ given year happens to carry. The range must be wide enough to swallow
+ `_BRIDGE_SCAN_BOUND_DAYS` (10) on every side of every probed date, or a
+ real calendar weekend/holiday just outside it could leak in.
+ """
+ overrides: dict[date, bool] = {}
+ for d in _dates_in(range_start, range_end):
+ overrides[d] = d not in free_dates
+ return overrides
+
+
+# ---------------------------------------------------------------------------
+# The discriminating test: an ordinary week's Monday/Friday bridge, its
+# Tue/Wed/Thu do not.
+# ---------------------------------------------------------------------------
+
+
+class TestOrdinaryWeekBridgesOnlyMondayAndFriday:
+ def test_monday_and_friday_get_weekend_wrap_reason_midweek_does_not(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ # FEB_2027: Mon 2027-02-01 -> Sun 2027-02-28, no real holidays nearby
+ # (see FEB_2027's own comment in test_allocation_service.py).
+ wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = {d.date: d for d in proposal.days}
+
+ monday = by_date[date(2027, 2, 1)]
+ friday = by_date[date(2027, 2, 5)]
+ tuesday = by_date[date(2027, 2, 2)]
+ wednesday = by_date[date(2027, 2, 3)]
+ thursday = by_date[date(2027, 2, 4)]
+
+ assert monday.day_type == "vacation"
+ assert monday.reason == REASON_VACATION_WRAP_WEEKEND
+ assert friday.day_type == "vacation"
+ assert friday.reason == REASON_VACATION_WRAP_WEEKEND
+
+ # Tue/Wed/Thu are genuinely interior -- tier 3, not the bridge tier.
+ # `salary_type == "monthly"` still picks vacation for them (vacation
+ # first), but the REASON must be tier-3's, proving the bridge tier
+ # did not (incorrectly) also claim them.
+ for interior_day in (tuesday, wednesday, thursday):
+ assert interior_day.day_type == "vacation"
+ assert interior_day.reason == REASON_VACATION_INTERIOR
+
+
+# ---------------------------------------------------------------------------
+# §6.1 policy change: an hourly parent, not forfeiting any vacation, still
+# gets the Friday/Monday bridged with vacation; mid-week stays FP.
+# ---------------------------------------------------------------------------
+
+
+class TestHourlyParentStillBridgesWeekends:
+ def test_hourly_no_forfeiture_bridges_but_keeps_interior_days_on_fp(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="hourly")
+ wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ # vacation_forfeiting deliberately empty -- this parent is not
+ # forecast to lose any vacation, so the OLD rule would have kept
+ # this parent on FP for every day, including Fri/Mon.
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = {d.date: d for d in proposal.days}
+
+ friday = by_date[date(2027, 2, 5)]
+ wednesday = by_date[date(2027, 2, 3)]
+
+ assert friday.day_type == "vacation"
+ assert friday.reason == REASON_VACATION_WRAP_WEEKEND
+ assert wednesday.day_type == "sjukpenning"
+
+
+# ---------------------------------------------------------------------------
+# Red-day reason selection: the WHOLE adjacent free run is checked for a real
+# red day, not just the immediately-touching day -- Midsommarafton (Fri, a
+# real named day) + Midsommardagen (Sat, real) + the following bare Sunday
+# form one free run; the klämdag on the Monday after must still see through
+# the intervening plain Sunday to the real red days earlier in the run.
+# ---------------------------------------------------------------------------
+
+
+class TestRedDayReasonLooksThroughWholeFreeRun:
+ def test_klamdag_after_midsommar_sees_past_the_bare_sunday(self) -> None:
+ # 2027: Midsommarafton Fri 6/25, Midsommardagen Sat 6/26, then a bare
+ # (generic) Sunday 6/27 -- verified against `get_swedish_holidays`.
+ # Monday 6/28 is the klämdag: its immediate neighbour (Sunday 6/27)
+ # is NOT itself a real red day, but the free run it belongs to is.
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ wish = _wish(child_id=child.id, name="Midsommar", start=(6, 21), end=(6, 30), weeks=1)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(10, 0, 0)},
+ wishes=[wish],
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = {d.date: d for d in proposal.days}
+
+ monday_after = by_date[date(2027, 6, 28)]
+ assert monday_after.day_type == "vacation"
+ assert monday_after.reason == REASON_VACATION_WRAP_RED_DAY
+
+
+# ---------------------------------------------------------------------------
+# Gap-length boundary: a <=3-day gap fenced by free days on both sides is
+# bridged in full (including its middle day); a 4-day gap's middle days are
+# not. Built on a fully-controlled synthetic calendar (`day_overrides`) so
+# the result never depends on which weekday the gap happens to fall on nor
+# on which real holidays the chosen year happens to carry nearby.
+# ---------------------------------------------------------------------------
+
+
+class TestGapLengthBoundary:
+ def test_three_day_gap_is_fully_bridged_including_middle_day(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ free_before = date(2027, 5, 10)
+ free_after = date(2027, 5, 14) # gap: 5/11, 5/12 (middle), 5/13
+ overrides = _forced_workdays_except(
+ date(2027, 4, 20), date(2027, 5, 24), {free_before, free_after}
+ )
+ wish = _wish(child_id=child.id, name="Gap3", start=(5, 10), end=(5, 14), weeks=1)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(10, 0, 0)},
+ wishes=[wish],
+ day_overrides=overrides,
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = {d.date: d for d in proposal.days}
+
+ day1, middle, day3 = (
+ by_date[date(2027, 5, 11)],
+ by_date[date(2027, 5, 12)],
+ by_date[date(2027, 5, 13)],
+ )
+ assert day1.day_type == "vacation"
+ assert day1.reason == REASON_VACATION_WRAP_WEEKEND
+ assert day3.day_type == "vacation"
+ assert day3.reason == REASON_VACATION_WRAP_WEEKEND
+ # The middle day is the one the rule change actually adds: rule 1
+ # (adjacency) can't see it, only rule 2 (short-run-middle) can.
+ assert middle.day_type == "vacation"
+ assert middle.reason == REASON_VACATION_WRAP_WEEKEND
+
+ def test_four_day_gap_middle_days_are_not_bridged(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="monthly")
+ free_before = date(2027, 6, 10)
+ free_after = date(2027, 6, 15) # gap: 6/11, 6/12, 6/13, 6/14 (4 days)
+ overrides = _forced_workdays_except(
+ date(2027, 5, 21), date(2027, 6, 25), {free_before, free_after}
+ )
+ wish = _wish(child_id=child.id, name="Gap4", start=(6, 10), end=(6, 15), weeks=1)
+ alloc_input = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(10, 0, 0)},
+ wishes=[wish],
+ day_overrides=overrides,
+ )
+
+ proposal = Allocator().propose(alloc_input)
+ by_date = {d.date: d for d in proposal.days}
+
+ edge_first = by_date[date(2027, 6, 11)]
+ middle_a = by_date[date(2027, 6, 12)]
+ middle_b = by_date[date(2027, 6, 13)]
+ edge_last = by_date[date(2027, 6, 14)]
+
+ # Still bridged: each touches a free day directly (rule 1).
+ assert edge_first.reason == REASON_VACATION_WRAP_WEEKEND
+ assert edge_last.reason == REASON_VACATION_WRAP_WEEKEND
+
+ # NOT bridged: the run is 4 days long, so rule 2 doesn't fire. Both
+ # still end up `day_type == "vacation"` (monthly salary -> tier 3
+ # picks vacation first too), but via the tier-3 interior reason.
+ assert middle_a.day_type == "vacation"
+ assert middle_a.reason == REASON_VACATION_INTERIOR
+ assert middle_b.day_type == "vacation"
+ assert middle_b.reason == REASON_VACATION_INTERIOR
+
+
+# ---------------------------------------------------------------------------
+# Determinism (mirrors TestCase11Determinism's pattern, §5 test #11):
+# repeating the whole `propose()` call with the bridge tier active must still
+# be byte-identical.
+# ---------------------------------------------------------------------------
+
+
+class TestBridgeTierDeterminism:
+ def test_repeated_runs_with_bridge_days_are_identical(self) -> None:
+ child = _child(birth_date=date(2020, 1, 1))
+ parent = _parent(salary_type="hourly")
+ wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
+ alloc_input: AllocationInput = _build_input(
+ today=date(2027, 1, 1),
+ horizon_end=date(2027, 12, 31),
+ children=[child],
+ parents=[parent],
+ pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
+ vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
+ wishes=[wish],
+ )
+
+ allocator = Allocator()
+ proposal_1 = allocator.propose(alloc_input)
+ proposal_2 = allocator.propose(alloc_input)
+
+ assert proposal_1 == proposal_2
diff --git a/frontend/src/components/dashboard/PlanWarningCard.test.tsx b/frontend/src/components/dashboard/PlanWarningCard.test.tsx
index 57d78ea..1216970 100644
--- a/frontend/src/components/dashboard/PlanWarningCard.test.tsx
+++ b/frontend/src/components/dashboard/PlanWarningCard.test.tsx
@@ -71,6 +71,52 @@ describe('PlanWarningCard', () => {
const warning = screen.getByTestId('dashboard-plan-warning-2')
expect(warning).toHaveTextContent('Semesterdagar riskerar att gå förlorade 2027')
expect(warning).toHaveTextContent('Sam riskerar att förlora 4 semesterdagar om inget görs')
+ // No `rescue_days_available` at all — the hint sentence must not appear,
+ // this stays exactly the one nag it always was.
+ expect(warning).not.toHaveTextContent('förslag på')
+ })
+
+ it('appends the rescue-suggestions hint when rescue_days_available > 0', () => {
+ render(
+ ,
+ )
+ const warning = screen.getByTestId('dashboard-plan-warning-2')
+ expect(warning).toHaveTextContent('Sam riskerar att förlora 4 semesterdagar om inget görs')
+ expect(warning).toHaveTextContent('Det finns förslag på 3 datum för att rädda dem')
+ })
+
+ it('does not append the rescue-suggestions hint when rescue_days_available is 0', () => {
+ render(
+ ,
+ )
+ const warning = screen.getByTestId('dashboard-plan-warning-2')
+ expect(warning).not.toHaveTextContent('förslag på')
})
it('falls back to the raw kind and messageKey for an unrecognised warning', () => {
diff --git a/frontend/src/components/dashboard/PlanWarningCard.tsx b/frontend/src/components/dashboard/PlanWarningCard.tsx
index ebb1d7b..a2644ef 100644
--- a/frontend/src/components/dashboard/PlanWarningCard.tsx
+++ b/frontend/src/components/dashboard/PlanWarningCard.tsx
@@ -73,6 +73,18 @@ export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProp
let body = warning.messageKey
if (bodyKey) {
body = t(bodyKey, params)
+ // §vacation-rescue — one extra sentence pointing at the concrete
+ // suggestions, only when there's at least one to point at. Appended
+ // (never replacing `vacation_expiring`'s own string) so the zero-rescue
+ // case stays exactly the one nag it always was.
+ const rescueDaysAvailable = warning.data.rescue_days_available
+ if (
+ warning.kind === 'vacation_expiring' &&
+ typeof rescueDaysAvailable === 'number' &&
+ rescueDaysAvailable > 0
+ ) {
+ body = `${body} ${t('planWarnings.vacationExpiringRescueHint', { count: rescueDaysAvailable })}`
+ }
}
return (
diff --git a/frontend/src/components/dashboard/VacationRescueTip.test.tsx b/frontend/src/components/dashboard/VacationRescueTip.test.tsx
new file mode 100644
index 0000000..e7f2069
--- /dev/null
+++ b/frontend/src/components/dashboard/VacationRescueTip.test.tsx
@@ -0,0 +1,132 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { VacationRescueTip } from './VacationRescueTip'
+import * as api from '@/services/api'
+import { useWorkspace } from '@/context'
+import { makeRescueDay, makeWorkspaceContextValue, makePlanApplyResponse } from '../../../tests/fixtures'
+
+vi.mock('@/services/api')
+vi.mock('@/context')
+
+const nameFor = (uuid: string) => (uuid === 'pd-a' ? 'Alex' : 'Okänd förälder')
+
+describe('VacationRescueTip', () => {
+ let bumpPlanVersion: ReturnType
+ let onDismissed: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ bumpPlanVersion = vi.fn()
+ onDismissed = vi.fn()
+ vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ bumpPlanVersion }))
+ vi.mocked(api.applyPlan).mockResolvedValue(makePlanApplyResponse())
+ vi.mocked(api.dismissRescueDays).mockResolvedValue(undefined)
+ })
+
+ const days = [
+ makeRescueDay({ date: '2026-12-28', parentPersonalDataUuid: 'pd-a', childId: 'child-1', year: 2026 }),
+ makeRescueDay({ date: '2026-12-25', parentPersonalDataUuid: 'pd-a', childId: 'child-1', year: 2026 }),
+ ]
+
+ it('renders the parent name, day count and every date, sorted ascending', () => {
+ render()
+ const tip = screen.getByTestId('dashboard-vacation-rescue-0')
+ expect(tip).toHaveClass('tip-card', 'tip-card--action')
+ expect(tip).toHaveTextContent('Rädda semesterdagar 2026')
+ expect(tip).toHaveTextContent('Alex riskerar att förlora 2 semesterdagar')
+
+ const dateLabels = screen.getAllByText(/december 2026/)
+ expect(dateLabels[0]).toHaveTextContent('25')
+ expect(dateLabels[1]).toHaveTextContent('28')
+ })
+
+ it('accepts a single date with one applyPlan call carrying that date only', async () => {
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByTestId('dashboard-vacation-rescue-0-accept-2026-12-28'))
+
+ await waitFor(() => expect(api.applyPlan).toHaveBeenCalledTimes(1))
+ expect(api.applyPlan).toHaveBeenCalledWith('ws-1', [
+ {
+ date: '2026-12-28',
+ parentPersonalDataUuid: 'pd-a',
+ childId: 'child-1',
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ },
+ ])
+ expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
+ })
+
+ it('accepts every date in exactly one applyPlan call, not N', async () => {
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByTestId('dashboard-vacation-rescue-0-accept-all'))
+
+ await waitFor(() => expect(api.applyPlan).toHaveBeenCalledTimes(1))
+ expect(api.applyPlan).toHaveBeenCalledWith('ws-1', [
+ {
+ date: '2026-12-25',
+ parentPersonalDataUuid: 'pd-a',
+ childId: 'child-1',
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ },
+ {
+ date: '2026-12-28',
+ parentPersonalDataUuid: 'pd-a',
+ childId: 'child-1',
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ },
+ ])
+ expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
+ })
+
+ it('dismisses the whole group by (parent, year), notifying via onDismissed, never bumping plan version', async () => {
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByTestId('dashboard-vacation-rescue-0-dismiss'))
+
+ await waitFor(() => expect(api.dismissRescueDays).toHaveBeenCalledWith('ws-1', 'pd-a', 2026))
+ // Defect 2: a dismiss writes no plan data, so it must never trigger a
+ // plan re-sync — only accept does that.
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ expect(onDismissed).toHaveBeenCalledWith('pd-a', 2026)
+ })
+
+ it('does not call onDismissed when the dismiss call fails', async () => {
+ vi.mocked(api.dismissRescueDays).mockRejectedValueOnce(new Error('boom'))
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByTestId('dashboard-vacation-rescue-0-dismiss'))
+
+ await waitFor(() => expect(screen.getByTestId('dashboard-vacation-rescue-0-error')).toBeInTheDocument())
+ expect(onDismissed).not.toHaveBeenCalled()
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('shows an error message and does not bump plan version when accept fails', async () => {
+ vi.mocked(api.applyPlan).mockRejectedValueOnce(new Error('boom'))
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByTestId('dashboard-vacation-rescue-0-accept-all'))
+
+ await waitFor(() => expect(screen.getByTestId('dashboard-vacation-rescue-0-error')).toBeInTheDocument())
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('renders nothing for an empty group', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/frontend/src/components/dashboard/VacationRescueTip.tsx b/frontend/src/components/dashboard/VacationRescueTip.tsx
new file mode 100644
index 0000000..891eac3
--- /dev/null
+++ b/frontend/src/components/dashboard/VacationRescueTip.tsx
@@ -0,0 +1,112 @@
+import { ReactNode } from 'react'
+import type { RescueDay } from '@/types'
+import { useTranslation } from '@/i18n'
+import { useRescueDayActions, RescueDismissedHandler } from '@/hooks/useRescueDayActions'
+import { formatDateForDisplay } from '@/utils/dates'
+
+export interface VacationRescueTipProps {
+ workspaceId: string | null
+ /** One (parentPersonalDataUuid, year) group — the caller (`Dashboard.tsx`)
+ * guarantees every entry shares both fields; this component reads them
+ * off the first entry. */
+ days: RescueDay[]
+ /** Resolves a parent uuid to a display name (never a raw uuid). */
+ nameFor: (uuid: string) => string
+ /** Position in the rescue-day group list — only used for test ids. */
+ index: number
+ /** Forwarded to `useRescueDayActions` — called once a dismiss actually
+ * succeeds server-side, so `Dashboard.tsx` can drop this group from view
+ * without triggering a plan re-sync (Defect 2, §vacation-rescue). */
+ onDismissed: RescueDismissedHandler
+}
+
+/**
+ * One vacation-rescue suggestion group (§vacation-rescue) rendered in the
+ * same visual as a backend `TipCard` (same `tip-card` classes) so the
+ * dashboard's tips read as one system. Unlike `SurplusAdviceTip` (which has
+ * no dismiss — it's purely derived), this card is actionable: accept one
+ * date, accept every date in a single batch call, or dismiss the whole
+ * group. All three go through `useRescueDayActions`, never the API client
+ * directly.
+ */
+export function VacationRescueTip({
+ workspaceId,
+ days,
+ nameFor,
+ index,
+ onDismissed,
+}: VacationRescueTipProps): ReactNode {
+ const { t, locale } = useTranslation()
+ const { isLoading, error, acceptDay, acceptAll, dismiss } = useRescueDayActions(workspaceId, onDismissed)
+
+ const first = days[0]
+ if (!first) return null
+
+ const parentName = nameFor(first.parentPersonalDataUuid)
+ const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date))
+ const title = t('planResults.vacationRescueTitle', { year: first.year })
+ const body = t('planResults.vacationRescueBody', {
+ parent_name: parentName,
+ count: sortedDays.length,
+ })
+
+ return (
+
+
+
{title}
+
+
+
{body}
+
+
+ {sortedDays.map((day) => (
+
+ {formatDateForDisplay(day.date, locale)}
+
+
+ ))}
+
+
+
+
+
+
+ {error && (
+
+ {t('planResults.vacationRescueError')}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index e46ceef..0e769df 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -17,3 +17,4 @@ export { useScraperFetch } from './useScraperFetch'
export { useDebouncedCallback } from './useDebouncedCallback'
export { usePreschoolPlanningDays } from './usePreschoolPlanningDays'
export { usePreschoolPlanningDaysList } from './usePreschoolPlanningDaysList'
+export { useRescueDayActions } from './useRescueDayActions'
diff --git a/frontend/src/hooks/useRescueDayActions.test.ts b/frontend/src/hooks/useRescueDayActions.test.ts
new file mode 100644
index 0000000..3caa59e
--- /dev/null
+++ b/frontend/src/hooks/useRescueDayActions.test.ts
@@ -0,0 +1,194 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { useRescueDayActions } from './useRescueDayActions'
+import * as api from '@/services/api'
+import { useWorkspace } from '@/context'
+import { makeWorkspaceContextValue, makeRescueDay, makePlanApplyResponse } from '../../tests/fixtures'
+
+vi.mock('@/services/api')
+vi.mock('@/context')
+
+describe('useRescueDayActions', () => {
+ let bumpPlanVersion: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ bumpPlanVersion = vi.fn()
+ vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ bumpPlanVersion }))
+ vi.mocked(api.applyPlan).mockResolvedValue(makePlanApplyResponse())
+ vi.mocked(api.dismissRescueDays).mockResolvedValue(undefined)
+ })
+
+ it('starts idle', () => {
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.error).toBeNull()
+ })
+
+ it('acceptDay applies a single-item batch and bumps plan version', async () => {
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+ const day = makeRescueDay({ date: '2026-12-28' })
+
+ await act(async () => {
+ await result.current.acceptDay(day)
+ })
+
+ expect(api.applyPlan).toHaveBeenCalledTimes(1)
+ expect(api.applyPlan).toHaveBeenCalledWith('ws-1', [
+ {
+ date: '2026-12-28',
+ parentPersonalDataUuid: 'pd-a',
+ childId: 'child-1',
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ },
+ ])
+ expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.error).toBeNull()
+ })
+
+ it('acceptAll issues exactly one applyPlan call for every date, not N', async () => {
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+ const days = [
+ makeRescueDay({ date: '2026-12-25' }),
+ makeRescueDay({ date: '2026-12-28' }),
+ makeRescueDay({ date: '2026-12-29' }),
+ ]
+
+ await act(async () => {
+ await result.current.acceptAll(days)
+ })
+
+ expect(api.applyPlan).toHaveBeenCalledTimes(1)
+ expect(api.applyPlan).toHaveBeenCalledWith(
+ 'ws-1',
+ days.map((d) => ({
+ date: d.date,
+ parentPersonalDataUuid: d.parentPersonalDataUuid,
+ childId: d.childId,
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ })),
+ )
+ expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
+ })
+
+ it('acceptAll does nothing for an empty list', async () => {
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+
+ await act(async () => {
+ await result.current.acceptAll([])
+ })
+
+ expect(api.applyPlan).not.toHaveBeenCalled()
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('dismiss posts the parent/year, never bumps plan version, and reports success via onDismissed', async () => {
+ const onDismissed = vi.fn()
+ const { result } = renderHook(() => useRescueDayActions('ws-1', onDismissed))
+
+ await act(async () => {
+ await result.current.dismiss('pd-a', 2026)
+ })
+
+ expect(api.dismissRescueDays).toHaveBeenCalledWith('ws-1', 'pd-a', 2026)
+ // Defect 2: dismissing writes no plan data, so it must never trigger a
+ // plan re-sync via bumpPlanVersion — only accept does that.
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ expect(onDismissed).toHaveBeenCalledWith('pd-a', 2026)
+ })
+
+ it('dismiss works without an onDismissed callback', async () => {
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+
+ await act(async () => {
+ await result.current.dismiss('pd-a', 2026)
+ })
+
+ expect(api.dismissRescueDays).toHaveBeenCalledWith('ws-1', 'pd-a', 2026)
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('does nothing when workspaceId is null', async () => {
+ const { result } = renderHook(() => useRescueDayActions(null))
+
+ await act(async () => {
+ await result.current.acceptDay(makeRescueDay())
+ await result.current.acceptAll([makeRescueDay()])
+ await result.current.dismiss('pd-a', 2026)
+ })
+
+ expect(api.applyPlan).not.toHaveBeenCalled()
+ expect(api.dismissRescueDays).not.toHaveBeenCalled()
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an acceptDay failure as error and never bumps plan version', async () => {
+ vi.mocked(api.applyPlan).mockRejectedValueOnce(new Error('Network down'))
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+
+ await act(async () => {
+ await result.current.acceptDay(makeRescueDay())
+ })
+
+ expect(result.current.error).toBe('Network down')
+ expect(result.current.isLoading).toBe(false)
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an acceptAll failure with a generic message for a non-Error throw', async () => {
+ vi.mocked(api.applyPlan).mockRejectedValueOnce('boom')
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+
+ await act(async () => {
+ await result.current.acceptAll([makeRescueDay()])
+ })
+
+ expect(result.current.error).toBe('Failed to accept the vacation-rescue days')
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('surfaces an acceptDay failure with a generic message for a non-Error throw', async () => {
+ vi.mocked(api.applyPlan).mockRejectedValueOnce('boom')
+ const { result } = renderHook(() => useRescueDayActions('ws-1'))
+
+ await act(async () => {
+ await result.current.acceptDay(makeRescueDay())
+ })
+
+ expect(result.current.error).toBe('Failed to accept the vacation-rescue day')
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a dismiss failure with a generic message for a non-Error throw', async () => {
+ vi.mocked(api.dismissRescueDays).mockRejectedValueOnce('boom')
+ const onDismissed = vi.fn()
+ const { result } = renderHook(() => useRescueDayActions('ws-1', onDismissed))
+
+ await act(async () => {
+ await result.current.dismiss('pd-a', 2026)
+ })
+
+ expect(result.current.error).toBe('Failed to dismiss the vacation-rescue days')
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ expect(onDismissed).not.toHaveBeenCalled()
+ })
+
+ it('surfaces a dismiss failure as error, never bumps plan version, and never calls onDismissed', async () => {
+ vi.mocked(api.dismissRescueDays).mockRejectedValueOnce(new Error('Server exploded'))
+ const onDismissed = vi.fn()
+ const { result } = renderHook(() => useRescueDayActions('ws-1', onDismissed))
+
+ await act(async () => {
+ await result.current.dismiss('pd-a', 2026)
+ })
+
+ expect(result.current.error).toBe('Server exploded')
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ expect(onDismissed).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/hooks/useRescueDayActions.ts b/frontend/src/hooks/useRescueDayActions.ts
new file mode 100644
index 0000000..c8ad1b1
--- /dev/null
+++ b/frontend/src/hooks/useRescueDayActions.ts
@@ -0,0 +1,142 @@
+import { useCallback, useState } from 'react'
+import * as api from '@/services/api'
+import { useWorkspace } from '@/context'
+import type { ProposedDayApplyItem, RescueDay } from '@/types'
+
+interface UseRescueDayActionsResult {
+ /** True while an accept/accept-all/dismiss call is in flight. Shared
+ * across the three actions — a `VacationRescueTip` only ever has one of
+ * them in flight at a time (its own controls disable while busy). */
+ isLoading: boolean
+ error: string | null
+ /** Accepts one suggested date — `POST /api/plan/apply` with a single-item
+ * batch. */
+ acceptDay: (day: RescueDay) => Promise
+ /** Accepts every date in `days` in exactly ONE `applyPlan` call — never
+ * one request per date. That's what makes "accept all" atomic from the
+ * backend's batch-scoped checks' point of view (see `applyPlan`'s doc
+ * comment in src/services/api.ts). */
+ acceptAll: (days: RescueDay[]) => Promise
+ /** Dismisses the whole (parent, year) suggestion group — 204, no days to
+ * name (the backend recomputes the currently-suggested group itself). */
+ dismiss: (parentPersonalDataUuid: string, year: number) => Promise
+}
+
+/** Called once a `dismiss(...)` call has actually succeeded server-side —
+ * `Dashboard.tsx` uses it to drop the group from `rescueDayGroups` locally.
+ * See `dismiss`'s own doc comment below for why this, and not
+ * `bumpPlanVersion`/a proposal refetch, is what a dismiss triggers. */
+export type RescueDismissedHandler = (parentPersonalDataUuid: string, year: number) => void
+
+/** A rescue day has no wish behind it and must persist pinned — see
+ * `RescueDay`'s doc comment (src/types/entities.ts) and
+ * `ProposedDayApplyItem.wishId`/`.isPinned` (src/types/api.ts). */
+function toApplyItem(day: RescueDay): ProposedDayApplyItem {
+ return {
+ date: day.date,
+ parentPersonalDataUuid: day.parentPersonalDataUuid,
+ childId: day.childId,
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ }
+}
+
+/**
+ * Wraps the two vacation-rescue write endpoints (`applyPlan`,
+ * `dismissRescueDays`) for `VacationRescueTip.tsx` — a component must never
+ * call the API client directly, only a hook.
+ *
+ * On a successful accept (one day or all), this hook calls
+ * `bumpPlanVersion()` (from `WorkspaceContext`) — accepting genuinely writes
+ * a plan input (`applyPlan` pins a real day), so `PlanSyncProvider` must
+ * re-sync for the rest of the plan to react; this hook never re-fetches or
+ * edits `PlanProposal` itself for that path. `PlanSyncProvider`'s own
+ * cycle-safety rule (it must never call `bumpPlanVersion`) does not apply
+ * here — that rule is about the *sync* effect, not about a caller reacting
+ * to a fresh user write.
+ *
+ * A successful **dismiss** deliberately does NOT call `bumpPlanVersion()`
+ * (Defect 2, §vacation-rescue) — dismissing writes only a `tip_dismissal`
+ * row, no plan data, so there is nothing for a plan re-sync to react to.
+ * Bumping anyway was observed swinging unrelated multi-year forecasts
+ * dramatically (propose-only vs. post-sync figures already disagree —
+ * pre-existing, out of scope — but a dismiss should never be what
+ * *triggers* that swing). A plain proposal refetch
+ * (`usePlanProposal.propose()`) was considered instead, but once
+ * `PlanSyncProvider` has completed even one sync — which happens on mount,
+ * before a user could ever reach a dismiss button — `usePlanProposal`
+ * permanently prefers `PlanSyncProvider.latestProposal` over a fresh
+ * `propose()` result (see that hook's doc comment), so a refetch here would
+ * silently do nothing visible. Instead, `dismiss` reports success through
+ * `onDismissed`, and the caller (`Dashboard.tsx`) removes the group from
+ * `rescueDayGroups` optimistically in local state — the same "local
+ * derived-state filter" shape Defect 3's tip suppression already uses.
+ *
+ * A failed call surfaces through `error` and never calls `bumpPlanVersion`
+ * or `onDismissed` — the suggestion stays visible so the user can retry,
+ * exactly like `useOptimizationTips.dismiss`'s persistence-failure comment.
+ */
+export function useRescueDayActions(
+ workspaceId: string | null,
+ onDismissed?: RescueDismissedHandler,
+): UseRescueDayActionsResult {
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const { bumpPlanVersion } = useWorkspace()
+
+ const acceptDay = useCallback(
+ async (day: RescueDay) => {
+ if (!workspaceId) return
+ setIsLoading(true)
+ setError(null)
+ try {
+ await api.applyPlan(workspaceId, [toApplyItem(day)])
+ bumpPlanVersion()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to accept the vacation-rescue day')
+ } finally {
+ setIsLoading(false)
+ }
+ },
+ [workspaceId, bumpPlanVersion],
+ )
+
+ const acceptAll = useCallback(
+ async (days: RescueDay[]) => {
+ if (!workspaceId || days.length === 0) return
+ setIsLoading(true)
+ setError(null)
+ try {
+ // Exactly one batch call for every date — never one call per date.
+ await api.applyPlan(workspaceId, days.map(toApplyItem))
+ bumpPlanVersion()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to accept the vacation-rescue days')
+ } finally {
+ setIsLoading(false)
+ }
+ },
+ [workspaceId, bumpPlanVersion],
+ )
+
+ const dismiss = useCallback(
+ async (parentPersonalDataUuid: string, year: number) => {
+ if (!workspaceId) return
+ setIsLoading(true)
+ setError(null)
+ try {
+ await api.dismissRescueDays(workspaceId, parentPersonalDataUuid, year)
+ // No bumpPlanVersion() here — see this hook's doc comment (Defect 2).
+ onDismissed?.(parentPersonalDataUuid, year)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to dismiss the vacation-rescue days')
+ } finally {
+ setIsLoading(false)
+ }
+ },
+ [workspaceId, onDismissed],
+ )
+
+ return { isLoading, error, acceptDay, acceptAll, dismiss }
+}
diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts
index 554eb53..708ef66 100644
--- a/frontend/src/i18n/en/plan.ts
+++ b/frontend/src/i18n/en/plan.ts
@@ -42,4 +42,18 @@ export const plan = {
'planWarnings.days_lost_at_age_4': '{parent_name} risks losing {expiring_days} parental-benefit {expiring_days|day|days} at the child\'s 4th birthday ({fourth_birthday}) — {projected_left_at_b4} {projected_left_at_b4|day|days} are projected to remain in total then.',
'planWarnings.vacationExpiringTitle': 'Vacation days at risk of being lost in {year}',
'planWarnings.vacation_expiring': '{parent_name} risks losing {expiring_days} vacation {expiring_days|day|days} unless something is done.',
+ // Appended to `vacation_expiring`'s body — only when the matching warning's
+ // `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.',
+ // §vacation-rescue — `VacationRescueTip.tsx`, one card per (parent, year)
+ // group of `PlanProposal.rescueDays`.
+ 'planResults.vacationRescueTitle': 'Rescue vacation days {year}',
+ 'planResults.vacationRescueBody': '{parent_name} risks losing {count} vacation {count|day|days} at the turn of the year — here are suggested dates to take them instead.',
+ 'planResults.vacationRescueAccept': 'Accept',
+ 'planResults.vacationRescueAcceptAria': 'Accept {date}',
+ 'planResults.vacationRescueAcceptAll': 'Accept all',
+ 'planResults.vacationRescueDismiss': 'Dismiss',
+ 'planResults.vacationRescueDismissAria': 'Dismiss the suggestions for {parent_name} {year}',
+ 'planResults.vacationRescueError': 'Could not save the change. Please try again.',
} as const
diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts
index f2019cf..d0281a4 100644
--- a/frontend/src/i18n/sv/plan.ts
+++ b/frontend/src/i18n/sv/plan.ts
@@ -42,4 +42,18 @@ export const plan = {
'planWarnings.days_lost_at_age_4': '{parent_name} riskerar att förlora {expiring_days} {expiring_days|föräldrapenningdag|föräldrapenningdagar} vid barnets 4-årsdag ({fourth_birthday}) — {projected_left_at_b4} {projected_left_at_b4|dag|dagar} beräknas då finnas kvar totalt.',
'planWarnings.vacationExpiringTitle': 'Semesterdagar riskerar att gå förlorade {year}',
'planWarnings.vacation_expiring': '{parent_name} riskerar att förlora {expiring_days} {expiring_days|semesterdag|semesterdagar} om inget görs.',
+ // Appended to `vacation_expiring`'s body — only when the matching warning's
+ // `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.',
+ // §vacation-rescue — `VacationRescueTip.tsx`, one card per (parent, year)
+ // group of `PlanProposal.rescueDays`.
+ 'planResults.vacationRescueTitle': 'Rädda semesterdagar {year}',
+ 'planResults.vacationRescueBody': '{parent_name} riskerar att förlora {count} {count|semesterdag|semesterdagar} vid årsskiftet — här är förslag på datum att ta ut dem i stället.',
+ 'planResults.vacationRescueAccept': 'Acceptera',
+ 'planResults.vacationRescueAcceptAria': 'Acceptera {date}',
+ 'planResults.vacationRescueAcceptAll': 'Acceptera alla',
+ 'planResults.vacationRescueDismiss': 'Avfärda',
+ 'planResults.vacationRescueDismissAria': 'Avfärda förslagen för {parent_name} {year}',
+ 'planResults.vacationRescueError': 'Det gick inte att spara ändringen. Försök igen.',
} as const
diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx
index 43d62ee..783525d 100644
--- a/frontend/src/pages/Dashboard.test.tsx
+++ b/frontend/src/pages/Dashboard.test.tsx
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
import { Dashboard } from './Dashboard'
import * as hooks from '@/hooks'
import { useParentalLeaves } from '@/hooks/useParentalLeaves'
+import * as api from '@/services/api'
import { useWorkspace, useAuth } from '@/context'
import {
makeAuthContextValue,
@@ -20,6 +21,7 @@ import {
makePreschoolPlanningDays,
makePlanProposal,
makeSurplusAdvice,
+ makeRescueDay,
makePlanWarning,
makeWindowOutcome,
makeParentalLeaveFetch,
@@ -37,6 +39,10 @@ const mockNavigate = vi.fn()
vi.mock('@/hooks')
vi.mock('@/hooks/useParentalLeaves')
+// `VacationRescueTip` uses `useRescueDayActions` directly (not the `@/hooks`
+// barrel, which stays auto-mocked above), so its real implementation runs
+// here and hits `@/services/api` for real unless this is mocked too.
+vi.mock('@/services/api')
vi.mock('@/context')
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
@@ -635,6 +641,110 @@ describe('Dashboard', () => {
expect(screen.getByTestId('dashboard-surplus-advice-0')).toHaveTextContent('Sam har råd med ytterligare 2 veckor')
expect(screen.getByTestId('dashboard-surplus-advice-1')).toHaveTextContent('Okänd förälder har råd')
})
+
+ it('shows the tips section for rescue days alone, grouped by (parent, year)', () => {
+ vi.mocked(hooks.usePlanProposal).mockReturnValue(
+ makePlanProposalResult({
+ data: makePlanProposal({
+ rescueDays: [
+ makeRescueDay({ date: '2026-12-28', parentPersonalDataUuid: 'pd-2', year: 2026 }),
+ makeRescueDay({ date: '2026-12-25', parentPersonalDataUuid: 'pd-2', year: 2026 }),
+ makeRescueDay({ date: '2027-12-30', parentPersonalDataUuid: 'pd-1', year: 2027 }),
+ ],
+ }),
+ }),
+ )
+ renderWithRouter()
+ expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument()
+
+ // Two groups: (pd-2, 2026) with both its dates, and (pd-1, 2027) alone.
+ const first = screen.getByTestId('dashboard-vacation-rescue-0')
+ const second = screen.getByTestId('dashboard-vacation-rescue-1')
+ expect(first).toHaveTextContent('Sam')
+ expect(first).toHaveTextContent('2 semesterdagar')
+ expect(second).toHaveTextContent('2027')
+ })
+
+ it('Defect 2: dismissing a rescue group removes its card locally without bumping plan version', async () => {
+ const bumpPlanVersion = vi.fn()
+ vi.mocked(useWorkspace).mockReturnValue(workspaceValue({ bumpPlanVersion }))
+ vi.mocked(api.dismissRescueDays).mockResolvedValue(undefined)
+ vi.mocked(hooks.usePlanProposal).mockReturnValue(
+ makePlanProposalResult({
+ data: makePlanProposal({
+ rescueDays: [
+ makeRescueDay({ date: '2026-12-28', parentPersonalDataUuid: 'pd-2', year: 2026 }),
+ makeRescueDay({ date: '2027-12-30', parentPersonalDataUuid: 'pd-1', year: 2027 }),
+ ],
+ }),
+ }),
+ )
+ const user = userEvent.setup()
+ renderWithRouter()
+
+ expect(screen.getByTestId('dashboard-vacation-rescue-0')).toBeInTheDocument()
+ expect(screen.getByTestId('dashboard-vacation-rescue-1')).toBeInTheDocument()
+
+ await user.click(screen.getByTestId('dashboard-vacation-rescue-0-dismiss'))
+
+ await waitFor(() => expect(api.dismissRescueDays).toHaveBeenCalledWith('ws-1', 'pd-2', 2026))
+ // The dismissed (pd-2, 2026) card is gone; the other group re-indexes
+ // down to slot 0 rather than leaving a hole.
+ await waitFor(() => expect(screen.queryAllByTestId(/^dashboard-vacation-rescue-\d+$/)).toHaveLength(1))
+ expect(screen.getByTestId('dashboard-vacation-rescue-0')).toHaveTextContent('2027')
+ // Defect 2's whole point: no plan re-sync from a pure suggestion-hide.
+ expect(bumpPlanVersion).not.toHaveBeenCalled()
+ })
+
+ it('Defect 3: suppresses the use_vacation_before_expiry tip for a parent/year that already has a rescue card', () => {
+ vi.mocked(hooks.useOptimizationTips).mockReturnValue(
+ makeOptimizationTipsResult({
+ data: [
+ makeTip({
+ id: 'use_vacation_before_expiry',
+ messageKey: 'tips.use_vacation_before_expiry',
+ data: { parent_name: 'Sam', lost_days: 12, year: 2026 },
+ }),
+ ],
+ }),
+ )
+ vi.mocked(hooks.usePlanProposal).mockReturnValue(
+ makePlanProposalResult({
+ data: makePlanProposal({
+ rescueDays: [makeRescueDay({ date: '2026-12-28', parentPersonalDataUuid: 'pd-2', year: 2026 })],
+ }),
+ }),
+ )
+ renderWithRouter()
+
+ expect(screen.getByTestId('dashboard-vacation-rescue-0')).toBeInTheDocument()
+ expect(screen.queryAllByTestId('tip-card')).toHaveLength(0)
+ })
+
+ it('Defect 3: still shows the use_vacation_before_expiry tip when no rescue group matches its (parent, year)', () => {
+ vi.mocked(hooks.useOptimizationTips).mockReturnValue(
+ makeOptimizationTipsResult({
+ data: [
+ makeTip({
+ id: 'use_vacation_before_expiry',
+ messageKey: 'tips.use_vacation_before_expiry',
+ data: { parent_name: 'Sam', lost_days: 12, year: 2027 },
+ }),
+ ],
+ }),
+ )
+ vi.mocked(hooks.usePlanProposal).mockReturnValue(
+ makePlanProposalResult({
+ data: makePlanProposal({
+ rescueDays: [makeRescueDay({ date: '2026-12-28', parentPersonalDataUuid: 'pd-2', year: 2026 })],
+ }),
+ }),
+ )
+ renderWithRouter()
+
+ expect(screen.getByTestId('dashboard-vacation-rescue-0')).toBeInTheDocument()
+ expect(screen.getAllByTestId('tip-card')).toHaveLength(1)
+ })
})
describe('"Detaljer" disclosure', () => {
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index b15071a..44a2690 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useEffect, useRef } from 'react'
+import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
@@ -25,10 +25,41 @@ import { Age4Bars } from '@/components/dashboard/Age4Bars'
import { YearRows } from '@/components/dashboard/YearRows'
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
import { PlanWarningCard } from '@/components/dashboard/PlanWarningCard'
+import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
-import type { Tip } from '@/types'
+import type { RescueDay, Tip } from '@/types'
+
+/** Key matching `rescueDayGroups`' own `Map` key below — shared so a
+ * locally-dismissed group (Defect 2, §vacation-rescue) can be filtered back
+ * out by the same identity it was grouped by. */
+function rescueGroupKey(parentPersonalDataUuid: string, year: number): string {
+ return `${parentPersonalDataUuid}:${year}`
+}
+
+/** Whether a `use_vacation_before_expiry` tip (`TipsService`, backend) is
+ * superseded by an existing vacation-rescue group for the same parent and
+ * year (Defect 3, §vacation-rescue) — otherwise the dashboard shows three
+ * different numbers for what a user reads as one problem. The tip's `data`
+ * only carries a resolved display name (`parent_name`), never a parent
+ * uuid (see `_rule_use_vacation_before_expiry` in `tips_service.py`), so
+ * matching goes through the same `nameFor` resolver both cards already use
+ * to render a name, rather than inventing a backend field to compare
+ * uuids directly. */
+function isSupersededByRescueGroup(
+ tip: Tip,
+ rescueDayGroups: RescueDay[][],
+ nameFor: (uuid: string) => string,
+): boolean {
+ if (tip.messageKey !== 'tips.use_vacation_before_expiry') return false
+ const { parent_name, year } = tip.data
+ if (typeof parent_name !== 'string' || typeof year !== 'number') return false
+ return rescueDayGroups.some((group) => {
+ const first = group[0]
+ return first !== undefined && first.year === year && nameFor(first.parentPersonalDataUuid) === parent_name
+ })
+}
/** Fallback horizon end when the analytics projection isn't available yet
* (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving
@@ -96,6 +127,43 @@ export function Dashboard(): ReactNode {
// dependency array.
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
+ // §vacation-rescue — group the proposal's suggest-only rescue days by
+ // (parent, year); each group renders as one `VacationRescueTip` card
+ // below. A `Map` keeps groups in first-seen order, which is already date
+ // order since the backend returns `rescue_days` sorted by (date, parent)
+ // — see `RescueDay`'s docstring (allocation_service.py). Computed above
+ // every early return (Rules of Hooks — this page returns early for both
+ // the loading skeleton and onboarding).
+ // §vacation-rescue dismissal (Defect 2) — a successful dismiss writes no
+ // plan data (just a `tip_dismissal` row server-side), so it must never
+ // trigger a plan re-sync (`bumpPlanVersion`) the way accept does. Instead
+ // the dismissed group is removed from view here, optimistically, in local
+ // state — keyed the same way `rescueDayGroups` itself groups, so it
+ // survives that memo re-running on any unrelated proposal refresh.
+ const [dismissedRescueGroupKeys, setDismissedRescueGroupKeys] = useState>(new Set())
+ const handleRescueDismissed = useCallback((parentPersonalDataUuid: string, year: number) => {
+ setDismissedRescueGroupKeys((prev) => {
+ const next = new Set(prev)
+ next.add(rescueGroupKey(parentPersonalDataUuid, year))
+ return next
+ })
+ }, [])
+
+ const rescueDayGroups = useMemo(() => {
+ const groups = new Map()
+ for (const day of proposal?.rescueDays ?? []) {
+ const key = rescueGroupKey(day.parentPersonalDataUuid, day.year)
+ if (dismissedRescueGroupKeys.has(key)) continue
+ const group = groups.get(key)
+ if (group) {
+ group.push(day)
+ } else {
+ groups.set(key, [day])
+ }
+ }
+ return Array.from(groups.values())
+ }, [proposal, dismissedRescueGroupKeys])
+
const orderedMembers = orderSelfFirst(members, personalDataUuid)
const { data: leaves, isLoading: leavesLoading } = useParentalLeaves(
orderedMembers.map((m) => m.uuid),
@@ -257,7 +325,7 @@ export function Dashboard(): ReactNode {
// which lands here without this component doing anything sync-specific.
const surplusAdvice = proposal?.surplusAdvice ?? []
const warnings = proposal?.warnings ?? []
- const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
+ const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0 || rescueDayGroups.length > 0
const heroRemedy = deriveHeroRemedy(tips)
return (
@@ -334,12 +402,28 @@ export function Dashboard(): ReactNode {
{t('tips.title')}
- {(tips ?? []).map((tip) => (
- dismissTip(tip.id)} />
- ))}
+ {(tips ?? [])
+ // Defect 3 — a `use_vacation_before_expiry` tip for a
+ // parent/year that already has a rescue card is redundant
+ // (three different numbers for one problem); the rescue
+ // card supersedes it, being both actionable and current.
+ .filter((tip) => !isSupersededByRescueGroup(tip, rescueDayGroups, nameForAdvice))
+ .map((tip) => (
+ dismissTip(tip.id)} />
+ ))}
{surplusAdvice.map((advice, index) => (
))}
+ {rescueDayGroups.map((group, index) => (
+
+ ))}
)}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts
index c5506a9..67a38b0 100644
--- a/frontend/src/services/api.test.ts
+++ b/frontend/src/services/api.test.ts
@@ -1604,6 +1604,15 @@ describe('endpoint functions (representative coverage)', () => {
expiring_at_b4: 10,
},
],
+ rescue_days: [
+ {
+ date: '2026-12-28',
+ parent_personal_data_uuid: 'pd1',
+ child_id: 'child1',
+ year: 2026,
+ reason: 'vacation_rescue',
+ },
+ ],
}),
})
@@ -1639,6 +1648,15 @@ describe('endpoint functions (representative coverage)', () => {
expect(result.age4Projections[0]!.expiringAtB4).toBe(10)
expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01')
expect(result.outcomes[0]!.shiftGainDays).toBe(3)
+ expect(result.rescueDays).toEqual([
+ {
+ date: '2026-12-28',
+ parentPersonalDataUuid: 'pd1',
+ childId: 'child1',
+ year: 2026,
+ reason: 'vacation_rescue',
+ },
+ ])
})
it('proposePlan defaults a missing cost breakdown field to 0', async () => {
@@ -1672,6 +1690,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
+ rescue_days: [],
}),
})
@@ -1699,6 +1718,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
+ rescue_days: [],
},
apply_result: {
accepted: true,
@@ -1819,6 +1839,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
+ rescue_days: [],
},
apply_result: {
accepted: false,
@@ -1891,6 +1912,109 @@ describe('endpoint functions (representative coverage)', () => {
})
})
+ it('applyPlan posts every day in one batch and maps the response', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ accepted: true,
+ created_count: 2,
+ skipped_identical_count: 0,
+ skipped_free_count: 0,
+ replaced_count: 0,
+ collisions: [],
+ rejections: [],
+ created_days: [],
+ }),
+ })
+
+ const result = await api.applyPlan('ws1', [
+ {
+ date: '2026-12-28',
+ parentPersonalDataUuid: 'pd1',
+ childId: 'child1',
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ },
+ {
+ date: '2026-12-29',
+ parentPersonalDataUuid: 'pd1',
+ childId: 'child1',
+ dayType: 'vacation',
+ wishId: null,
+ isPinned: true,
+ },
+ ])
+
+ expect(lastRequest()).toEqual({ url: '/api/plan/apply', method: 'POST' })
+ expect(lastRequestBody()).toEqual({
+ workspace_id: 'ws1',
+ days: [
+ {
+ date: '2026-12-28',
+ parent_personal_data_uuid: 'pd1',
+ child_id: 'child1',
+ day_type: 'vacation',
+ is_dubbeldag: false,
+ wish_id: null,
+ is_pinned: true,
+ },
+ {
+ date: '2026-12-29',
+ parent_personal_data_uuid: 'pd1',
+ child_id: 'child1',
+ day_type: 'vacation',
+ is_dubbeldag: false,
+ wish_id: null,
+ is_pinned: true,
+ },
+ ],
+ replace: false,
+ })
+ expect(result.accepted).toBe(true)
+ expect(result.createdCount).toBe(2)
+ })
+
+ it('applyPlan passes replace through when set', async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ accepted: true,
+ created_count: 1,
+ skipped_identical_count: 0,
+ skipped_free_count: 0,
+ replaced_count: 1,
+ collisions: [],
+ rejections: [],
+ created_days: [],
+ }),
+ })
+
+ await api.applyPlan(
+ 'ws1',
+ [{ date: '2026-12-28', parentPersonalDataUuid: 'pd1', childId: 'child1', dayType: 'vacation' }],
+ true,
+ )
+
+ expect(lastRequestBody()).toMatchObject({ replace: true })
+ })
+
+ it('dismissRescueDays posts the parent/year and resolves to undefined on 204', async () => {
+ fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
+
+ const result = await api.dismissRescueDays('ws1', 'pd1', 2026)
+
+ expect(lastRequest()).toEqual({ url: '/api/plan/rescue-days/dismiss', method: 'POST' })
+ expect(lastRequestBody()).toEqual({
+ workspace_id: 'ws1',
+ parent_personal_data_uuid: 'pd1',
+ year: 2026,
+ })
+ expect(result).toBeUndefined()
+ })
+
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index e06dab7..8a2cccf 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -26,6 +26,8 @@ import type {
PreschoolPlanningDaysPlannedRequest,
PreschoolPlanningDaysTakenRequest,
CreateInvitationResponse,
+ ProposedDayApplyItem,
+ PlanApplyResponse,
} from '@/types'
import type {
Workspace,
@@ -449,6 +451,14 @@ interface RawPlanAge4Projection {
expiring_at_b4: number
}
+interface RawRescueDay {
+ date: string
+ parent_personal_data_uuid: string
+ child_id: string
+ year: number
+ reason: string
+}
+
interface RawPlanProposal {
days: RawProposedDay[]
outcomes: RawWindowOutcome[]
@@ -457,6 +467,7 @@ interface RawPlanProposal {
vacation_balances: RawVacationBalance[]
surplus_advice: RawSurplusAdvice[]
age4_projections: RawPlanAge4Projection[]
+ rescue_days: RawRescueDay[]
}
function toCostBreakdown(raw: Record): CostBreakdown {
@@ -531,6 +542,13 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
savableAfterB4: p.savable_after_b4,
expiringAtB4: p.expiring_at_b4,
})),
+ rescueDays: raw.rescue_days.map((r) => ({
+ date: r.date,
+ parentPersonalDataUuid: r.parent_personal_data_uuid,
+ childId: r.child_id,
+ year: r.year,
+ reason: r.reason,
+ })),
}
}
@@ -1074,7 +1092,7 @@ interface RawPlanApplyResponse {
created_days: object[]
}
-function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanApplyResponse {
+function toPlanApplyResponse(raw: RawPlanApplyResponse): PlanApplyResponse {
return {
accepted: raw.accepted,
createdCount: raw.created_count,
@@ -1093,6 +1111,37 @@ function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanA
}
}
+/** Accepts a (possibly user-edited) set of proposed days as real plan —
+ * §4/`PlanApplyRequest`. Always resolves (200) with `accepted: false` and a
+ * `collisions`/`rejections` breakdown when the batch is refused; that is an
+ * expected outcome for the caller to render, not a thrown `ApiRequestError`.
+ * Sending every day in one call (rather than one call per day) matters for
+ * batch-scoped checks on the backend (e.g. the dubbeldagar-quota check) —
+ * see `PlanApplyRequest`'s docstring (backend/app/models/plan.py). */
+export async function applyPlan(
+ workspaceId: string,
+ days: ProposedDayApplyItem[],
+ replace = false,
+): Promise {
+ const raw = (await apiFetch('/plan/apply', {
+ method: 'POST',
+ body: JSON.stringify({
+ workspace_id: workspaceId,
+ days: days.map((d) => ({
+ date: d.date,
+ parent_personal_data_uuid: d.parentPersonalDataUuid,
+ child_id: d.childId,
+ day_type: d.dayType,
+ is_dubbeldag: d.isDubbeldag ?? false,
+ wish_id: d.wishId ?? null,
+ is_pinned: d.isPinned ?? false,
+ })),
+ replace,
+ }),
+ })) as RawPlanApplyResponse
+ return toPlanApplyResponse(raw)
+}
+
interface RawPlanChange {
kind: string
date: string
@@ -1196,6 +1245,25 @@ export async function syncPlan(workspaceId: string): Promise {
}
}
+/** Dismisses one (parent, year) group of suggested vacation-rescue days
+ * (§vacation-rescue) so it stops appearing in the next `propose`/`sync` —
+ * until the underlying dates change, at which point it resurfaces. 204,
+ * nothing to map. */
+export async function dismissRescueDays(
+ workspaceId: string,
+ parentPersonalDataUuid: string,
+ year: number,
+): Promise {
+ return apiFetch('/plan/rescue-days/dismiss', {
+ method: 'POST',
+ body: JSON.stringify({
+ workspace_id: workspaceId,
+ parent_personal_data_uuid: parentPersonalDataUuid,
+ year,
+ }),
+ })
+}
+
// ---------------------------------------------------------------------------
// Workspace members (used to resolve/select which parent a wish belongs to)
// ---------------------------------------------------------------------------
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 32c7d01..94f502e 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -546,6 +546,49 @@
color: var(--c-text-muted);
}
+/* Vacation-rescue tip (§vacation-rescue) — an actionable `tip-card` variant:
+ * a plain date list, each with its own accept button, plus accept-all/
+ * dismiss. Buttons size to their own (bilingual) label text, never a fixed
+ * width, since "Acceptera alla"/"Accept all" differ in length. */
+.vacation-rescue-tip__dates {
+ list-style: none;
+ margin: var(--sp-3) 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+}
+
+.vacation-rescue-tip__date {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--sp-3);
+}
+
+.vacation-rescue-tip__date-label {
+ font-size: var(--fs-sm);
+ color: var(--c-text);
+}
+
+.vacation-rescue-tip__accept,
+.vacation-rescue-tip__accept-all,
+.vacation-rescue-tip__dismiss {
+ flex-shrink: 0;
+ width: auto;
+ white-space: nowrap;
+}
+
+.vacation-rescue-tip__actions {
+ margin-top: var(--sp-3);
+}
+
+.vacation-rescue-tip__error {
+ margin: var(--sp-2) 0 0;
+ font-size: var(--fs-sm);
+ color: var(--c-danger);
+}
+
/* "Detaljer" — one disclosure row at the foot of the page. */
.dashboard__details {
margin-top: var(--sp-6);
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 3de3444..d73d875 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -160,6 +160,13 @@ export interface ProposedDayApplyItem {
childId: string
dayType: 'sjukpenning' | 'lagsta' | 'vacation' | 'free'
isDubbeldag?: boolean
+ // Links the persisted row back to the wish that produced it; `null`/absent
+ // for a day with no wish behind it (e.g. an accepted vacation-rescue day —
+ // see `RescueDay` in entities.ts).
+ wishId?: string | null
+ // Same semantics as `PlannedLeaveDay.isPinned` — an accepted rescue day is
+ // always sent pinned so a later `sync` never silently retypes/evicts it.
+ isPinned?: boolean
}
export interface DayCollision {
diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts
index d8ef5e4..bef45de 100644
--- a/frontend/src/types/entities.ts
+++ b/frontend/src/types/entities.ts
@@ -496,6 +496,22 @@ export interface PlanAge4Projection {
expiringAtB4: number
}
+/** §vacation-rescue — a suggest-only concrete date to spend a vacation day
+ * that would otherwise expire this calendar year. Not a `ProposedDay`: it
+ * has no wish behind it and is never picked up by anything that reads
+ * `PlanProposal.days` (balance netting, calendar rendering, exports) until
+ * the user explicitly accepts it via `applyPlan`. `childId` is required
+ * (unlike `ProposedDay.childId`) purely so it survives persistence — see
+ * `RescueDay` (backend `app/services/allocation_service.py`) for why.
+ */
+export interface RescueDay {
+ date: string // ISO-8601 date
+ parentPersonalDataUuid: string
+ childId: string
+ year: number
+ reason: string
+}
+
export interface PlanProposal {
days: ProposedDay[]
outcomes: WindowOutcome[]
@@ -504,4 +520,5 @@ export interface PlanProposal {
vacationBalances: VacationBalance[]
surplusAdvice: SurplusAdvice[]
age4Projections: PlanAge4Projection[]
+ rescueDays: RescueDay[]
}
diff --git a/frontend/tests/fixtures/hooks.ts b/frontend/tests/fixtures/hooks.ts
index f5a16d2..1657990 100644
--- a/frontend/tests/fixtures/hooks.ts
+++ b/frontend/tests/fixtures/hooks.ts
@@ -29,6 +29,7 @@ import type { useChildSettings } from '@/hooks/useChildSettings'
import type { useCalendarDays } from '@/hooks/useCalendarDays'
import type { usePreschoolPlanningDays } from '@/hooks/usePreschoolPlanningDays'
import type { usePreschoolPlanningDaysList } from '@/hooks/usePreschoolPlanningDaysList'
+import type { useRescueDayActions } from '@/hooks/useRescueDayActions'
import type { useShowWeekNumbers } from '@/hooks/useCalendarPreferences'
import { makeChild, makePersonalData, makeVacationDays, makeWorkspace, makeParentalLeaveFetch } from './mockData'
import { makeWorkspaceOverview } from './analytics'
@@ -296,6 +297,19 @@ export function makePreschoolPlanningDaysResult(
}
}
+export function makeRescueDayActionsResult(
+ overrides: Partial> = {},
+): ReturnType {
+ return {
+ isLoading: false,
+ error: null,
+ acceptDay: vi.fn(),
+ acceptAll: vi.fn(),
+ dismiss: vi.fn(),
+ ...overrides,
+ }
+}
+
export function makePreschoolPlanningDaysListResult(
overrides: Partial> = {},
): ReturnType {
diff --git a/frontend/tests/fixtures/plan.ts b/frontend/tests/fixtures/plan.ts
index 0495335..67f247c 100644
--- a/frontend/tests/fixtures/plan.ts
+++ b/frontend/tests/fixtures/plan.ts
@@ -19,6 +19,7 @@ import type {
ProposedDayApplyItem,
Rejection,
ResourceShortage,
+ RescueDay,
ShortfallRemedy,
SurplusAdvice,
VacationBalance,
@@ -141,6 +142,21 @@ export function makePlanAge4Projection(overrides: Partial =
}
}
+/** §vacation-rescue — one suggest-only concrete date (see `RescueDay` in
+ * src/types/entities.ts). Defaults to a date within `makePlanProposal`'s
+ * implicit current-year assumption for tests that don't care about the
+ * exact date. */
+export function makeRescueDay(overrides: Partial = {}): RescueDay {
+ return {
+ date: '2026-12-28',
+ parentPersonalDataUuid: 'pd-a',
+ childId: 'child-1',
+ year: 2026,
+ reason: 'vacation_rescue',
+ ...overrides,
+ }
+}
+
export function makePlanProposal(overrides: Partial = {}): PlanProposal {
return {
days: [makeProposedDay()],
@@ -150,6 +166,7 @@ export function makePlanProposal(overrides: Partial = {}): PlanPro
vacationBalances: [],
surplusAdvice: [],
age4Projections: [],
+ rescueDays: [],
...overrides,
}
}