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
@@ -0,0 +1,178 @@
"""SQLite implementation of DayAssertionRepository.
Follows the `SQLitePlannedDayExclusionRepository` reference shape: map rows
<-> entities explicitly in `_row_to_entity`, always parameterize queries,
raise `NotFoundError` from `update`/`delete` when the row is missing.
"""
from datetime import date, datetime
from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.day_assertion import DayAssertion
from app.repositories.day_assertion_repository import DayAssertionRepository
from app.repositories.filter_validator import validate_filters
from app.utils.errors import NotFoundError
class SQLiteDayAssertionRepository(DayAssertionRepository):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
@staticmethod
def _row_to_entity(row: aiosqlite.Row) -> DayAssertion:
return DayAssertion(
id=UUID(row["id"]),
workspace_id=UUID(row["workspace_id"]),
child_id=UUID(row["child_id"]),
parent_personal_data_uuid=UUID(row["parent_personal_data_uuid"]),
date=date.fromisoformat(row["date"]),
day_type=row["day_type"],
is_dubbeldag=bool(row["is_dubbeldag"]),
wish_id=UUID(row["wish_id"]) if row["wish_id"] is not None else None,
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
async def create(self, item: DayAssertion) -> DayAssertion:
now = datetime.now()
item.created_at = item.created_at or now
item.updated_at = item.updated_at or now
await self._conn.execute(
"""
INSERT INTO day_assertion
(id, workspace_id, child_id, parent_personal_data_uuid, date,
day_type, is_dubbeldag, wish_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
str(item.child_id),
str(item.parent_personal_data_uuid),
item.date.isoformat(),
item.day_type,
int(item.is_dubbeldag),
str(item.wish_id) if item.wish_id is not None else None,
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
)
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> DayAssertion | None:
cursor = await self._conn.execute("SELECT * FROM day_assertion WHERE id = ?", (str(id),))
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def get_by_natural_key(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> DayAssertion | None:
cursor = await self._conn.execute(
"""
SELECT * FROM day_assertion
WHERE workspace_id = ? AND child_id = ?
AND parent_personal_data_uuid = ? AND date = ?
""",
(
str(workspace_id),
str(child_id),
str(parent_personal_data_uuid),
day.isoformat(),
),
)
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def update(self, id: UUID, item: DayAssertion) -> DayAssertion:
item.updated_at = datetime.now()
cursor = await self._conn.execute(
"""
UPDATE day_assertion
SET day_type = ?, is_dubbeldag = ?, wish_id = ?, updated_at = ?
WHERE id = ?
""",
(
item.day_type,
int(item.is_dubbeldag),
str(item.wish_id) if item.wish_id is not None else None,
item.updated_at.isoformat(),
str(id),
),
)
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"DayAssertion {id} not found")
refreshed = await self.get_by_id(id)
assert refreshed is not None
return refreshed
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM day_assertion WHERE id = ?", (str(id),))
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"DayAssertion {id} not found")
async def delete_by_natural_key(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> None:
await self._conn.execute(
"""
DELETE FROM day_assertion
WHERE workspace_id = ? AND child_id = ?
AND parent_personal_data_uuid = ? AND date = ?
""",
(
str(workspace_id),
str(child_id),
str(parent_personal_data_uuid),
day.isoformat(),
),
)
await commit(self._conn)
async def upsert(self, item: DayAssertion) -> DayAssertion:
existing = await self.get_by_natural_key(
item.workspace_id, item.child_id, item.parent_personal_data_uuid, item.date
)
if existing is None:
return await self.create(item)
existing.day_type = item.day_type
existing.is_dubbeldag = item.is_dubbeldag
existing.wish_id = item.wish_id
return await self.update(existing.id, existing)
async def list(self, **filters: object) -> list[DayAssertion]:
validate_filters(
filters, {"workspace_id", "child_id", "parent_personal_data_uuid", "wish_id"}
)
clauses: list[str] = []
params: list[object] = []
for column in ("workspace_id", "child_id", "parent_personal_data_uuid", "wish_id"):
if column in filters and filters[column] is not None:
value = filters[column]
clauses.append(f"{column} = ?")
params.append(str(value) if isinstance(value, UUID) else value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
cursor = await self._conn.execute(
f"SELECT * FROM day_assertion{where} ORDER BY date ASC", params
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
async def list_by_workspace(self, workspace_id: UUID) -> list[DayAssertion]:
return await self.list(workspace_id=workspace_id)
async def list_by_wish(self, wish_id: UUID) -> list[DayAssertion]:
return await self.list(wish_id=wish_id)
@@ -1,133 +0,0 @@
"""SQLite implementation of PlannedDayExclusionRepository.
Follows the SQLiteDayOverrideRepository reference shape: map rows <-> entities
explicitly in `_row_to_entity`, always parameterize queries, raise
NotFoundError from update/delete when the row is missing.
"""
from datetime import date, datetime
from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.planned_day_exclusion import PlannedDayExclusion
from app.repositories.filter_validator import validate_filters
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.utils.errors import NotFoundError
class SQLitePlannedDayExclusionRepository(PlannedDayExclusionRepository):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
@staticmethod
def _row_to_entity(row: aiosqlite.Row) -> PlannedDayExclusion:
return PlannedDayExclusion(
id=UUID(row["id"]),
workspace_id=UUID(row["workspace_id"]),
child_id=UUID(row["child_id"]),
parent_personal_data_uuid=UUID(row["parent_personal_data_uuid"]),
date=date.fromisoformat(row["date"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
async def create(self, item: PlannedDayExclusion) -> PlannedDayExclusion:
now = datetime.now()
item.created_at = item.created_at or now
item.updated_at = now
await self._conn.execute(
"""
INSERT INTO planned_day_exclusion
(id, workspace_id, child_id, parent_personal_data_uuid, date,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
str(item.child_id),
str(item.parent_personal_data_uuid),
item.date.isoformat(),
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
)
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> PlannedDayExclusion | None:
cursor = await self._conn.execute(
"SELECT * FROM planned_day_exclusion WHERE id = ?", (str(id),)
)
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def get_by_natural_key(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> PlannedDayExclusion | None:
cursor = await self._conn.execute(
"""
SELECT * FROM planned_day_exclusion
WHERE workspace_id = ? AND child_id = ?
AND parent_personal_data_uuid = ? AND date = ?
""",
(
str(workspace_id),
str(child_id),
str(parent_personal_data_uuid),
day.isoformat(),
),
)
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def update(self, id: UUID, item: PlannedDayExclusion) -> PlannedDayExclusion:
"""Only `date` is mutable; the rest of the row IS its identity.
Kept for the `Repository[T]` contract — the service never calls it
(an exclusion is created or deleted, never edited in place).
"""
item.updated_at = datetime.now()
cursor = await self._conn.execute(
"UPDATE planned_day_exclusion SET date = ?, updated_at = ? WHERE id = ?",
(item.date.isoformat(), item.updated_at.isoformat(), str(id)),
)
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PlannedDayExclusion {id} not found")
refreshed = await self.get_by_id(id)
assert refreshed is not None # just updated
return refreshed
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute(
"DELETE FROM planned_day_exclusion WHERE id = ?", (str(id),)
)
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PlannedDayExclusion {id} not found")
async def list(self, **filters: object) -> list[PlannedDayExclusion]:
validate_filters(filters, {"workspace_id", "child_id", "parent_personal_data_uuid"})
clauses: list[str] = []
params: list[object] = []
for column in ("workspace_id", "child_id", "parent_personal_data_uuid"):
if column in filters and filters[column] is not None:
value = filters[column]
clauses.append(f"{column} = ?")
params.append(str(value) if isinstance(value, UUID) else value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
cursor = await self._conn.execute(
f"SELECT * FROM planned_day_exclusion{where} ORDER BY date ASC", params
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedDayExclusion]:
return await self.list(workspace_id=workspace_id)
@@ -27,7 +27,6 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
day_type=row["day_type"],
is_dubbeldag=bool(row["is_dubbeldag"]),
wish_id=UUID(row["wish_id"]) if row["wish_id"] is not None else None,
is_pinned=bool(row["is_pinned"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
@@ -40,8 +39,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
"""
INSERT INTO planned_leave_day
(id, workspace_id, child_id, parent_personal_data_uuid, date,
day_type, is_dubbeldag, wish_id, is_pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
day_type, is_dubbeldag, wish_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
@@ -52,7 +51,6 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
item.day_type,
int(item.is_dubbeldag),
str(item.wish_id) if item.wish_id is not None else None,
int(item.is_pinned),
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
@@ -72,14 +70,13 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
cursor = await self._conn.execute(
"""
UPDATE planned_leave_day
SET day_type = ?, is_dubbeldag = ?, wish_id = ?, is_pinned = ?, updated_at = ?
SET day_type = ?, is_dubbeldag = ?, wish_id = ?, updated_at = ?
WHERE id = ?
""",
(
item.day_type,
int(item.is_dubbeldag),
str(item.wish_id) if item.wish_id is not None else None,
int(item.is_pinned),
item.updated_at.isoformat(),
str(id),
),
@@ -174,6 +171,13 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
row = await cursor.fetchone()
return int(row[0]) if row else 0
async def list_by_wish(self, wish_id: UUID) -> list[PlannedLeaveDay]:
cursor = await self._conn.execute(
"SELECT * FROM planned_leave_day WHERE wish_id = ? ORDER BY date ASC", (str(wish_id),)
)
rows = await cursor.fetchall()
return [self._row_to_entity(r) for r in rows]
async def delete_by_wish(self, wish_id: UUID) -> int:
cursor = await self._conn.execute(
"DELETE FROM planned_leave_day WHERE wish_id = ?", (str(wish_id),)
@@ -199,7 +203,6 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
item.day_type,
int(item.is_dubbeldag),
str(item.wish_id) if item.wish_id is not None else None,
int(item.is_pinned),
item.created_at.isoformat(),
item.updated_at.isoformat(),
)
@@ -208,8 +211,8 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
"""
INSERT INTO planned_leave_day
(id, workspace_id, child_id, parent_personal_data_uuid, date,
day_type, is_dubbeldag, wish_id, is_pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
day_type, is_dubbeldag, wish_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
@@ -231,18 +234,24 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
await commit(self._conn)
return total
async def update_metadata_many(self, pairs: list[tuple[UUID, UUID | None, bool]]) -> None:
async def update_metadata_many(self, pairs: list[tuple[UUID, UUID | None]]) -> None:
"""Bulk-update each row's `wish_id` (attribution) only.
Restructure step 3b: `is_pinned` is gone from this table (migration
010) — "pinned" is a `DayAssertion` fact now, never a
`planned_leave_day` column, so there is nothing left for a bulk
metadata update to do to it.
"""
if not pairs:
return
now = datetime.now().isoformat()
rows = [
(str(wish_id) if wish_id is not None else None, int(is_pinned), now, str(id_))
for id_, wish_id, is_pinned in pairs
(str(wish_id) if wish_id is not None else None, now, str(id_)) for id_, wish_id in pairs
]
await self._conn.executemany(
"""
UPDATE planned_leave_day
SET wish_id = ?, is_pinned = ?, updated_at = ?
SET wish_id = ?, updated_at = ?
WHERE id = ?
""",
rows,