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
+61 -19
View File
@@ -23,13 +23,11 @@ from app.entities.user import User
from app.entities.workspace import Workspace
from app.main import app
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
SQLitePlannedDayExclusionRepository,
)
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.preschool_planning_days_repository import (
SQLitePreschoolPlanningDaysRepository,
@@ -48,9 +46,9 @@ from app.services.allocation_service import AllocationInputLoader, PlanningEngin
from app.services.analytics_service import AnalyticsService
from app.services.auth_service import AuthService
from app.services.child_service import ChildService
from app.services.day_assertion_service import DayAssertionService
from app.services.day_override_service import DayOverrideService
from app.services.leave_wish_service import LeaveWishService
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
from app.services.planning_service import PlanningService
from app.services.preschool_planning_days_service import PreschoolPlanningDaysService
from app.services.tips_service import TipsService
@@ -283,10 +281,32 @@ async def leave_wish_service(
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
child_repo: SQLiteChildRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> LeaveWishService:
# `child_repo` lets an open-ended ("until the child turns 4") series
# materialise against the child's real birth date; see LeaveWishService.
return LeaveWishService(leave_wish_repo, planned_leave_repo, child_repo)
# `day_assertion_repo` lets `delete()` clear a pinned/promoted row's
# assertion alongside the row itself (restructure step 3b).
return LeaveWishService(leave_wish_repo, planned_leave_repo, child_repo, day_assertion_repo)
@pytest_asyncio.fixture
async def leave_wish_service_with_uow(
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
child_repo: SQLiteChildRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
sqlite_uow: SQLiteUnitOfWork,
) -> LeaveWishService:
"""A `LeaveWishService` wired with a real `SQLiteUnitOfWork` — for tests
exercising `create`/`update`/`delete`'s multi-row atomicity (this
service previously had none at all). See `planning_service_with_uow`
above for the same pattern.
"""
return LeaveWishService(
leave_wish_repo, planned_leave_repo, child_repo, day_assertion_repo, uow=sqlite_uow
)
# ---------------------------------------------------------------------------
@@ -309,23 +329,41 @@ async def day_override_service(
# ---------------------------------------------------------------------------
# PlannedDayExclusion (user-removed days — see app/entities/planned_day_exclusion.py)
# DayAssertion (restructure step 3 — see app/entities/day_assertion.py)
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def planned_day_exclusion_repo(
async def day_assertion_repo(
connection: aiosqlite.Connection,
) -> SQLitePlannedDayExclusionRepository:
return SQLitePlannedDayExclusionRepository(connection)
) -> SQLiteDayAssertionRepository:
return SQLiteDayAssertionRepository(connection)
@pytest_asyncio.fixture
async def planned_day_exclusion_service(
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
async def day_assertion_service(
day_assertion_repo: SQLiteDayAssertionRepository,
) -> DayAssertionService:
return DayAssertionService(day_assertion_repo)
@pytest_asyncio.fixture
async def day_assertion_service_with_uow(
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
) -> PlannedDayExclusionService:
return PlannedDayExclusionService(planned_day_exclusion_repo, planned_leave_repo)
sqlite_uow: SQLiteUnitOfWork,
) -> DayAssertionService:
"""A `DayAssertionService` wired with mirroring AND a real `SQLiteUnitOfWork`
— for tests that specifically exercise the assert-then-mirror atomicity
fix (a failure mid-mirror must leave no assertion committed). See
`planning_service_with_uow` above for the same pattern.
"""
return DayAssertionService(
day_assertion_repo,
planned_leave_repo=planned_leave_repo,
uow=sqlite_uow,
)
@pytest_asyncio.fixture
@@ -364,7 +402,7 @@ async def allocation_input_loader(
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
day_override_repo: SQLiteDayOverrideRepository,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> AllocationInputLoader:
return AllocationInputLoader(
workspace_repo=workspace_repo,
@@ -375,7 +413,7 @@ async def allocation_input_loader(
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
day_override_repo=day_override_repo,
exclusion_repo=planned_day_exclusion_repo,
day_assertion_repo=day_assertion_repo,
)
@@ -393,7 +431,8 @@ async def planning_service(
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planning_engine_service: PlanningEngineService,
leave_wish_repo: SQLiteLeaveWishRepository,
leave_wish_service: LeaveWishService,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> PlanningService:
return PlanningService(
planned_leave_repo,
@@ -401,7 +440,8 @@ async def planning_service(
parental_leave_repo,
vacation_repo,
planning_engine_service,
leave_wish_repo=leave_wish_repo,
leave_wish_service=leave_wish_service,
day_assertion_repo=day_assertion_repo,
)
@@ -427,8 +467,9 @@ async def planning_service_with_uow(
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planning_engine_service: PlanningEngineService,
leave_wish_repo: SQLiteLeaveWishRepository,
leave_wish_service: LeaveWishService,
sqlite_uow: SQLiteUnitOfWork,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> PlanningService:
return PlanningService(
planned_leave_repo,
@@ -436,8 +477,9 @@ async def planning_service_with_uow(
parental_leave_repo,
vacation_repo,
planning_engine_service,
leave_wish_repo=leave_wish_repo,
uow=sqlite_uow,
leave_wish_service=leave_wish_service,
day_assertion_repo=day_assertion_repo,
)