From 014503e89b17f4b7453fc7270b536a46d908d5ad Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 21 Sep 2026 07:00:18 +0200 Subject: [PATCH] Spend vacation on the days worth having, and name the ones about to expire The planner knew exactly how many semesterdagar a parent was about to forfeit and never placed a single day to prevent it. It also had no weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on every tier-3 interior vacation day, including a lone Tuesday in February. Two changes. Spend better. A workday adjacent to a free stretch, or in the middle of a gap of three or fewer fenced by free days, now takes vacation -- _vacation_bridge_reason, consulted for every parent type. This departs from spec 6.1's "wrapping suppressed for hourly": the FP-preservation argument is salary-type independent, only the pay argument was hourly-moot. In a both-parents window, both now take vacation on such a date rather than one paying with FP. After funding, reallocate_vacation_by_value swaps vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold vacation improves while how many never drops -- greedy first, re-timed after, never withheld. An invariant test asserts the count can only rise. Name the losses. A new pass proposes concrete dates for vacation that would otherwise expire, current year only, ranked red-day bridge then weekend bridge then interior, later dates first. They ride on PlanProposal.rescue_days, kept off `days` so nothing downstream commits them, and surface on the Dashboard with accept-one, accept-all (one batched /api/plan/apply, not N) and dismiss. Dismissal reuses the tip_dismissal table, keyed on (parent, year) identity rather than the group's dates -- those are recomputed from the whole plan's residual on every propose, so date-hashing made dismissal self-defeating. Dismiss does not bump the plan version; it changes no data and must not trigger a write. Measured on the seeded demo: 12 forfeiting days for one parent now yield 12 proposed dates, all Fridays or Mondays. Total vacation placed is unchanged at 203 days, confirming the reallocation only re-times. Three defects here were found by driving a browser, while 2273 unit tests passed -- all three lived in the seam between layers that unit tests mock away. Adds API-level tests over the real request sequence. Rate limiting is now off in tests (one file consumed the whole registration budget) with the suite's first real 429 assertion. PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along the way: Dashboard figures swing on the first sync with no user action. Backend 946 tests, frontend 1341, coverage above all gates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm --- PLANNING_ENGINE_SPEC.md | 36 +- PROPOSE_VS_SYNC_BUG.md | 77 ++ backend/app/dependencies.py | 7 +- backend/app/models/plan.py | 47 + backend/app/routers/plan.py | 25 + backend/app/services/allocation_service.py | 945 +++++++++++++++++- backend/tests/conftest.py | 49 +- .../tests/integration/test_rate_limiting.py | 55 + .../test_vacation_rescue_api_flow.py | 298 ++++++ backend/tests/unit/test_plan_router.py | 153 +++ .../test_services/test_allocation_service.py | 98 +- .../test_allocation_service_branches.py | 70 +- .../test_allocation_service_coleave_bridge.py | 305 ++++++ ...est_allocation_service_rescue_dismissal.py | 563 +++++++++++ ...test_allocation_service_vacation_rescue.py | 355 +++++++ ...allocation_service_vacation_reservation.py | 582 +++++++++++ .../test_allocation_service_vacation_wrap.py | 292 ++++++ .../dashboard/PlanWarningCard.test.tsx | 46 + .../components/dashboard/PlanWarningCard.tsx | 12 + .../dashboard/VacationRescueTip.test.tsx | 132 +++ .../dashboard/VacationRescueTip.tsx | 112 +++ frontend/src/hooks/index.ts | 1 + .../src/hooks/useRescueDayActions.test.ts | 194 ++++ frontend/src/hooks/useRescueDayActions.ts | 142 +++ frontend/src/i18n/en/plan.ts | 14 + frontend/src/i18n/sv/plan.ts | 14 + frontend/src/pages/Dashboard.test.tsx | 110 ++ frontend/src/pages/Dashboard.tsx | 96 +- frontend/src/services/api.test.ts | 124 +++ frontend/src/services/api.ts | 70 +- frontend/src/styles/components.css | 43 + frontend/src/types/api.ts | 7 + frontend/src/types/entities.ts | 17 + frontend/tests/fixtures/hooks.ts | 14 + frontend/tests/fixtures/plan.ts | 17 + 35 files changed, 5023 insertions(+), 99 deletions(-) create mode 100644 PROPOSE_VS_SYNC_BUG.md create mode 100644 backend/tests/integration/test_rate_limiting.py create mode 100644 backend/tests/integration/test_vacation_rescue_api_flow.py create mode 100644 backend/tests/unit/test_services/test_allocation_service_coleave_bridge.py create mode 100644 backend/tests/unit/test_services/test_allocation_service_rescue_dismissal.py create mode 100644 backend/tests/unit/test_services/test_allocation_service_vacation_rescue.py create mode 100644 backend/tests/unit/test_services/test_allocation_service_vacation_reservation.py create mode 100644 backend/tests/unit/test_services/test_allocation_service_vacation_wrap.py create mode 100644 frontend/src/components/dashboard/VacationRescueTip.test.tsx create mode 100644 frontend/src/components/dashboard/VacationRescueTip.tsx create mode 100644 frontend/src/hooks/useRescueDayActions.test.ts create mode 100644 frontend/src/hooks/useRescueDayActions.ts 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, } }