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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
This commit is contained in:
2026-09-20 13:07:36 +02:00
parent 16390fec50
commit 61e5487078
44 changed files with 1891 additions and 60 deletions
+4
View File
@@ -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,
)
+20 -1
View File
@@ -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)
+76 -1
View File
@@ -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