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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user