782415122d
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
89 lines
3.9 KiB
Python
89 lines
3.9 KiB
Python
"""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)
|