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
+88
View File
@@ -0,0 +1,88 @@
"""Domain entity: DayAssertion — one user assertion about one calendar day.
Restructure step 3 (intent vs. derived output — see
`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`). Replaces
THREE previously-conflated concepts on `planned_leave_day`:
- a **pinned day** (`PlannedLeaveDay.is_pinned=True`) = an assertion with a
concrete `day_type`.
- an **ad-hoc day** (`PlannedLeaveDay.wish_id is None`) = an assertion with no
wish attached.
- a **`PlannedDayExclusion`** row = an assertion with `day_type='none'`
("planner, never place a day here").
One row per (workspace, child, parent, date) — see the natural-key UNIQUE
constraint in the migration/bootstrap DDL. `wish_id` is attachment only (for
calendar band naming/grouping); it does NOT mean the planner owns the row —
ownership is the whole point an assertion asserts against.
Deliberately NO strength/priority field: an assertion is absolute by nature.
Strength only matters *between wishes* (`LeaveWish.priority` already exists
for that) — adding it here would reintroduce the exact ambiguity this table
exists to remove.
Step 3a (current): `day_assertion` is additive. `planned_leave_day.is_pinned`
and `planned_day_exclusion` stay in place, unused by anything new, so the
migration is reversible and step 3b can drop them once the frontend moves off
the compatibility shim (see `PlanningService`/`PlannedDayExclusionService`).
"""
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date, datetime
from uuid import UUID
# `day_type='none'` is the assertion-shaped equivalent of a `PlannedDayExclusion`
# row: "the planner may not place leave here." Not a real `planned_leave_day`
# type (that CHECK constraint still only allows sjukpenning/lagsta/vacation).
DAY_TYPE_NONE = "none"
@dataclass
class DayAssertion:
id: UUID
workspace_id: UUID
child_id: UUID
parent_personal_data_uuid: UUID
date: date
day_type: str # 'sjukpenning' | 'lagsta' | 'vacation' | 'none'
is_dubbeldag: bool = False
# Attachment only — see module docstring. Nullable: an ad-hoc assertion
# (the pre-restructure "wish_id IS NULL" shape) has none.
wish_id: UUID | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
def partition_assertion_keys(
assertions: Iterable[DayAssertion],
) -> tuple[frozenset[tuple[UUID, UUID, date]], frozenset[tuple[UUID, UUID, date]]]:
"""Split a batch of assertions into `(pinned_keys, excluded_keys)`.
Restructure step 3b. Both sets are keyed `(parent_personal_data_uuid,
child_id, date)` — the shape every consumer wants:
`AllocationInputLoader` (via `AllocationInput.pinned_days`/
`excluded_days`), `PlanningService.sync_plan`'s own pre-write snapshot,
and `PlanningService`'s read-side `is_pinned` annotation on
`PlannedLeaveDayResponse`. A `day_type != 'none'` assertion is "pinned"
(immovable, concrete); `day_type == 'none'` is "excluded" (the planner
may never place a day here).
This is deliberately the ONE function every reader of "is this day
locked" calls. Step 3a shipped two independent readers of the
equivalent `is_pinned` signal (the allocator's loader, and `sync_plan`'s
own separate `list()` snapshot) that disagreed — a day pinned only via
`DayAssertion` was kept by the allocator but then deleted by
`sync_plan` (see the restructure notes' "Bug found in 3a"). Routing
every reader through this single, pure function is what makes that bug
class structurally impossible rather than merely fixed once.
"""
pinned: set[tuple[UUID, UUID, date]] = set()
excluded: set[tuple[UUID, UUID, date]] = set()
for a in assertions:
key = (a.parent_personal_data_uuid, a.child_id, a.date)
if a.day_type == DAY_TYPE_NONE:
excluded.add(key)
else:
pinned.add(key)
return frozenset(pinned), frozenset(excluded)
@@ -1,40 +0,0 @@
"""Domain entity: PlannedDayExclusion.
"The planner may not place leave for THIS parent, for THIS child, on THIS
date." One row is one user instruction, recorded when the user removes a
single day out of a planner-placed period ("this day" in the calendar's
three-way remove). Without it, the next `POST /api/plan/sync` would simply
re-derive the day from the wish — the wish still says "a full week", and
nothing anywhere said that one date was off the table.
Deliberately NOT modelled as a `DayOverride`. A `DayOverride` answers a
*calendar* question ("is this date a working day for this workspace?"), is
workspace-wide, and is applied inside `is_free_day`. This answers a
*planning* question ("may a leave day be placed here?"), is per parent and
per child, and is applied where a window is funded. Folding the second into
the first would mean a removed Tuesday silently became a non-working day for
everyone and everything, analytics included.
Keyed per parent (see `PlannedDayExclusionService`): a both-parents period
places one day per parent on the same date, and removing one parent's day
must not silently remove the other's. The allocator only drops the date from
a window entirely when no responsible parent may take it — see
`_Ledger._excluded_window_dates`.
"""
from dataclasses import dataclass
from datetime import date, datetime
from uuid import UUID
@dataclass
class PlannedDayExclusion:
"""One (workspace, child, parent, date) the planner must leave empty."""
id: UUID
workspace_id: UUID
child_id: UUID
parent_personal_data_uuid: UUID
date: date
created_at: datetime | None = None
updated_at: datetime | None = None
+15 -4
View File
@@ -7,6 +7,21 @@ from uuid import UUID
@dataclass
class PlannedLeaveDay:
"""A single derived/persisted calendar day of planned leave.
Restructure step 3b: this row is now purely derived output — "is this
day pinned/immovable" is no longer one of its own columns. That fact
lives in `DayAssertion` (see `app/entities/day_assertion.py`) instead,
keyed by (workspace, child, parent, date); the allocator (`_Ledger`)
consults `AllocationInput.pinned_days` (built from assertions), never a
field on this class. `PlanningService`'s read paths (`get`/`list_*`)
still annotate a display-only `is_pinned` onto the response for the
calendar UI (which needs to render a lock icon) — see
`PlanningService._annotate_pinned` — but that is done explicitly at the
read boundary, not carried by this entity nor by any DB column
(migration 010 drops it).
"""
id: UUID
workspace_id: UUID
child_id: UUID
@@ -18,9 +33,5 @@ class PlannedLeaveDay:
# days have none). Lets the UI click a multi-day band on a calendar and edit
# it as one period instead of one day at a time.
wish_id: UUID | None = None
# True once the user hand-edits this day. A later task makes the allocator
# refuse to retype a pinned day on replan; this field only carries that
# state through the stack so far.
is_pinned: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None