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