From 014503e89b17f4b7453fc7270b536a46d908d5ad Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 21 Sep 2026 07:00:18 +0200 Subject: [PATCH 1/2] 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, } } From 9c675588777d573ab7337bf8b821f529d821a2cc Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 21 Sep 2026 07:22:05 +0200 Subject: [PATCH 2/2] Tell the user when vacation days move between periods reallocate_vacation_by_value swaps vacation onto higher-value dates 1-for-1 with parental-leave days. Add an Easter period with no vacation left and the planner quietly takes vacation off the summer period, puts foraldrapenning there, and spends it on the Easter klamdagar. Nothing said so. sync_plan already emitted the evidence -- paired 'retyped' changes -- but PlanChange.kind's 'retyped' existed only as a type declaration, rendered nowhere, with no copy in either locale. findVacationSwaps pairs a vacation -> FP retype with an FP -> vacation retype for the same parent, aggregating multiple pairs into one group and ignoring any retype that is not a genuine two-sided swap. summarizeVacationSwaps names the periods via the existing resolveWishName, so a preset wish is re-translated rather than shown raw, and falls back to dates when no single wish resolves -- never a uuid. It joins the existing Calendar sync toast rather than adding a second toast system. Frontend 1356 tests, coverage 98.22 / 94.95 / 92.31 / 98.22, above all gates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm --- frontend/src/i18n/en/calendar.ts | 6 + frontend/src/i18n/sv/calendar.ts | 6 + frontend/src/pages/Calendar.test.tsx | 116 +++++++++++++++ frontend/src/pages/Calendar.tsx | 13 +- frontend/src/utils/vacationSwaps.test.ts | 178 +++++++++++++++++++++++ frontend/src/utils/vacationSwaps.ts | 137 +++++++++++++++++ 6 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/vacationSwaps.test.ts create mode 100644 frontend/src/utils/vacationSwaps.ts diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 392d5f4..76976b2 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -88,6 +88,12 @@ export const calendar = { '{name} ({start}–{end}): only {daysFunded} of {daysWished} days funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.toastRedDayShift': '{name}: moved to {start} – {end} to catch {days} extra free {days|day|days}', + // Reallocation pass — `reallocate_vacation_by_value` swaps vacation onto + // better dates 1-for-1 with parental leave. `{from}`/`{to}` already carry + // the period name plus its dates (e.g. "Summer (Jun 20, Jun 21)"), or the + // dates alone when no single period can be named — see vacationSwaps.ts. + 'calendar.toastVacationSwap': + '{count} {count|vacation day|vacation days} moved from {from} to {to} — replaced with parental leave', 'calendar.undoButton': 'Undo', 'calendar.undoError': 'Failed to undo the change', 'calendar.dismissToastAria': 'Dismiss the message', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index b024c29..51bd1cf 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -88,6 +88,12 @@ export const calendar = { '{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.toastRedDayShift': '{name}: flyttad till {start} – {end} för att fånga {days} extra {days|ledig dag|lediga dagar}', + // Reallocation pass — `reallocate_vacation_by_value` swaps vacation onto + // better dates 1-for-1 with föräldrapenning. `{from}`/`{to}` already carry + // the period name plus its dates (e.g. "Sommar (20 jun, 21 jun)"), or the + // dates alone when no single period can be named — see vacationSwaps.ts. + 'calendar.toastVacationSwap': + '{count} {count|semesterdag|semesterdagar} flyttades från {from} till {to} — ersattes med föräldrapenning', 'calendar.undoButton': 'Ångra', 'calendar.undoError': 'Det gick inte att ångra ändringen', 'calendar.dismissToastAria': 'Stäng meddelandet', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index b7651dd..4efda43 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -1477,6 +1477,122 @@ describe('Calendar', () => { }) }) + describe('§vacation-swap-visibility — reallocation toast', () => { + // `reallocate_vacation_by_value` (backend) swaps vacation onto better + // dates 1-for-1 with föräldrapenning after funding — a pair of + // 'retyped' changes for the same parent (vacation -> FP on one date, + // FP -> vacation on another). This must surface as its own toast + // fragment, naming the periods involved, not just get folded into the + // generic "N days changed" count. + const summerWish = makeLeaveWish({ id: 'lw-summer', name: 'Sommar', nameMessageKey: 'wishEditor.seasonSommar' }) + const easterWish = makeLeaveWish({ id: 'lw-easter', name: 'Påsk', nameMessageKey: null }) + const swapChanges = [ + makePlanChange({ + kind: 'retyped', + date: '2028-07-10', + parentPersonalDataUuid: 'person-1', + oldDayType: 'vacation', + newDayType: 'sjukpenning', + oldWishId: 'lw-summer', + newWishId: 'lw-summer', + }), + makePlanChange({ + kind: 'retyped', + date: '2028-04-14', + parentPersonalDataUuid: 'person-1', + oldDayType: 'sjukpenning', + newDayType: 'vacation', + oldWishId: 'lw-easter', + newWishId: 'lw-easter', + }), + ] + + it('names both periods when the planner moves vacation between them (Swedish)', async () => { + const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) + const add = vi.fn().mockResolvedValue(created) + const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: swapChanges })) + vi.mocked(useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ add, data: [created, summerWish, easterWish] }), + ) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + + render() + fireEvent.click(screen.getByTestId('preset-click')) + + const toast = await screen.findByTestId('calendar-toast') + expect(toast).toHaveTextContent('Sommar') + expect(toast).toHaveTextContent('Påsk') + expect(toast).toHaveTextContent('föräldrapenning') + }) + + it('names both periods when the planner moves vacation between them (English)', async () => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + }) + + try { + const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) + const add = vi.fn().mockResolvedValue(created) + const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: swapChanges })) + vi.mocked(useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ add, data: [created, summerWish, easterWish] }), + ) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + + render( + + + , + ) + fireEvent.click(screen.getByTestId('preset-click')) + + const toast = await screen.findByTestId('calendar-toast') + // The preset's name is re-translated (English catalogue), the + // free-typed "Påsk" stays literal — same rule as everywhere else + // `resolveWishName` is used. + expect(toast).toHaveTextContent('Summer') + expect(toast).toHaveTextContent('Påsk') + expect(toast).toHaveTextContent('parental leave') + } finally { + Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true }) + vi.unstubAllGlobals() + } + }) + + it('does not show the swap toast for an unrelated retype', async () => { + const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) + const add = vi.fn().mockResolvedValue(created) + const sync = vi.fn().mockResolvedValue( + makePlanSyncResponse({ + changes: [ + makePlanChange({ + kind: 'retyped', + parentPersonalDataUuid: 'person-1', + oldDayType: 'sjukpenning', + newDayType: 'lagsta', + oldWishId: 'lw-summer', + newWishId: 'lw-summer', + }), + ], + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ add, data: [created, summerWish] }), + ) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + + render() + fireEvent.click(screen.getByTestId('preset-click')) + + const toast = await screen.findByTestId('calendar-toast') + expect(toast).not.toHaveTextContent('föräldrapenning') + expect(toast).not.toHaveTextContent('Påsk') + }) + }) + describe('§Task7 auto-sync moved to PlanSyncProvider', () => { it('performs no sync of its own on mount — that is now PlanSyncProvider, mounted globally inside WorkspaceProvider', () => { const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index d369942..a6e1710 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -35,7 +35,8 @@ import { Icon } from '@/components/common/Icon' import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName' import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates' import { renderRejection, type Translate } from '@/utils/rejectionMessages' -import { useTranslation } from '@/i18n' +import { summarizeVacationSwaps } from '@/utils/vacationSwaps' +import { useTranslation, type Locale } from '@/i18n' import './calendar.css' const WEEKDAY_KEYS = [ @@ -234,6 +235,7 @@ function buildSyncToastMessage( members: PersonalData[], watchWishId: string | undefined, t: Translate, + locale: Locale, watchWish?: LeaveWish, ): string | null { const parts: string[] = [] @@ -241,6 +243,12 @@ function buildSyncToastMessage( const changesMessage = summarizeChanges(result.changes, wishes, t) if (changesMessage) parts.push(changesMessage) + // §vacation-swap-visibility — `reallocate_vacation_by_value` (backend) + // silently swaps vacation onto better dates 1-for-1 with föräldrapenning; + // say so explicitly rather than leaving it buried in the generic "N dagar + // ändrades" count above. + parts.push(...summarizeVacationSwaps(result.changes, wishes, t, locale)) + if (watchWishId) { const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId) if (outcome && outcome.shortfallDays > 0) { @@ -408,6 +416,7 @@ export function Calendar(): ReactNode { membersHook.data, options?.watchWishId, t, + locale, options?.watchWish, ) if (message) showToast(message, options?.undo ?? null) @@ -418,7 +427,7 @@ export function Calendar(): ReactNode { return undefined } }, - [syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast], + [syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, locale, showToast], ) // The one-time-per-workspace auto-sync that used to live here (so wishes diff --git a/frontend/src/utils/vacationSwaps.test.ts b/frontend/src/utils/vacationSwaps.test.ts new file mode 100644 index 0000000..ea107c7 --- /dev/null +++ b/frontend/src/utils/vacationSwaps.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest' +import { findVacationSwaps, summarizeVacationSwaps } from './vacationSwaps' +import { translate } from '@/i18n' +import { makeLeaveWish, makePlanChange } from '../../tests/fixtures' + +const sv = (key: Parameters[1], params?: Parameters[2]) => + translate('sv', key, params) +const en = (key: Parameters[1], params?: Parameters[2]) => + translate('en', key, params) + +describe('findVacationSwaps', () => { + it('pairs an away (vacation -> FP) change with a gained (FP -> vacation) change for the same parent', () => { + const changes = [ + makePlanChange({ + kind: 'retyped', + date: '2028-07-10', + parentPersonalDataUuid: 'pd-a', + oldDayType: 'vacation', + newDayType: 'sjukpenning', + oldWishId: 'lw-summer', + newWishId: 'lw-summer', + }), + makePlanChange({ + kind: 'retyped', + date: '2028-04-14', + parentPersonalDataUuid: 'pd-a', + oldDayType: 'sjukpenning', + newDayType: 'vacation', + oldWishId: 'lw-easter', + newWishId: 'lw-easter', + }), + ] + + const swaps = findVacationSwaps(changes) + + expect(swaps).toEqual([ + { + parentPersonalDataUuid: 'pd-a', + count: 1, + awayDates: ['2028-07-10'], + gainedDates: ['2028-04-14'], + fromWishId: 'lw-summer', + toWishId: 'lw-easter', + }, + ]) + }) + + it('aggregates multiple swap pairs for the same parent into one group', () => { + const changes = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-07-11', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'lagsta', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + makePlanChange({ kind: 'retyped', date: '2028-04-15', parentPersonalDataUuid: 'pd-a', oldDayType: 'lagsta', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + ] + + const swaps = findVacationSwaps(changes) + + expect(swaps).toHaveLength(1) + expect(swaps[0]?.count).toBe(2) + expect(swaps[0]?.awayDates).toEqual(['2028-07-10', '2028-07-11']) + expect(swaps[0]?.gainedDates).toEqual(['2028-04-14', '2028-04-15']) + }) + + it('ignores an unrelated retype (e.g. a dubbeldag flag flip) — no compensating gain/loss', () => { + const changes = [ + makePlanChange({ + kind: 'retyped', + parentPersonalDataUuid: 'pd-a', + oldDayType: 'sjukpenning', + newDayType: 'sjukpenning', + oldIsDubbeldag: false, + newIsDubbeldag: true, + }), + ] + + expect(findVacationSwaps(changes)).toEqual([]) + }) + + it('does not treat a lone vacation-away retype (nothing gained back) as a swap', () => { + const changes = [ + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning' }), + ] + + expect(findVacationSwaps(changes)).toEqual([]) + }) + + it('returns nothing for an empty change set', () => { + expect(findVacationSwaps([])).toEqual([]) + }) + + it('keeps separate parents in separate groups', () => { + const changes = [ + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', date: '2028-07-10', oldDayType: 'vacation', newDayType: 'sjukpenning' }), + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', date: '2028-04-14', oldDayType: 'sjukpenning', newDayType: 'vacation' }), + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-b', date: '2028-08-01', oldDayType: 'vacation', newDayType: 'lagsta' }), + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-b', date: '2028-04-15', oldDayType: 'lagsta', newDayType: 'vacation' }), + ] + + const swaps = findVacationSwaps(changes) + expect(swaps.map((s) => s.parentPersonalDataUuid)).toEqual(['pd-a', 'pd-b']) + }) +}) + +describe('summarizeVacationSwaps', () => { + // `summer` carries a real preset message key, so its display name must be + // re-translated per locale (see `resolveWishName`) rather than showing + // whatever language it happened to be created in. `easter` is a + // free-typed name — no season preset for Påsk exists — so it stays + // literal in both locales, exactly as a user's own custom period name + // would. + const summer = makeLeaveWish({ id: 'lw-summer', name: 'Sommar', nameMessageKey: 'wishEditor.seasonSommar' }) + const easter = makeLeaveWish({ id: 'lw-easter', name: 'Påsk', nameMessageKey: null }) + + const swapChanges = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + ] + + it('names both periods and reports the count in Swedish', () => { + const [message] = summarizeVacationSwaps(swapChanges, [summer, easter], sv, 'sv') + expect(message).toContain('Sommar') + expect(message).toContain('Påsk') + expect(message).toContain('1') + expect(message).toContain('semesterdag') + expect(message).toContain('föräldrapenning') + }) + + it('names both periods, re-translating the preset name, and reports the count in English', () => { + const [message] = summarizeVacationSwaps(swapChanges, [summer, easter], en, 'en') + expect(message).toContain('Summer') + expect(message).not.toContain('Sommar') + expect(message).toContain('Påsk') + expect(message).toContain('1') + expect(message).toContain('vacation day') + expect(message).toContain('parental leave') + }) + + it('produces nothing for an empty change set', () => { + expect(summarizeVacationSwaps([], [summer, easter], sv, 'sv')).toEqual([]) + }) + + it('produces nothing for an unrelated retype', () => { + const changes = [ + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'lagsta' }), + ] + expect(summarizeVacationSwaps(changes, [summer, easter], sv, 'sv')).toEqual([]) + }) + + it('falls back to the dates alone (never a raw uuid) when no wish can be resolved', () => { + const changes = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: null, newWishId: null }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: null, newWishId: null }), + ] + + const [message] = summarizeVacationSwaps(changes, [summer, easter], sv, 'sv') + expect(message).toBeDefined() + expect(message).not.toContain('lw-summer') + expect(message).not.toContain('lw-easter') + expect(message).not.toContain('null') + // Compact date labels (day + short month), not the raw ISO strings. + expect(message).not.toContain('2028-07-10') + expect(message).not.toContain('2028-04-14') + }) + + it('aggregates multiple swaps for one parent into one message', () => { + const changes = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-07-11', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'lagsta', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + makePlanChange({ kind: 'retyped', date: '2028-04-15', parentPersonalDataUuid: 'pd-a', oldDayType: 'lagsta', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + ] + + const messages = summarizeVacationSwaps(changes, [summer, easter], sv, 'sv') + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('2') + expect(messages[0]).toContain('semesterdagar') + }) +}) diff --git a/frontend/src/utils/vacationSwaps.ts b/frontend/src/utils/vacationSwaps.ts new file mode 100644 index 0000000..d5a4b04 --- /dev/null +++ b/frontend/src/utils/vacationSwaps.ts @@ -0,0 +1,137 @@ +/** + * Detects and summarises the vacation<->parental-leave (FP) swaps + * `reallocate_vacation_by_value` (backend/app/services/allocation_service.py) + * performs once, after funding, as part of `POST /api/plan/sync`: it moves + * vacation 1-for-1 onto higher-value dates — a lower-value vacation day is + * retyped to an FP type (`sjukpenning`/`lagsta`), and a better date (a + * weekend bridge or red-day klämdag) is retyped from FP to vacation. The + * day *count* per (parent, year) never changes, only which dates hold it — + * until now this was invisible: it produces `PlanChange.kind === 'retyped'` + * entries indistinguishable from any other retype, with nothing in the UI + * calling it out. + * + * A swap shows up in `PlanSyncResponse.changes` as a PAIR of 'retyped' + * entries for the same parent: one `vacation` -> FP, and one FP -> + * `vacation`. `findVacationSwaps` pairs those up per parent (pure, + * structural — no i18n); `summarizeVacationSwaps` composes the toast copy + * from the result, same house style as `Calendar.tsx`'s other + * `summarize*` helpers — structured data in, composed copy out. + */ +import type { Locale } from '@/i18n' +import type { LeaveWish, PlanChange } from '@/types' +import type { Translate } from './rejectionMessages' +import { resolveWishName } from './wishName' + +function isFpDayType(dayType: PlanChange['oldDayType']): dayType is 'sjukpenning' | 'lagsta' { + return dayType === 'sjukpenning' || dayType === 'lagsta' +} + +/** `null` when `ids` is empty or disagrees (more than one distinct non-null + * id) — nothing single to name in that case, so a caller falls back to the + * dates themselves rather than guessing. */ +function soleWishId(ids: (string | null)[]): string | null { + const distinct = new Set(ids.filter((id): id is string => id !== null)) + return distinct.size === 1 ? [...distinct][0]! : null +} + +/** One parent's aggregated vacation<->FP reallocation from a single sync's + * `changes` — the pure "what moved" shape. `fromWishId`/`toWishId` name the + * period the days moved away from/into, when every date on that side + * agrees on a single wish; `null` otherwise. */ +export interface VacationSwap { + parentPersonalDataUuid: string + /** Vacation days moved away == vacation days gained, by construction (the + * reallocation is always 1-for-1) — one count, not two. Takes the + * smaller side defensively, so a mismatch (which should never happen) + * never overstates the toast. */ + count: number + awayDates: string[] + gainedDates: string[] + fromWishId: string | null + toWishId: string | null +} + +/** Pairs up genuine vacation<->FP swaps per parent from a sync's `changes`. + * A parent with vacation retyped away but nothing retyped back to vacation + * (or vice versa) is some other kind of retype — not this reallocation + * pass — and is skipped entirely, per the "only genuine swaps" rule. */ +export function findVacationSwaps(changes: PlanChange[]): VacationSwap[] { + const away = new Map() + const gained = new Map() + + for (const change of changes) { + if (change.kind !== 'retyped') continue + if (change.oldDayType === 'vacation' && isFpDayType(change.newDayType)) { + const list = away.get(change.parentPersonalDataUuid) ?? [] + list.push(change) + away.set(change.parentPersonalDataUuid, list) + } else if (change.newDayType === 'vacation' && isFpDayType(change.oldDayType)) { + const list = gained.get(change.parentPersonalDataUuid) ?? [] + list.push(change) + gained.set(change.parentPersonalDataUuid, list) + } + } + + const parents = new Set([...away.keys(), ...gained.keys()]) + const swaps: VacationSwap[] = [] + for (const parent of parents) { + const awayChanges = away.get(parent) ?? [] + const gainedChanges = gained.get(parent) ?? [] + if (awayChanges.length === 0 || gainedChanges.length === 0) continue + + swaps.push({ + parentPersonalDataUuid: parent, + count: Math.min(awayChanges.length, gainedChanges.length), + awayDates: awayChanges.map((c) => c.date).sort(), + gainedDates: gainedChanges.map((c) => c.date).sort(), + // The day's own wish shouldn't change across a mere retype — prefer + // `oldWishId`/`newWishId` respectively, but fall back to the other + // field so a wish id that happens to differ still resolves. + fromWishId: soleWishId(awayChanges.map((c) => c.oldWishId ?? c.newWishId)), + toWishId: soleWishId(gainedChanges.map((c) => c.newWishId ?? c.oldWishId)), + }) + } + return swaps.sort((a, b) => a.parentPersonalDataUuid.localeCompare(b.parentPersonalDataUuid)) +} + +/** Compact "20 juni"/"Jun 20" label for a list of dates — a toast has no + * room for `formatDateForDisplay`'s full "20 juni 2026" per date, and the + * year is redundant within one sync's toast. */ +function formatDateList(dates: string[], locale: Locale): string { + const formatter = new Intl.DateTimeFormat(locale === 'sv' ? 'sv-SE' : 'en-US', { + day: 'numeric', + month: 'short', + }) + return dates.map((d) => formatter.format(new Date(`${d}T00:00:00Z`))).join(', ') +} + +/** One side (origin or destination) of a swap: the wish name plus its + * dates when a single wish can be named, or the dates alone — never a raw + * uuid — when it can't. */ +function describeSide( + dates: string[], + wishId: string | null, + wishes: LeaveWish[], + t: Translate, + locale: Locale, +): string { + const wish = wishId ? wishes.find((w) => w.id === wishId) : undefined + const dateLabel = formatDateList(dates, locale) + return wish ? `${resolveWishName(wish, t)} (${dateLabel})` : dateLabel +} + +/** One toast fragment per parent with a genuine vacation<->FP swap this + * sync — empty when nothing moved. Multiple swap pairs for the same parent + * aggregate into that parent's single message rather than one per pair. */ +export function summarizeVacationSwaps( + changes: PlanChange[], + wishes: LeaveWish[], + t: Translate, + locale: Locale, +): string[] { + return findVacationSwaps(changes).map((swap) => { + const from = describeSide(swap.awayDates, swap.fromWishId, wishes, t, locale) + const to = describeSide(swap.gainedDates, swap.toWishId, wishes, t, locale) + return t('calendar.toastVacationSwap', { count: swap.count, from, to }) + }) +}