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
+177 -101
View File
@@ -14,28 +14,24 @@ from datetime import date
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from app.models.leave_wish import LeaveWishResponse, LeaveWishUpdate
from app.models.planned_leave import PlannedLeaveDayResponse
from app.services.allocation_service import PlanProposal
from app.services.planning_service import (
DayCollision,
EvictionCandidate,
EvictionPreview,
DayLockAction,
PlanApplyResult,
PlanChange,
PlanSyncResult,
ProposedDayInput,
Rejection,
ResourceShortage,
WishCreateInput,
WishSyncResult,
WishUpdateInput,
)
# Wire-level mirror of `PlanningService`'s `EVICTION_CHOICE_*` constants —
# kept as a `Literal` here (rather than importing the string constants) so
# an invalid value 422s at the Pydantic boundary instead of silently
# reaching the service as a no-op "do nothing".
EvictionChoice = Literal["drop_future_pl", "drop_latest_vacation_this_year"]
class PlanProposeRequest(BaseModel):
workspace_id: UUID
@@ -98,6 +94,11 @@ class WindowOutcomeResponse(BaseModel):
# — spendable days the flexible placement search saved versus the naive
# bound-start placement. 0 for a fixed window or a reused flexible one.
shift_gain_days: int = 0
# Mirrors `WindowOutcome.series_id` — see its docstring. Lets a caller
# that only knows ONE occurrence's wish id (e.g. `sync_with_wish`'s
# returned `wish.id`) find every OTHER outcome in this same batch that
# belongs to the same repeating wish.
series_id: UUID | None = None
class PlanWarningResponse(BaseModel):
@@ -197,6 +198,7 @@ class PlanProposalResponse(BaseModel):
for r in o.remedies
],
shift_gain_days=o.shift_gain_days,
series_id=o.series_id,
)
for o in proposal.outcomes
],
@@ -275,7 +277,6 @@ class ProposedDayApplyItem(BaseModel):
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$")
is_dubbeldag: bool = False
wish_id: UUID | None = None
is_pinned: bool = False
def to_domain(self) -> ProposedDayInput:
return ProposedDayInput(
@@ -285,7 +286,6 @@ class ProposedDayApplyItem(BaseModel):
day_type=self.day_type,
is_dubbeldag=self.is_dubbeldag,
wish_id=self.wish_id,
is_pinned=self.is_pinned,
)
@@ -362,15 +362,6 @@ class PlanApplyResponse(BaseModel):
class PlanSyncRequest(BaseModel):
workspace_id: UUID
# Which of the three user-facing options resolves a would-evict save
# (§ eviction preview). Omitted/`None` — the default — means "do
# nothing": the save is refused and nothing is mutated, exactly as if
# the user picked the explicit cancel option. Only meaningful when the
# response's `eviction_preview.would_evict` was `True` on a prior call
# with the same edit already persisted upstream (e.g. via the leave-wish
# endpoints) — a plain sync that fits within budget ignores this field
# entirely (never asked for a choice in the first place).
eviction_choice: EvictionChoice | None = None
class PlanChangeResponse(BaseModel):
@@ -391,6 +382,12 @@ class PlanChangeResponse(BaseModel):
new_is_dubbeldag: bool | None
old_wish_id: UUID | None
new_wish_id: UUID | None
# Only ever `True` on a `kind == 'removed'` change — see `PlanChange.
# evicted`'s docstring: distinguishes "this wish's own edit shed the
# row" (evicted=False, the ordinary case) from "a different wish's edit
# won a shared pool ahead of it" (evicted=True) so the UI can call out
# displacement distinctly instead of folding it into a silent count.
evicted: bool
@classmethod
def from_domain(cls, change: PlanChange) -> "PlanChangeResponse":
@@ -405,84 +402,7 @@ class PlanChangeResponse(BaseModel):
new_is_dubbeldag=change.new_is_dubbeldag,
old_wish_id=change.old_wish_id,
new_wish_id=change.new_wish_id,
)
class EvictionCandidateResponse(BaseModel):
"""One previously-persisted day a fresh sync would otherwise silently
delete — see `EvictionCandidate`'s docstring. `wish_name` is `None` when
the owning wish's name couldn't be resolved (never a reason to hide the
day itself).
"""
date: date
parent_personal_data_uuid: UUID
child_id: UUID
day_type: str
is_dubbeldag: bool
wish_id: UUID
wish_name: str | None
@classmethod
def from_domain(cls, candidate: EvictionCandidate) -> "EvictionCandidateResponse":
return cls(
date=candidate.date,
parent_personal_data_uuid=candidate.parent_personal_data_uuid,
child_id=candidate.child_id,
day_type=candidate.day_type,
is_dubbeldag=candidate.is_dubbeldag,
wish_id=candidate.wish_id,
wish_name=candidate.wish_name,
)
class ResourceShortageResponse(BaseModel):
"""One exhausted resource behind an eviction preview — see
`ResourceShortage`'s docstring. `resource_kind == 'parental_leave'`
carries `tier`/`child_id` (`year` is `None`); `resource_kind ==
'vacation'` carries `year` instead (`tier`/`child_id` are `None`).
Structured on purpose — the frontend composes the warning text itself,
never a backend-rendered string.
"""
resource_kind: str
parent_personal_data_uuid: UUID
tier: str | None
child_id: UUID | None
year: int | None
days_evicted: int
@classmethod
def from_domain(cls, shortage: ResourceShortage) -> "ResourceShortageResponse":
return cls(
resource_kind=shortage.resource_kind,
parent_personal_data_uuid=shortage.parent_personal_data_uuid,
tier=shortage.tier,
child_id=shortage.child_id,
year=shortage.year,
days_evicted=shortage.days_evicted,
)
class EvictionPreviewResponse(BaseModel):
"""Always present on `PlanSyncResponse`; `would_evict: false` with empty
lists in the common (fits-within-budget) case — see
`EvictionPreview`'s docstring.
"""
would_evict: bool
candidates: list[EvictionCandidateResponse]
shortages: list[ResourceShortageResponse]
@classmethod
def from_domain(cls, preview: EvictionPreview) -> "EvictionPreviewResponse":
return cls(
would_evict=preview.would_evict,
candidates=[EvictionCandidateResponse.from_domain(c) for c in preview.candidates],
shortages=[ResourceShortageResponse.from_domain(s) for s in preview.shortages],
evicted=change.evicted,
)
@@ -490,7 +410,6 @@ class PlanSyncResponse(BaseModel):
proposal: PlanProposalResponse
apply_result: PlanApplyResponse
changes: list[PlanChangeResponse]
eviction_preview: EvictionPreviewResponse
@classmethod
def from_domain(cls, result: PlanSyncResult) -> "PlanSyncResponse":
@@ -498,5 +417,162 @@ class PlanSyncResponse(BaseModel):
proposal=PlanProposalResponse.from_domain(result.proposal),
apply_result=PlanApplyResponse.from_domain(result.apply_result),
changes=[PlanChangeResponse.from_domain(c) for c in result.changes],
eviction_preview=EvictionPreviewResponse.from_domain(result.eviction_preview),
)
# ---------------------------------------------------------------------------
# POST /api/plan/sync-with-wish — the calendar's four create/edit-then-sync
# paths, collapsed into one atomic backend call. See
# `PlanningService.sync_with_wish`'s docstring for the atomicity guarantee.
# ---------------------------------------------------------------------------
class WishCreatePayload(BaseModel):
"""Fields for the wish behind a drawn/promoted/preset period.
Field-for-field identical to `LeaveWishCreate` minus `workspace_id`
(carried once, on `PlanSyncWithWishRequest` itself).
"""
child_id: UUID | None = None
name: str = Field(min_length=1, max_length=255)
name_message_key: str | None = Field(default=None, max_length=255)
window_start_month: int = Field(ge=1, le=12)
window_start_day: int = Field(ge=1, le=31)
window_end_month: int = Field(ge=1, le=12)
window_end_day: int = Field(ge=1, le=31)
target_weeks: int = Field(ge=1, le=52)
target_days: int | None = Field(default=None, ge=1)
recurrence: str = "once"
first_year: int = Field(ge=2000, le=2200)
last_year: int | None = Field(default=None, ge=2000, le=2200)
priority: int = Field(default=1, ge=1)
parent_mode: str = "either"
parent_personal_data_uuid: UUID | None = None
window_mode: str = "fixed"
series_id: UUID | None = None
wants_dubbeldag: bool = False
def to_domain(self) -> WishCreateInput:
return WishCreateInput(
child_id=self.child_id,
name=self.name,
name_message_key=self.name_message_key,
window_start_month=self.window_start_month,
window_start_day=self.window_start_day,
window_end_month=self.window_end_month,
window_end_day=self.window_end_day,
target_weeks=self.target_weeks,
target_days=self.target_days,
recurrence=self.recurrence,
first_year=self.first_year,
last_year=self.last_year,
priority=self.priority,
parent_mode=self.parent_mode,
parent_personal_data_uuid=self.parent_personal_data_uuid,
window_mode=self.window_mode,
series_id=self.series_id,
wants_dubbeldag=self.wants_dubbeldag,
)
class WishUpdatePayload(BaseModel):
"""One wish's occurrence/series edit, addressed by id — reuses
`LeaveWishUpdate` verbatim for `patch` (it already has no `workspace_id`
field, so it needs no stripped-down mirror the way `WishCreatePayload`
does).
"""
leave_wish_id: UUID
scope: Literal["occurrence", "series"] = "occurrence"
patch: LeaveWishUpdate
def to_domain(self) -> WishUpdateInput:
fields_set = self.patch.model_fields_set
p = self.patch
return WishUpdateInput(
leave_wish_id=self.leave_wish_id,
scope=self.scope,
child_id=p.child_id,
name=p.name,
name_message_key=p.name_message_key,
window_start_month=p.window_start_month,
window_start_day=p.window_start_day,
window_end_month=p.window_end_month,
window_end_day=p.window_end_day,
target_weeks=p.target_weeks,
target_days=p.target_days,
recurrence=p.recurrence,
first_year=p.first_year,
last_year=p.last_year,
priority=p.priority,
parent_mode=p.parent_mode,
parent_personal_data_uuid=p.parent_personal_data_uuid,
window_mode=p.window_mode,
wants_dubbeldag=p.wants_dubbeldag,
clear_child_id="child_id" in fields_set and p.child_id is None,
clear_last_year="last_year" in fields_set and p.last_year is None,
clear_parent_personal_data_uuid=(
"parent_personal_data_uuid" in fields_set and p.parent_personal_data_uuid is None
),
clear_name_message_key=(
"name_message_key" in fields_set and p.name_message_key is None
),
clear_target_days="target_days" in fields_set and p.target_days is None,
)
DayLockActionKind = Literal["attach", "pin", "unpin", "detach"]
class DayLockActionRequest(BaseModel):
"""One already-persisted `planned_leave_day` row to re-point/pin/delete
as part of the same atomic call — see `DayLockAction`'s docstring
(app/services/planning_service.py) for what each `action` does.
"""
planned_day_id: UUID
action: DayLockActionKind
def to_domain(self) -> DayLockAction:
return DayLockAction(planned_day_id=self.planned_day_id, action=self.action)
class PlanSyncWithWishRequest(BaseModel):
workspace_id: UUID
# Exactly one of these two — this call always touches exactly one wish.
wish_create: WishCreatePayload | None = None
wish_update: WishUpdatePayload | None = None
day_actions: list[DayLockActionRequest] = Field(default_factory=list)
@model_validator(mode="after")
def _exactly_one_wish_op(self) -> "PlanSyncWithWishRequest":
if (self.wish_create is None) == (self.wish_update is None):
raise ValueError("exactly one of wish_create/wish_update must be provided")
return self
class PlanSyncWithWishResponse(BaseModel):
"""`wish` reflects the fields the caller asked for even when
`sync.apply_result.accepted` is `False` — see `WishSyncResult`'s
docstring: in that case nothing was actually persisted (the whole point
of the atomic call), but the frontend still needs the would-be id/fields
to render the outcome (e.g. matching `sync.proposal.outcomes` by
`wish_id`).
"""
wish: LeaveWishResponse
sync: PlanSyncResponse
@classmethod
def from_domain(cls, result: WishSyncResult) -> "PlanSyncWithWishResponse":
return cls(
wish=LeaveWishResponse.from_entity(result.wish),
sync=PlanSyncResponse.from_domain(result.sync),
)