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 && (
+