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
+51 -30
View File
@@ -23,11 +23,11 @@ from app.entities.child import Child
from app.entities.personal_data import PersonalData
from app.entities.user import User
from app.repositories.child_repository import ChildRepository
from app.repositories.day_assertion_repository import DayAssertionRepository
from app.repositories.day_override_repository import DayOverrideRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.preschool_planning_days_repository import (
PreschoolPlanningDaysRepository,
@@ -41,13 +41,11 @@ from app.repositories.workspace_repository import (
WorkspaceRepository,
)
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,
@@ -65,10 +63,10 @@ from app.services.analytics_service import AnalyticsService
from app.services.auth_service import AuthService
from app.services.calendar_service import CalendarService
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.personal_data_service import PersonalDataService
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
@@ -196,15 +194,13 @@ def get_leave_wish_repository(conn: ConnectionDep) -> LeaveWishRepository:
LeaveWishRepositoryDep = Annotated[LeaveWishRepository, Depends(get_leave_wish_repository)]
def get_planned_day_exclusion_repository(conn: ConnectionDep) -> PlannedDayExclusionRepository:
def get_day_assertion_repository(conn: ConnectionDep) -> DayAssertionRepository:
if settings.database_type == "sqlite":
return SQLitePlannedDayExclusionRepository(conn)
return SQLiteDayAssertionRepository(conn)
raise ValueError(f"Unsupported database_type: {settings.database_type}")
PlannedDayExclusionRepositoryDep = Annotated[
PlannedDayExclusionRepository, Depends(get_planned_day_exclusion_repository)
]
DayAssertionRepositoryDep = Annotated[DayAssertionRepository, Depends(get_day_assertion_repository)]
def get_preschool_planning_days_repository(
@@ -278,10 +274,17 @@ def get_leave_wish_service(
repo: LeaveWishRepositoryDep,
planned_leave_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
day_assertion_repo: DayAssertionRepositoryDep,
uow: UnitOfWorkDep,
) -> LeaveWishService:
# `child_repo` resolves "until the child turns 4" into a concrete last
# year when a repeating wish is materialised — see LeaveWishService.
return LeaveWishService(repo, planned_leave_repo, child_repo)
# `day_assertion_repo` is what `delete()` uses to clear a pinned/promoted
# row's assertion alongside the row itself (restructure step 3b). `uow`
# makes `create`/`update`/`delete`'s multi-row writes atomic — this
# service previously had none, so a crash mid-series-delete/re-projection
# left a partially-applied series (adversarial-review-adjacent fix).
return LeaveWishService(repo, planned_leave_repo, child_repo, day_assertion_repo, uow=uow)
LeaveWishServiceDep = Annotated[LeaveWishService, Depends(get_leave_wish_service)]
@@ -294,21 +297,6 @@ def get_day_override_service(repo: DayOverrideRepositoryDep) -> DayOverrideServi
DayOverrideServiceDep = Annotated[DayOverrideService, Depends(get_day_override_service)]
def get_planned_day_exclusion_service(
repo: PlannedDayExclusionRepositoryDep,
planned_leave_repo: PlannedLeaveRepositoryDep,
uow: UnitOfWorkDep,
) -> PlannedDayExclusionService:
# `planned_leave_repo` is what makes "remove this day" true immediately
# rather than only after the next sync — see the service's docstring.
return PlannedDayExclusionService(repo, planned_leave_repo, uow=uow)
PlannedDayExclusionServiceDep = Annotated[
PlannedDayExclusionService, Depends(get_planned_day_exclusion_service)
]
def get_calendar_service(day_override_service: DayOverrideServiceDep) -> CalendarService:
return CalendarService(day_override_service)
@@ -396,6 +384,32 @@ def get_analytics_service(
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
def get_day_assertion_service(
repo: DayAssertionRepositoryDep,
planned_leave_repo: PlannedLeaveRepositoryDep,
analytics_service: AnalyticsServiceDep,
workspace_repo: WorkspaceRepositoryDep,
day_override_repo: DayOverrideRepositoryDep,
uow: UnitOfWorkDep,
) -> DayAssertionService:
# Restructure step 3b: this is now the ONE write path behind
# `POST /api/day-assertions`, mirroring into `planned_leave_day` and
# enforcing the same guards `PlanningService.add_planned_day`/`update`
# used to — see `DayAssertionService`'s docstring. `uow` makes the
# assertion write + mirror write atomic (adversarial-review fix).
return DayAssertionService(
repo,
planned_leave_repo=planned_leave_repo,
analytics_service=analytics_service,
uow=uow,
workspace_repo=workspace_repo,
day_override_repo=day_override_repo,
)
DayAssertionServiceDep = Annotated[DayAssertionService, Depends(get_day_assertion_service)]
def get_allocation_input_loader(
workspace_repo: WorkspaceRepositoryDep,
child_repo: ChildRepositoryDep,
@@ -405,8 +419,8 @@ def get_allocation_input_loader(
planned_leave_repo: PlannedLeaveRepositoryDep,
leave_wish_repo: LeaveWishRepositoryDep,
day_override_repo: DayOverrideRepositoryDep,
exclusion_repo: PlannedDayExclusionRepositoryDep,
preschool_planning_days_repo: PreschoolPlanningDaysRepositoryDep,
day_assertion_repo: DayAssertionRepositoryDep,
) -> AllocationInputLoader:
return AllocationInputLoader(
workspace_repo=workspace_repo,
@@ -417,8 +431,8 @@ def get_allocation_input_loader(
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
day_override_repo=day_override_repo,
exclusion_repo=exclusion_repo,
preschool_planning_days_repo=preschool_planning_days_repo,
day_assertion_repo=day_assertion_repo,
)
@@ -438,10 +452,11 @@ def get_planning_service(
parental_leave_repo: ParentalLeaveRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
planning_engine_service: PlanningEngineServiceDep,
leave_wish_repo: LeaveWishRepositoryDep,
uow: UnitOfWorkDep,
workspace_repo: WorkspaceRepositoryDep,
day_override_repo: DayOverrideRepositoryDep,
leave_wish_service: LeaveWishServiceDep,
day_assertion_repo: DayAssertionRepositoryDep,
) -> PlanningService:
return PlanningService(
repo,
@@ -449,10 +464,16 @@ def get_planning_service(
parental_leave_repo,
vacation_repo,
planning_engine_service,
leave_wish_repo=leave_wish_repo,
uow=uow,
workspace_repo=workspace_repo,
day_override_repo=day_override_repo,
# Only needed by `sync_with_wish` (POST /api/plan/sync-with-wish) —
# see that method's docstring.
leave_wish_service=leave_wish_service,
# `pinned_keys` (sync_plan's own pre-write snapshot) and
# `sync_with_wish`'s 'attach'/'pin'/'unpin' day-lock actions — see
# PlanningService's docstring.
day_assertion_repo=day_assertion_repo,
)