From 61e5487078ab80cc5e6c053a8473da6320ffcbff Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 20 Sep 2026 13:07:36 +0200 Subject: [PATCH] Free days, vacation forfeiture, and global auto-replanning Free days (Sat/Sun/red days, subject to DayOverride) can never be vacation, and can only be parental leave when the workspace opts in via take_pl_on_red_days. The allocator already honoured this; the hand-pin path bypassed it entirely. - PlanningService gains optional workspace_repo/day_override_repo deps and _reject_illegal_free_day, enforced in add_planned_day and update. Reuses allocation_service.is_free_day rather than re-deriving it, and no-ops when the deps are unset so existing callers are unaffected. - update_planned_day had no ValidationError handler at all, so a validation failure there 500'd instead of 422'ing. Fixed. - Calendar filters the pin write loop by the day's isFree; the modal explains free days stay off, and that pinning opts a day out of automatic re-planning. Vacation days at risk of forfeiture were computed and sent over the wire but never rendered: an at-risk column with the age-out/over-cap split, and a dashboard key figure. PlanProposal.warnings was likewise parsed but rendered nowhere, for all three kinds. Adds PlanWarningCard in a Risks section above the tips, with parent names resolved and dates formatted. POST /api/plan/sync now fires from the workspace layer on every plan-input change rather than only from Calendar, so a Settings edit or a pinned-day save re-plans day types instead of merely refetching. Overlapping bumps coalesce into one trailing run; a rejection cannot wedge the in-flight guard. eviction_preview was silently dropped by the frontend despite always being on the wire, and is now surfaced through context. Verified: backend 890 passed (91.49% coverage), frontend 1297 passed, ruff/eslint/tsc clean. Browser-checked in Swedish and English. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY --- backend/app/dependencies.py | 4 + backend/app/routers/planned_days.py | 21 +- backend/app/services/planning_service.py | 77 ++++- .../tests/integration/test_user_journey.py | 31 +- .../unit/test_planning_service_free_days.py | 302 ++++++++++++++++++ .../components/dashboard/KeyFigures.test.tsx | 21 +- .../src/components/dashboard/KeyFigures.tsx | 20 +- .../dashboard/PlanWarningCard.test.tsx | 93 ++++++ .../components/dashboard/PlanWarningCard.tsx | 88 +++++ .../modals/PlannedDayModal.test.tsx | 74 +++++ .../src/components/modals/PlannedDayModal.tsx | 39 +++ .../vacation-days/VacationDaysTable.test.tsx | 41 ++- .../vacation-days/VacationDaysTable.tsx | 35 ++ .../vacation-days/vacation-rows.css | 23 ++ .../src/context/PlanSyncProvider.test.tsx | 205 ++++++++++++ frontend/src/context/PlanSyncProvider.tsx | 125 ++++++++ frontend/src/context/WorkspaceContext.tsx | 7 +- frontend/src/context/index.ts | 1 + frontend/src/hooks/useChildSettings.test.ts | 11 + frontend/src/hooks/useChildSettings.ts | 8 +- .../hooks/usePreschoolPlanningDays.test.ts | 16 +- .../src/hooks/usePreschoolPlanningDays.ts | 13 +- frontend/src/hooks/useVacationDays.test.ts | 10 + frontend/src/hooks/useVacationDays.ts | 8 +- frontend/src/i18n/en/calendar.ts | 6 + frontend/src/i18n/en/dashboard.ts | 3 + frontend/src/i18n/en/plan.ts | 12 + frontend/src/i18n/en/settings.ts | 3 + frontend/src/i18n/sv/calendar.ts | 6 + frontend/src/i18n/sv/dashboard.ts | 3 + frontend/src/i18n/sv/plan.ts | 12 + frontend/src/i18n/sv/settings.ts | 3 + frontend/src/pages/Calendar.test.tsx | 132 ++++++-- frontend/src/pages/Calendar.tsx | 46 ++- frontend/src/pages/Dashboard.test.tsx | 103 +++++- frontend/src/pages/Dashboard.tsx | 28 ++ frontend/src/pages/Settings.test.tsx | 54 ++++ frontend/src/pages/Settings.tsx | 38 ++- frontend/src/services/api.test.ts | 91 ++++++ frontend/src/services/api.ts | 50 +++ frontend/src/styles/components.css | 4 + frontend/src/types/api.ts | 4 + frontend/src/types/entities.ts | 39 +++ frontend/tests/fixtures/plan.ts | 41 +++ 44 files changed, 1891 insertions(+), 60 deletions(-) create mode 100644 backend/tests/unit/test_planning_service_free_days.py create mode 100644 frontend/src/components/dashboard/PlanWarningCard.test.tsx create mode 100644 frontend/src/components/dashboard/PlanWarningCard.tsx create mode 100644 frontend/src/context/PlanSyncProvider.test.tsx create mode 100644 frontend/src/context/PlanSyncProvider.tsx diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index ea4437b..a6d8c84 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -440,6 +440,8 @@ def get_planning_service( planning_engine_service: PlanningEngineServiceDep, leave_wish_repo: LeaveWishRepositoryDep, uow: UnitOfWorkDep, + workspace_repo: WorkspaceRepositoryDep, + day_override_repo: DayOverrideRepositoryDep, ) -> PlanningService: return PlanningService( repo, @@ -449,6 +451,8 @@ def get_planning_service( planning_engine_service, leave_wish_repo=leave_wish_repo, uow=uow, + workspace_repo=workspace_repo, + day_override_repo=day_override_repo, ) diff --git a/backend/app/routers/planned_days.py b/backend/app/routers/planned_days.py index e0358c9..8a8502a 100644 --- a/backend/app/routers/planned_days.py +++ b/backend/app/routers/planned_days.py @@ -19,6 +19,23 @@ def _to_response(day) -> PlannedLeaveDayResponse: return PlannedLeaveDayResponse.from_entity(day) +def _to_http_error(exc: ValidationError) -> HTTPException: + """Map a `ValidationError` to a 422, forwarding its `message_key` + `data` + + (§3.5) as structured `detail` when present — e.g. the free-day hand-pin + guard (`PlanningService._reject_illegal_free_day`) — so the frontend can + translate it instead of parsing an English sentence. Falls back to the + plain message for the (more common) error raised without one. Mirrors + `app.routers.workspace._to_http_error`. + """ + if exc.message_key: + return HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"message_key": exc.message_key, "data": exc.data}, + ) + return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) + + @router.post("", response_model=PlannedLeaveDayResponse, status_code=status.HTTP_201_CREATED) async def create_planned_day( payload: PlannedLeaveDayCreate, @@ -41,7 +58,7 @@ async def create_planned_day( ) return _to_response(day) except ValidationError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise _to_http_error(exc) from exc @router.get("", response_model=list[PlannedLeaveDayResponse]) @@ -96,6 +113,8 @@ async def update_planned_day( return _to_response(day) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc + except ValidationError as exc: + raise _to_http_error(exc) from exc @router.delete("/{planned_leave_day_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index ab146c1..cdaa6f6 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -8,14 +8,22 @@ from dateutil.relativedelta import relativedelta from app.config import settings from app.entities.planned_leave import PlannedLeaveDay +from app.repositories.day_override_repository import DayOverrideRepository from app.repositories.leave_wish_repository import LeaveWishRepository from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork from app.repositories.vacation_days_repository import VacationDaysRepository -from app.services.allocation_service import PlanningEngineService, PlanProposal, WindowOutcome +from app.repositories.workspace_repository import WorkspaceRepository +from app.services.allocation_service import ( + PlanningEngineService, + PlanProposal, + WindowOutcome, + is_free_day, +) from app.services.analytics_service import AnalyticsService from app.utils.errors import DubbeldagError, NotFoundError, ValidationError +from app.utils.holidays import get_swedish_holidays from app.utils.tip_predicates import default_tier_pool_share # The two day-type buckets a persisted day can belong to for eviction @@ -215,6 +223,8 @@ class PlanningService: planning_engine_service: PlanningEngineService | None = None, leave_wish_repo: LeaveWishRepository | None = None, uow: UnitOfWork | None = None, + workspace_repo: WorkspaceRepository | None = None, + day_override_repo: DayOverrideRepository | None = None, ) -> None: self._repo = repo self._analytics = analytics_service @@ -237,6 +247,59 @@ class PlanningService: # behavior; `app.dependencies.get_planning_service` always wires a # real `SQLiteUnitOfWork`. self._uow = uow or NoOpUnitOfWork() + # Only needed by `_reject_illegal_free_day` (the hand-pin guard: a + # free day — weekend/red day, per workspace `DayOverride` — can never + # be `vacation`, and may only be `sjukpenning`/`lagsta` when the + # workspace opted in via `take_pl_on_red_days`). Optional for the + # same reason `_engine`/`_leave_wish_repo` are: existing direct- + # construction call sites (scripts/seed_demo.py, older test + # fixtures) don't wire these up and must keep today's behavior + # unchanged — the guard is a no-op when either is unset. + self._workspace_repo = workspace_repo + self._day_override_repo = day_override_repo + + async def _reject_illegal_free_day(self, workspace_id: UUID, d: date, day_type: str) -> None: + """Authoritative backend guard behind the calendar's hand-pin path. + + A free day (Saturday, Sunday, or Swedish red day, subject to the + workspace's `DayOverride`s — see `allocation_service.is_free_day`, + the single source of truth for "is this day free") can never be + persisted as `vacation`. It may be `sjukpenning`/`lagsta` only when + the workspace opted in via `Workspace.take_pl_on_red_days` (default + False). No-op for any other `day_type`, and — critically — a no-op + entirely when either optional repo isn't wired, so callers that + don't pass them (scripts/seed_demo.py, existing test fixtures) keep + byte-identical behavior to before this guard existed. + """ + if self._workspace_repo is None or self._day_override_repo is None: + return + if day_type not in ("vacation", "sjukpenning", "lagsta"): + return + + holidays_map = get_swedish_holidays(d.year) + overrides = { + o.date: o.is_working_day + for o in await self._day_override_repo.list_by_workspace(workspace_id) + } + if not is_free_day(d, holidays_map, overrides): + return + + if day_type == "vacation": + raise ValidationError( + "Vacation cannot be planned on a free day (weekend or red day)", + message_key="planning.rejection.vacationOnFreeDay", + data={"date": d.isoformat()}, + ) + + workspace = await self._workspace_repo.get_by_id(workspace_id) + take_pl_on_red_days = workspace.take_pl_on_red_days if workspace is not None else False + if not take_pl_on_red_days: + raise ValidationError( + "Parental leave cannot be planned on a free day unless the workspace " + "setting take_pl_on_red_days is enabled", + message_key="planning.rejection.parentalLeaveOnFreeDayDisabled", + data={"date": d.isoformat()}, + ) async def add_planned_day( self, @@ -253,6 +316,11 @@ class PlanningService: Validates that the day is available (dubbeldagar rules, no double-booking). """ + # Validation 0: a free day (weekend/red day) can never be vacation, + # and may only be parental leave when the workspace opted in — see + # `_reject_illegal_free_day`. + await self._reject_illegal_free_day(workspace_id, date, day_type) + # Validation 1: Check dubbeldagar quota if this is a dubbeldag if is_dubbeldag: await self._analytics.validate_dubbeldagar(child_id, date) @@ -343,6 +411,13 @@ class PlanningService: entity = await self.get(planned_leave_day_id, workspace_id) if day_type is not None: + # A free day (weekend/red day) can never be vacation, and may + # only be parental leave when the workspace opted in — checked + # against the row's own workspace (authoritative regardless of + # whether a caller passed `workspace_id` for the 404 check + # above) and its own date, since only `day_type` is changing + # here. See `_reject_illegal_free_day`. + await self._reject_illegal_free_day(entity.workspace_id, entity.date, day_type) entity.day_type = day_type if is_dubbeldag is not None: entity.is_dubbeldag = is_dubbeldag diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index 0132258..2667edb 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -24,7 +24,7 @@ different range. from __future__ import annotations -from datetime import date +from datetime import date, timedelta from typing import Any from uuid import UUID, uuid4 @@ -37,7 +37,8 @@ from httpx import ASGITransport, AsyncClient from app.config import settings from app.dependencies import get_connection from app.main import app -from app.utils.holidays import cached_year_bounds +from app.services.allocation_service import is_free_day +from app.utils.holidays import cached_year_bounds, get_swedish_holidays _PASSWORD = "correct horse battery staple" @@ -209,6 +210,26 @@ async def _list_planned_days( return resp.json() +def _next_workday(start: date) -> date: + """The next date at/after `start` that isn't free (weekend/red day). + + Used for probe rows whose exact date doesn't matter except that it must + be legal to plan as `vacation` — see + `PlanningService._reject_illegal_free_day`. `start` (typically + `date.today()`) is otherwise arbitrary and, per this file's own + docstring, deliberately never hardcoded. + """ + d = start + year = d.year + holidays_map = get_swedish_holidays(year) + while is_free_day(d, holidays_map): + d += timedelta(days=1) + if d.year != year: + year = d.year + holidays_map = get_swedish_holidays(year) + return d + + def _paid_days_for_wish(proposal: dict[str, Any], wish_id: str) -> list[dict[str, Any]]: """Proposed days belonging to `wish_id` that would actually cost a @@ -1061,7 +1082,11 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival: "workspace_id": str(ws_id), "child_id": str(child_id), "parent_personal_data_uuid": str(pd_uuid), - "date": date.today().isoformat(), + # An ad-hoc probe row for the is_pinned round trip below — + # the exact date is irrelevant except that it must be legal + # to plan as `vacation` (never a free day; see + # `PlanningService._reject_illegal_free_day`). + "date": _next_workday(date.today()).isoformat(), "day_type": "vacation", "is_dubbeldag": False, "wish_id": wish_id, diff --git a/backend/tests/unit/test_planning_service_free_days.py b/backend/tests/unit/test_planning_service_free_days.py new file mode 100644 index 0000000..bead624 --- /dev/null +++ b/backend/tests/unit/test_planning_service_free_days.py @@ -0,0 +1,302 @@ +"""Tests for `PlanningService`'s free-day guard (hand-pin path). + +A free day (Saturday, Sunday, or Swedish red day, subject to a workspace +`DayOverride`) can never be persisted as `vacation`; it may only be +`sjukpenning`/`lagsta` when the workspace opted in via +`Workspace.take_pl_on_red_days` (default False). See +`PlanningService._reject_illegal_free_day` — the authoritative backend guard +behind the calendar's hand-pin path (Calendar.tsx filters client-side too, +but this is what actually protects the data). + +The guard is a no-op when the service is constructed without its two new +optional deps (`workspace_repo`/`day_override_repo`) — existing direct- +construction call sites (scripts/seed_demo.py, the plain `planning_service` +fixture used by every other test in this suite) must keep today's behavior +byte-identical. +""" + +from datetime import date, datetime +from uuid import uuid4 + +import pytest +import pytest_asyncio + +from app.entities.planned_leave import PlannedLeaveDay +from app.entities.workspace import Workspace +from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository +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.planned_leave_repository import SQLitePlannedLeaveRepository +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 PlanningEngineService +from app.services.analytics_service import AnalyticsService +from app.services.planning_service import PlanningService +from app.utils.errors import ValidationError +from tests.conftest import SeededIds + +# A plain Saturday with no coinciding public holiday. +_SATURDAY = date(2026, 6, 13) +# Juldagen (Christmas Day) 2026 — a Friday, so this exercises the "real red +# day" branch of `is_free_day`, not just the weekend one. +_RED_DAY = date(2026, 12, 25) +# An ordinary Tuesday — never free. +_WEEKDAY = date(2026, 6, 16) + + +@pytest_asyncio.fixture +async def guarded_planning_service( + planned_leave_repo: SQLitePlannedLeaveRepository, + analytics_service: AnalyticsService, + parental_leave_repo: SQLiteParentalLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + planning_engine_service: PlanningEngineService, + leave_wish_repo: SQLiteLeaveWishRepository, + workspace_repo: SQLiteWorkspaceRepository, + day_override_repo: SQLiteDayOverrideRepository, +) -> PlanningService: + """A `PlanningService` with the free-day guard's deps wired — mirrors + + `app.dependencies.get_planning_service`, unlike the plain + `planning_service` fixture (which deliberately leaves them unset; see + `TestFreeDayGuardNoOp` below). + """ + return PlanningService( + planned_leave_repo, + analytics_service, + parental_leave_repo, + vacation_repo, + planning_engine_service, + leave_wish_repo=leave_wish_repo, + workspace_repo=workspace_repo, + day_override_repo=day_override_repo, + ) + + +async def _set_take_pl_on_red_days( + workspace_repo: SQLiteWorkspaceRepository, workspace: Workspace, value: bool +) -> None: + workspace.take_pl_on_red_days = value + await workspace_repo.update(workspace.id, workspace) + + +class TestAddPlannedDayFreeDayGuard: + async def test_rejects_vacation_on_saturday( + self, guarded_planning_service: PlanningService, seeded_ids: SeededIds + ) -> None: + with pytest.raises(ValidationError) as exc_info: + await guarded_planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_SATURDAY, + day_type="vacation", + ) + assert exc_info.value.message_key == "planning.rejection.vacationOnFreeDay" + + async def test_rejects_vacation_on_red_day( + self, guarded_planning_service: PlanningService, seeded_ids: SeededIds + ) -> None: + with pytest.raises(ValidationError) as exc_info: + await guarded_planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_RED_DAY, + day_type="vacation", + ) + assert exc_info.value.message_key == "planning.rejection.vacationOnFreeDay" + + async def test_accepts_vacation_on_a_weekday( + self, guarded_planning_service: PlanningService, seeded_ids: SeededIds + ) -> None: + result = await guarded_planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_WEEKDAY, + day_type="vacation", + ) + assert result.day_type == "vacation" + + async def test_rejects_sjukpenning_on_free_day_when_flag_off( + self, guarded_planning_service: PlanningService, seeded_ids: SeededIds + ) -> None: + with pytest.raises(ValidationError) as exc_info: + await guarded_planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_SATURDAY, + day_type="sjukpenning", + ) + assert exc_info.value.message_key == "planning.rejection.parentalLeaveOnFreeDayDisabled" + + async def test_accepts_sjukpenning_on_free_day_when_flag_on( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + workspace_repo: SQLiteWorkspaceRepository, + ) -> None: + workspace = await workspace_repo.get_by_id(seeded_ids.workspace_id) + assert workspace is not None + await _set_take_pl_on_red_days(workspace_repo, workspace, True) + + result = await guarded_planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_SATURDAY, + day_type="sjukpenning", + ) + assert result.day_type == "sjukpenning" + + async def test_accepts_lagsta_on_free_day_when_flag_on( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + workspace_repo: SQLiteWorkspaceRepository, + ) -> None: + workspace = await workspace_repo.get_by_id(seeded_ids.workspace_id) + assert workspace is not None + await _set_take_pl_on_red_days(workspace_repo, workspace, True) + + result = await guarded_planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_RED_DAY, + day_type="lagsta", + ) + assert result.day_type == "lagsta" + + +async def _create_row_directly( + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + day: date, + day_type: str, +) -> PlannedLeaveDay: + """Seed a `planned_leave_day` row straight through the repository, + + bypassing `PlanningService.add_planned_day` — used where the row's + starting state (a free-day date already holding a PL type) could only + ever have been reached with `take_pl_on_red_days` on, but the test wants + to exercise `update`'s guard independent of that flag's current value. + """ + now = datetime.now() + return await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=day, + day_type=day_type, + created_at=now, + updated_at=now, + ) + ) + + +class TestUpdateFreeDayGuard: + async def test_rejects_retyping_to_vacation_on_saturday( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + planned_leave_repo: SQLitePlannedLeaveRepository, + ) -> None: + row = await _create_row_directly(planned_leave_repo, seeded_ids, _SATURDAY, "sjukpenning") + with pytest.raises(ValidationError) as exc_info: + await guarded_planning_service.update(row.id, day_type="vacation") + assert exc_info.value.message_key == "planning.rejection.vacationOnFreeDay" + + async def test_rejects_retyping_to_vacation_on_red_day( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + planned_leave_repo: SQLitePlannedLeaveRepository, + ) -> None: + row = await _create_row_directly(planned_leave_repo, seeded_ids, _RED_DAY, "lagsta") + with pytest.raises(ValidationError) as exc_info: + await guarded_planning_service.update(row.id, day_type="vacation") + assert exc_info.value.message_key == "planning.rejection.vacationOnFreeDay" + + async def test_rejects_sjukpenning_on_free_day_when_flag_off( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + planned_leave_repo: SQLitePlannedLeaveRepository, + ) -> None: + # Starts as `lagsta` (only reachable with the flag on, but the flag + # is off for this test — see `_create_row_directly`'s docstring) so + # retyping to `sjukpenning` exercises only `update`'s own guard, + # checked against the row's own (free) date. + row = await _create_row_directly(planned_leave_repo, seeded_ids, _SATURDAY, "lagsta") + with pytest.raises(ValidationError) as exc_info: + await guarded_planning_service.update(row.id, day_type="sjukpenning") + assert exc_info.value.message_key == "planning.rejection.parentalLeaveOnFreeDayDisabled" + + async def test_accepts_sjukpenning_on_free_day_when_flag_on( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + planned_leave_repo: SQLitePlannedLeaveRepository, + workspace_repo: SQLiteWorkspaceRepository, + ) -> None: + workspace = await workspace_repo.get_by_id(seeded_ids.workspace_id) + assert workspace is not None + await _set_take_pl_on_red_days(workspace_repo, workspace, True) + + row = await _create_row_directly(planned_leave_repo, seeded_ids, _SATURDAY, "lagsta") + updated = await guarded_planning_service.update(row.id, day_type="sjukpenning") + assert updated.day_type == "sjukpenning" + + async def test_update_of_an_unrelated_field_still_checks_day_type_when_given( + self, + guarded_planning_service: PlanningService, + seeded_ids: SeededIds, + planned_leave_repo: SQLitePlannedLeaveRepository, + ) -> None: + """Passing `is_pinned` alongside an illegal `day_type` must still + + reject — the guard runs whenever `day_type` is part of the call, + regardless of what else is being updated in the same call. + """ + row = await _create_row_directly(planned_leave_repo, seeded_ids, _SATURDAY, "lagsta") + with pytest.raises(ValidationError): + await guarded_planning_service.update(row.id, day_type="vacation", is_pinned=True) + + +class TestFreeDayGuardNoOp: + """Behaviour must be byte-identical for callers that don't wire the new + + deps — `scripts/seed_demo.py`, and every other test in this suite that + uses the plain `planning_service` fixture. + """ + + async def test_add_planned_day_allows_vacation_on_saturday_without_deps( + self, planning_service: PlanningService, seeded_ids: SeededIds + ) -> None: + result = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_SATURDAY, + day_type="vacation", + ) + assert result.day_type == "vacation" + assert result.date == _SATURDAY + + async def test_update_allows_retyping_to_vacation_on_saturday_without_deps( + self, planning_service: PlanningService, seeded_ids: SeededIds + ) -> None: + row = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=_SATURDAY, + day_type="sjukpenning", + ) + updated = await planning_service.update(row.id, day_type="vacation") + assert updated.day_type == "vacation" diff --git a/frontend/src/components/dashboard/KeyFigures.test.tsx b/frontend/src/components/dashboard/KeyFigures.test.tsx index 7a27ae3..d2615d2 100644 --- a/frontend/src/components/dashboard/KeyFigures.test.tsx +++ b/frontend/src/components/dashboard/KeyFigures.test.tsx @@ -3,22 +3,24 @@ import { render, screen } from '@testing-library/react' import { KeyFigures } from './KeyFigures' describe('KeyFigures', () => { - it('renders the three combined figures with their labels', () => { - render() + it('renders the four combined figures with their labels', () => { + render() expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270') expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('Dagar kvar') expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30') expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('Använda') expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100') expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('Förfaller vid 4 år') + expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('12') + expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('Semester som riskerar att gå förlorad') }) it('wears danger ink on the expiring figure only when it is non-zero', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass( 'key-figures__value--danger', ) - rerender() + rerender() expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass( 'key-figures__value--danger', ) @@ -26,4 +28,15 @@ describe('KeyFigures', () => { 'key-figures__value--danger', ) }) + + it('wears danger ink on the vacation-at-risk figure only when it is non-zero', () => { + const { rerender } = render() + expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).toHaveClass( + 'key-figures__value--danger', + ) + rerender() + expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).not.toHaveClass( + 'key-figures__value--danger', + ) + }) }) diff --git a/frontend/src/components/dashboard/KeyFigures.tsx b/frontend/src/components/dashboard/KeyFigures.tsx index 8b236a2..543b181 100644 --- a/frontend/src/components/dashboard/KeyFigures.tsx +++ b/frontend/src/components/dashboard/KeyFigures.tsx @@ -8,19 +8,31 @@ export interface KeyFiguresProps { daysUsed: number /** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */ expiringAtB4: number + /** Sum of every parent/year's `VacationYearBalance.expiring` from the + * current year onward — vacation days forecast to be forfeited (age-out + * or over the carry-forward cap), the reason the planner may shift + * placements toward spending vacation sooner. */ + vacationAtRisk: number } /** - * The three headline figures under the hero — large tabular numerals with - * a 14px label each, no tiles. The expiring figure wears danger ink only - * when it is non-zero: a zero is good news and should read as quiet. + * The four headline figures under the hero — large tabular numerals with + * a 14px label each, no tiles. The expiring and vacation-at-risk figures + * wear danger ink only when non-zero: a zero is good news and should read + * as quiet. */ -export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode { +export function KeyFigures({ daysLeft, daysUsed, expiringAtB4, vacationAtRisk }: KeyFiguresProps): ReactNode { const { t } = useTranslation() const figures = [ { key: 'left', label: t('dashboard.figureLeft'), value: daysLeft, danger: false }, { key: 'used', label: t('dashboard.figureUsed'), value: daysUsed, danger: false }, { key: 'expiring', label: t('dashboard.figureExpiring'), value: expiringAtB4, danger: expiringAtB4 > 0 }, + { + key: 'vacationAtRisk', + label: t('dashboard.figureVacationAtRisk'), + value: vacationAtRisk, + danger: vacationAtRisk > 0, + }, ] return (
diff --git a/frontend/src/components/dashboard/PlanWarningCard.test.tsx b/frontend/src/components/dashboard/PlanWarningCard.test.tsx new file mode 100644 index 0000000..24fa144 --- /dev/null +++ b/frontend/src/components/dashboard/PlanWarningCard.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PlanWarningCard } from './PlanWarningCard' +import { makePlanWarning } from '../../../tests/fixtures' + +const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder') + +describe('PlanWarningCard', () => { + it('renders a sgi_risk warning with the parent name and a formatted (non-ISO) week date', () => { + render( + , + ) + const warning = screen.getByTestId('dashboard-plan-warning-0') + expect(warning).toHaveClass('tip-card', 'tip-card--danger') + expect(warning).toHaveTextContent('Risk för SGI-skyddet') + expect(warning).toHaveTextContent('Sam är planerad för endast 1 av minst 3 ledighetsdagar veckan som börjar 8 mars 2027') + expect(warning).not.toHaveTextContent('pd-b') + expect(warning).not.toHaveTextContent('2027-03-08') + }) + + it('renders a days_lost_at_age_4 warning with the parent name and a formatted (non-ISO) birthday', () => { + render( + , + ) + const warning = screen.getByTestId('dashboard-plan-warning-1') + expect(warning).toHaveTextContent('Dagar riskerar att gå förlorade vid 4 år') + expect(warning).toHaveTextContent('Sam riskerar att förlora 15 föräldrapenningdagar vid barnets 4-årsdag (1 juni 2029)') + expect(warning).toHaveTextContent('40 dagar beräknas då finnas kvar totalt') + expect(warning).not.toHaveTextContent('2029-06-01') + }) + + it('renders a vacation_expiring warning with the parent name and its figures', () => { + render( + , + ) + const warning = screen.getByTestId('dashboard-plan-warning-2') + expect(warning).toHaveTextContent('Semesterdagar riskerar att gå förlorade') + expect(warning).toHaveTextContent('4 semesterdagar riskerar att gå förlorade 2027') + }) + + it('falls back to the raw kind and messageKey for an unrecognised warning', () => { + render( + , + ) + const warning = screen.getByTestId('dashboard-plan-warning-3') + expect(warning).toHaveTextContent('brand_new_kind') + expect(warning).toHaveTextContent('planWarnings.brand_new_kind') + }) + + it('has no dismiss control', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/dashboard/PlanWarningCard.tsx b/frontend/src/components/dashboard/PlanWarningCard.tsx new file mode 100644 index 0000000..37f1742 --- /dev/null +++ b/frontend/src/components/dashboard/PlanWarningCard.tsx @@ -0,0 +1,88 @@ +import { ReactNode } from 'react' +import type { PlanWarning } from '@/types' +import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n' +import { formatDateForDisplay } from '@/utils/dates' + +// The three `PlanWarning` kinds the backend can emit +// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`, +// `_vacation_expiry_warnings`). `kind` -> title, `messageKey` -> the body +// template. The backend's own `message_key` is already `f"planWarnings. +// {kind}"`, i.e. a real translation key — routed through a lookup anyway +// (like `SurplusAdviceTip`'s `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised +// kind (a newer backend rule this catalogue hasn't caught up with) falls +// back to the raw value instead of rendering a missing-key placeholder. +const PLAN_WARNING_TITLE_KEYS: Record = { + sgi_risk: 'planWarnings.sgiRiskTitle', + days_lost_at_age_4: 'planWarnings.daysLostAtAge4Title', + vacation_expiring: 'planWarnings.vacationExpiringTitle', +} + +const PLAN_WARNING_BODY_KEYS: Record = { + 'planWarnings.sgi_risk': 'planWarnings.sgi_risk', + 'planWarnings.days_lost_at_age_4': 'planWarnings.days_lost_at_age_4', + 'planWarnings.vacation_expiring': 'planWarnings.vacation_expiring', +} + +// `data` fields that carry an ISO-8601 date and must go through +// `formatDateForDisplay` before interpolation — a raw ISO string is never +// shown to the user. +const DATE_FIELDS_BY_KIND: Record = { + sgi_risk: ['week_start'], + days_lost_at_age_4: ['fourth_birthday'], + vacation_expiring: [], +} + +export interface PlanWarningCardProps { + warning: PlanWarning + /** Resolves a parent uuid to a display name (never a raw uuid) — + * `Dashboard.tsx`'s `nameForAdvice`, which already falls back to + * `planResults.unknownParent`. */ + nameFor: (uuid: string) => string + /** Position in the warnings list — only used for the test id. */ + index: number +} + +/** + * One plan-proposal warning rendered in the same visual as a backend + * `TipCard` (same `tip-card` classes) so the dashboard reads as one system, + * but with the danger-ink dot (`var(--c-danger)`, the same colour + * `KeyFigures`/`Age4Bars`/`YearRows` already use for at-risk figures) — + * warnings are risks, not suggestions. No dismiss: it is derived from the + * plan and goes away when the plan changes. + */ +export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProps): ReactNode { + const { t, locale } = useTranslation() + const titleKey = PLAN_WARNING_TITLE_KEYS[warning.kind] + const bodyKey = PLAN_WARNING_BODY_KEYS[warning.messageKey] + const title = titleKey ? t(titleKey) : warning.kind + + let body = warning.messageKey + if (bodyKey) { + const params = toTranslationParams(warning.data) + const uuid = warning.data.parent_personal_data_uuid + if (typeof uuid === 'string') { + params.parent_name = nameFor(uuid) + } + for (const field of DATE_FIELDS_BY_KIND[warning.kind] ?? []) { + const raw = warning.data[field] + if (typeof raw === 'string') { + params[field] = formatDateForDisplay(raw, locale) + } + } + body = t(bodyKey, params) + } + + return ( +
+
+

{title}

+
+

{body}

+
+ ) +} diff --git a/frontend/src/components/modals/PlannedDayModal.test.tsx b/frontend/src/components/modals/PlannedDayModal.test.tsx index d4a65f9..a7d6d77 100644 --- a/frontend/src/components/modals/PlannedDayModal.test.tsx +++ b/frontend/src/components/modals/PlannedDayModal.test.tsx @@ -701,6 +701,80 @@ describe('PlannedDayModal', () => { }) }) + describe('free-day hand-pin guard notices (§ free days never consume vacation)', () => { + it('warns that free days stay off when pinning vacation over a selection that includes one', async () => { + render( + , + ) + await pickType('vacation') + expect(screen.getByTestId('free-days-stay-off-notice')).toHaveTextContent( + 'Lediga dagar (helg/röd dag) i urvalet lämnas lediga', + ) + }) + + it('warns for a PL type on a free day when takePlOnRedDays is off', async () => { + render( + , + ) + await pickType('sjukpenning') + expect(screen.getByTestId('free-days-stay-off-notice')).toBeInTheDocument() + }) + + it('does not warn for a PL type on a free day once takePlOnRedDays is on', async () => { + render( + , + ) + await pickType('sjukpenning') + expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument() + }) + + it('does not warn when the selection has no free days', async () => { + render() + await pickType('vacation') + expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument() + }) + + it('notes that pinning excludes the day from automatic re-planning', async () => { + render() + await openAdvanced() + await pickType('vacation') + expect(screen.getByTestId('pin-exclusion-notice')).toBeInTheDocument() + }) + + it('does not duplicate the pin-exclusion notice when the day is already pinned (the pinned notice already says so)', () => { + const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true }) + render() + expect(screen.getByTestId('pinned-notice')).toBeInTheDocument() + expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument() + }) + + it('shows neither notice while the type select is still on "let the planner choose"', () => { + render() + expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument() + expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument() + }) + }) + describe('removed days (PlannedDayExclusion) — the way back', () => { it('names the parent whose day was removed and offers to restore it', async () => { const onRestoreExcluded = vi.fn().mockResolvedValue(undefined) diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index 1187a61..ca20697 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -68,6 +68,14 @@ export interface PlannedDayModalProps { isRangeMode?: boolean | undefined dayCount?: number | undefined defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined + /** Dates in the current selection that are free days (weekend/red day, + * per workspace `DayOverride`) — a `vacation` pin always leaves these off, + * and a `sjukpenning`/`lagsta` pin does too unless `takePlOnRedDays`. + * Drives the "will stay off" notice below the type select. */ + freeDatesInSelection?: string[] | undefined + /** Mirrors `Workspace.takePlOnRedDays` (see Settings) — whether this + * workspace opted in to funding free days with parental leave. */ + takePlOnRedDays?: boolean | undefined } export function PlannedDayModal({ @@ -88,6 +96,8 @@ export function PlannedDayModal({ isRangeMode = false, dayCount = 1, defaultDayType, + freeDatesInSelection = [], + takePlOnRedDays = false, }: PlannedDayModalProps): ReactNode { const { t, locale } = useTranslation() const hasMultipleParents = parents.length > 1 @@ -118,6 +128,23 @@ export function PlannedDayModal({ const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen) const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen + // A concrete type is about to be pinned (not the 'auto' planner-decides + // choice) — the two notices below only make sense once that's true. + const isPinningConcreteType = pinnedPath && dayType !== 'auto' + // The chosen type would leave every free day in the selection (weekend/red + // day) as a day off: always true for `vacation`, and true for a PL type + // unless the workspace opted in via `takePlOnRedDays` — mirrors + // `Calendar.tsx`'s `handleSavePinnedDay` filter exactly. + const freeDaysWillStayOff = + isPinningConcreteType && + freeDatesInSelection.length > 0 && + (dayType === 'vacation' || !takePlOnRedDays) + // Pinning excludes the day from `sync_plan`'s automatic re-planning — + // worth saying prospectively (about to pin) even when the day isn't + // already pinned; `existingDay?.isPinned` below already covers the + // already-pinned case, so this doesn't repeat there. + const showPinExclusionNotice = isPinningConcreteType && !existingDay?.isPinned + const [localError, setLocalError] = useState(null) const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) @@ -359,6 +386,18 @@ export function PlannedDayModal({ + {freeDaysWillStayOff && ( +

+ {t('plannedDayModal.freeDaysStayOffNotice')} +

+ )} + + {showPinExclusionNotice && ( +

+ {t('plannedDayModal.pinExcludesFromSyncNotice')} +

+ )} + {hasMultipleParents && (