Separate user intent from planner output via day assertions

The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:20:14 +02:00
parent fc90cf9a5f
commit 782415122d
101 changed files with 9542 additions and 3930 deletions
+83
View File
@@ -0,0 +1,83 @@
"""HTTP routes for day assertions — restructure step 3b.
Replaces the write side of the legacy `/api/planned-days` (hand-pin/ad-hoc)
and `/api/planned-day-exclusions` endpoints (both deleted this step) with one
endpoint expressing all three: a pin (`day_type` concrete), an ad-hoc day
(`wish_id` omitted), and an exclusion (`day_type='none'`). Routers stay
thin: validate via Pydantic, call the service, map domain errors to HTTP.
"""
from datetime import date
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import (
DayAssertionServiceDep,
WorkspaceIdDep,
WorkspaceScopeDep,
)
from app.models.day_assertion import DayAssertionCreate, DayAssertionResponse
from app.utils.errors import ValidationError
router = APIRouter(prefix="/api/day-assertions", tags=["day-assertions"])
@router.post("", response_model=DayAssertionResponse, status_code=status.HTTP_201_CREATED)
async def assert_day(
payload: DayAssertionCreate,
service: DayAssertionServiceDep,
scope: WorkspaceScopeDep,
) -> DayAssertionResponse:
scope.check_workspace(payload.workspace_id)
await scope.check_child(payload.child_id)
await scope.check_member(payload.parent_personal_data_uuid)
try:
entity = await service.assert_day(
workspace_id=payload.workspace_id,
child_id=payload.child_id,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
day=payload.date,
day_type=payload.day_type,
is_dubbeldag=payload.is_dubbeldag,
wish_id=payload.wish_id,
)
except ValidationError as exc:
if exc.message_key:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message_key": exc.message_key, "data": exc.data},
) from exc
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return DayAssertionResponse.from_entity(entity)
@router.get("/workspace/{workspace_id}", response_model=list[DayAssertionResponse])
async def list_day_assertions(
workspace_id: WorkspaceIdDep, service: DayAssertionServiceDep
) -> list[DayAssertionResponse]:
entities = await service.list_for_workspace(workspace_id)
return [DayAssertionResponse.from_entity(e) for e in entities]
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
async def clear_day_assertion(
service: DayAssertionServiceDep,
scope: WorkspaceScopeDep,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
date: date,
) -> None:
"""Retract the assertion at this (workspace, child, parent, date), if
any — idempotent, matching `DayAssertionService.clear`. By natural key
rather than by id: every caller (un-pinning a hand-added day, deleting
an ad-hoc band, restoring an exclusion) already knows the key from
context and would otherwise have to look the assertion up first just to
delete it.
"""
scope.check_workspace(workspace_id)
await scope.check_child(child_id)
await scope.check_member(parent_personal_data_uuid)
await service.clear(workspace_id, child_id, parent_personal_data_uuid, date)
+57 -11
View File
@@ -17,6 +17,8 @@ from app.models.plan import (
PlanProposeRequest,
PlanSyncRequest,
PlanSyncResponse,
PlanSyncWithWishRequest,
PlanSyncWithWishResponse,
)
from app.utils.errors import NotFoundError, ValidationError
@@ -84,22 +86,66 @@ async def sync_plan(
can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of
silently rewriting the calendar.
Doubles as its own eviction preview: when the re-derived plan would
silently delete another wish's days (a shared FP/vacation pool ran out
for that wish's parent), this call writes NOTHING and returns
`eviction_preview` describing what ran out and which days would go —
unless `eviction_choice` names one of the two caller-authorized
eviction strategies, in which case exactly that category is freed and
the edit is applied. No separate round trip for the common (fits-
within-budget) case; see `PlanningService.sync_plan`'s docstring.
When the re-derived plan would displace another wish's already-placed
days (a shared FP/vacation pool ran out for that wish's parent), this
call still writes — displacement is a normal, informational outcome, not
a refusal. The displaced rows come back as 'removed' entries in
`changes` with `evicted: true` (see `PlanChange`/`PlanChangeResponse`),
so the UI can call them out distinctly instead of silently rewriting the
calendar. See `PlanningService.sync_plan`'s docstring.
"""
scope.check_workspace(payload.workspace_id)
try:
result = await service.sync_plan(
payload.workspace_id, eviction_choice=payload.eviction_choice
)
result = await service.sync_plan(payload.workspace_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return PlanSyncResponse.from_domain(result)
@router.post("/sync-with-wish", response_model=PlanSyncWithWishResponse)
async def sync_plan_with_wish(
payload: PlanSyncWithWishRequest, service: PlanningServiceDep, scope: WorkspaceScopeDep
) -> PlanSyncWithWishResponse:
"""One atomic call: create-or-update the `LeaveWish` behind a calendar
period, apply the day-lock/detach list, and sync — all in one unit of
work (`PlanningService.sync_with_wish`). Replaces the client-orchestrated
"create/patch the wish, then re-point or delete the affected planned
days, then sync" 3-phase sequence (and the compensating rollback it
needed) that used to back the calendar's promote/draw/edit/preset paths:
a rejected sync now rolls back the wish write and every day-lock action
with it, server-side, so the frontend never needs to undo a half-applied
edit by hand.
Same "always 200, `accepted: false` is a structured outcome" contract as
`POST /api/plan/sync` — see that endpoint's docstring. The one case still
raised as an HTTP error is a genuinely missing/foreign workspace, child,
parent, wish, or `planned_day_id` (404), or invalid wish fields (422).
"""
scope.check_workspace(payload.workspace_id)
if payload.wish_create is not None:
if payload.wish_create.child_id is not None:
await scope.check_child(payload.wish_create.child_id)
if payload.wish_create.parent_personal_data_uuid is not None:
await scope.check_member(payload.wish_create.parent_personal_data_uuid)
if payload.wish_update is not None:
patch = payload.wish_update.patch
if patch.child_id is not None:
await scope.check_child(patch.child_id)
if patch.parent_personal_data_uuid is not None:
await scope.check_member(patch.parent_personal_data_uuid)
try:
result = await service.sync_with_wish(
workspace_id=payload.workspace_id,
wish_create=payload.wish_create.to_domain() if payload.wish_create else None,
wish_update=payload.wish_update.to_domain() if payload.wish_update else None,
day_actions=[a.to_domain() for a in payload.day_actions],
)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return PlanSyncWithWishResponse.from_domain(result)
@@ -1,64 +0,0 @@
"""HTTP routes for planned-day exclusions.
Routers stay thin: validate via Pydantic, call the service, map domain
errors to HTTP.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import (
PlannedDayExclusionServiceDep,
WorkspaceIdDep,
WorkspaceScopeDep,
)
from app.models.planned_day_exclusion import (
PlannedDayExclusionCreate,
PlannedDayExclusionResponse,
)
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/api/planned-day-exclusions", tags=["planned-day-exclusions"])
@router.post("", response_model=PlannedDayExclusionResponse, status_code=status.HTTP_201_CREATED)
async def create_planned_day_exclusion(
payload: PlannedDayExclusionCreate,
service: PlannedDayExclusionServiceDep,
scope: WorkspaceScopeDep,
) -> PlannedDayExclusionResponse:
scope.check_workspace(payload.workspace_id)
await scope.check_child(payload.child_id)
await scope.check_member(payload.parent_personal_data_uuid)
try:
entity = await service.exclude(
workspace_id=payload.workspace_id,
child_id=payload.child_id,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
day=payload.date,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return PlannedDayExclusionResponse.from_entity(entity)
@router.get("/workspace/{workspace_id}", response_model=list[PlannedDayExclusionResponse])
async def list_planned_day_exclusions(
workspace_id: WorkspaceIdDep, service: PlannedDayExclusionServiceDep
) -> list[PlannedDayExclusionResponse]:
entities = await service.list_for_workspace(workspace_id)
return [PlannedDayExclusionResponse.from_entity(e) for e in entities]
@router.delete("/{exclusion_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_planned_day_exclusion(
exclusion_id: UUID,
service: PlannedDayExclusionServiceDep,
scope: WorkspaceScopeDep,
) -> None:
"""Un-exclude the day: the planner may place leave here again."""
try:
await service.delete(exclusion_id, workspace_id=scope.workspace_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
+34 -86
View File
@@ -1,64 +1,44 @@
"""HTTP routes for planned leave days (calendar planning)."""
"""HTTP routes for planned leave days (calendar planning) — READ ONLY.
Restructure step 3b: `planned_leave_day` is purely derived output now. The
write endpoints that used to live here (`POST`/`PUT`/`DELETE`) are gone —
every write goes through `POST /api/plan/sync(-with-wish)` (the allocator)
or `POST`/`DELETE /api/day-assertions` (a hand-pin, an ad-hoc day, or an
exclusion). Routers stay thin: validate via Pydantic, call the service, map
domain errors to HTTP.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from app.dependencies import PlanningServiceDep, WorkspaceIdDep, WorkspaceScopeDep
from app.models.planned_leave import (
PlannedLeaveDayCreate,
PlannedLeaveDayResponse,
PlannedLeaveDayUpdate,
)
from app.utils.errors import NotFoundError, ValidationError
from app.entities.planned_leave import PlannedLeaveDay
from app.models.planned_leave import PlannedLeaveDayResponse
from app.services.planning_service import PlanningService
from app.utils.errors import NotFoundError
router = APIRouter(prefix="/api/planned-days", tags=["planned-days"])
def _to_response(day) -> PlannedLeaveDayResponse:
return PlannedLeaveDayResponse.from_entity(day)
async def _to_responses(
service: PlanningService, days: list[PlannedLeaveDay]
) -> list[PlannedLeaveDayResponse]:
"""Annotate a batch of rows with the live `is_pinned` signal in one shot
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`.
(one `pinned_keys` read per workspace, not one per row) — see
`PlanningService.pinned_keys`.
"""
if exc.message_key:
return HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message_key": exc.message_key, "data": exc.data},
workspace_ids = {d.workspace_id for d in days}
pinned: set[tuple[UUID, UUID, object]] = set()
for ws_id in workspace_ids:
pinned |= await service.pinned_keys(ws_id)
return [
PlannedLeaveDayResponse.from_entity(
d, is_pinned=(d.parent_personal_data_uuid, d.child_id, d.date) in pinned
)
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,
service: PlanningServiceDep,
scope: WorkspaceScopeDep,
) -> PlannedLeaveDayResponse:
scope.check_workspace(payload.workspace_id)
await scope.check_child(payload.child_id)
await scope.check_member(payload.parent_personal_data_uuid)
try:
day = await service.add_planned_day(
workspace_id=payload.workspace_id,
child_id=payload.child_id,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
date=payload.date,
day_type=payload.day_type,
is_dubbeldag=payload.is_dubbeldag,
wish_id=payload.wish_id,
is_pinned=payload.is_pinned,
)
return _to_response(day)
except ValidationError as exc:
raise _to_http_error(exc) from exc
for d in days
]
@router.get("", response_model=list[PlannedLeaveDayResponse])
@@ -69,7 +49,7 @@ async def list_planned_days(
) -> list[PlannedLeaveDayResponse]:
await scope.check_child(child_id)
days = await service.list_by_child(child_id)
return [_to_response(d) for d in days]
return await _to_responses(service, days)
@router.get("/{planned_leave_day_id}", response_model=PlannedLeaveDayResponse)
@@ -80,9 +60,12 @@ async def get_planned_day(
) -> PlannedLeaveDayResponse:
try:
day = await service.get(planned_leave_day_id, workspace_id=scope.workspace_id)
return _to_response(day)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
is_pinned = (day.parent_personal_data_uuid, day.child_id, day.date) in (
await service.pinned_keys(day.workspace_id)
)
return PlannedLeaveDayResponse.from_entity(day, is_pinned=is_pinned)
@router.get("/workspace/{workspace_id}", response_model=list[PlannedLeaveDayResponse])
@@ -91,39 +74,4 @@ async def list_planned_days_by_workspace(
service: PlanningServiceDep,
) -> list[PlannedLeaveDayResponse]:
days = await service.list_by_workspace(workspace_id)
return [_to_response(d) for d in days]
@router.put("/{planned_leave_day_id}", response_model=PlannedLeaveDayResponse)
async def update_planned_day(
planned_leave_day_id: UUID,
payload: PlannedLeaveDayUpdate,
service: PlanningServiceDep,
scope: WorkspaceScopeDep,
) -> PlannedLeaveDayResponse:
try:
day = await service.update(
planned_leave_day_id,
day_type=payload.day_type,
is_dubbeldag=payload.is_dubbeldag,
wish_id=payload.wish_id,
is_pinned=payload.is_pinned,
workspace_id=scope.workspace_id,
)
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)
async def delete_planned_day(
planned_leave_day_id: UUID,
service: PlanningServiceDep,
scope: WorkspaceScopeDep,
) -> None:
try:
await service.remove_planned_day(planned_leave_day_id, workspace_id=scope.workspace_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return await _to_responses(service, days)