From 782415122d0c9e1ed05eca490b47462085b1e194 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 21 Sep 2026 07:20:14 +0200 Subject: [PATCH] Separate user intent from planner output via day assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV --- .gitignore | 2 + CLAUDE.md | 4 +- backend/README.md | 7 +- backend/app/db/connection.py | 20 +- backend/app/dependencies.py | 81 +- backend/app/entities/day_assertion.py | 88 + backend/app/entities/planned_day_exclusion.py | 40 - backend/app/entities/planned_leave.py | 19 +- backend/app/main.py | 4 +- ...nned_day_exclusion.py => day_assertion.py} | 23 +- backend/app/models/plan.py | 278 ++- backend/app/models/planned_leave.py | 41 +- .../repositories/day_assertion_repository.py | 79 + .../planned_day_exclusion_repository.py | 28 - .../repositories/planned_leave_repository.py | 20 +- .../sqlite/day_assertion_repository.py | 178 ++ .../planned_day_exclusion_repository.py | 133 - .../sqlite/planned_leave_repository.py | 35 +- backend/app/routers/day_assertion.py | 83 + backend/app/routers/plan.py | 68 +- backend/app/routers/planned_day_exclusions.py | 64 - backend/app/routers/planned_days.py | 120 +- backend/app/services/allocation_service.py | 214 +- backend/app/services/day_assertion_service.py | 216 ++ backend/app/services/day_guards.py | 55 + backend/app/services/leave_wish_service.py | 163 +- .../services/planned_day_exclusion_service.py | 132 - backend/app/services/planning_service.py | 812 +++--- .../migrations/versions/009_day_assertion.py | 197 ++ .../versions/010_drop_pinned_and_exclusion.py | 134 + backend/tests/conftest.py | 80 +- ...st_migration_009_day_assertion_backfill.py | 303 +++ ..._migration_010_downgrade_reconstruction.py | 325 +++ .../tests/integration/test_user_journey.py | 124 +- backend/tests/unit/test_authz.py | 101 +- backend/tests/unit/test_day_assertion.py | 511 ++++ .../tests/unit/test_day_assertion_router.py | 135 + backend/tests/unit/test_db_unit_of_work.py | 3 +- .../tests/unit/test_leave_wish_repository.py | 184 +- backend/tests/unit/test_leave_wish_router.py | 141 ++ backend/tests/unit/test_plan_router.py | 68 +- .../unit/test_plan_sync_with_wish_router.py | 133 + .../tests/unit/test_planned_day_exclusion.py | 367 --- backend/tests/unit/test_planning_service.py | 90 +- .../tests/unit/test_planning_service_apply.py | 18 +- .../unit/test_planning_service_free_days.py | 7 +- .../tests/unit/test_planning_service_sync.py | 785 ++++-- .../test_planning_service_sync_with_wish.py | 212 ++ .../test_allocation_day_assertion.py | 354 +++ .../test_allocation_excluded_days.py | 8 +- .../test_allocation_input_loader.py | 22 +- .../test_services/test_allocation_service.py | 82 +- .../test_services/test_analytics_service.py | 123 + frontend/e2e/global-setup.ts | 4 +- frontend/e2e/support/api.ts | 13 +- frontend/e2e/tests/drag-create-delete.spec.ts | 19 +- frontend/package.json | 4 +- .../components/balance/BalanceFlow.test.tsx | 28 + .../src/components/balance/BalanceFlow.tsx | 1 + .../calendar/PeriodPopover.test.tsx | 161 +- .../src/components/calendar/PeriodPopover.tsx | 640 +++-- .../components/calendar/period-popover.css | 8 +- .../components/common/CalendarDay.test.tsx | 2 +- .../src/components/common/CalendarDay.tsx | 8 +- .../src/components/common/CalendarWeek.tsx | 8 +- .../modals/PlannedDayModal.test.tsx | 2 +- .../src/components/modals/PlannedDayModal.tsx | 9 +- .../src/context/PlanSyncProvider.test.tsx | 63 - frontend/src/context/PlanSyncProvider.tsx | 31 +- frontend/src/hooks/index.ts | 2 +- frontend/src/hooks/useDayAssertions.test.ts | 151 ++ frontend/src/hooks/useDayAssertions.ts | 78 + frontend/src/hooks/usePlanSync.test.ts | 40 +- frontend/src/hooks/usePlanSync.ts | 28 +- .../src/hooks/usePlannedDayExclusions.test.ts | 98 - frontend/src/hooks/usePlannedDayExclusions.ts | 75 - frontend/src/hooks/usePlannedDays.test.ts | 323 +-- frontend/src/hooks/usePlannedDays.ts | 52 +- frontend/src/i18n/en/calendar.ts | 43 +- frontend/src/i18n/en/plan.ts | 3 + frontend/src/i18n/en/settings.ts | 2 +- frontend/src/i18n/sv/calendar.ts | 43 +- frontend/src/i18n/sv/plan.ts | 3 + frontend/src/i18n/sv/settings.ts | 2 +- frontend/src/pages/Calendar.test.tsx | 2212 ++++++++++++++--- frontend/src/pages/Calendar.tsx | 764 ++++-- frontend/src/pages/Onboarding.test.tsx | 20 + frontend/src/pages/Onboarding.tsx | 1 + frontend/src/pages/Settings.test.tsx | 44 +- frontend/src/pages/Settings.tsx | 20 +- frontend/src/pages/calendar.css | 6 +- frontend/src/services/api.test.ts | 210 +- frontend/src/services/api.ts | 259 +- frontend/src/types/api.ts | 84 +- frontend/src/types/entities.ts | 75 +- frontend/src/utils/rejectionMessages.test.ts | 36 + frontend/src/utils/rejectionMessages.ts | 15 + frontend/tests/fixtures/hooks.ts | 18 +- frontend/tests/fixtures/mockData.ts | 11 +- frontend/tests/fixtures/plan.ts | 74 +- ideas.md | 165 +- 101 files changed, 9542 insertions(+), 3930 deletions(-) create mode 100644 backend/app/entities/day_assertion.py delete mode 100644 backend/app/entities/planned_day_exclusion.py rename backend/app/models/{planned_day_exclusion.py => day_assertion.py} (53%) create mode 100644 backend/app/repositories/day_assertion_repository.py delete mode 100644 backend/app/repositories/planned_day_exclusion_repository.py create mode 100644 backend/app/repositories_impl/sqlite/day_assertion_repository.py delete mode 100644 backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py create mode 100644 backend/app/routers/day_assertion.py delete mode 100644 backend/app/routers/planned_day_exclusions.py create mode 100644 backend/app/services/day_assertion_service.py create mode 100644 backend/app/services/day_guards.py delete mode 100644 backend/app/services/planned_day_exclusion_service.py create mode 100644 backend/migrations/versions/009_day_assertion.py create mode 100644 backend/migrations/versions/010_drop_pinned_and_exclusion.py create mode 100644 backend/tests/integration/test_migration_009_day_assertion_backfill.py create mode 100644 backend/tests/integration/test_migration_010_downgrade_reconstruction.py create mode 100644 backend/tests/unit/test_day_assertion.py create mode 100644 backend/tests/unit/test_day_assertion_router.py create mode 100644 backend/tests/unit/test_plan_sync_with_wish_router.py delete mode 100644 backend/tests/unit/test_planned_day_exclusion.py create mode 100644 backend/tests/unit/test_planning_service_sync_with_wish.py create mode 100644 backend/tests/unit/test_services/test_allocation_day_assertion.py create mode 100644 frontend/src/hooks/useDayAssertions.test.ts create mode 100644 frontend/src/hooks/useDayAssertions.ts delete mode 100644 frontend/src/hooks/usePlannedDayExclusions.test.ts delete mode 100644 frontend/src/hooks/usePlannedDayExclusions.ts diff --git a/.gitignore b/.gitignore index dc93b00..f8bc0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__/ *.py[codz] *$py.class +.claude/worktrees/ + graphify-out backend/debug diff --git a/CLAUDE.md b/CLAUDE.md index 1e6f52f..2cedd81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,9 @@ npm run dev # BOTH: FastAPI (:8000) + Vite dev server, concurrently Backend standalone, from `backend/`: ```bash uv sync # deps incl. dev group -uv run uvicorn app.main:app --reload --port 8000 # serve API +# --reload-exclude keeps the dev SQLite file out of the watched tree — see +# backend/README.md for why. +uv run uvicorn app.main:app --reload --reload-exclude '*.db' --reload-exclude '*.db-shm' --reload-exclude '*.db-wal' --reload-exclude '*.db-journal' --port 8000 # serve API ``` Scraper (FK login via Playwright) is **off by default**. Locally: `npm run login` from `frontend/` (sets `APP_SCRAPER_ENABLED=true APP_SCRAPER_HEADLESS=true`). Chromium installs separately: `uv run playwright install chromium`. diff --git a/backend/README.md b/backend/README.md index b529f2b..63fa594 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,7 +12,12 @@ until the remaining Postgres repositories land. ```bash uv sync # install deps (incl. dev group) -uv run uvicorn app.main:app --reload # run dev server on :8000 +# --reload-exclude keeps the dev SQLite file (and its -wal/-shm/-journal +# siblings) out of the watched tree — a write-heavy request otherwise +# self-triggers a reload mid-request and drops the connection, returning a +# 503 *after* the write already committed (see frontend/package.json's +# dev:backend/login:backend for the same flags). +uv run uvicorn app.main:app --reload --reload-exclude '*.db' --reload-exclude '*.db-shm' --reload-exclude '*.db-wal' --reload-exclude '*.db-journal' # run dev server on :8000 uv run pytest # run tests with coverage uv run ruff check . # lint ``` diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index 2b12d63..e2ce40c 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -171,7 +171,6 @@ CREATE TABLE IF NOT EXISTS planned_leave_day ( day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation')), is_dubbeldag INTEGER NOT NULL DEFAULT 0, wish_id TEXT, - is_pinned INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid), @@ -193,21 +192,26 @@ CREATE TABLE IF NOT EXISTS day_override ( FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); --- "The planner may not place leave for THIS parent, for THIS child, on THIS --- date" — see app/entities/planned_day_exclusion.py for why this is its own --- table and not another day_override. -CREATE TABLE IF NOT EXISTS planned_day_exclusion ( +-- One user assertion about one calendar day (restructure step 3 — see +-- app/entities/day_assertion.py). Restructure step 3b dropped +-- planned_leave_day.is_pinned and the planned_day_exclusion table (migration +-- 010): this table is now the sole source for both "pinned" and "excluded". +CREATE TABLE IF NOT EXISTS day_assertion ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, child_id TEXT NOT NULL, parent_personal_data_uuid TEXT NOT NULL, date DATE NOT NULL, + day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation','none')), + is_dubbeldag INTEGER NOT NULL DEFAULT 0, + wish_id TEXT, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, child_id, parent_personal_data_uuid, date), FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, - FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, + FOREIGN KEY (wish_id) REFERENCES leave_wish(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS tip_dismissal ( @@ -255,10 +259,10 @@ CREATE INDEX IF NOT EXISTS idx_invitation_invited_by ON workspace_invitation (invited_by_user_id); CREATE INDEX IF NOT EXISTS idx_vacation_days_workspace ON vacation_days (workspace_id); -CREATE INDEX IF NOT EXISTS idx_planned_day_exclusion_workspace - ON planned_day_exclusion (workspace_id); CREATE INDEX IF NOT EXISTS idx_preschool_planning_days_child ON preschool_planning_days (child_id); +CREATE INDEX IF NOT EXISTS idx_day_assertion_workspace + ON day_assertion (workspace_id); """ diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index a6d8c84..11bdf3a 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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, ) diff --git a/backend/app/entities/day_assertion.py b/backend/app/entities/day_assertion.py new file mode 100644 index 0000000..240c836 --- /dev/null +++ b/backend/app/entities/day_assertion.py @@ -0,0 +1,88 @@ +"""Domain entity: DayAssertion — one user assertion about one calendar day. + +Restructure step 3 (intent vs. derived output — see +`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`). Replaces +THREE previously-conflated concepts on `planned_leave_day`: + +- a **pinned day** (`PlannedLeaveDay.is_pinned=True`) = an assertion with a + concrete `day_type`. +- an **ad-hoc day** (`PlannedLeaveDay.wish_id is None`) = an assertion with no + wish attached. +- a **`PlannedDayExclusion`** row = an assertion with `day_type='none'` + ("planner, never place a day here"). + +One row per (workspace, child, parent, date) — see the natural-key UNIQUE +constraint in the migration/bootstrap DDL. `wish_id` is attachment only (for +calendar band naming/grouping); it does NOT mean the planner owns the row — +ownership is the whole point an assertion asserts against. + +Deliberately NO strength/priority field: an assertion is absolute by nature. +Strength only matters *between wishes* (`LeaveWish.priority` already exists +for that) — adding it here would reintroduce the exact ambiguity this table +exists to remove. + +Step 3a (current): `day_assertion` is additive. `planned_leave_day.is_pinned` +and `planned_day_exclusion` stay in place, unused by anything new, so the +migration is reversible and step 3b can drop them once the frontend moves off +the compatibility shim (see `PlanningService`/`PlannedDayExclusionService`). +""" + +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date, datetime +from uuid import UUID + +# `day_type='none'` is the assertion-shaped equivalent of a `PlannedDayExclusion` +# row: "the planner may not place leave here." Not a real `planned_leave_day` +# type (that CHECK constraint still only allows sjukpenning/lagsta/vacation). +DAY_TYPE_NONE = "none" + + +@dataclass +class DayAssertion: + id: UUID + workspace_id: UUID + child_id: UUID + parent_personal_data_uuid: UUID + date: date + day_type: str # 'sjukpenning' | 'lagsta' | 'vacation' | 'none' + is_dubbeldag: bool = False + # Attachment only — see module docstring. Nullable: an ad-hoc assertion + # (the pre-restructure "wish_id IS NULL" shape) has none. + wish_id: UUID | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +def partition_assertion_keys( + assertions: Iterable[DayAssertion], +) -> tuple[frozenset[tuple[UUID, UUID, date]], frozenset[tuple[UUID, UUID, date]]]: + """Split a batch of assertions into `(pinned_keys, excluded_keys)`. + + Restructure step 3b. Both sets are keyed `(parent_personal_data_uuid, + child_id, date)` — the shape every consumer wants: + `AllocationInputLoader` (via `AllocationInput.pinned_days`/ + `excluded_days`), `PlanningService.sync_plan`'s own pre-write snapshot, + and `PlanningService`'s read-side `is_pinned` annotation on + `PlannedLeaveDayResponse`. A `day_type != 'none'` assertion is "pinned" + (immovable, concrete); `day_type == 'none'` is "excluded" (the planner + may never place a day here). + + This is deliberately the ONE function every reader of "is this day + locked" calls. Step 3a shipped two independent readers of the + equivalent `is_pinned` signal (the allocator's loader, and `sync_plan`'s + own separate `list()` snapshot) that disagreed — a day pinned only via + `DayAssertion` was kept by the allocator but then deleted by + `sync_plan` (see the restructure notes' "Bug found in 3a"). Routing + every reader through this single, pure function is what makes that bug + class structurally impossible rather than merely fixed once. + """ + pinned: set[tuple[UUID, UUID, date]] = set() + excluded: set[tuple[UUID, UUID, date]] = set() + for a in assertions: + key = (a.parent_personal_data_uuid, a.child_id, a.date) + if a.day_type == DAY_TYPE_NONE: + excluded.add(key) + else: + pinned.add(key) + return frozenset(pinned), frozenset(excluded) diff --git a/backend/app/entities/planned_day_exclusion.py b/backend/app/entities/planned_day_exclusion.py deleted file mode 100644 index 32bafab..0000000 --- a/backend/app/entities/planned_day_exclusion.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Domain entity: PlannedDayExclusion. - -"The planner may not place leave for THIS parent, for THIS child, on THIS -date." One row is one user instruction, recorded when the user removes a -single day out of a planner-placed period ("this day" in the calendar's -three-way remove). Without it, the next `POST /api/plan/sync` would simply -re-derive the day from the wish — the wish still says "a full week", and -nothing anywhere said that one date was off the table. - -Deliberately NOT modelled as a `DayOverride`. A `DayOverride` answers a -*calendar* question ("is this date a working day for this workspace?"), is -workspace-wide, and is applied inside `is_free_day`. This answers a -*planning* question ("may a leave day be placed here?"), is per parent and -per child, and is applied where a window is funded. Folding the second into -the first would mean a removed Tuesday silently became a non-working day for -everyone and everything, analytics included. - -Keyed per parent (see `PlannedDayExclusionService`): a both-parents period -places one day per parent on the same date, and removing one parent's day -must not silently remove the other's. The allocator only drops the date from -a window entirely when no responsible parent may take it — see -`_Ledger._excluded_window_dates`. -""" - -from dataclasses import dataclass -from datetime import date, datetime -from uuid import UUID - - -@dataclass -class PlannedDayExclusion: - """One (workspace, child, parent, date) the planner must leave empty.""" - - id: UUID - workspace_id: UUID - child_id: UUID - parent_personal_data_uuid: UUID - date: date - created_at: datetime | None = None - updated_at: datetime | None = None diff --git a/backend/app/entities/planned_leave.py b/backend/app/entities/planned_leave.py index 236d735..e3384da 100644 --- a/backend/app/entities/planned_leave.py +++ b/backend/app/entities/planned_leave.py @@ -7,6 +7,21 @@ from uuid import UUID @dataclass class PlannedLeaveDay: + """A single derived/persisted calendar day of planned leave. + + Restructure step 3b: this row is now purely derived output — "is this + day pinned/immovable" is no longer one of its own columns. That fact + lives in `DayAssertion` (see `app/entities/day_assertion.py`) instead, + keyed by (workspace, child, parent, date); the allocator (`_Ledger`) + consults `AllocationInput.pinned_days` (built from assertions), never a + field on this class. `PlanningService`'s read paths (`get`/`list_*`) + still annotate a display-only `is_pinned` onto the response for the + calendar UI (which needs to render a lock icon) — see + `PlanningService._annotate_pinned` — but that is done explicitly at the + read boundary, not carried by this entity nor by any DB column + (migration 010 drops it). + """ + id: UUID workspace_id: UUID child_id: UUID @@ -18,9 +33,5 @@ class PlannedLeaveDay: # days have none). Lets the UI click a multi-day band on a calendar and edit # it as one period instead of one day at a time. wish_id: UUID | None = None - # True once the user hand-edits this day. A later task makes the allocator - # refuse to retype a pinned day on replan; this field only carries that - # state through the stack so far. - is_pinned: bool = False created_at: datetime | None = None updated_at: datetime | None = None diff --git a/backend/app/main.py b/backend/app/main.py index bf799f2..e36d579 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,12 +23,12 @@ from app.routers import vacation_days from app.routers.analytics import router as analytics_router from app.routers.auth import router as auth_router from app.routers.calendar import router as calendar_router +from app.routers.day_assertion import router as day_assertion_router from app.routers.day_overrides import router as day_overrides_router from app.routers.fetch_data import router as fetch_data_router from app.routers.fetch_data import scraper_router from app.routers.leave_wishes import router as leave_wishes_router from app.routers.plan import router as plan_router -from app.routers.planned_day_exclusions import router as planned_day_exclusions_router from app.routers.planned_days import router as planned_days_router from app.routers.preschool_planning_days import router as preschool_planning_days_router from app.routers.settings import child_router, settings_child_router, tips_router @@ -128,7 +128,7 @@ def _register_routers(app: FastAPI) -> None: app.include_router(child_router) app.include_router(settings_child_router) app.include_router(planned_days_router) - app.include_router(planned_day_exclusions_router) + app.include_router(day_assertion_router) app.include_router(vacation_days.router) app.include_router(preschool_planning_days_router) app.include_router(day_overrides_router) diff --git a/backend/app/models/planned_day_exclusion.py b/backend/app/models/day_assertion.py similarity index 53% rename from backend/app/models/planned_day_exclusion.py rename to backend/app/models/day_assertion.py index 033bcfb..fb10bd8 100644 --- a/backend/app/models/planned_day_exclusion.py +++ b/backend/app/models/day_assertion.py @@ -1,4 +1,4 @@ -"""API schemas (Pydantic) for PlannedDayExclusion. +"""API schemas (Pydantic) for DayAssertion. Schemas are the API boundary — they validate request bodies and shape responses, kept separate from the domain entity so the wire format can @@ -8,35 +8,46 @@ evolve independently. from datetime import date, datetime from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field -from app.entities.planned_day_exclusion import PlannedDayExclusion +from app.entities.day_assertion import DayAssertion -class PlannedDayExclusionCreate(BaseModel): +class DayAssertionCreate(BaseModel): workspace_id: UUID child_id: UUID parent_personal_data_uuid: UUID date: date + day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|none)$") + is_dubbeldag: bool = False + # Attachment only (band naming) — None means an ad-hoc assertion. See + # `app.entities.day_assertion.DayAssertion`. + wish_id: UUID | None = None -class PlannedDayExclusionResponse(BaseModel): +class DayAssertionResponse(BaseModel): id: UUID workspace_id: UUID child_id: UUID parent_personal_data_uuid: UUID date: date + day_type: str + is_dubbeldag: bool + wish_id: UUID | None created_at: datetime | None updated_at: datetime | None @classmethod - def from_entity(cls, entity: PlannedDayExclusion) -> "PlannedDayExclusionResponse": + def from_entity(cls, entity: DayAssertion) -> "DayAssertionResponse": return cls( id=entity.id, workspace_id=entity.workspace_id, child_id=entity.child_id, parent_personal_data_uuid=entity.parent_personal_data_uuid, date=entity.date, + day_type=entity.day_type, + is_dubbeldag=entity.is_dubbeldag, + wish_id=entity.wish_id, created_at=entity.created_at, updated_at=entity.updated_at, ) diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py index a15e496..52f065e 100644 --- a/backend/app/models/plan.py +++ b/backend/app/models/plan.py @@ -14,28 +14,24 @@ from datetime import date from typing import Literal from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator +from app.models.leave_wish import LeaveWishResponse, LeaveWishUpdate from app.models.planned_leave import PlannedLeaveDayResponse from app.services.allocation_service import PlanProposal from app.services.planning_service import ( DayCollision, - EvictionCandidate, - EvictionPreview, + DayLockAction, PlanApplyResult, PlanChange, PlanSyncResult, ProposedDayInput, Rejection, - ResourceShortage, + WishCreateInput, + WishSyncResult, + WishUpdateInput, ) -# Wire-level mirror of `PlanningService`'s `EVICTION_CHOICE_*` constants — -# kept as a `Literal` here (rather than importing the string constants) so -# an invalid value 422s at the Pydantic boundary instead of silently -# reaching the service as a no-op "do nothing". -EvictionChoice = Literal["drop_future_pl", "drop_latest_vacation_this_year"] - class PlanProposeRequest(BaseModel): workspace_id: UUID @@ -98,6 +94,11 @@ class WindowOutcomeResponse(BaseModel): # — spendable days the flexible placement search saved versus the naive # bound-start placement. 0 for a fixed window or a reused flexible one. shift_gain_days: int = 0 + # Mirrors `WindowOutcome.series_id` — see its docstring. Lets a caller + # that only knows ONE occurrence's wish id (e.g. `sync_with_wish`'s + # returned `wish.id`) find every OTHER outcome in this same batch that + # belongs to the same repeating wish. + series_id: UUID | None = None class PlanWarningResponse(BaseModel): @@ -197,6 +198,7 @@ class PlanProposalResponse(BaseModel): for r in o.remedies ], shift_gain_days=o.shift_gain_days, + series_id=o.series_id, ) for o in proposal.outcomes ], @@ -275,7 +277,6 @@ class ProposedDayApplyItem(BaseModel): day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$") is_dubbeldag: bool = False wish_id: UUID | None = None - is_pinned: bool = False def to_domain(self) -> ProposedDayInput: return ProposedDayInput( @@ -285,7 +286,6 @@ class ProposedDayApplyItem(BaseModel): day_type=self.day_type, is_dubbeldag=self.is_dubbeldag, wish_id=self.wish_id, - is_pinned=self.is_pinned, ) @@ -362,15 +362,6 @@ class PlanApplyResponse(BaseModel): class PlanSyncRequest(BaseModel): workspace_id: UUID - # Which of the three user-facing options resolves a would-evict save - # (§ eviction preview). Omitted/`None` — the default — means "do - # nothing": the save is refused and nothing is mutated, exactly as if - # the user picked the explicit cancel option. Only meaningful when the - # response's `eviction_preview.would_evict` was `True` on a prior call - # with the same edit already persisted upstream (e.g. via the leave-wish - # endpoints) — a plain sync that fits within budget ignores this field - # entirely (never asked for a choice in the first place). - eviction_choice: EvictionChoice | None = None class PlanChangeResponse(BaseModel): @@ -391,6 +382,12 @@ class PlanChangeResponse(BaseModel): new_is_dubbeldag: bool | None old_wish_id: UUID | None new_wish_id: UUID | None + # Only ever `True` on a `kind == 'removed'` change — see `PlanChange. + # evicted`'s docstring: distinguishes "this wish's own edit shed the + # row" (evicted=False, the ordinary case) from "a different wish's edit + # won a shared pool ahead of it" (evicted=True) so the UI can call out + # displacement distinctly instead of folding it into a silent count. + evicted: bool @classmethod def from_domain(cls, change: PlanChange) -> "PlanChangeResponse": @@ -405,84 +402,7 @@ class PlanChangeResponse(BaseModel): new_is_dubbeldag=change.new_is_dubbeldag, old_wish_id=change.old_wish_id, new_wish_id=change.new_wish_id, - ) - - -class EvictionCandidateResponse(BaseModel): - """One previously-persisted day a fresh sync would otherwise silently - - delete — see `EvictionCandidate`'s docstring. `wish_name` is `None` when - the owning wish's name couldn't be resolved (never a reason to hide the - day itself). - """ - - date: date - parent_personal_data_uuid: UUID - child_id: UUID - day_type: str - is_dubbeldag: bool - wish_id: UUID - wish_name: str | None - - @classmethod - def from_domain(cls, candidate: EvictionCandidate) -> "EvictionCandidateResponse": - return cls( - date=candidate.date, - parent_personal_data_uuid=candidate.parent_personal_data_uuid, - child_id=candidate.child_id, - day_type=candidate.day_type, - is_dubbeldag=candidate.is_dubbeldag, - wish_id=candidate.wish_id, - wish_name=candidate.wish_name, - ) - - -class ResourceShortageResponse(BaseModel): - """One exhausted resource behind an eviction preview — see - - `ResourceShortage`'s docstring. `resource_kind == 'parental_leave'` - carries `tier`/`child_id` (`year` is `None`); `resource_kind == - 'vacation'` carries `year` instead (`tier`/`child_id` are `None`). - Structured on purpose — the frontend composes the warning text itself, - never a backend-rendered string. - """ - - resource_kind: str - parent_personal_data_uuid: UUID - tier: str | None - child_id: UUID | None - year: int | None - days_evicted: int - - @classmethod - def from_domain(cls, shortage: ResourceShortage) -> "ResourceShortageResponse": - return cls( - resource_kind=shortage.resource_kind, - parent_personal_data_uuid=shortage.parent_personal_data_uuid, - tier=shortage.tier, - child_id=shortage.child_id, - year=shortage.year, - days_evicted=shortage.days_evicted, - ) - - -class EvictionPreviewResponse(BaseModel): - """Always present on `PlanSyncResponse`; `would_evict: false` with empty - - lists in the common (fits-within-budget) case — see - `EvictionPreview`'s docstring. - """ - - would_evict: bool - candidates: list[EvictionCandidateResponse] - shortages: list[ResourceShortageResponse] - - @classmethod - def from_domain(cls, preview: EvictionPreview) -> "EvictionPreviewResponse": - return cls( - would_evict=preview.would_evict, - candidates=[EvictionCandidateResponse.from_domain(c) for c in preview.candidates], - shortages=[ResourceShortageResponse.from_domain(s) for s in preview.shortages], + evicted=change.evicted, ) @@ -490,7 +410,6 @@ class PlanSyncResponse(BaseModel): proposal: PlanProposalResponse apply_result: PlanApplyResponse changes: list[PlanChangeResponse] - eviction_preview: EvictionPreviewResponse @classmethod def from_domain(cls, result: PlanSyncResult) -> "PlanSyncResponse": @@ -498,5 +417,162 @@ class PlanSyncResponse(BaseModel): proposal=PlanProposalResponse.from_domain(result.proposal), apply_result=PlanApplyResponse.from_domain(result.apply_result), changes=[PlanChangeResponse.from_domain(c) for c in result.changes], - eviction_preview=EvictionPreviewResponse.from_domain(result.eviction_preview), + ) + + +# --------------------------------------------------------------------------- +# POST /api/plan/sync-with-wish — the calendar's four create/edit-then-sync +# paths, collapsed into one atomic backend call. See +# `PlanningService.sync_with_wish`'s docstring for the atomicity guarantee. +# --------------------------------------------------------------------------- + + +class WishCreatePayload(BaseModel): + """Fields for the wish behind a drawn/promoted/preset period. + + Field-for-field identical to `LeaveWishCreate` minus `workspace_id` + (carried once, on `PlanSyncWithWishRequest` itself). + """ + + child_id: UUID | None = None + name: str = Field(min_length=1, max_length=255) + name_message_key: str | None = Field(default=None, max_length=255) + + window_start_month: int = Field(ge=1, le=12) + window_start_day: int = Field(ge=1, le=31) + window_end_month: int = Field(ge=1, le=12) + window_end_day: int = Field(ge=1, le=31) + + target_weeks: int = Field(ge=1, le=52) + target_days: int | None = Field(default=None, ge=1) + recurrence: str = "once" + first_year: int = Field(ge=2000, le=2200) + last_year: int | None = Field(default=None, ge=2000, le=2200) + + priority: int = Field(default=1, ge=1) + parent_mode: str = "either" + parent_personal_data_uuid: UUID | None = None + + window_mode: str = "fixed" + series_id: UUID | None = None + wants_dubbeldag: bool = False + + def to_domain(self) -> WishCreateInput: + return WishCreateInput( + child_id=self.child_id, + name=self.name, + name_message_key=self.name_message_key, + window_start_month=self.window_start_month, + window_start_day=self.window_start_day, + window_end_month=self.window_end_month, + window_end_day=self.window_end_day, + target_weeks=self.target_weeks, + target_days=self.target_days, + recurrence=self.recurrence, + first_year=self.first_year, + last_year=self.last_year, + priority=self.priority, + parent_mode=self.parent_mode, + parent_personal_data_uuid=self.parent_personal_data_uuid, + window_mode=self.window_mode, + series_id=self.series_id, + wants_dubbeldag=self.wants_dubbeldag, + ) + + +class WishUpdatePayload(BaseModel): + """One wish's occurrence/series edit, addressed by id — reuses + `LeaveWishUpdate` verbatim for `patch` (it already has no `workspace_id` + field, so it needs no stripped-down mirror the way `WishCreatePayload` + does). + """ + + leave_wish_id: UUID + scope: Literal["occurrence", "series"] = "occurrence" + patch: LeaveWishUpdate + + def to_domain(self) -> WishUpdateInput: + fields_set = self.patch.model_fields_set + p = self.patch + return WishUpdateInput( + leave_wish_id=self.leave_wish_id, + scope=self.scope, + child_id=p.child_id, + name=p.name, + name_message_key=p.name_message_key, + window_start_month=p.window_start_month, + window_start_day=p.window_start_day, + window_end_month=p.window_end_month, + window_end_day=p.window_end_day, + target_weeks=p.target_weeks, + target_days=p.target_days, + recurrence=p.recurrence, + first_year=p.first_year, + last_year=p.last_year, + priority=p.priority, + parent_mode=p.parent_mode, + parent_personal_data_uuid=p.parent_personal_data_uuid, + window_mode=p.window_mode, + wants_dubbeldag=p.wants_dubbeldag, + clear_child_id="child_id" in fields_set and p.child_id is None, + clear_last_year="last_year" in fields_set and p.last_year is None, + clear_parent_personal_data_uuid=( + "parent_personal_data_uuid" in fields_set and p.parent_personal_data_uuid is None + ), + clear_name_message_key=( + "name_message_key" in fields_set and p.name_message_key is None + ), + clear_target_days="target_days" in fields_set and p.target_days is None, + ) + + +DayLockActionKind = Literal["attach", "pin", "unpin", "detach"] + + +class DayLockActionRequest(BaseModel): + """One already-persisted `planned_leave_day` row to re-point/pin/delete + + as part of the same atomic call — see `DayLockAction`'s docstring + (app/services/planning_service.py) for what each `action` does. + """ + + planned_day_id: UUID + action: DayLockActionKind + + def to_domain(self) -> DayLockAction: + return DayLockAction(planned_day_id=self.planned_day_id, action=self.action) + + +class PlanSyncWithWishRequest(BaseModel): + workspace_id: UUID + # Exactly one of these two — this call always touches exactly one wish. + wish_create: WishCreatePayload | None = None + wish_update: WishUpdatePayload | None = None + day_actions: list[DayLockActionRequest] = Field(default_factory=list) + + @model_validator(mode="after") + def _exactly_one_wish_op(self) -> "PlanSyncWithWishRequest": + if (self.wish_create is None) == (self.wish_update is None): + raise ValueError("exactly one of wish_create/wish_update must be provided") + return self + + +class PlanSyncWithWishResponse(BaseModel): + """`wish` reflects the fields the caller asked for even when + + `sync.apply_result.accepted` is `False` — see `WishSyncResult`'s + docstring: in that case nothing was actually persisted (the whole point + of the atomic call), but the frontend still needs the would-be id/fields + to render the outcome (e.g. matching `sync.proposal.outcomes` by + `wish_id`). + """ + + wish: LeaveWishResponse + sync: PlanSyncResponse + + @classmethod + def from_domain(cls, result: WishSyncResult) -> "PlanSyncWithWishResponse": + return cls( + wish=LeaveWishResponse.from_entity(result.wish), + sync=PlanSyncResponse.from_domain(result.sync), ) diff --git a/backend/app/models/planned_leave.py b/backend/app/models/planned_leave.py index 7a8fa6c..84e93c7 100644 --- a/backend/app/models/planned_leave.py +++ b/backend/app/models/planned_leave.py @@ -7,33 +7,24 @@ so the wire format can evolve independently. from datetime import date, datetime from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel from app.entities.planned_leave import PlannedLeaveDay -class PlannedLeaveDayCreate(BaseModel): - workspace_id: UUID - child_id: UUID - parent_personal_data_uuid: UUID - date: date - day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation)$") - is_dubbeldag: bool = False - # Which `LeaveWish` produced this day; None = ad-hoc. - wish_id: UUID | None = None - is_pinned: bool = False - - -class PlannedLeaveDayUpdate(BaseModel): - """Partial update — all fields optional.""" - - day_type: str | None = Field(None, pattern="^(sjukpenning|lagsta|vacation)$") - is_dubbeldag: bool | None = None - wish_id: UUID | None = None - is_pinned: bool | None = None - - class PlannedLeaveDayResponse(BaseModel): + """Read-only wire shape. `planned_leave_day` is purely derived output + + now (restructure step 3b) — there is no `PlannedLeaveDayCreate`/`Update` + model any more; every write goes through `POST /api/plan/sync(-with- + wish)` or `POST /api/day-assertions`. `is_pinned` is NOT a column on the + entity (migration 010 dropped it) — it is computed live from + `DayAssertion` and passed in explicitly by the caller (see + `PlanningService._annotate_pinned`), so a lock icon in the calendar UI + keeps working unchanged even though nothing on `planned_leave_day` + itself carries that state any more. + """ + id: UUID workspace_id: UUID child_id: UUID @@ -47,7 +38,9 @@ class PlannedLeaveDayResponse(BaseModel): updated_at: datetime | None @classmethod - def from_entity(cls, entity: PlannedLeaveDay) -> "PlannedLeaveDayResponse": + def from_entity( + cls, entity: PlannedLeaveDay, is_pinned: bool = False + ) -> "PlannedLeaveDayResponse": return cls( id=entity.id, workspace_id=entity.workspace_id, @@ -57,7 +50,7 @@ class PlannedLeaveDayResponse(BaseModel): day_type=entity.day_type, is_dubbeldag=entity.is_dubbeldag, wish_id=entity.wish_id, - is_pinned=entity.is_pinned, + is_pinned=is_pinned, created_at=entity.created_at, updated_at=entity.updated_at, ) diff --git a/backend/app/repositories/day_assertion_repository.py b/backend/app/repositories/day_assertion_repository.py new file mode 100644 index 0000000..abcd4a3 --- /dev/null +++ b/backend/app/repositories/day_assertion_repository.py @@ -0,0 +1,79 @@ +"""Abstract repository interface for DayAssertion. + +Adds entity-specific queries on top of the generic CRUD contract. Concrete +implementations live in app/repositories_impl//. Mirrors +`PlannedDayExclusionRepository`'s shape (natural-key lookup + workspace list), +since a `DayAssertion` supersedes that entity's role for the allocator (see +`app/entities/day_assertion.py`). +""" + +from abc import abstractmethod +from datetime import date +from uuid import UUID + +from app.entities.day_assertion import DayAssertion +from app.repositories.base import Repository + + +class DayAssertionRepository(Repository[DayAssertion]): + @abstractmethod + async def get_by_natural_key( + self, + workspace_id: UUID, + child_id: UUID, + parent_personal_data_uuid: UUID, + day: date, + ) -> DayAssertion | None: + """Return the assertion for a given (workspace, child, parent, date) (UNIQUE), or None.""" + + @abstractmethod + async def list_by_workspace(self, workspace_id: UUID) -> list[DayAssertion]: + """Return every assertion for a workspace, ordered by date. + + This is what `AllocationInputLoader` reads wholesale once per + `load()` call — see its docstring for how the result is folded into + `AllocationInput`. + """ + + @abstractmethod + async def list_by_wish(self, wish_id: UUID) -> list[DayAssertion]: + """Return every assertion attached to this wish (`wish_id` on the + + assertion itself — NOT derived by walking `planned_leave_day` rows). + Adversarial-review fix (restructure step 3b): `LeaveWishService. + _clear_assertions_for` used to only clear an assertion at each + SURVIVING `planned_leave_day` row's natural key, so an assertion + whose mirrored row had already gone missing (e.g. `DayAssertionService` + crashing between its own assertion write and its mirror write, before + that method's `uow` fix) was invisible to it and outlived the wish as + a dangling `wish_id=NULL` ad-hoc assertion (the FK is `ON DELETE SET + NULL`), permanently locking that date with no UI path to find it. + Querying by `wish_id` directly closes that hole regardless of + whether a mirrored row exists. + """ + + @abstractmethod + async def delete_by_natural_key( + self, + workspace_id: UUID, + child_id: UUID, + parent_personal_data_uuid: UUID, + day: date, + ) -> None: + """Delete the assertion at this key, if any. A no-op when none exists. + + Idempotent by design (unlike `delete(id)`, which raises + `NotFoundError`) — callers clearing a slot rarely know whether an + assertion exists there, and shouldn't have to check first. + """ + + @abstractmethod + async def upsert(self, item: DayAssertion) -> DayAssertion: + """Create-or-replace the assertion at `item`'s natural key. + + The single write primitive every dual-write call site (`PlanningService`, + `PlannedDayExclusionService`) uses — a caller never needs to know + whether a row already exists at this (workspace, child, parent, date) + before writing the current fact. `item.id` is ignored on an update + (the existing row's id is kept) and used as-is on a fresh insert. + """ diff --git a/backend/app/repositories/planned_day_exclusion_repository.py b/backend/app/repositories/planned_day_exclusion_repository.py deleted file mode 100644 index 0d72629..0000000 --- a/backend/app/repositories/planned_day_exclusion_repository.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Abstract repository interface for PlannedDayExclusion. - -Adds entity-specific queries on top of the generic CRUD contract. Concrete -implementations live in app/repositories_impl//. -""" - -from abc import abstractmethod -from datetime import date -from uuid import UUID - -from app.entities.planned_day_exclusion import PlannedDayExclusion -from app.repositories.base import Repository - - -class PlannedDayExclusionRepository(Repository[PlannedDayExclusion]): - @abstractmethod - async def get_by_natural_key( - self, - workspace_id: UUID, - child_id: UUID, - parent_personal_data_uuid: UUID, - day: date, - ) -> PlannedDayExclusion | None: - """Return the entry for a given (workspace, child, parent, date) (UNIQUE), or None.""" - - @abstractmethod - async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedDayExclusion]: - """Return all exclusions for a workspace, ordered by date.""" diff --git a/backend/app/repositories/planned_leave_repository.py b/backend/app/repositories/planned_leave_repository.py index ecbd963..b726fa6 100644 --- a/backend/app/repositories/planned_leave_repository.py +++ b/backend/app/repositories/planned_leave_repository.py @@ -34,6 +34,16 @@ class PlannedLeaveRepository(Repository[PlannedLeaveDay]): async def count_dubbeldagar(self, child_id: UUID, from_date: date | None = None) -> int: """Count dubbeldagar already planned for a child (after from_date).""" + @abstractmethod + async def list_by_wish(self, wish_id: UUID) -> list[PlannedLeaveDay]: + """Every row currently attributed to this wish. + + `LeaveWishService.delete()` reads this BEFORE `delete_by_wish` to + learn each row's (workspace, child, parent, date) key, so it can + clear any `DayAssertion` sitting at that key too — once the rows + are gone there is nothing left to look their keys up from. + """ + @abstractmethod async def delete_by_wish(self, wish_id: UUID) -> int: """Delete every row attributed to this wish (pinned or not) and return the count deleted. @@ -65,11 +75,13 @@ class PlannedLeaveRepository(Repository[PlannedLeaveDay]): """ @abstractmethod - async def update_metadata_many(self, pairs: list[tuple[UUID, UUID | None, bool]]) -> None: - """Bulk-update `(wish_id, is_pinned)` for existing rows via `executemany`. + async def update_metadata_many(self, pairs: list[tuple[UUID, UUID | None]]) -> None: + """Bulk-update `wish_id` for existing rows via `executemany`. - Each pair is `(id, wish_id, is_pinned)`. Does not touch `day_type`/ + Each pair is `(id, wish_id)`. Does not touch `day_type`/ `is_dubbeldag` — this is for the metadata-only path in `apply_proposed_days` (a row whose type is unchanged but whose - `wish_id`/`is_pinned` differ from the incoming batch). + `wish_id` differs from the incoming batch). `is_pinned` is not a + column on this entity any more (restructure step 3b, migration + 010) — "pinned" is a `DayAssertion` fact now. """ diff --git a/backend/app/repositories_impl/sqlite/day_assertion_repository.py b/backend/app/repositories_impl/sqlite/day_assertion_repository.py new file mode 100644 index 0000000..db15492 --- /dev/null +++ b/backend/app/repositories_impl/sqlite/day_assertion_repository.py @@ -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) diff --git a/backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py b/backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py deleted file mode 100644 index b4440c7..0000000 --- a/backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py +++ /dev/null @@ -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) diff --git a/backend/app/repositories_impl/sqlite/planned_leave_repository.py b/backend/app/repositories_impl/sqlite/planned_leave_repository.py index 2502199..a43c61a 100644 --- a/backend/app/repositories_impl/sqlite/planned_leave_repository.py +++ b/backend/app/repositories_impl/sqlite/planned_leave_repository.py @@ -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, diff --git a/backend/app/routers/day_assertion.py b/backend/app/routers/day_assertion.py new file mode 100644 index 0000000..9b194ca --- /dev/null +++ b/backend/app/routers/day_assertion.py @@ -0,0 +1,83 @@ +"""HTTP routes for day assertions — restructure step 3b. + +Replaces the write side of the legacy `/api/planned-days` (hand-pin/ad-hoc) +and `/api/planned-day-exclusions` endpoints (both deleted this step) with one +endpoint expressing all three: a pin (`day_type` concrete), an ad-hoc day +(`wish_id` omitted), and an exclusion (`day_type='none'`). Routers stay +thin: validate via Pydantic, call the service, map domain errors to HTTP. +""" + +from datetime import date +from uuid import UUID + +from fastapi import APIRouter, HTTPException, status + +from app.dependencies import ( + DayAssertionServiceDep, + WorkspaceIdDep, + WorkspaceScopeDep, +) +from app.models.day_assertion import DayAssertionCreate, DayAssertionResponse +from app.utils.errors import ValidationError + +router = APIRouter(prefix="/api/day-assertions", tags=["day-assertions"]) + + +@router.post("", response_model=DayAssertionResponse, status_code=status.HTTP_201_CREATED) +async def assert_day( + payload: DayAssertionCreate, + service: DayAssertionServiceDep, + scope: WorkspaceScopeDep, +) -> DayAssertionResponse: + scope.check_workspace(payload.workspace_id) + await scope.check_child(payload.child_id) + await scope.check_member(payload.parent_personal_data_uuid) + try: + entity = await service.assert_day( + workspace_id=payload.workspace_id, + child_id=payload.child_id, + parent_personal_data_uuid=payload.parent_personal_data_uuid, + day=payload.date, + day_type=payload.day_type, + is_dubbeldag=payload.is_dubbeldag, + wish_id=payload.wish_id, + ) + except ValidationError as exc: + if exc.message_key: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"message_key": exc.message_key, "data": exc.data}, + ) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + return DayAssertionResponse.from_entity(entity) + + +@router.get("/workspace/{workspace_id}", response_model=list[DayAssertionResponse]) +async def list_day_assertions( + workspace_id: WorkspaceIdDep, service: DayAssertionServiceDep +) -> list[DayAssertionResponse]: + entities = await service.list_for_workspace(workspace_id) + return [DayAssertionResponse.from_entity(e) for e in entities] + + +@router.delete("", status_code=status.HTTP_204_NO_CONTENT) +async def clear_day_assertion( + service: DayAssertionServiceDep, + scope: WorkspaceScopeDep, + workspace_id: UUID, + child_id: UUID, + parent_personal_data_uuid: UUID, + date: date, +) -> None: + """Retract the assertion at this (workspace, child, parent, date), if + + any — idempotent, matching `DayAssertionService.clear`. By natural key + rather than by id: every caller (un-pinning a hand-added day, deleting + an ad-hoc band, restoring an exclusion) already knows the key from + context and would otherwise have to look the assertion up first just to + delete it. + """ + scope.check_workspace(workspace_id) + await scope.check_child(child_id) + await scope.check_member(parent_personal_data_uuid) + await service.clear(workspace_id, child_id, parent_personal_data_uuid, date) diff --git a/backend/app/routers/plan.py b/backend/app/routers/plan.py index 7172788..7f13faf 100644 --- a/backend/app/routers/plan.py +++ b/backend/app/routers/plan.py @@ -17,6 +17,8 @@ from app.models.plan import ( PlanProposeRequest, PlanSyncRequest, PlanSyncResponse, + PlanSyncWithWishRequest, + PlanSyncWithWishResponse, ) from app.utils.errors import NotFoundError, ValidationError @@ -84,22 +86,66 @@ async def sync_plan( can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of silently rewriting the calendar. - Doubles as its own eviction preview: when the re-derived plan would - silently delete another wish's days (a shared FP/vacation pool ran out - for that wish's parent), this call writes NOTHING and returns - `eviction_preview` describing what ran out and which days would go — - unless `eviction_choice` names one of the two caller-authorized - eviction strategies, in which case exactly that category is freed and - the edit is applied. No separate round trip for the common (fits- - within-budget) case; see `PlanningService.sync_plan`'s docstring. + When the re-derived plan would displace another wish's already-placed + days (a shared FP/vacation pool ran out for that wish's parent), this + call still writes — displacement is a normal, informational outcome, not + a refusal. The displaced rows come back as 'removed' entries in + `changes` with `evicted: true` (see `PlanChange`/`PlanChangeResponse`), + so the UI can call them out distinctly instead of silently rewriting the + calendar. See `PlanningService.sync_plan`'s docstring. """ scope.check_workspace(payload.workspace_id) try: - result = await service.sync_plan( - payload.workspace_id, eviction_choice=payload.eviction_choice - ) + result = await service.sync_plan(payload.workspace_id) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc except ValidationError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc return PlanSyncResponse.from_domain(result) + + +@router.post("/sync-with-wish", response_model=PlanSyncWithWishResponse) +async def sync_plan_with_wish( + payload: PlanSyncWithWishRequest, service: PlanningServiceDep, scope: WorkspaceScopeDep +) -> PlanSyncWithWishResponse: + """One atomic call: create-or-update the `LeaveWish` behind a calendar + + period, apply the day-lock/detach list, and sync — all in one unit of + work (`PlanningService.sync_with_wish`). Replaces the client-orchestrated + "create/patch the wish, then re-point or delete the affected planned + days, then sync" 3-phase sequence (and the compensating rollback it + needed) that used to back the calendar's promote/draw/edit/preset paths: + a rejected sync now rolls back the wish write and every day-lock action + with it, server-side, so the frontend never needs to undo a half-applied + edit by hand. + + Same "always 200, `accepted: false` is a structured outcome" contract as + `POST /api/plan/sync` — see that endpoint's docstring. The one case still + raised as an HTTP error is a genuinely missing/foreign workspace, child, + parent, wish, or `planned_day_id` (404), or invalid wish fields (422). + """ + scope.check_workspace(payload.workspace_id) + if payload.wish_create is not None: + if payload.wish_create.child_id is not None: + await scope.check_child(payload.wish_create.child_id) + if payload.wish_create.parent_personal_data_uuid is not None: + await scope.check_member(payload.wish_create.parent_personal_data_uuid) + if payload.wish_update is not None: + patch = payload.wish_update.patch + if patch.child_id is not None: + await scope.check_child(patch.child_id) + if patch.parent_personal_data_uuid is not None: + await scope.check_member(patch.parent_personal_data_uuid) + + try: + result = await service.sync_with_wish( + workspace_id=payload.workspace_id, + wish_create=payload.wish_create.to_domain() if payload.wish_create else None, + wish_update=payload.wish_update.to_domain() if payload.wish_update else None, + day_actions=[a.to_domain() for a in payload.day_actions], + ) + except NotFoundError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc + except ValidationError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + return PlanSyncWithWishResponse.from_domain(result) diff --git a/backend/app/routers/planned_day_exclusions.py b/backend/app/routers/planned_day_exclusions.py deleted file mode 100644 index 67b0e7c..0000000 --- a/backend/app/routers/planned_day_exclusions.py +++ /dev/null @@ -1,64 +0,0 @@ -"""HTTP routes for planned-day exclusions. - -Routers stay thin: validate via Pydantic, call the service, map domain -errors to HTTP. -""" - -from uuid import UUID - -from fastapi import APIRouter, HTTPException, status - -from app.dependencies import ( - PlannedDayExclusionServiceDep, - WorkspaceIdDep, - WorkspaceScopeDep, -) -from app.models.planned_day_exclusion import ( - PlannedDayExclusionCreate, - PlannedDayExclusionResponse, -) -from app.utils.errors import NotFoundError, ValidationError - -router = APIRouter(prefix="/api/planned-day-exclusions", tags=["planned-day-exclusions"]) - - -@router.post("", response_model=PlannedDayExclusionResponse, status_code=status.HTTP_201_CREATED) -async def create_planned_day_exclusion( - payload: PlannedDayExclusionCreate, - service: PlannedDayExclusionServiceDep, - scope: WorkspaceScopeDep, -) -> PlannedDayExclusionResponse: - scope.check_workspace(payload.workspace_id) - await scope.check_child(payload.child_id) - await scope.check_member(payload.parent_personal_data_uuid) - try: - entity = await service.exclude( - workspace_id=payload.workspace_id, - child_id=payload.child_id, - parent_personal_data_uuid=payload.parent_personal_data_uuid, - day=payload.date, - ) - except ValidationError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc - return PlannedDayExclusionResponse.from_entity(entity) - - -@router.get("/workspace/{workspace_id}", response_model=list[PlannedDayExclusionResponse]) -async def list_planned_day_exclusions( - workspace_id: WorkspaceIdDep, service: PlannedDayExclusionServiceDep -) -> list[PlannedDayExclusionResponse]: - entities = await service.list_for_workspace(workspace_id) - return [PlannedDayExclusionResponse.from_entity(e) for e in entities] - - -@router.delete("/{exclusion_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_planned_day_exclusion( - exclusion_id: UUID, - service: PlannedDayExclusionServiceDep, - scope: WorkspaceScopeDep, -) -> None: - """Un-exclude the day: the planner may place leave here again.""" - try: - await service.delete(exclusion_id, workspace_id=scope.workspace_id) - except NotFoundError as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc diff --git a/backend/app/routers/planned_days.py b/backend/app/routers/planned_days.py index 8a8502a..472df80 100644 --- a/backend/app/routers/planned_days.py +++ b/backend/app/routers/planned_days.py @@ -1,64 +1,44 @@ -"""HTTP routes for planned leave days (calendar planning).""" +"""HTTP routes for planned leave days (calendar planning) — READ ONLY. + +Restructure step 3b: `planned_leave_day` is purely derived output now. The +write endpoints that used to live here (`POST`/`PUT`/`DELETE`) are gone — +every write goes through `POST /api/plan/sync(-with-wish)` (the allocator) +or `POST`/`DELETE /api/day-assertions` (a hand-pin, an ad-hoc day, or an +exclusion). Routers stay thin: validate via Pydantic, call the service, map +domain errors to HTTP. +""" from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status from app.dependencies import PlanningServiceDep, WorkspaceIdDep, WorkspaceScopeDep -from app.models.planned_leave import ( - PlannedLeaveDayCreate, - PlannedLeaveDayResponse, - PlannedLeaveDayUpdate, -) -from app.utils.errors import NotFoundError, ValidationError +from app.entities.planned_leave import PlannedLeaveDay +from app.models.planned_leave import PlannedLeaveDayResponse +from app.services.planning_service import PlanningService +from app.utils.errors import NotFoundError router = APIRouter(prefix="/api/planned-days", tags=["planned-days"]) -def _to_response(day) -> PlannedLeaveDayResponse: - return PlannedLeaveDayResponse.from_entity(day) +async def _to_responses( + service: PlanningService, days: list[PlannedLeaveDay] +) -> list[PlannedLeaveDayResponse]: + """Annotate a batch of rows with the live `is_pinned` signal in one shot - -def _to_http_error(exc: ValidationError) -> HTTPException: - """Map a `ValidationError` to a 422, forwarding its `message_key` + `data` - - (§3.5) as structured `detail` when present — e.g. the free-day hand-pin - guard (`PlanningService._reject_illegal_free_day`) — so the frontend can - translate it instead of parsing an English sentence. Falls back to the - plain message for the (more common) error raised without one. Mirrors - `app.routers.workspace._to_http_error`. + (one `pinned_keys` read per workspace, not one per row) — see + `PlanningService.pinned_keys`. """ - if exc.message_key: - return HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={"message_key": exc.message_key, "data": exc.data}, + workspace_ids = {d.workspace_id for d in days} + pinned: set[tuple[UUID, UUID, object]] = set() + for ws_id in workspace_ids: + pinned |= await service.pinned_keys(ws_id) + return [ + PlannedLeaveDayResponse.from_entity( + d, is_pinned=(d.parent_personal_data_uuid, d.child_id, d.date) in pinned ) - return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) - - -@router.post("", response_model=PlannedLeaveDayResponse, status_code=status.HTTP_201_CREATED) -async def create_planned_day( - payload: PlannedLeaveDayCreate, - service: PlanningServiceDep, - scope: WorkspaceScopeDep, -) -> PlannedLeaveDayResponse: - scope.check_workspace(payload.workspace_id) - await scope.check_child(payload.child_id) - await scope.check_member(payload.parent_personal_data_uuid) - try: - day = await service.add_planned_day( - workspace_id=payload.workspace_id, - child_id=payload.child_id, - parent_personal_data_uuid=payload.parent_personal_data_uuid, - date=payload.date, - day_type=payload.day_type, - is_dubbeldag=payload.is_dubbeldag, - wish_id=payload.wish_id, - is_pinned=payload.is_pinned, - ) - return _to_response(day) - except ValidationError as exc: - raise _to_http_error(exc) from exc + for d in days + ] @router.get("", response_model=list[PlannedLeaveDayResponse]) @@ -69,7 +49,7 @@ async def list_planned_days( ) -> list[PlannedLeaveDayResponse]: await scope.check_child(child_id) days = await service.list_by_child(child_id) - return [_to_response(d) for d in days] + return await _to_responses(service, days) @router.get("/{planned_leave_day_id}", response_model=PlannedLeaveDayResponse) @@ -80,9 +60,12 @@ async def get_planned_day( ) -> PlannedLeaveDayResponse: try: day = await service.get(planned_leave_day_id, workspace_id=scope.workspace_id) - return _to_response(day) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc + is_pinned = (day.parent_personal_data_uuid, day.child_id, day.date) in ( + await service.pinned_keys(day.workspace_id) + ) + return PlannedLeaveDayResponse.from_entity(day, is_pinned=is_pinned) @router.get("/workspace/{workspace_id}", response_model=list[PlannedLeaveDayResponse]) @@ -91,39 +74,4 @@ async def list_planned_days_by_workspace( service: PlanningServiceDep, ) -> list[PlannedLeaveDayResponse]: days = await service.list_by_workspace(workspace_id) - return [_to_response(d) for d in days] - - -@router.put("/{planned_leave_day_id}", response_model=PlannedLeaveDayResponse) -async def update_planned_day( - planned_leave_day_id: UUID, - payload: PlannedLeaveDayUpdate, - service: PlanningServiceDep, - scope: WorkspaceScopeDep, -) -> PlannedLeaveDayResponse: - try: - day = await service.update( - planned_leave_day_id, - day_type=payload.day_type, - is_dubbeldag=payload.is_dubbeldag, - wish_id=payload.wish_id, - is_pinned=payload.is_pinned, - workspace_id=scope.workspace_id, - ) - return _to_response(day) - except NotFoundError as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc - except ValidationError as exc: - raise _to_http_error(exc) from exc - - -@router.delete("/{planned_leave_day_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_planned_day( - planned_leave_day_id: UUID, - service: PlanningServiceDep, - scope: WorkspaceScopeDep, -) -> None: - try: - await service.remove_planned_day(planned_leave_day_id, workspace_id=scope.workspace_id) - except NotFoundError as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc + return await _to_responses(service, days) diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 3e98393..ebc5eb2 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -25,14 +25,15 @@ from dateutil.relativedelta import relativedelta from app.config import settings from app.entities.child import Child +from app.entities.day_assertion import partition_assertion_keys from app.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay 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 from app.repositories.vacation_days_repository import VacationDaysRepository @@ -142,7 +143,13 @@ MESSAGE_KEY_WINDOW_EXPLANATION = "planOutcomes.windowExplanation" _SUNDAY_HOLIDAY_NAME = SUNDAY_HOLIDAY_NAME _DUBBELDAGAR_MONTHS_LIMIT = settings.rules.dubbeldagar_months_limit -_MAX_DUBBELDAGAR = settings.rules.max_dubbeldagar +# NOTE: `max_dubbeldagar` is deliberately NOT snapshotted at module-import +# time (unlike `_DUBBELDAGAR_MONTHS_LIMIT` above) — `AnalyticsService. +# validate_dubbeldagar`/`_validate_dubbeldagar_batch` read +# `settings.rules.max_dubbeldagar` LIVE, and a stale allocator snapshot could +# propose a dubbeldag the validator then rejects if the setting changes +# mid-process. `_fund_both` reads it live instead — see `max_dubbeldagar` +# local below. # NOTE: no module-level "_MAX_SAVABLE_AT_B4" here on purpose — which age-4 cap # applies (96 per child vs. 48 per parent) depends purely on parent count, so # it is resolved once per `propose()` run by `AllocationInputLoader` onto @@ -284,18 +291,35 @@ class AllocationInput: # (the wish was reassigned) — matched by `wish_id`, not by parent, for # exactly that reason. Never used for resource netting or `_existing_lookup`. all_existing_planned: list[PlannedLeaveDay] | None = None + # Restructure step 3b — `(parent_uuid, child_id, date)` for every + # `DayAssertion` with a concrete `day_type` (`'sjukpenning'|'lagsta'| + # 'vacation'`), i.e. every day the user has locked in place. This is the + # ONE signal `_Ledger` consumes for "is this date immovable" — + # `_reserve_pinned_days`, `_persisted_by_wish`/`_pinned_dates_by_wish`, + # and `AllocationInputLoader.load`'s `only_pinned_existing` filter all + # check membership here instead of reading a per-row `is_pinned` flag + # (that column no longer exists on `planned_leave_day` — see migration + # 010). Populated by `AllocationInputLoader.load` via + # `app.entities.day_assertion.partition_assertion_keys`, the same + # function `PlanningService.sync_plan`'s own pre-write snapshot uses — + # see that function's docstring for why routing every reader through it + # matters. Empty by default, so a hand-built `AllocationInput` in the + # test suite that doesn't opt in produces byte-identical output. + pinned_days: frozenset[tuple[UUID, UUID, date]] = frozenset() # §remove-this-day — every (parent_uuid, child_id, date) the user has - # explicitly removed from the plan (see `PlannedDayExclusion`). The - # allocator must never place leave on one of these, however emphatically - # the wish covering it asks: "removing a single day stays permanent, even - # on an auto-added period day". Empty by default, so every hand-built - # `AllocationInput` in the test suite keeps producing byte-identical - # output. Read only via `_Ledger._is_excluded` / - # `_Ledger._excluded_window_dates`; deliberately NOT merged into - # `day_overrides`/`is_free_day`, which answer the unrelated *calendar* - # question "is this date a working day for this workspace" (a removed - # Tuesday is still a working Tuesday, for analytics and for everyone - # else in the workspace). + # explicitly removed from the plan: a `DayAssertion` with `day_type= + # 'none'` (restructure step 3b — previously a `PlannedDayExclusion` row, + # now folded into the same table/signal as `pinned_days` above, via the + # same `partition_assertion_keys` call). The allocator must never place + # leave on one of these, however emphatically the wish covering it asks: + # "removing a single day stays permanent, even on an auto-added period + # day". Empty by default, so every hand-built `AllocationInput` in the + # test suite keeps producing byte-identical output. Read only via + # `_Ledger._is_excluded` / `_Ledger._excluded_window_dates`; deliberately + # NOT merged into `day_overrides`/`is_free_day`, which answer the + # unrelated *calendar* question "is this date a working day for this + # workspace" (a removed Tuesday is still a working Tuesday, for + # analytics and for everyone else in the workspace). excluded_days: frozenset[tuple[UUID, UUID, date]] = frozenset() # §Task 1 (per-year preschool reserve) — (child_id, year) -> the TRUE # reserve for that specific calendar year, sourced from a @@ -440,6 +464,17 @@ class WindowOutcome: # versus the naive bound-start placement. 0 for every fixed window and # for a reused flexible window (see `_WishWindow`). shift_gain_days: int = 0 + # Bug 4 fix (day-assertion-subsystem review) — the SAME `series_id` every + # sibling occurrence of a repeating wish shares (`None` for a standalone + # wish). A materialised series is one `LeaveWish` row PER YEAR, each with + # its OWN `wish_id`, so a caller that only knows the occurrence it just + # edited (`sync_with_wish`'s returned `wish.id`) cannot otherwise tell + # which OTHER outcomes in this same batch belong to the same series — + # see `Calendar.tsx`'s `buildSyncToastMessage`, which used to check only + # the edited occurrence's own outcome and so silently missed a 100% + # shortfall on a sibling year (accepted, no rejection, empty band, no + # warning — indistinguishable from "nothing changed"). + series_id: UUID | None = None @dataclass(frozen=True) @@ -642,6 +677,18 @@ class _FlexibleWishYear: anchor_year: int bound_start: date bound_end: date + # §silent-failure gap fix — True when the wish's RAW bound (before any + # clipping) ends entirely before `today`. Unlike the horizon-future case + # (excluded from this list entirely — it will come back into range on a + # later re-run) or a merely-partially-past bound (clipped to + # `>= today` and funded as an ordinary shortfall, unchanged), a fully + # elapsed bound can never be revisited. `Allocator.propose()` reports it + # via `build_unplaced_outcome` instead of running it through + # `resolve_flexible_window` — `bound_start`/`bound_end` here are the + # wish's original, UNCLIPPED dates (clipping them to `today` would make + # `bound_start > bound_end`), which is also what a user wants to see: the + # window they actually asked for, not a clipped fragment of it. + fully_past: bool = False def _expand_flexible_wish_years(alloc_input: AllocationInput) -> list[_FlexibleWishYear]: @@ -660,7 +707,25 @@ def _expand_flexible_wish_years(alloc_input: AllocationInput) -> list[_FlexibleW if wish.window_mode != "flexible": continue bound_start, bound_end = wish.window() - if bound_end < alloc_input.today or bound_start > alloc_input.horizon_end: + if bound_start > alloc_input.horizon_end: + # Not yet in range -- excluded, not reported: a later re-run + # (horizon advances, or the user edits the bound) can still pick + # this wish-year up, so there is nothing to tell the user today. + continue + if bound_end < alloc_input.today: + # §silent-failure gap fix -- fully elapsed, and (unlike the + # horizon-future case above) this can NEVER come back into + # range. Keep it, unclipped, so `Allocator.propose()` reports it + # as an 'unplaced' outcome instead of dropping it with no signal. + wish_years.append( + _FlexibleWishYear( + wish=wish, + anchor_year=wish.first_year, + bound_start=bound_start, + bound_end=bound_end, + fully_past=True, + ) + ) continue clipped_start = max(bound_start, alloc_input.today) clipped_end = min(bound_end, alloc_input.horizon_end) @@ -908,7 +973,7 @@ class _Ledger: continue key = (p.wish_id, p.child_id) self._persisted_by_wish.setdefault(key, set()).add(p.date) - if p.is_pinned: + if (p.parent_personal_data_uuid, p.child_id, p.date) in alloc_input.pinned_days: self._pinned_dates_by_wish.setdefault(key, set()).add(p.date) self._reserve_pinned_days() @@ -930,7 +995,8 @@ class _Ledger: type or parent — this method only handles the resource debit. """ for p in self._input.existing_planned: - if not p.is_pinned: + key = (p.parent_personal_data_uuid, p.child_id, p.date) + if key not in self._input.pinned_days: continue if p.day_type == "vacation": self._spend_vacation(p.parent_personal_data_uuid, p.date) @@ -1052,8 +1118,8 @@ class _Ledger: ) -> set[date]: """Dates that leave a window ENTIRELY because no responsible parent may take them. - The exclusion itself is per parent (see `PlannedDayExclusionService` - for why), but what a *window* should do about it depends on how many + The exclusion itself is per parent (see `DayAssertionService` for + why), but what a *window* should do about it depends on how many parents that window puts on leave per date: - 'dubbeldag' (both parents off on the same dates): removing one @@ -1477,6 +1543,9 @@ class _Ledger: """ parent_a, parent_b = responsible.parents[0], responsible.parents[1] child = self._resolve_wish_child(wish) + # Read live, not from a module-import-time snapshot — see NOTE near + # `_DUBBELDAGAR_MONTHS_LIMIT` above. + max_dubbeldagar = settings.rules.max_dubbeldagar if child is None: days, cost = self._fund_swap(wish, w, dates, red_cluster, responsible) @@ -1500,7 +1569,7 @@ class _Ledger: pool_a is not None and pool_b is not None and any(d <= eligible_end for d in dates) - and used < _MAX_DUBBELDAGAR + and used < max_dubbeldagar and self._peek_fp_tier(pool_a) is not None and self._peek_fp_tier(pool_b) is not None ) @@ -1592,7 +1661,7 @@ class _Ledger: and pool_a is not None and pool_b is not None and d <= eligible_end - and used < _MAX_DUBBELDAGAR + and used < max_dubbeldagar ): tier_a = self._peek_fp_tier(pool_a) tier_b = self._peek_fp_tier(pool_b) @@ -1784,6 +1853,7 @@ class _Ledger: cost_breakdown=cost, remedies=remedies, shift_gain_days=w.shift_gain_days, + series_id=wish.series_id, ) return days, outcome @@ -2384,6 +2454,7 @@ class _Ledger: }, cost_breakdown=_new_cost(), remedies=remedies, + series_id=wish.series_id, ) # -- warnings ------------------------------------------------------------ @@ -2793,6 +2864,15 @@ class Allocator: flexible_wish_years = _expand_flexible_wish_years(alloc_input) flexible_wish_years.sort(key=lambda fw: (fw.wish.priority, fw.bound_start, fw.wish.id.int)) for fw in flexible_wish_years: + if fw.fully_past: + # §silent-failure gap fix -- the bound elapsed entirely + # before `today`; there is nothing left to search for. + outcomes.append( + ledger.build_unplaced_outcome( + fw.wish, fw.anchor_year, fw.bound_start, fw.bound_end + ) + ) + continue window = ledger.resolve_flexible_window( fw.wish, fw.anchor_year, fw.bound_start, fw.bound_end ) @@ -2877,8 +2957,8 @@ class AllocationInputLoader: planned_leave_repo: PlannedLeaveRepository, leave_wish_repo: LeaveWishRepository, day_override_repo: DayOverrideRepository | None = None, - exclusion_repo: PlannedDayExclusionRepository | None = None, preschool_planning_days_repo: PreschoolPlanningDaysRepository | None = None, + day_assertion_repo: DayAssertionRepository | None = None, ) -> None: self._workspace_repo = workspace_repo self._children_repo = child_repo @@ -2888,15 +2968,22 @@ class AllocationInputLoader: self._planned_leave_repo = planned_leave_repo self._leave_wish_repo = leave_wish_repo self._day_override_repo = day_override_repo - self._exclusion_repo = exclusion_repo # §Task 1 (per-year preschool reserve) — optional, like - # `day_override_repo`/`exclusion_repo` above: `None` (every caller - # that hasn't been wired up yet, including every existing DI - # container until it's updated to pass one) means "no per-year rows - # available", so `load()` leaves `AllocationInput. - # preschool_reserve_by_year` empty and `_preschool_reserve_days` - # falls back to the old flat `per_year * years` lump sum unchanged. + # `day_override_repo` above: `None` (every caller that hasn't been + # wired up yet, including every existing DI container until it's + # updated to pass one) means "no per-year rows available", so + # `load()` leaves `AllocationInput.preschool_reserve_by_year` empty + # and `_preschool_reserve_days` falls back to the old flat + # `per_year * years` lump sum unchanged. self._preschool_planning_days_repo = preschool_planning_days_repo + # Restructure step 3b — `None` (an old test/script that hand-builds + # a loader without one) means "no assertions available at all", so + # `load()` produces empty `pinned_days`/`excluded_days`. Production + # (`app.dependencies.get_allocation_input_loader`) always wires one: + # `DayAssertion` is now the ONLY source for both signals — the + # `is_pinned` column and the `planned_day_exclusion` table are gone + # (migration 010). + self._day_assertion_repo = day_assertion_repo async def load( self, @@ -2910,14 +2997,27 @@ class AllocationInputLoader: `only_pinned_existing` (§Task 4, see `PlanningEngineService.propose`) restricts `existing_planned` — and everything derived from it here (pool/vacation netting) — to rows that must stay immovable: pinned - rows (`is_pinned`) and ad-hoc rows with no `wish_id`. Every other - (unpinned, wish-attributed) row is left out entirely, so the - allocator treats that date as open and re-derives it fresh. Pinned - rows are *never* netted out of the pools/vacation budgets here even - when `only_pinned_existing` is False — the pure `_Ledger` does that - itself (`_reserve_pinned_days`), matching how it reserves preschool - planning days off the top, so the debit happens in one place - regardless of which mode loaded the input. + rows (in `pinned_days`, see below), ad-hoc rows with no `wish_id`, + and rows dated strictly before `today` ("the past is fact" — see the + inline comment below; an elapsed date is never re-derived by the + allocator regardless of this flag, so it must stay visible here or + it would be counted as available capacity it has already spent). + Every other (unpinned, wish-attributed, future-or-today) row is left + out entirely, so the allocator treats that date as open and + re-derives it fresh. Pinned rows are *never* netted out of the + pools/vacation budgets here even when `only_pinned_existing` is + False — the pure `_Ledger` does that itself (`_reserve_pinned_ + days`), matching how it reserves preschool planning days off the + top, so the debit happens in one place regardless of which mode + loaded the input. + + Restructure step 3b — "pinned"/"excluded" both come from ONE read of + `DayAssertion` (`self._day_assertion_repo`), split via + `app.entities.day_assertion.partition_assertion_keys` into + `AllocationInput.pinned_days`/`excluded_days`. `planned_leave_day` + no longer carries an `is_pinned` column at all (migration 010) and + `PlannedDayExclusion` is gone — `DayAssertion` is the sole source + for both signals now, not a fallback layered on top of them. """ today = today or date.today() @@ -2929,6 +3029,13 @@ class AllocationInputLoader: parent_entities = await self._personal_data_repo.list_by_workspace(workspace_id) wishes = await self._leave_wish_repo.get_by_workspace(workspace_id) + assertions = ( + await self._day_assertion_repo.list_by_workspace(workspace_id) + if self._day_assertion_repo is not None + else [] + ) + pinned_days, excluded_days = partition_assertion_keys(assertions) + children = [_to_child_facts(c) for c in child_entities] parents = [ ParentFacts( @@ -2958,10 +3065,23 @@ class AllocationInputLoader: all_existing_planned.extend(planned_for_child) if only_pinned_existing: # Hide unpinned, wish-attributed rows entirely -- see the - # docstring above. Pinned rows and ad-hoc (no wish_id) rows - # stay visible/immovable either way. + # docstring above -- EXCEPT ones dated strictly before + # `today`. "The past is fact": `_expand_all_windows`/ + # `_expand_flexible_wish_years` clip every window to + # `>= today`, so an elapsed date is never re-derived no + # matter how far this loader hides it -- hiding it here too + # would leave it neither preserved (that's `sync_plan`'s + # job, via its own `resolved_today`-gated `stale_keys`) NOR + # debited from the pools/vacation budgets below, letting the + # plan over-report remaining days by exactly the count of + # elapsed auto-placed days. Pinned rows and ad-hoc (no + # wish_id) rows stay visible/immovable either way. visible_for_child = [ - d for d in planned_for_child if d.is_pinned or d.wish_id is None + d + for d in planned_for_child + if (d.parent_personal_data_uuid, d.child_id, d.date) in pinned_days + or d.wish_id is None + or d.date < today ] else: visible_for_child = planned_for_child @@ -2995,7 +3115,7 @@ class AllocationInputLoader: for d in visible_for_child if d.parent_personal_data_uuid == parent.uuid and d.day_type in ("sjukpenning", "lagsta") - and not d.is_pinned + and (d.parent_personal_data_uuid, d.child_id, d.date) not in pinned_days ] sjuk_committed = sum(1 for d in committed if d.day_type == "sjukpenning") lagsta_committed = sum(1 for d in committed if d.day_type == "lagsta") @@ -3032,7 +3152,7 @@ class AllocationInputLoader: if d.parent_personal_data_uuid == parent_uuid and d.day_type == "vacation" and d.date.year == year - and not d.is_pinned + and (d.parent_personal_data_uuid, d.child_id, d.date) not in pinned_days ) vacation: dict[tuple[UUID, int], VacationBudget] = {} @@ -3141,12 +3261,11 @@ class AllocationInputLoader: # removal is an instruction about the plan, not a planned row, so a # re-plan must see every one of them or the removed day comes back on # the very next sync — which is the bug this exists to fix. - excluded_days: frozenset[tuple[UUID, UUID, date]] = frozenset() - if self._exclusion_repo is not None: - exclusions = await self._exclusion_repo.list_by_workspace(workspace_id) - excluded_days = frozenset( - (e.parent_personal_data_uuid, e.child_id, e.date) for e in exclusions - ) + # + # Restructure step 3b — `excluded_days` is exactly the `excluded` + # half of `partition_assertion_keys(assertions)`, computed once above + # alongside `pinned_days`. `PlannedDayExclusion` is gone (migration + # 010); a `day_type='none'` `DayAssertion` is the only source now. return AllocationInput( today=today, @@ -3162,6 +3281,7 @@ class AllocationInputLoader: day_overrides=day_overrides, take_pl_on_red_days=ws.take_pl_on_red_days, all_existing_planned=all_existing_planned, + pinned_days=pinned_days, excluded_days=excluded_days, preschool_reserve_by_year=preschool_reserve_by_year, vacation_forfeiting=vacation_forfeiting, diff --git a/backend/app/services/day_assertion_service.py b/backend/app/services/day_assertion_service.py new file mode 100644 index 0000000..96c54d0 --- /dev/null +++ b/backend/app/services/day_assertion_service.py @@ -0,0 +1,216 @@ +"""Business logic for DayAssertion — the intent-vs-derived-output restructure's + +single record of "what the user asserts about one calendar day" (see +`app/entities/day_assertion.py`). Restructure step 3b: this service is now +the ONE write path behind `POST /api/day-assertions`, superseding both the +old hand-pin write on `/api/planned-days` and `/api/planned-day-exclusions` +(both deleted). It owns the natural-key upsert convention AND mirrors every +write into `planned_leave_day`, the same way `PlannedDayExclusionService` +used to for the `'none'` case — a `DayAssertion` for a concrete `day_type` is +also, immediately, a `planned_leave_day` row with that same day_type/ +is_dubbeldag/wish_id (so the calendar renders it without waiting for the +next `POST /api/plan/sync`), and a `day_type='none'` assertion still deletes +whatever `planned_leave_day` row occupied that slot. + +Enforces the SAME guard `PlanningService.add_planned_day`/`update` used to +(`app.services.day_guards.reject_illegal_free_day` — a free day can never be +`vacation`, and may be `sjukpenning`/`lagsta` only when the workspace opted +in) and the same per-day dubbeldagar-window check +(`AnalyticsService.validate_dubbeldagar`), so hand-pinning a day through this +endpoint is exactly as guarded as the legacy `/api/planned-days` write was — +both optional constructor deps, like `PlanningService`'s own guards, so a +caller that never wires them (older direct-construction call sites) keeps +whatever behaviour it had. +""" + +from datetime import date, datetime +from uuid import UUID, uuid4 + +from app.entities.day_assertion import DAY_TYPE_NONE, DayAssertion +from app.entities.planned_leave import PlannedLeaveDay +from app.entities.workspace import Workspace +from app.repositories.day_assertion_repository import DayAssertionRepository +from app.repositories.day_override_repository import DayOverrideRepository +from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork +from app.repositories.workspace_repository import WorkspaceRepository +from app.services.analytics_service import AnalyticsService +from app.services.day_guards import reject_illegal_free_day +from app.utils.errors import NotFoundError, ValidationError + +_VALID_DAY_TYPES = frozenset({"sjukpenning", "lagsta", "vacation", DAY_TYPE_NONE}) + + +class DayAssertionService: + def __init__( + self, + repo: DayAssertionRepository, + planned_leave_repo: PlannedLeaveRepository | None = None, + analytics_service: AnalyticsService | None = None, + workspace_repo: WorkspaceRepository | None = None, + day_override_repo: DayOverrideRepository | None = None, + uow: UnitOfWork | None = None, + ) -> None: + self._repo = repo + # Only needed for the `planned_leave_day` mirroring below — optional + # so a direct-construction caller that only wants pure assertion + # semantics (tests) doesn't have to wire it. + self._planned_leave_repo = planned_leave_repo + self._analytics = analytics_service + self._workspace_repo = workspace_repo + self._day_override_repo = day_override_repo + # Adversarial-review fix (restructure step 3b): `assert_day`/`clear` + # each do an assertion write followed by a `planned_leave_day` mirror + # write (create-or-replace, or a delete) — two separate commits with + # no unit of work, exactly the shape `PlanningService` wraps in + # `self._uow()` everywhere else. Defaults to a no-op so existing + # direct-construction call sites (tests) keep today's per-write-commit + # behavior; `app.dependencies.get_day_assertion_service` always wires + # a real `SQLiteUnitOfWork`. + self._uow = uow or NoOpUnitOfWork() + + async def _reject_illegal_free_day(self, workspace_id: UUID, d: date, day_type: str) -> None: + if self._workspace_repo is None or self._day_override_repo is None: + return + overrides = { + o.date: o.is_working_day + for o in await self._day_override_repo.list_by_workspace(workspace_id) + } + workspace: Workspace | None = await self._workspace_repo.get_by_id(workspace_id) + reject_illegal_free_day(workspace, overrides, d, day_type) + + async def assert_day( + self, + workspace_id: UUID, + child_id: UUID, + parent_personal_data_uuid: UUID, + day: date, + day_type: str, + is_dubbeldag: bool = False, + wish_id: UUID | None = None, + ) -> DayAssertion: + """Record (or overwrite) the user's assertion for this (workspace, + + child, parent, date), and mirror it into `planned_leave_day` so the + calendar reflects it immediately. `day_type='none'` is the + exclusion-shaped assertion — "the planner may not place leave here" + — and mirrors by DELETING any `planned_leave_day` row at this key + instead. Idempotent by natural key: asserting the same slot again + overwrites rather than duplicating, and always OVERWRITES whatever + assertion already exists there (never merges), matching + `PlannedDayExclusionService.exclude()`'s precedent. + """ + if day_type not in _VALID_DAY_TYPES: + raise ValidationError(f"invalid day_type for a DayAssertion: {day_type!r}") + + await self._reject_illegal_free_day(workspace_id, day, day_type) + if is_dubbeldag and self._analytics is not None: + # `DubbeldagError` is itself a `ValidationError` (see + # `app.utils.errors`) — nothing to translate, matches + # `PlanningService.add_planned_day`'s own Validation 1 exactly. + await self._analytics.validate_dubbeldagar(child_id, day) + + now = datetime.now() + async with self._uow(): + created = await self._repo.upsert( + DayAssertion( + id=uuid4(), + workspace_id=workspace_id, + child_id=child_id, + parent_personal_data_uuid=parent_personal_data_uuid, + date=day, + day_type=day_type, + is_dubbeldag=is_dubbeldag, + wish_id=wish_id, + created_at=now, + updated_at=now, + ) + ) + await self._mirror_into_planned_leave( + workspace_id, + child_id, + parent_personal_data_uuid, + day, + day_type, + is_dubbeldag, + wish_id, + ) + return created + + async def _mirror_into_planned_leave( + self, + workspace_id: UUID, + child_id: UUID, + parent_personal_data_uuid: UUID, + day: date, + day_type: str, + is_dubbeldag: bool, + wish_id: UUID | None, + ) -> None: + if self._planned_leave_repo is None: + return + existing = [ + row + for row in await self._planned_leave_repo.list_by_workspace_child( + workspace_id, child_id, from_date=day, to_date=day + ) + if row.parent_personal_data_uuid == parent_personal_data_uuid + ] + if existing: + await self._planned_leave_repo.delete_many([row.id for row in existing]) + if day_type == DAY_TYPE_NONE: + return + now = datetime.now() + await self._planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=workspace_id, + child_id=child_id, + parent_personal_data_uuid=parent_personal_data_uuid, + date=day, + day_type=day_type, + is_dubbeldag=is_dubbeldag, + wish_id=wish_id, + created_at=now, + updated_at=now, + ) + ) + + async def clear( + self, + workspace_id: UUID, + child_id: UUID, + parent_personal_data_uuid: UUID, + day: date, + ) -> None: + """Retract the assertion at this slot, if any (a no-op when none + + exists), and delete the mirrored `planned_leave_day` row at the same + key, if any. Used for: un-pinning a hand-added day entirely (the + Advanced modal's plain delete), deleting an ad-hoc band's days, and + restoring a `day_type='none'` exclusion. + """ + async with self._uow(): + await self._repo.delete_by_natural_key( + workspace_id, child_id, parent_personal_data_uuid, day + ) + if self._planned_leave_repo is None: + return + existing = [ + row + for row in await self._planned_leave_repo.list_by_workspace_child( + workspace_id, child_id, from_date=day, to_date=day + ) + if row.parent_personal_data_uuid == parent_personal_data_uuid + ] + if existing: + await self._planned_leave_repo.delete_many([row.id for row in existing]) + + async def get(self, assertion_id: UUID, workspace_id: UUID | None = None) -> DayAssertion: + entity = await self._repo.get_by_id(assertion_id) + if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id): + raise NotFoundError(f"DayAssertion {assertion_id} not found") + return entity + + async def list_for_workspace(self, workspace_id: UUID) -> list[DayAssertion]: + return await self._repo.list_by_workspace(workspace_id) diff --git a/backend/app/services/day_guards.py b/backend/app/services/day_guards.py new file mode 100644 index 0000000..73955a1 --- /dev/null +++ b/backend/app/services/day_guards.py @@ -0,0 +1,55 @@ +"""Shared write-time guard: a free day can never be `vacation`. + +Extracted out of `PlanningService._reject_illegal_free_day` (restructure step +3b) so `DayAssertionService` — which now owns the hand-pin write path too, +see its module docstring — enforces the exact same rule instead of growing +its own copy. A free day (Saturday, Sunday, or Swedish red day, subject to a +workspace's `DayOverride`s — see `allocation_service.is_free_day`, the single +source of truth for "is this day free") can never be persisted as `vacation`, +and may be `sjukpenning`/`lagsta` only when the workspace opted in via +`Workspace.take_pl_on_red_days` (default False). +""" + +from datetime import date + +from app.entities.workspace import Workspace +from app.services.allocation_service import is_free_day +from app.utils.errors import ValidationError +from app.utils.holidays import get_swedish_holidays + + +def reject_illegal_free_day( + workspace: Workspace | None, + day_overrides: dict[date, bool], + d: date, + day_type: str, +) -> None: + """Raise `ValidationError` iff `day_type` may not be planned on `d`. + + No-op for any `day_type` other than `sjukpenning`/`lagsta`/`vacation` + (e.g. `'none'`, the exclusion-shaped assertion — excluding a day is + always allowed, free or not) and a no-op entirely when `d` is not a free + day in the first place. + """ + if day_type not in ("vacation", "sjukpenning", "lagsta"): + return + + holidays_map = get_swedish_holidays(d.year) + if not is_free_day(d, holidays_map, day_overrides): + return + + if day_type == "vacation": + raise ValidationError( + "Vacation cannot be planned on a free day (weekend or red day)", + message_key="planning.rejection.vacationOnFreeDay", + data={"date": d.isoformat()}, + ) + + take_pl_on_red_days = workspace.take_pl_on_red_days if workspace is not None else False + if not take_pl_on_red_days: + raise ValidationError( + "Parental leave cannot be planned on a free day unless the workspace " + "setting take_pl_on_red_days is enabled", + message_key="planning.rejection.parentalLeaveOnFreeDayDisabled", + data={"date": d.isoformat()}, + ) diff --git a/backend/app/services/leave_wish_service.py b/backend/app/services/leave_wish_service.py index 22ec17e..d962608 100644 --- a/backend/app/services/leave_wish_service.py +++ b/backend/app/services/leave_wish_service.py @@ -23,10 +23,13 @@ caller the occurrence belongs to a repeating series. from datetime import date, datetime from uuid import UUID, uuid4 +from app.entities.day_assertion import DAY_TYPE_NONE from app.entities.leave_wish import LeaveWish, project_window from app.repositories.child_repository import ChildRepository +from app.repositories.day_assertion_repository import DayAssertionRepository from app.repositories.leave_wish_repository import LeaveWishRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork from app.utils.errors import NotFoundError, ValidationError from app.utils.holidays import cached_year_bounds @@ -67,6 +70,8 @@ class LeaveWishService: repo: LeaveWishRepository, planned_leave_repo: PlannedLeaveRepository, child_repo: ChildRepository | None = None, + day_assertion_repo: DayAssertionRepository | None = None, + uow: UnitOfWork | None = None, ) -> None: self._repo = repo self._planned_leave_repo = planned_leave_repo @@ -75,6 +80,26 @@ class LeaveWishService: # open-ended ("until the child turns 4") series falls back to the # childless horizon instead of the child's actual 4th birthday. self._child_repo = child_repo + # Optional, like `child_repo` above — `None` means existing + # direct-construction call sites (older tests) keep today's + # behaviour: `delete()` still removes every `planned_leave_day` row, + # it just doesn't also clear the `DayAssertion` at each row's key. + # See `delete()`'s docstring for why that cleanup matters now. + self._day_assertion_repo = day_assertion_repo + # `delete()`/`_materialise_series()` are each a multi-statement + # sequence over possibly several rows (a series delete/re-projection + # touches every occurrence's `planned_leave_day` rows, `DayAssertion` + # rows, and the `LeaveWish` rows themselves) with no atomicity of + # their own — this service has never had a `uow`, so a crash midway + # left a partially-deleted/partially-reprojected series. Defaults to + # a no-op so existing direct-construction call sites (tests, + # `PlanningService.sync_with_wish`'s own nested call — nesting a + # `unit_of_work()` inside one already open is a safe passthrough, + # see `app.db.connection.unit_of_work`) keep working unchanged; + # `app.dependencies.get_leave_wish_service` wires a real + # `SQLiteUnitOfWork` for the standalone `/api/leave-wishes` router, + # which previously had none at all. + self._uow = uow or NoOpUnitOfWork() async def create( self, @@ -165,11 +190,12 @@ class LeaveWishService: created_at=now, updated_at=now, ) - created = await self._repo.create(anchor) - if repeats and series_id is None: - end_year = await self._resolve_series_end_year(child_id, first_year, last_year) - for year in range(first_year + 1, end_year + 1): - await self._repo.create(_project_occurrence(created, year)) + async with self._uow(): + created = await self._repo.create(anchor) + if repeats and series_id is None: + end_year = await self._resolve_series_end_year(child_id, first_year, last_year) + for year in range(first_year + 1, end_year + 1): + await self._repo.create(_project_occurrence(created, year)) return created async def get(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> LeaveWish: @@ -328,32 +354,33 @@ class LeaveWishService: wants_dubbeldag=entity.wants_dubbeldag, ) - siblings = ( - [s for s in await self._repo.get_by_series(entity.series_id) if s.id != entity.id] - if entity.series_id is not None - else [] - ) + async with self._uow(): + siblings = ( + [s for s in await self._repo.get_by_series(entity.series_id) if s.id != entity.id] + if entity.series_id is not None + else [] + ) - if was_series and not will_repeat: - # "Stop repeating": keep the clicked occurrence, drop the rest. - entity.series_id = None - await self._delete_rows(siblings) - return await self._repo.update(entity.id, entity) + if was_series and not will_repeat: + # "Stop repeating": keep the clicked occurrence, drop the rest. + entity.series_id = None + await self._delete_rows(siblings) + return await self._repo.update(entity.id, entity) - if not will_repeat: - return await self._repo.update(entity.id, entity) + if not will_repeat: + return await self._repo.update(entity.id, entity) - if not was_series: - # "Make this repeat": this occurrence becomes the series anchor. - entity.series_id = uuid4() + if not was_series: + # "Make this repeat": this occurrence becomes the series anchor. + entity.series_id = uuid4() - anchor = await self._repo.update(entity.id, entity) - if was_series and scope != SCOPE_SERIES: - # A single year of an existing series changed; siblings keep - # whatever the user (or the planner) had already given them. + anchor = await self._repo.update(entity.id, entity) + if was_series and scope != SCOPE_SERIES: + # A single year of an existing series changed; siblings keep + # whatever the user (or the planner) had already given them. + return anchor + await self._materialise_series(anchor, siblings) return anchor - await self._materialise_series(anchor, siblings) - return anchor async def delete( self, @@ -361,8 +388,9 @@ class LeaveWishService: workspace_id: UUID | None = None, scope: str = SCOPE_OCCURRENCE, ) -> None: - """Delete a wish occurrence (or a whole series) and every - `planned_leave_day` row it produced. + """Delete a wish occurrence (or a whole series), every + `planned_leave_day` row it produced, and any `DayAssertion` locking + one of those rows in place. Under the calendar-direct-manipulation UX a period *is* its wish: the band the user drew, the days the allocator filled it with, and any @@ -380,30 +408,89 @@ class LeaveWishService: a signal that the day should outlive the period a user explicitly chose to delete. This also matches the ad-hoc-period delete path (`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already - deletes every day in the band regardless of `is_pinned` with no + deletes every day in the band regardless of pin state with no special-casing. + **Restructure step 3b — the `DayAssertion` cleanup.** A pinned or + promoted-and-locked row's assertion (`day_type` concrete — the + 'attach'/'pin' day-lock actions, see `PlanningService._assert_pin`) + is cleared at the same key, for every row this call deletes. + Deliberately unconditional: nothing in the stored data distinguishes + an assertion the user made independently and *later* attached to + this wish ('attach') from one made *because* the day was already + under this wish ('pin') — both look identical (concrete `day_type`, + `wish_id` = this wish). Left alone, a surviving assertion would + become invisible to the frontend the moment its mirrored + `planned_leave_day` row is deleted above (nothing in the UI lists an + assertion with no row behind it) while still silently pinning + whatever new row a later sync/hand-pin happens to place at that same + (workspace, child, parent, date) — a slot the user cannot see, let + alone unlock, through the UI. Deleting it is the safe default: the + user can trivially re-pin a day they still want locked, which is a + far smaller cost than a permanently and invisibly stuck slot. + + A `day_type='none'` assertion (an exclusion) is the one exception — + left untouched. An exclusion is never actually produced with this + wish's `wish_id` by any current write path (a `planned_leave_day` + row's `day_type` CHECK constraint doesn't allow `'none'`, so 'attach'/ + 'pin' can never assert one), but even if one existed, "the planner + may not place leave here" is a standing instruction about the DATE, + not about this particular period — see the restructure notes' + already-accepted "exclusions outlive the wish they were made + against" behaviour (keyed without `wish_id` on purpose). + `scope='series'` removes every year of the repeating wish this - occurrence belongs to; `'occurrence'` removes just this year's row, - which is now a plain delete rather than the range-narrowing (and - split-the-wish-in-two) contortion the single-row shape forced on the - frontend. + occurrence belongs to (and every occurrence's own rows/assertions, + not just the clicked year's); `'occurrence'` removes just this + year's row, which is now a plain delete rather than the + range-narrowing (and split-the-wish-in-two) contortion the + single-row shape forced on the frontend. """ if scope not in _VALID_SCOPE: raise ValidationError(f"scope must be one of {_VALID_SCOPE}") - entity = await self.get(leave_wish_id, workspace_id) - targets = [entity] - if scope == SCOPE_SERIES and entity.series_id is not None: - targets = await self._repo.get_by_series(entity.series_id) - await self._delete_rows(targets) + async with self._uow(): + entity = await self.get(leave_wish_id, workspace_id) + targets = [entity] + if scope == SCOPE_SERIES and entity.series_id is not None: + targets = await self._repo.get_by_series(entity.series_id) + await self._delete_rows(targets) # -- internals --------------------------------------------------------- async def _delete_rows(self, rows: list[LeaveWish]) -> None: for row in rows: + await self._clear_assertions_for(row.id) await self._planned_leave_repo.delete_by_wish(row.id) await self._repo.delete(row.id) + async def _clear_assertions_for(self, wish_id: UUID) -> None: + """Clear every `DayAssertion` attached to this wish — except a + + `'none'` (exclusion) assertion, which outlives the wish on purpose. + See `delete()`'s docstring for the full reasoning. + + Adversarial-review fix: queries `DayAssertionRepository.list_by_wish` + directly by `wish_id` rather than walking the wish's surviving + `planned_leave_day` rows and clearing at each row's natural key. The + row-walk was blind to a DANGLING assertion — one whose mirrored + `planned_leave_day` row had already gone missing (e.g. a crash + between `DayAssertionService`'s own assertion write and its mirror + write) — which then outlived the wish as a `wish_id=NULL` ad-hoc + assertion (FK `ON DELETE SET NULL`) that silently, permanently + locked its date with no UI path to find it. Querying by `wish_id` + catches that assertion regardless of whether a mirror row survives. + """ + if self._day_assertion_repo is None: + return + for assertion in await self._day_assertion_repo.list_by_wish(wish_id): + if assertion.day_type != DAY_TYPE_NONE: + await self._day_assertion_repo.delete_by_natural_key( + assertion.workspace_id, + assertion.child_id, + assertion.parent_personal_data_uuid, + assertion.date, + ) + async def _materialise_series(self, anchor: LeaveWish, siblings: list[LeaveWish]) -> None: """Make the rows of `anchor`'s series agree with `anchor`. diff --git a/backend/app/services/planned_day_exclusion_service.py b/backend/app/services/planned_day_exclusion_service.py deleted file mode 100644 index 2aa25c1..0000000 --- a/backend/app/services/planned_day_exclusion_service.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Business logic for planned-day exclusions. - -The service owns rules; the repository only persists. Routers call services, -not repositories directly. Mirrors `DayOverrideService`'s -upsert-by-natural-key shape: a workspace has at most one exclusion per -(child, parent, date). - -**Why the parent is part of the key.** A both-parents period places one -`planned_leave_day` per parent on the same date, and the calendar draws one -band per parent. "Remove this day" is always clicked on ONE band, so it -removes ONE parent's day; keying the exclusion on (workspace, child, date) -alone would make removing a day from one parent's band silently cancel the -partner's leave that day too — a change the user never asked for and, on a -shared plan, one the other parent would discover only by noticing the hole. -The UI says whose day it removed (`calendar.periodDeleteConfirmDay` / -`calendar.toastDayExcluded` name the parent). A date only leaves a window -entirely when no responsible parent may take it — see -`_Ledger._excluded_window_dates`. - -**Removing the day itself.** Creating an exclusion also deletes the matching -`planned_leave_day` row, if one exists, so "remove this day" is true the -moment the call returns rather than only after the next -`POST /api/plan/sync`. The subsequent sync (which the calendar runs anyway) -is what keeps the rest of the plan consistent; the allocator will not -re-derive this date, which is the whole point of the row. -""" - -from datetime import date, datetime -from uuid import UUID, uuid4 - -from app.entities.planned_day_exclusion import PlannedDayExclusion -from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository -from app.repositories.planned_leave_repository import PlannedLeaveRepository -from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork -from app.utils.errors import NotFoundError - - -class PlannedDayExclusionService: - def __init__( - self, - repo: PlannedDayExclusionRepository, - planned_leave_repo: PlannedLeaveRepository | None = None, - uow: UnitOfWork | None = None, - ) -> None: - self._repo = repo - self._planned_leave_repo = planned_leave_repo - self._uow = uow or NoOpUnitOfWork() - - async def exclude( - self, - workspace_id: UUID, - child_id: UUID, - parent_personal_data_uuid: UUID, - day: date, - ) -> PlannedDayExclusion: - """Create (or return) the exclusion, and drop the day it removes. - - Idempotent: excluding an already-excluded day returns the existing - row rather than raising, so a double click (or a retried request) - cannot produce a UNIQUE violation. - """ - async with self._uow(): - existing = await self._repo.get_by_natural_key( - workspace_id, child_id, parent_personal_data_uuid, day - ) - if existing is not None: - await self._delete_planned_day( - workspace_id, child_id, parent_personal_data_uuid, day - ) - return existing - - entity = PlannedDayExclusion( - id=uuid4(), - workspace_id=workspace_id, - child_id=child_id, - parent_personal_data_uuid=parent_personal_data_uuid, - date=day, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - created = await self._repo.create(entity) - await self._delete_planned_day(workspace_id, child_id, parent_personal_data_uuid, day) - return created - - async def _delete_planned_day( - self, - workspace_id: UUID, - child_id: UUID, - parent_personal_data_uuid: UUID, - day: date, - ) -> None: - """Remove the `planned_leave_day` this exclusion forbids, if it exists. - - A pinned row is deleted too: the user just said, explicitly and for - this exact date, that they do not want leave here — that instruction - is newer than the pin. Undo removes the exclusion and re-syncs, which - restores the day as an ordinary planner-placed (unpinned) day. - """ - if self._planned_leave_repo is None: - return - rows = await self._planned_leave_repo.list_by_workspace_child( - workspace_id, child_id, from_date=day, to_date=day - ) - ids = [r.id for r in rows if r.parent_personal_data_uuid == parent_personal_data_uuid] - if ids: - await self._planned_leave_repo.delete_many(ids) - - async def get( - self, exclusion_id: UUID, workspace_id: UUID | None = None - ) -> PlannedDayExclusion: - entity = await self._repo.get_by_id(exclusion_id) - if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id): - raise NotFoundError(f"PlannedDayExclusion {exclusion_id} not found") - return entity - - async def list_for_workspace(self, workspace_id: UUID) -> list[PlannedDayExclusion]: - return await self._repo.list_by_workspace(workspace_id) - - async def delete(self, exclusion_id: UUID, workspace_id: UUID | None = None) -> None: - """Un-exclude: the planner may place a day here again on the next sync.""" - await self.get(exclusion_id, workspace_id) # raises NotFoundError if missing/foreign - await self._repo.delete(exclusion_id) - - async def get_excluded_keys(self, workspace_id: UUID) -> frozenset[tuple[UUID, UUID, date]]: - """Return {(parent_uuid, child_id, date)} for every exclusion in a workspace. - - This is the shape `AllocationInputLoader` feeds into - `AllocationInput.excluded_days` — see `_Ledger._is_excluded`, the - single place it is consulted. - """ - rows = await self._repo.list_by_workspace(workspace_id) - return frozenset((r.parent_personal_data_uuid, r.child_id, r.date) for r in rows) diff --git a/backend/app/services/planning_service.py b/backend/app/services/planning_service.py index cdaa6f6..c84ddb4 100644 --- a/backend/app/services/planning_service.py +++ b/backend/app/services/planning_service.py @@ -7,36 +7,23 @@ from uuid import UUID, uuid4 from dateutil.relativedelta import relativedelta from app.config import settings +from app.entities.day_assertion import DayAssertion, partition_assertion_keys from app.entities.planned_leave import PlannedLeaveDay +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.planned_leave_repository import PlannedLeaveRepository from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork from app.repositories.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import WorkspaceRepository -from app.services.allocation_service import ( - PlanningEngineService, - PlanProposal, - WindowOutcome, - is_free_day, -) +from app.entities.leave_wish import LeaveWish +from app.services.allocation_service import PlanningEngineService, PlanProposal, WindowOutcome from app.services.analytics_service import AnalyticsService +from app.services.day_guards import reject_illegal_free_day +from app.services.leave_wish_service import LeaveWishService from app.utils.errors import DubbeldagError, NotFoundError, ValidationError -from app.utils.holidays import get_swedish_holidays from app.utils.tip_predicates import default_tier_pool_share -# The two day-type buckets a persisted day can belong to for eviction -# purposes -- see `EvictionCandidate`/`ResourceShortage` below. Exhaustive and -# mutually exclusive with `_PERSISTABLE_DAY_TYPES` (defined below). -_PL_DAY_TYPES = {"sjukpenning", "lagsta"} - -# `sync_plan`'s two caller-authorized eviction strategies (§ eviction-preview -# feature). Anything else (including `None`, the default) means "do nothing": -# never evict without an explicit instruction. -EVICTION_CHOICE_DROP_FUTURE_PL = "drop_future_pl" -EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR = "drop_latest_vacation_this_year" - # Day types that can actually be persisted as a `planned_leave_day` row. A # proposal may also contain 'free' entries (automatic weekend/red-day # non-workdays, see allocation_service.ProposedDay) purely for explainability @@ -70,8 +57,6 @@ class ProposedDayInput: day_type: str is_dubbeldag: bool = False wish_id: UUID | None = None - # True once the user hand-edits this day; see `PlannedLeaveDay.is_pinned`. - is_pinned: bool = False @dataclass(frozen=True) @@ -132,6 +117,21 @@ class PlanChange: - 'retyped' — the row survived but `day_type`/`is_dubbeldag` changed. - 'reattributed' — same day and type, but `wish_id` changed (the day now belongs to a different period). + + `evicted` can be `True` on a 'removed' OR a 'reattributed' change — both + are ways a wish can lose a date it still wants to a *different* wish's + higher-priority claim (see `PlanningService._genuinely_evicted_rows`): + 'removed' when the two wishes land on different (child, parent, date) + keys so the losing wish's row is deleted outright, 'reattributed' when + they land on the EXACT SAME key so the row survives and is merely + repointed at the winning wish's `wish_id` — that repointing is still a + genuine displacement of the OLD wish, not the ordinary "wish itself was + edited/retargeted" case, and must be called out the same way. Step 2 of + the intent-vs-derived-output restructure made displacement an + informational outcome rather than a refusal — `sync_plan` always writes, + and this flag is what lets the UI call out "N days were freed up for a + higher-priority period" instead of folding it into the same silent + "N days changed" count as a self-caused edit. """ kind: str @@ -144,65 +144,7 @@ class PlanChange: new_is_dubbeldag: bool | None old_wish_id: UUID | None new_wish_id: UUID | None - - -@dataclass(frozen=True) -class EvictionCandidate: - """One previously-persisted day a fresh `sync_plan` would otherwise - - silently delete because a *different* wish's edit consumed the shared - pool ahead of it (see `PlanningService._genuinely_evicted_rows` for the - "genuinely evicted" vs. "this wish's own window shrank" distinction -- - only the former ever appears here). Carries everything the frontend - needs to name the day without the backend pre-rendering prose: - `wish_name` is resolved from the *pre-write* `LeaveWish` row (`None` - when the service has no `LeaveWishRepository` wired, or the wish's name - can't be found -- degrades gracefully rather than failing the preview). - """ - - date: date - parent_personal_data_uuid: UUID - child_id: UUID - day_type: str - is_dubbeldag: bool - wish_id: UUID - wish_name: str | None - - -@dataclass(frozen=True) -class ResourceShortage: - """One exhausted resource implicated by an eviction preview. - - Names precisely what ran out (§3 of the eviction-preview requirement): - `resource_kind == 'parental_leave'` carries `tier` + `child_id` (a - specific sjukpenning/lagsta pool for one parent+child); `resource_kind - == 'vacation'` carries `year` instead (`tier`/`child_id` are `None` -- - vacation is budgeted per parent+year, not per child). Never a - pre-rendered string -- the frontend composes the warning copy itself. - """ - - resource_kind: str # 'parental_leave' | 'vacation' - parent_personal_data_uuid: UUID - tier: str | None - child_id: UUID | None - year: int | None - days_evicted: int - - -@dataclass(frozen=True) -class EvictionPreview: - """What a `sync_plan` call would silently delete, and why -- always - - present on `PlanSyncResult` (empty/inert when nothing would be - evicted, so the common case costs the caller nothing extra to check). - """ - - would_evict: bool - candidates: list[EvictionCandidate] = field(default_factory=list) - shortages: list[ResourceShortage] = field(default_factory=list) - - -_NO_EVICTION_PREVIEW = EvictionPreview(would_evict=False) + evicted: bool = False @dataclass(frozen=True) @@ -210,7 +152,137 @@ class PlanSyncResult: proposal: PlanProposal apply_result: PlanApplyResult changes: list[PlanChange] = field(default_factory=list) - eviction_preview: EvictionPreview = _NO_EVICTION_PREVIEW + + +# --------------------------------------------------------------------------- +# POST /api/plan/sync-with-wish — the calendar's four create/edit-then-sync +# paths (drag-select, "promote" an ad-hoc band, edit a wish-backed band, pick +# a preset), each collapsed into ONE atomic backend call. See +# `PlanningService.sync_with_wish`'s docstring for the shape and the +# atomicity guarantee. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class WishCreateInput: + """Everything `LeaveWishService.create` needs, minus `workspace_id` + + (carried once on `sync_with_wish` itself). Mirrors `LeaveWishCreate` + field-for-field — kept as a separate domain dataclass (not the Pydantic + model) for the same reason `ProposedDayInput` is: the service stays + independent of the wire schema. + """ + + name: str + window_start_month: int + window_start_day: int + window_end_month: int + window_end_day: int + target_weeks: int + first_year: int + child_id: UUID | None = None + name_message_key: str | None = None + target_days: int | None = None + recurrence: str = "once" + last_year: int | None = None + priority: int = 1 + parent_mode: str = "either" + parent_personal_data_uuid: UUID | None = None + window_mode: str = "fixed" + series_id: UUID | None = None + wants_dubbeldag: bool = False + + +@dataclass(frozen=True) +class WishUpdateInput: + """Everything `LeaveWishService.update` needs for one occurrence/series edit. + + Mirrors `LeaveWishUpdate` field-for-field, including its `clear_*` + "explicitly set to null" flags (see that model's docstring) — the + router computes those from the patch's `model_fields_set`, exactly as + `PUT /api/leave-wishes/{id}` already does. + """ + + leave_wish_id: UUID + scope: str = "occurrence" + child_id: UUID | None = None + name: str | None = None + name_message_key: str | None = None + window_start_month: int | None = None + window_start_day: int | None = None + window_end_month: int | None = None + window_end_day: int | None = None + target_weeks: int | None = None + target_days: int | None = None + recurrence: str | None = None + first_year: int | None = None + last_year: int | None = None + priority: int | None = None + parent_mode: str | None = None + parent_personal_data_uuid: UUID | None = None + window_mode: str | None = None + wants_dubbeldag: bool | None = None + clear_child_id: bool = False + clear_last_year: bool = False + clear_parent_personal_data_uuid: bool = False + clear_name_message_key: bool = False + clear_target_days: bool = False + + +# What `sync_with_wish` may do to one already-persisted `planned_leave_day` +# row before running the sync — see that method's docstring for when the +# calendar uses which. Restructure step 3b: "pin"/"unpin" are `DayAssertion` +# writes now (`PlanningService._assert_pin`/`_clear_pin`), not a column flip +# — there is no `is_pinned` column left on `planned_leave_day`. +# - 'attach' — re-point the row at this call's wish AND assert it (the +# "promote, locked" path: keep these exact days, just under the new wish). +# - 'pin' / 'unpin' — assert/retract at the row's own key, `wish_id` +# untouched (editing an existing wish-backed band's Planering ladder). +# - 'detach' — delete the row outright and retract any assertion at its key +# (hand it back to the planner to refill from scratch). +_DAY_LOCK_ACTIONS = frozenset({"attach", "pin", "unpin", "detach"}) + + +@dataclass(frozen=True) +class DayLockAction: + planned_day_id: UUID + action: str # one of `_DAY_LOCK_ACTIONS` + + +@dataclass(frozen=True) +class WishSyncResult: + """`sync_with_wish`'s return value: the wish as created/updated, plus the + + same `PlanSyncResult` `sync_plan` itself returns. `wish` reflects the + fields the caller asked for even when `sync.apply_result.accepted` is + `False` — the whole point of the atomic call is that in that case the + wish (and every day-lock action) was rolled back and does NOT exist in + the database, but the caller (the calendar's toast/undo UI) still needs + the would-be id/fields to render the outcome, e.g. matching `sync. + proposal.outcomes` by `wish_id`. Always check `sync.apply_result. + accepted` before treating `wish` as persisted. + """ + + wish: LeaveWish + sync: PlanSyncResult + + +class _SyncWithWishRejected(Exception): + """Internal control-flow only — never escapes `sync_with_wish`. + + Raised inside `sync_with_wish`'s `unit_of_work()` block the moment the + inner `sync_plan` call comes back `accepted=False`, so the surrounding + `unit_of_work()` rolls back the wish create/update and every day-lock + action along with it — without this, `sync_plan` reporting a rejection + is an ordinary return value, not an exception, and the transaction would + otherwise commit the wish anyway (exactly the orphan-wish bug this + endpoint exists to make structurally impossible). + """ + + def __init__(self, wish: LeaveWish, result: PlanSyncResult) -> None: + super().__init__("sync_plan rejected the batch; rolling back the wish/day changes too") + self.wish = wish + self.result = result class PlanningService: @@ -221,10 +293,11 @@ class PlanningService: parental_leave_repo: ParentalLeaveRepository, vacation_repo: VacationDaysRepository, planning_engine_service: PlanningEngineService | None = None, - leave_wish_repo: LeaveWishRepository | None = None, uow: UnitOfWork | None = None, workspace_repo: WorkspaceRepository | None = None, day_override_repo: DayOverrideRepository | None = None, + leave_wish_service: LeaveWishService | None = None, + day_assertion_repo: DayAssertionRepository | None = None, ) -> None: self._repo = repo self._analytics = analytics_service @@ -235,13 +308,6 @@ class PlanningService: # ever use the per-day CRUD/`apply_proposed_days` paths don't need to # wire up a `PlanningEngineService` they'll never use. self._engine = planning_engine_service - # Only needed by `sync_plan`'s eviction preview, to resolve a stale - # row's `wish_id` back to a human-readable name (`EvictionCandidate. - # wish_name`) -- optional for the same reason `_engine` is: existing - # direct-construction call sites that never hit the eviction path - # don't need to wire one up. `wish_name` degrades to `None` when this - # is unset, never raises. - self._leave_wish_repo = leave_wish_repo # Defaults to a no-op so pre-existing direct-construction call sites # (scripts/seed_demo.py, test fixtures) keep today's per-write-commit # behavior; `app.dependencies.get_planning_service` always wires a @@ -251,55 +317,103 @@ class PlanningService: # free day — weekend/red day, per workspace `DayOverride` — can never # be `vacation`, and may only be `sjukpenning`/`lagsta` when the # workspace opted in via `take_pl_on_red_days`). Optional for the - # same reason `_engine`/`_leave_wish_repo` are: existing direct- + # same reason `_engine` is: existing direct- # construction call sites (scripts/seed_demo.py, older test # fixtures) don't wire these up and must keep today's behavior # unchanged — the guard is a no-op when either is unset. self._workspace_repo = workspace_repo self._day_override_repo = day_override_repo + # Only needed by `sync_with_wish` (POST /api/plan/sync-with-wish) — + # optional for the same reason `_engine` is: existing direct- + # construction call sites that never hit that path don't need to + # wire one up. + self._leave_wish_service = leave_wish_service + # Restructure step 3b — the ONLY source of "is this day pinned" + # (`pinned_keys`, consulted by `sync_plan`'s own pre-write snapshot + # and by the router for display) and what `sync_with_wish`'s + # 'attach'/'pin'/'unpin' day-lock actions write to. Optional so + # existing direct-construction call sites that never touch those + # paths (scripts/seed_demo.py, older test fixtures) don't need to + # wire one up; `app.dependencies.get_planning_service` always does. + self._day_assertion_repo = day_assertion_repo async def _reject_illegal_free_day(self, workspace_id: UUID, d: date, day_type: str) -> None: """Authoritative backend guard behind the calendar's hand-pin path. - A free day (Saturday, Sunday, or Swedish red day, subject to the - workspace's `DayOverride`s — see `allocation_service.is_free_day`, - the single source of truth for "is this day free") can never be - persisted as `vacation`. It may be `sjukpenning`/`lagsta` only when - the workspace opted in via `Workspace.take_pl_on_red_days` (default - False). No-op for any other `day_type`, and — critically — a no-op - entirely when either optional repo isn't wired, so callers that - don't pass them (scripts/seed_demo.py, existing test fixtures) keep - byte-identical behavior to before this guard existed. + No-op entirely when either optional repo isn't wired, so callers + that don't pass them (scripts/seed_demo.py, existing test fixtures) + keep byte-identical behavior to before this guard existed. The rule + itself lives in `app.services.day_guards.reject_illegal_free_day` — + shared with `DayAssertionService`, which now owns the hand-pin write + path too (restructure step 3b) — so it is enforced in exactly one + place instead of two copies drifting apart. """ if self._workspace_repo is None or self._day_override_repo is None: return - if day_type not in ("vacation", "sjukpenning", "lagsta"): - return - - holidays_map = get_swedish_holidays(d.year) overrides = { o.date: o.is_working_day for o in await self._day_override_repo.list_by_workspace(workspace_id) } - if not is_free_day(d, holidays_map, overrides): - return - - if day_type == "vacation": - raise ValidationError( - "Vacation cannot be planned on a free day (weekend or red day)", - message_key="planning.rejection.vacationOnFreeDay", - data={"date": d.isoformat()}, - ) - workspace = await self._workspace_repo.get_by_id(workspace_id) - take_pl_on_red_days = workspace.take_pl_on_red_days if workspace is not None else False - if not take_pl_on_red_days: - raise ValidationError( - "Parental leave cannot be planned on a free day unless the workspace " - "setting take_pl_on_red_days is enabled", - message_key="planning.rejection.parentalLeaveOnFreeDayDisabled", - data={"date": d.isoformat()}, + reject_illegal_free_day(workspace, overrides, d, day_type) + + async def pinned_keys(self, workspace_id: UUID) -> frozenset[tuple[UUID, UUID, date]]: + """`(parent_uuid, child_id, date)` for every locked-in-place day in + + this workspace — the ONE function every reader of "is this day + pinned" in this class calls (`sync_plan`'s pre-write snapshot, and + the read paths below that annotate `PlannedLeaveDayResponse. + is_pinned` for the calendar UI's lock icon). See + `app.entities.day_assertion.partition_assertion_keys`'s docstring + for why step 3a's two independently-computed readers is exactly the + bug class this single shared call prevents. + """ + if self._day_assertion_repo is None: + return frozenset() + assertions = await self._day_assertion_repo.list_by_workspace(workspace_id) + pinned, _excluded = partition_assertion_keys(assertions) + return pinned + + async def is_pinned(self, row: PlannedLeaveDay) -> bool: + """Display-only helper for a single row — see `pinned_keys`.""" + pinned = await self.pinned_keys(row.workspace_id) + return (row.parent_personal_data_uuid, row.child_id, row.date) in pinned + + async def _assert_pin(self, row: PlannedLeaveDay, wish_id: UUID | None) -> None: + """Lock `row`'s current `day_type`/`is_dubbeldag` in place via a + + `DayAssertion` at its own (workspace, child, parent, date) key, + attached to `wish_id` (attachment only, for band naming — see + `app.entities.day_assertion.DayAssertion`). Used by `sync_with_ + wish`'s 'attach'/'pin' day-lock actions. No-op when no + `DayAssertionRepository` is wired (existing direct-construction call + sites that never hit `sync_with_wish`). + """ + if self._day_assertion_repo is None: + return + now = datetime.now() + await self._day_assertion_repo.upsert( + DayAssertion( + id=uuid4(), + workspace_id=row.workspace_id, + child_id=row.child_id, + parent_personal_data_uuid=row.parent_personal_data_uuid, + date=row.date, + day_type=row.day_type, + is_dubbeldag=row.is_dubbeldag, + wish_id=wish_id, + created_at=now, + updated_at=now, ) + ) + + async def _clear_pin(self, row: PlannedLeaveDay) -> None: + """Retract any assertion at `row`'s key — see `_assert_pin`.""" + if self._day_assertion_repo is None: + return + await self._day_assertion_repo.delete_by_natural_key( + row.workspace_id, row.child_id, row.parent_personal_data_uuid, row.date + ) async def add_planned_day( self, @@ -310,11 +424,15 @@ class PlanningService: day_type: str, is_dubbeldag: bool = False, wish_id: UUID | None = None, - is_pinned: bool = False, ) -> PlannedLeaveDay: """Add a planned leave day. Validates that the day is available (dubbeldagar rules, no double-booking). + Purely a derived-row write — "pinned" is never a parameter here any + more (restructure step 3b): a caller that wants this day locked in + place asserts it separately, via `DayAssertionService.assert_day` + (`POST /api/day-assertions`), which mirrors its own write into this + same table. """ # Validation 0: a free day (weekend/red day) can never be vacation, # and may only be parental leave when the workspace opted in — see @@ -349,7 +467,6 @@ class PlanningService: day_type=day_type, is_dubbeldag=is_dubbeldag, wish_id=wish_id, - is_pinned=is_pinned, created_at=now, updated_at=now, ) @@ -404,10 +521,22 @@ class PlanningService: day_type: str | None = None, is_dubbeldag: bool | None = None, wish_id: UUID | None = None, - is_pinned: bool | None = None, workspace_id: UUID | None = None, + _clear_wish_id: bool = False, ) -> PlannedLeaveDay: - """Update a planned day (partial update).""" + """Update a planned day (partial update). + + `is_pinned` is not a parameter (restructure step 3b) — see + `add_planned_day`'s docstring. + + `_clear_wish_id` follows the same convention as `LeaveWishService. + update`'s `_clear_*` flags: a nullable field's "no change" and "set + to None" both look like `None` on the wire, so an OMITTED `wish_id` + (leave unchanged) and an explicit `wish_id=None` (detach from the + wish) must be told apart by a separate flag rather than conflated — + callers/routers pass `_clear_wish_id=True` only when the request + payload explicitly set `wish_id: null`. + """ entity = await self.get(planned_leave_day_id, workspace_id) if day_type is not None: @@ -421,10 +550,10 @@ class PlanningService: entity.day_type = day_type if is_dubbeldag is not None: entity.is_dubbeldag = is_dubbeldag - if wish_id is not None: + if _clear_wish_id: + entity.wish_id = None + elif wish_id is not None: entity.wish_id = wish_id - if is_pinned is not None: - entity.is_pinned = is_pinned return await self._repo.update(planned_leave_day_id, entity) @@ -457,8 +586,8 @@ class PlanningService: identical batch creates nothing and is reported as all `skipped_identical_count`. Identity here is `day_type`/`is_dubbeldag` only — a day whose only difference from what's persisted is - `wish_id`/`is_pinned` still counts as `skipped_identical_count`, but - is quietly written as a metadata-only update rather than left stale; + `wish_id` still counts as `skipped_identical_count`, but is quietly + written as a metadata-only update rather than left stale; it is never treated as a collision. A date already planned *differently* (in `day_type`/`is_dubbeldag`) is a collision — by default the whole batch is refused and nothing is written; pass @@ -513,9 +642,9 @@ class PlanningService: key = (d.child_id, d.parent_personal_data_uuid, d.date) prior = deduped.get(key) # Conflict is keyed on day_type/is_dubbeldag only — the same - # (child, parent, date) appearing twice with different `wish_id`/ - # `is_pinned` is not a genuine conflict (attribution metadata, - # not the persisted day's identity); the later entry simply wins. + # (child, parent, date) appearing twice with a different `wish_id` + # is not a genuine conflict (attribution metadata, not the + # persisted day's identity); the later entry simply wins. if prior is not None and ( prior.day_type != d.day_type or prior.is_dubbeldag != d.is_dubbeldag ): @@ -597,11 +726,10 @@ class PlanningService: skipped_identical_count += 1 # Identical-vs-collision is (and stays) keyed on # day_type/is_dubbeldag only. A row whose only difference is - # `wish_id`/`is_pinned` is never a collision requiring - # `replace=True` — it's a plain metadata update, written - # below without touching `skipped_identical_count` or - # `collisions`. - if existing.wish_id != d.wish_id or existing.is_pinned != d.is_pinned: + # `wish_id` is never a collision requiring `replace=True` + # — it's a plain metadata update, written below without + # touching `skipped_identical_count` or `collisions`. + if existing.wish_id != d.wish_id: to_update_metadata.append((existing, d)) continue collisions.append( @@ -679,7 +807,6 @@ class PlanningService: day_type=d.day_type, is_dubbeldag=d.is_dubbeldag, wish_id=d.wish_id, - is_pinned=d.is_pinned, created_at=now, updated_at=now, ) @@ -690,11 +817,10 @@ class PlanningService: created = to_insert if to_update_metadata: - metadata_pairs: list[tuple[UUID, UUID | None, bool]] = [] + metadata_pairs: list[tuple[UUID, UUID | None]] = [] for existing, d in to_update_metadata: existing.wish_id = d.wish_id - existing.is_pinned = d.is_pinned - metadata_pairs.append((existing.id, d.wish_id, d.is_pinned)) + metadata_pairs.append((existing.id, d.wish_id)) await self._repo.update_metadata_many(metadata_pairs) return PlanApplyResult( @@ -738,7 +864,6 @@ class PlanningService: self, workspace_id: UUID, today: date | None = None, - eviction_choice: str | None = None, ) -> PlanSyncResult: """Propose, persist, and diff — the single call the direct- @@ -748,40 +873,44 @@ class PlanningService: persisted afterwards — never reconstructed from the client side or guessed from `apply_proposed_days`'s own (coarser) counters. - **Eviction preview/resolution.** Not every stale key (see the module - comment above) is a silent-data-loss bug: a wish the user just - edited to claim fewer/different dates sheds its OWN old rows as - 'removed' `changes` — expected, and applied immediately with no - friction. The bug this parameter fixes is different: a stale row + **Displacement is informational, never a refusal.** Not every stale + key (see the module comment above) means the same thing: a wish the + user just edited to claim fewer/different dates sheds its OWN old + rows as 'removed' `changes` — self-caused, expected. A stale row whose wish was NOT touched, still wants exactly what it always wanted, but lost the priority race for a shared pool to some OTHER - wish's edit. `_genuinely_evicted_rows` tells the two apart by - checking, for each stale row, whether ITS OWN wish's fresh - `WindowOutcome` for that date now reports `shortfall_days > 0` — the - wish still wants that capacity and didn't get it, as opposed to no - longer wanting it at all. + wish's edit, is different — "genuinely evicted". `_genuinely_evicted_ + rows` tells the two apart by checking, for each stale row, whether + ITS OWN wish's fresh `WindowOutcome` for that date now reports + `shortfall_days > 0` — the wish still wants that capacity and didn't + get it, as opposed to no longer wanting it at all. - When nothing is genuinely evicted, this call behaves exactly as - before (no extra round trip, no extra friction — requirement 5). - When something would be genuinely evicted: - - `eviction_choice is None` (the default): nothing is written at - all, not even the harmless parts of the batch — "never evict - without an explicit instruction". The returned `eviction_preview` - names what ran out (`ResourceShortage`) and exactly which days - would go (`EvictionCandidate`), so the caller can ask the user. - - `eviction_choice` names one of the two caller-authorized - strategies (`EVICTION_CHOICE_DROP_FUTURE_PL` / - `EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR`): only the - genuinely-evicted rows matching that category (future PL days / - this calendar year's vacation days) are added to `delete_keys`, - on top of the always-deleted self-caused stale rows. If that - doesn't free enough (e.g. the real shortage was the OTHER - category), `apply_proposed_days`' own balance validation rejects - the whole batch exactly as it would have anyway — atomic, nothing - partially written. + Step 2 of the intent-vs-derived-output restructure removed the + earlier behavior where a genuine eviction refused to write anything + at all pending a caller-supplied `eviction_choice` — no caller ever + supplied one, so that refusal was permanent and silent (a background + sync from anywhere other than Settings had nowhere to surface it). + The allocator's own model is already "shortfall, not failure" (a + `WindowOutcome` reports `shortfall_days` rather than refusing to + propose); eviction now follows the same rule. This call always + writes every stale key's deletion alongside the fresh batch — a + genuinely evicted row is reported back as a 'removed' `PlanChange` + with `evicted=True` (see that field's docstring) so the UI can call + it out distinctly, but it never blocks the write. Pinned days are structurally excluded from `stale_keys` itself (see - below), so no eviction strategy can ever reach one. + below), so a pinned row can never be displaced. A row dated + strictly before `resolved_today` is excluded the same way -- "the + past is fact": the allocator's fixed/flexible window expansion clips + every window to `>= today` (`_expand_all_windows`/ + `_expand_flexible_wish_years` in `allocation_service.py`), so a + wish's already-elapsed dates are never present in `proposal.days` + no matter how genuinely that wish still "owns" them -- without this + exclusion they'd always look stale and get silently deleted the + first time `sync_plan` runs after they elapse. `today` itself is + NOT past (still inside the allocator's own `>= today` window and + therefore still legitimately re-plannable in the same call), so + only `row.date < resolved_today` is frozen, not `<=`. """ if self._engine is None: raise ValidationError("PlanningService.sync_plan requires a PlanningEngineService") @@ -798,7 +927,16 @@ class PlanningService: before_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = { (d.child_id, d.parent_personal_data_uuid, d.date): d for d in before } - pinned_keys = {key for key, d in before_by_key.items() if d.is_pinned} + # Restructure step 3b — `self.pinned_keys` (a single, shared + # `DayAssertion` read — see its docstring) is the SAME call + # `AllocationInputLoader.load()`'s `only_pinned_existing=True` + # filter effectively bases its own immovable-day set on, so this + # snapshot can never disagree with what `self._engine.propose(..., + # only_pinned_existing=True)` just below treated as immovable — + # the exact bug class step 3a's two independent readers caused + # (see the restructure notes' "Bug found in 3a"). + raw_pinned = await self.pinned_keys(workspace_id) + pinned_keys = {(child_id, parent_uuid, d) for (parent_uuid, child_id, d) in raw_pinned} proposal = await self._engine.propose( workspace_id, today=today, only_pinned_existing=True @@ -812,7 +950,6 @@ class PlanningService: day_type=d.day_type, is_dubbeldag=d.is_dubbeldag, wish_id=d.wish_id, - is_pinned=False, ) for d in proposal.days if d.day_type != "free" @@ -826,15 +963,20 @@ class PlanningService: batch_keys = {(d.child_id, d.parent_personal_data_uuid, d.date) for d in batch} # Rows the freshly-computed plan no longer accounts for at all: - # unpinned, wish-attributed, and absent from this run's batch — e.g. - # the wish was deleted, or its window moved elsewhere. Passed as - # `delete_keys` so `apply_proposed_days` validates the deletions - # together with the creates/replaces (see the comment above this - # method) rather than deleting them itself afterwards. + # unpinned, wish-attributed, dated `>= resolved_today` (see the + # "past is fact" note in this method's docstring), and absent + # from this run's batch — e.g. the wish was deleted, or its + # window moved elsewhere. Passed as `delete_keys` so + # `apply_proposed_days` validates the deletions together with + # the creates/replaces (see the comment above this method) + # rather than deleting them itself afterwards. stale_keys = { key for key, row in before_by_key.items() - if not row.is_pinned and row.wish_id is not None and key not in batch_keys + if key not in pinned_keys + and row.wish_id is not None + and row.date >= resolved_today + and key not in batch_keys } stale_rows = [before_by_key[key] for key in stale_keys] @@ -842,35 +984,12 @@ class PlanningService: # exact capacity (fresh `shortfall_days > 0`) but lost it to a # different wish's edit — see this method's docstring. Every # other stale row is "self-caused" (the wish itself moved/ - # shrank/was retargeted) and is always deleted, exactly like - # before this parameter existed. + # shrank/was retargeted). Both kinds are always deleted — the + # split only matters for how the resulting `PlanChange` is + # flagged, never for whether the write happens. evicted_rows = self._genuinely_evicted_rows(stale_rows, proposal.outcomes) - eviction_preview = await self._build_eviction_preview(workspace_id, evicted_rows) - - if not evicted_rows: - delete_keys = stale_keys - elif eviction_choice is None: - return PlanSyncResult( - proposal=proposal, - apply_result=PlanApplyResult( - accepted=False, - rejections=[ - Rejection( - message_key="planSync.rejection.evictionChoiceRequired", - data={"count": len(evicted_rows)}, - ) - ], - ), - changes=[], - eviction_preview=eviction_preview, - ) - else: - evicted_keys = {self._row_key(row) for row in evicted_rows} - self_caused_keys = stale_keys - evicted_keys - authorized_keys = self._eviction_delete_keys( - evicted_rows, eviction_choice, resolved_today - ) - delete_keys = self_caused_keys | authorized_keys + evicted_keys = {self._row_key(row) for row in evicted_rows} + delete_keys = stale_keys apply_result = await self.apply_proposed_days( workspace_id=workspace_id, days=batch, replace=True, delete_keys=delete_keys @@ -918,6 +1037,7 @@ class PlanningService: new_is_dubbeldag=None, old_wish_id=old.wish_id, new_wish_id=None, + evicted=key in evicted_keys, ) ) elif old is not None and new is not None: @@ -937,6 +1057,19 @@ class PlanningService: ) ) elif old.wish_id != new.wish_id: + # A same-date collision between two DIFFERENT + # wishes never shows up in `stale_keys` (the key + # survives — only `wish_id` changes), so without + # this check it would silently look like an + # ordinary metadata update instead of the OLD + # wish genuinely losing this date to the new + # one. Reuses the exact same "does the row's own + # wish still have unmet shortfall here" test + # `_genuinely_evicted_rows` applies to deleted + # stale rows. + reattributed_evicted = bool( + self._genuinely_evicted_rows([old], proposal.outcomes) + ) changes.append( PlanChange( kind="reattributed", @@ -949,6 +1082,7 @@ class PlanningService: new_is_dubbeldag=new.is_dubbeldag, old_wish_id=old.wish_id, new_wish_id=new.wish_id, + evicted=reattributed_evicted, ) ) @@ -956,9 +1090,172 @@ class PlanningService: proposal=proposal, apply_result=apply_result, changes=changes, - eviction_preview=eviction_preview, ) + # -- sync-with-wish (POST /api/plan/sync-with-wish) -------------------- + # + # Step 1 of the intent-vs-derived-output restructure: promoting a band, + # drawing a new one, editing a wish-backed one, or picking a preset used + # to be a client-orchestrated 3-phase transaction (create/patch the wish + # -> re-point or delete the affected `planned_leave_day` rows -> sync) + # with compensating rollback written in React. A tab close, crash, or + # network drop between any two phases left that half-applied — no amount + # of client-side rollback logic can fix a transaction that isn't one. + # This method IS the fix: the wish write, every day-lock action, and the + # sync all run inside one `self._uow()` unit of work, so the frontend + # needs exactly one call and gets an atomic all-or-nothing result. + + async def sync_with_wish( + self, + workspace_id: UUID, + wish_create: WishCreateInput | None = None, + wish_update: WishUpdateInput | None = None, + day_actions: list[DayLockAction] | None = None, + ) -> WishSyncResult: + """One atomic call: create-or-update the wish, apply the day-lock/ + + detach list, and sync — the single backend call behind all four of + the calendar's create/edit-then-sync paths: + + - drag-select's default path (`wish_create` only, no `day_actions`); + - promoting an ad-hoc band (`wish_create` + `day_actions` of + 'attach' — keep the band's exact days, re-pointed at the new wish + -- or 'detach' -- hand them back to the planner); + - editing an existing wish-backed band (`wish_update` + + `day_actions` of 'pin'/'unpin', following the Planering ladder); + - picking a preset (`wish_create` only). + + Exactly one of `wish_create`/`wish_update` must be given -- this call + always touches exactly one wish, never zero, never both. + + **Atomicity.** Everything below runs inside one `self._uow()` unit of + work. `sync_plan` reporting `accepted=False` (a structural batch + error, or a genuine balance/dubbeldagar-quota/eligibility rejection + -- eviction alone no longer causes one, see `sync_plan`'s docstring) + is an ordinary return value, not an exception -- left alone, the + surrounding unit of work would still commit the wish create/update + and every day-lock action even though the sync itself wrote nothing, + recreating exactly the orphan-wish bug this endpoint replaces. + `_SyncWithWishRejected` exists to force that case to roll back too: + raised the moment `sync_plan` comes back rejected, caught right + outside the `async with self._uow():` block (by which point the + rollback has already happened), and translated back into a normal, + non-throwing `WishSyncResult` -- a rejection is a structured, + expected outcome for the UI to render (see `PlanSyncResult`), not an + HTTP error. A genuine exception (a `ValidationError` from the wish's + own fields, a `NotFoundError` for a stale `planned_day_id`) is NOT + caught here -- it propagates normally, the unit of work still rolls + everything back, and the router maps it to an HTTP error exactly as + the granular endpoints already do. + + `day_actions` runs BEFORE `sync_plan` so the fresh proposal sees + their final state -- an 'attach'/'pin' row must already be pinned + (and, for 'attach', already pointing at the new wish) when `sync_plan` + reads its pre-write snapshot, or the sync would treat it as an + ordinary unpinned/stale row and silently retype or displace it; a + 'detach' row must already be gone so the capacity it held is credited + back before the batch's new demand is checked (see `apply_proposed_ + days`'s `delete_keys` docstring for why order matters there too). + """ + if self._leave_wish_service is None: + raise ValidationError("PlanningService.sync_with_wish requires a LeaveWishService") + if (wish_create is None) == (wish_update is None): + raise ValidationError("sync_with_wish requires exactly one of wish_create/wish_update") + for action in day_actions or []: + if action.action not in _DAY_LOCK_ACTIONS: + raise ValidationError(f"unknown day lock action: {action.action!r}") + + try: + async with self._uow(): + if wish_create is not None: + wish = await self._leave_wish_service.create( + workspace_id=workspace_id, + child_id=wish_create.child_id, + name=wish_create.name, + name_message_key=wish_create.name_message_key, + window_start_month=wish_create.window_start_month, + window_start_day=wish_create.window_start_day, + window_end_month=wish_create.window_end_month, + window_end_day=wish_create.window_end_day, + target_weeks=wish_create.target_weeks, + target_days=wish_create.target_days, + recurrence=wish_create.recurrence, + first_year=wish_create.first_year, + last_year=wish_create.last_year, + priority=wish_create.priority, + parent_mode=wish_create.parent_mode, + parent_personal_data_uuid=wish_create.parent_personal_data_uuid, + window_mode=wish_create.window_mode, + series_id=wish_create.series_id, + wants_dubbeldag=wish_create.wants_dubbeldag, + ) + else: + assert wish_update is not None # exactly-one check above + wish = await self._leave_wish_service.update( + leave_wish_id=wish_update.leave_wish_id, + workspace_id=workspace_id, + scope=wish_update.scope, + child_id=wish_update.child_id, + name=wish_update.name, + name_message_key=wish_update.name_message_key, + window_start_month=wish_update.window_start_month, + window_start_day=wish_update.window_start_day, + window_end_month=wish_update.window_end_month, + window_end_day=wish_update.window_end_day, + target_weeks=wish_update.target_weeks, + target_days=wish_update.target_days, + recurrence=wish_update.recurrence, + first_year=wish_update.first_year, + last_year=wish_update.last_year, + priority=wish_update.priority, + parent_mode=wish_update.parent_mode, + parent_personal_data_uuid=wish_update.parent_personal_data_uuid, + window_mode=wish_update.window_mode, + wants_dubbeldag=wish_update.wants_dubbeldag, + _clear_child_id=wish_update.clear_child_id, + _clear_last_year=wish_update.clear_last_year, + _clear_parent_personal_data_uuid=( + wish_update.clear_parent_personal_data_uuid + ), + _clear_name_message_key=wish_update.clear_name_message_key, + _clear_target_days=wish_update.clear_target_days, + ) + + for action in day_actions or []: + if action.action == "attach": + # Re-point the row at this call's wish (still a plain + # `planned_leave_day` field) AND lock it in place — + # restructure step 3b expresses "pin" purely as a + # `DayAssertion` write, there is no `is_pinned` column + # left to flip. + row = await self.update( + action.planned_day_id, wish_id=wish.id, workspace_id=workspace_id + ) + await self._assert_pin(row, wish_id=wish.id) + elif action.action == "pin": + row = await self.get(action.planned_day_id, workspace_id=workspace_id) + await self._assert_pin(row, wish_id=row.wish_id) + elif action.action == "unpin": + row = await self.get(action.planned_day_id, workspace_id=workspace_id) + await self._clear_pin(row) + else: # "detach" + row = await self.get(action.planned_day_id, workspace_id=workspace_id) + await self.remove_planned_day( + action.planned_day_id, workspace_id=workspace_id + ) + # Hand the slot back to the planner with a clean + # slate — a stray assertion here would keep it + # locked/excluded even though the row is gone. + await self._clear_pin(row) + + sync_result = await self.sync_plan(workspace_id) + if not sync_result.apply_result.accepted: + raise _SyncWithWishRejected(wish, sync_result) + except _SyncWithWishRejected as rejected: + return WishSyncResult(wish=rejected.wish, sync=rejected.result) + + return WishSyncResult(wish=wish, sync=sync_result) + @staticmethod def _row_key(row: PlannedLeaveDay) -> tuple[UUID, UUID, date]: return (row.child_id, row.parent_personal_data_uuid, row.date) @@ -994,105 +1291,6 @@ class PlanningService: evicted.append(row) return evicted - async def _build_eviction_preview( - self, workspace_id: UUID, evicted_rows: list[PlannedLeaveDay] - ) -> EvictionPreview: - """Shape `evicted_rows` into the (candidates, shortages) pair the - - frontend renders the warning + day list from. Deterministic - ordering (date descending, tie-broken on parent/child/day_type) - regardless of the set-iteration order `evicted_rows` arrived in. - """ - if not evicted_rows: - return _NO_EVICTION_PREVIEW - - wish_by_id: dict[UUID, str] = {} - if self._leave_wish_repo is not None: - wishes = await self._leave_wish_repo.get_by_workspace(workspace_id) - wish_by_id = {w.id: w.name for w in wishes} - - ordered = sorted( - evicted_rows, - key=lambda r: ( - r.date, - str(r.parent_personal_data_uuid), - str(r.child_id), - r.day_type, - ), - reverse=True, - ) - - candidates = [ - EvictionCandidate( - date=row.date, - parent_personal_data_uuid=row.parent_personal_data_uuid, - child_id=row.child_id, - day_type=row.day_type, - is_dubbeldag=row.is_dubbeldag, - wish_id=row.wish_id, - wish_name=wish_by_id.get(row.wish_id), - ) - for row in ordered - # `evicted_rows` structurally always has a non-null `wish_id` - # (`_genuinely_evicted_rows` skips ad-hoc rows) -- this filter is - # defense-in-depth, and narrows the type for the constructor call - # below rather than needing a `# type: ignore`. - if row.wish_id is not None - ] - - shortage_counts: dict[tuple[str, UUID, str | None, UUID | None, int | None], int] = {} - shortage_order: list[tuple[str, UUID, str | None, UUID | None, int | None]] = [] - for row in ordered: - if row.day_type in _PL_DAY_TYPES: - key = ( - "parental_leave", - row.parent_personal_data_uuid, - row.day_type, - row.child_id, - None, - ) - else: - key = ("vacation", row.parent_personal_data_uuid, None, None, row.date.year) - if key not in shortage_counts: - shortage_order.append(key) - shortage_counts[key] = shortage_counts.get(key, 0) + 1 - - shortages = [ - ResourceShortage( - resource_kind=key[0], - parent_personal_data_uuid=key[1], - tier=key[2], - child_id=key[3], - year=key[4], - days_evicted=shortage_counts[key], - ) - for key in shortage_order - ] - - return EvictionPreview(would_evict=True, candidates=candidates, shortages=shortages) - - @staticmethod - def _eviction_delete_keys( - evicted_rows: list[PlannedLeaveDay], eviction_choice: str, today: date - ) -> set[tuple[UUID, UUID, date]]: - """The subset of `evicted_rows` the caller's chosen strategy authorizes - - deleting — never more than what's genuinely evicted, and never a - pinned row (structurally impossible here: `evicted_rows` is always a - subset of `stale_keys`, which already excludes pinned rows). - """ - if eviction_choice == EVICTION_CHOICE_DROP_FUTURE_PL: - authorized = [ - r for r in evicted_rows if r.day_type in _PL_DAY_TYPES and r.date >= today - ] - elif eviction_choice == EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR: - authorized = [ - r for r in evicted_rows if r.day_type == "vacation" and r.date.year == today.year - ] - else: - authorized = [] - return {PlanningService._row_key(r) for r in authorized} - async def _validate_dubbeldagar_batch( self, pending: list[ProposedDayInput], diff --git a/backend/migrations/versions/009_day_assertion.py b/backend/migrations/versions/009_day_assertion.py new file mode 100644 index 0000000..4ea8efd --- /dev/null +++ b/backend/migrations/versions/009_day_assertion.py @@ -0,0 +1,197 @@ +"""Add day_assertion: the intent-vs-derived-output restructure's step 3. + +`planned_leave_day` used to do three jobs at once — allocator output cache, +user override store (`is_pinned`, and ad-hoc rows with `wish_id IS NULL`), and +resource ledger/historical record. That blur was the root cause of a string +of defects (see `~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`, +step 3). This migration adds `day_assertion` — one row per (workspace, child, +parent, date) recording what the USER asserts about that day, independent of +what the planner has derived: + +- a pinned `planned_leave_day` row (`is_pinned=1`) -> an assertion carrying + that row's `day_type`/`is_dubbeldag`/`wish_id`. +- an ad-hoc `planned_leave_day` row (`wish_id IS NULL`) -> an assertion the + same way (a row can be both pinned AND ad-hoc; backfilled once, not twice). +- every `planned_day_exclusion` row -> an assertion with `day_type='none'` + ("the planner may not place leave here"). Backfilled AFTER the + `planned_leave_day` pass and written with `INSERT OR REPLACE`, so an + exclusion overwrites whatever assertion its (workspace, child, parent, + date) already picked up — matching runtime behavior, where + `PlannedDayExclusionService.exclude()` always deletes any existing + `planned_leave_day` row at that key, so the two are never meant to + coexist; this only matters for historical data where they still might. + +Step 3a is deliberately additive and reversible: `planned_leave_day.is_pinned` +and the `planned_day_exclusion` table are NOT dropped here (`downgrade()` +leaves them exactly as it found them, since it never touched them). They stay +in place, unused by the allocator's new assertion-based input, so the +frontend can keep calling the legacy `/api/planned-days` and +`/api/planned-day-exclusions` endpoints (now a compatibility shim writing +`day_assertion` rows underneath — see `PlanningService`/ +`PlannedDayExclusionService`) until step 3b moves it over and drops both. + +Must stay byte-for-byte equivalent to the `day_assertion` table in +`app/db/connection.py`'s `_FULL_SCHEMA` — +`tests/integration/test_schema_parity.py` asserts exactly that. + +Revision ID: 009 +Revises: 008 +Create Date: 2026-09-20 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from alembic import op + +revision: str = "009" +down_revision: str | None = "008" +branch_labels: str | None = None +depends_on: str | None = None + +_TABLE_DDL = """ +CREATE TABLE IF NOT EXISTS day_assertion ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + child_id TEXT NOT NULL, + parent_personal_data_uuid TEXT NOT NULL, + date DATE NOT NULL, + day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation','none')), + is_dubbeldag INTEGER NOT NULL DEFAULT 0, + wish_id TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(workspace_id, child_id, parent_personal_data_uuid, date), + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, + FOREIGN KEY (wish_id) REFERENCES leave_wish(id) ON DELETE SET NULL +) +""" + +_INDEX_DDL = """ +CREATE INDEX IF NOT EXISTS idx_day_assertion_workspace + ON day_assertion (workspace_id) +""" + + +def upgrade() -> None: + from sqlalchemy import text + + conn = op.get_bind() + op.execute(_TABLE_DDL) + op.execute(_INDEX_DDL) + + now = datetime.now().isoformat() + + # -- pinned / ad-hoc planned_leave_day rows -> concrete assertions ------- + planned_rows = conn.execute( + text( + "SELECT workspace_id, child_id, parent_personal_data_uuid, date, " + "day_type, is_dubbeldag, wish_id FROM planned_leave_day " + "WHERE is_pinned = 1 OR wish_id IS NULL" + ) + ).fetchall() + for row in planned_rows: + conn.execute( + text( + "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 (:id, :workspace_id, :child_id, :parent_personal_data_uuid, :date, " + " :day_type, :is_dubbeldag, :wish_id, :now, :now)" + ), + { + "id": str(uuid.uuid4()), + "workspace_id": row.workspace_id, + "child_id": row.child_id, + "parent_personal_data_uuid": row.parent_personal_data_uuid, + "date": row.date, + "day_type": row.day_type, + "is_dubbeldag": row.is_dubbeldag, + "wish_id": row.wish_id, + "now": now, + }, + ) + + # -- planned_day_exclusion rows -> day_type='none' assertions ------------ + # `INSERT OR REPLACE`: an exclusion overwrites whatever the pass above + # wrote for the same (workspace, child, parent, date) — see module + # docstring for why the two should never really coexist, and why + # exclusion wins when historical data disagrees. + exclusion_rows = conn.execute( + text( + "SELECT workspace_id, child_id, parent_personal_data_uuid, date " + "FROM planned_day_exclusion" + ) + ).fetchall() + for row in exclusion_rows: + conn.execute( + text( + "INSERT OR REPLACE INTO day_assertion " + "(id, workspace_id, child_id, parent_personal_data_uuid, date, " + " day_type, is_dubbeldag, wish_id, created_at, updated_at) " + "VALUES (:id, :workspace_id, :child_id, :parent_personal_data_uuid, :date, " + " 'none', 0, NULL, :now, :now)" + ), + { + "id": str(uuid.uuid4()), + "workspace_id": row.workspace_id, + "child_id": row.child_id, + "parent_personal_data_uuid": row.parent_personal_data_uuid, + "date": row.date, + "now": now, + }, + ) + + +def downgrade() -> None: + """Drop `day_assertion` — the point of no return for anything it alone + records. + + On a `downgrade` chain past 010, 010's `downgrade()` has already run and + reconstructed `is_pinned`/`planned_day_exclusion` from this table (see + its docstring). The one thing that reconstruction cannot produce is a + concrete assertion with no matching `planned_leave_day` row — there is + no pre-009 column to put that fact in. Once this migration drops the + table, that fact is gone for good, so it is worth surfacing rather than + dropping in silence: warn on stdout, listing each orphaned key, before + the `DROP TABLE`. This does not block the downgrade — an operator + rolling back a bad deploy needs the rollback to complete, not a new + failure — it only makes sure they see what is about to become + unrecoverable. + """ + from sqlalchemy import text + + conn = op.get_bind() + orphaned = conn.execute( + text( + "SELECT da.workspace_id, da.child_id, da.parent_personal_data_uuid, " + " da.date, da.day_type " + "FROM day_assertion da " + "WHERE da.day_type != 'none' AND NOT EXISTS (" + " SELECT 1 FROM planned_leave_day pld " + " WHERE pld.workspace_id = da.workspace_id " + " AND pld.child_id = da.child_id " + " AND pld.parent_personal_data_uuid = da.parent_personal_data_uuid " + " AND pld.date = da.date" + ")" + ) + ).fetchall() + if orphaned: + print( + f"WARNING: dropping day_assertion discards {len(orphaned)} row(s) with no " + "matching planned_leave_day row to carry the fact forward (the pre-009 " + "schema has no field for it). Unrecoverable after this migration:" + ) + for row in orphaned: + print( + f" workspace={row.workspace_id} child={row.child_id} " + f"parent={row.parent_personal_data_uuid} date={row.date} " + f"day_type={row.day_type}" + ) + + op.execute("DROP INDEX IF EXISTS idx_day_assertion_workspace") + op.execute("DROP TABLE IF EXISTS day_assertion") diff --git a/backend/migrations/versions/010_drop_pinned_and_exclusion.py b/backend/migrations/versions/010_drop_pinned_and_exclusion.py new file mode 100644 index 0000000..7feb28a --- /dev/null +++ b/backend/migrations/versions/010_drop_pinned_and_exclusion.py @@ -0,0 +1,134 @@ +"""Drop planned_leave_day.is_pinned and planned_day_exclusion — restructure step 3b. + +Step 3a (migration 009) added `day_assertion` as an additive, reversible +replacement for both: a pinned `planned_leave_day` row, an ad-hoc row +(`wish_id IS NULL`), and a `planned_day_exclusion` row. Step 3b moved every +reader and writer onto `day_assertion` (see `AllocationInputLoader`, +`PlanningService.pinned_keys`/`sync_plan`, `DayAssertionService`) and deleted +the `/api/planned-days` write endpoints and the `/api/planned-day-exclusions` +router entirely — nothing reads either dropped column/table any more. + +`planned_leave_day` is now purely derived output: no `is_pinned` column, +its ONE writer is `sync_plan`/`DayAssertionService`'s mirroring. SQLite +(3.35+, in use here) supports `ALTER TABLE ... DROP COLUMN` directly for a +column with no index/CHECK constraint referencing it, which `is_pinned` +never had. + +Must stay in step with `app/db/connection.py`'s `_FULL_SCHEMA` — +`tests/integration/test_schema_parity.py` asserts exactly that. + +Revision ID: 010 +Revises: 009 +Create Date: 2026-09-20 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "010" +down_revision: str | None = "009" +branch_labels: str | None = None +depends_on: str | None = None + +_EXCLUSION_TABLE_DDL = """ +CREATE TABLE IF NOT EXISTS planned_day_exclusion ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + child_id TEXT NOT NULL, + parent_personal_data_uuid TEXT NOT NULL, + date DATE NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(workspace_id, child_id, parent_personal_data_uuid, date), + FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, + FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, + FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE +) +""" + +_EXCLUSION_INDEX_DDL = """ +CREATE INDEX IF NOT EXISTS idx_planned_day_exclusion_workspace + ON planned_day_exclusion (workspace_id) +""" + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_planned_day_exclusion_workspace") + op.execute("DROP TABLE IF EXISTS planned_day_exclusion") + op.execute("ALTER TABLE planned_leave_day DROP COLUMN is_pinned") + + +def downgrade() -> None: + """Recreate `is_pinned`/`planned_day_exclusion`, reconstructed from + `day_assertion` — which still holds every fact at this point, since this + migration is the only one that ever drops the legacy shapes, and it does + so on `upgrade()`, not here. (009's `downgrade()`, which runs *after* + this one on a `downgrade` past 010, is what finally drops `day_assertion` + itself — see that migration's docstring for the point past which nothing + is recoverable.) + + Reconstruction, not just DDL: + - a `day_assertion` row with a concrete `day_type` + (`sjukpenning`/`lagsta`/`vacation`) sets `is_pinned = 1` on the + `planned_leave_day` row at the same natural key (workspace_id, + child_id, parent_personal_data_uuid, date). + - a `day_assertion` row with `day_type = 'none'` becomes a + `planned_day_exclusion` row at that key, reusing the assertion's own + id/timestamps (they're 1:1, and an id only needs to be unique within + its own table). + + Two ways this is honestly NOT a lossless round trip: + - A concrete assertion with no matching `planned_leave_day` row (an + ad-hoc/pinned day whose mirrored row was since deleted, e.g. + re-derived away by a later plan sync) has nothing to set `is_pinned` + on. The `UPDATE` below simply matches zero rows for it — the fact + is not an error, just unrepresentable in the pre-009 schema. (009's + `downgrade()` warns about exactly this before it drops the table + that's the last place the fact exists.) + - `day_assertion` never recorded *why* a row was a concrete assertion — + pinned, ad-hoc (`wish_id IS NULL`), or both; 009's own upgrade + backfill already conflates them (`WHERE is_pinned = 1 OR wish_id IS + NULL`). So a row that was ad-hoc but never pinned comes back with + `is_pinned = 1` instead of its original `0` — over-restored, not + under-restored, and only for rows already exempt from the allocator + via `wish_id IS NULL`. + - A row that was pinned AND later collided with an exclusion at the + same key before 009's upgrade ran: 009's `INSERT OR REPLACE` already + kept only the `'none'` assertion (exclusion wins — see 009's + docstring). That pinned fact was discarded at upgrade time, long + before this downgrade runs; there is no assertion left to recover it + from. The exclusion itself, however, round-trips correctly. + """ + op.execute("ALTER TABLE planned_leave_day ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0") + op.execute(_EXCLUSION_TABLE_DDL) + op.execute(_EXCLUSION_INDEX_DDL) + + from sqlalchemy import text + + conn = op.get_bind() + + # Concrete assertions -> re-pin the matching planned_leave_day row, if + # one still exists at that natural key (see docstring: if not, there is + # nothing to pin, and that's fine). + conn.execute( + text( + "UPDATE planned_leave_day SET is_pinned = 1 " + "WHERE (workspace_id, child_id, parent_personal_data_uuid, date) IN (" + " SELECT workspace_id, child_id, parent_personal_data_uuid, date " + " FROM day_assertion WHERE day_type != 'none'" + ")" + ) + ) + + # 'none' assertions -> recreate the exclusion rows they replaced. + conn.execute( + text( + "INSERT INTO planned_day_exclusion " + "(id, workspace_id, child_id, parent_personal_data_uuid, date, " + " created_at, updated_at) " + "SELECT id, workspace_id, child_id, parent_personal_data_uuid, date, " + " created_at, updated_at " + "FROM day_assertion WHERE day_type = 'none'" + ) + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8f48e50..13e47ef 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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, ) diff --git a/backend/tests/integration/test_migration_009_day_assertion_backfill.py b/backend/tests/integration/test_migration_009_day_assertion_backfill.py new file mode 100644 index 0000000..04cbe31 --- /dev/null +++ b/backend/tests/integration/test_migration_009_day_assertion_backfill.py @@ -0,0 +1,303 @@ +"""Migration 009's backfill: three legacy shapes -> one `day_assertion` row each. + +`test_schema_parity.py` proves the migration chain and `_FULL_SCHEMA` describe +the same *shape*. This proves 009 moves the *data* correctly — restructure +step 3 (see `~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`): + +- a pinned `planned_leave_day` row (`is_pinned=1`) -> an assertion carrying + its `day_type`/`is_dubbeldag`/`wish_id`. +- an ad-hoc `planned_leave_day` row (`wish_id IS NULL`) -> an assertion the + same way, even when NOT pinned. +- an ordinary planner-derived row (`is_pinned=0`, `wish_id` set) -> nothing: + it is exactly what the new table must NOT treat as an assertion. +- every `planned_day_exclusion` row -> a `day_type='none'` assertion. +- when a legacy exclusion and a pinned/ad-hoc row coincide at the same key, + the exclusion wins (`day_type='none'` survives, not the pin). + +Driven through the real Alembic chain (upgrade to 008, insert pre-009 rows, +upgrade to head), not through `_FULL_SCHEMA`, because the pre-009 backfill +source rows must exist in their pre-migration shape. +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import date, datetime +from pathlib import Path + +from alembic import command +from alembic.config import Config + + +def _alembic_config(db_path: Path) -> Config: + cfg = Config("alembic.ini") + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + return cfg + + +def _seed_workspace_and_child(conn: sqlite3.Connection, now: str) -> tuple[str, str]: + ws_id = str(uuid.uuid4()) + child_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", (ws_id, "WS", now) + ) + conn.execute( + "INSERT INTO child (id, workspace_id, name, birth_date, " + "preschool_start_date, preschool_planning_days_per_year, created_at) " + "VALUES (?, ?, ?, ?, NULL, 0, ?)", + (child_id, ws_id, "Barn", date(2026, 5, 1).isoformat(), now), + ) + return ws_id, child_id + + +def _insert_planned_leave_day( + conn: sqlite3.Connection, + *, + workspace_id: str, + child_id: str, + parent_id: str, + day: date, + day_type: str, + is_pinned: bool, + wish_id: str | None, + is_dubbeldag: bool = False, + now: str, +) -> None: + conn.execute( + "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + str(uuid.uuid4()), + workspace_id, + child_id, + parent_id, + day.isoformat(), + day_type, + int(is_dubbeldag), + wish_id, + int(is_pinned), + now, + now, + ), + ) + + +def _insert_exclusion( + conn: sqlite3.Connection, + *, + workspace_id: str, + child_id: str, + parent_id: str, + day: date, + now: str, +) -> None: + conn.execute( + "INSERT INTO planned_day_exclusion " + "(id, workspace_id, child_id, parent_personal_data_uuid, date, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), workspace_id, child_id, parent_id, day.isoformat(), now, now), + ) + + +def _assertions(db_path: Path, workspace_id: str) -> list[sqlite3.Row]: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + return conn.execute( + "SELECT * FROM day_assertion WHERE workspace_id = ? ORDER BY date", (workspace_id,) + ).fetchall() + finally: + conn.close() + + +def test_pinned_row_becomes_a_concrete_assertion(tmp_path: Path) -> None: + db = tmp_path / "pinned.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + wish_id = str(uuid.uuid4()) + _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=date(2027, 2, 3), + day_type="lagsta", + is_pinned=True, + wish_id=wish_id, + is_dubbeldag=True, + now=now, + ) + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + + rows = _assertions(db, ws_id) + assert len(rows) == 1 + assert rows[0]["day_type"] == "lagsta" + assert bool(rows[0]["is_dubbeldag"]) is True + assert rows[0]["wish_id"] == wish_id + assert rows[0]["parent_personal_data_uuid"] == parent_id + + +def test_ad_hoc_row_becomes_an_assertion_even_when_not_pinned(tmp_path: Path) -> None: + db = tmp_path / "adhoc.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=date(2027, 3, 1), + day_type="vacation", + is_pinned=False, + wish_id=None, + now=now, + ) + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + + rows = _assertions(db, ws_id) + assert len(rows) == 1 + assert rows[0]["day_type"] == "vacation" + assert rows[0]["wish_id"] is None + + +def test_ordinary_planner_derived_row_is_not_backfilled(tmp_path: Path) -> None: + """The whole point of the new table: a derived (unpinned, wish-attributed) + + row is NOT a user assertion and must not become one. + """ + db = tmp_path / "derived.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + wish_id = str(uuid.uuid4()) + _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=date(2027, 4, 1), + day_type="sjukpenning", + is_pinned=False, + wish_id=wish_id, + now=now, + ) + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + + assert _assertions(db, ws_id) == [] + + +def test_exclusion_row_becomes_a_none_assertion(tmp_path: Path) -> None: + db = tmp_path / "exclusion.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + _insert_exclusion( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=date(2027, 2, 3), + now=now, + ) + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + + rows = _assertions(db, ws_id) + assert len(rows) == 1 + assert rows[0]["day_type"] == "none" + assert rows[0]["wish_id"] is None + assert bool(rows[0]["is_dubbeldag"]) is False + + +def test_exclusion_wins_over_a_coinciding_pinned_row(tmp_path: Path) -> None: + """Historical data where an exclusion and a pinned row share a key — + + shouldn't happen going forward (`PlannedDayExclusionService.exclude()` + always deletes the `planned_leave_day` row first), but the migration must + resolve it the same way at rest: the exclusion (a stronger, later + instruction) wins. + """ + db = tmp_path / "conflict.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + day = date(2027, 5, 1) + _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=day, + day_type="sjukpenning", + is_pinned=True, + wish_id=None, + now=now, + ) + _insert_exclusion( + conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=day, now=now + ) + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + + rows = _assertions(db, ws_id) + assert len(rows) == 1 + assert rows[0]["day_type"] == "none" + + +def test_downgrade_drops_the_table(tmp_path: Path) -> None: + db = tmp_path / "downgrade.db" + command.upgrade(_alembic_config(db), "head") + + conn = sqlite3.connect(db) + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() + } + conn.close() + assert "day_assertion" in tables + + command.downgrade(_alembic_config(db), "008") + + conn = sqlite3.connect(db) + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() + } + # is_pinned / planned_day_exclusion are untouched by 009 either direction — + # step 3a is additive, see the migration's module docstring. + assert "planned_day_exclusion" in tables + conn.close() + assert "day_assertion" not in tables diff --git a/backend/tests/integration/test_migration_010_downgrade_reconstruction.py b/backend/tests/integration/test_migration_010_downgrade_reconstruction.py new file mode 100644 index 0000000..3fe03ed --- /dev/null +++ b/backend/tests/integration/test_migration_010_downgrade_reconstruction.py @@ -0,0 +1,325 @@ +"""Migration 010's downgrade: reconstruct `is_pinned`/`planned_day_exclusion` +from `day_assertion` instead of losing them. + +The naive downgrade — `ADD COLUMN is_pinned ... DEFAULT 0` plus a bare +`CREATE TABLE planned_day_exclusion` — silently unpins every previously +pinned day and drops every exclusion, then a further `downgrade` to 008 +deletes `day_assertion` itself, at which point the facts are gone for good. +This proves the fixed downgrade instead reconstructs both shapes from +`day_assertion` (still present at the moment 010's `downgrade()` runs) for a +full `upgrade head` -> `downgrade 008` round trip, and pins down exactly the +two cases that are honestly NOT round-trippable (see 010's docstring): + +- a concrete assertion whose `wish_id IS NULL`-driven mirrored row was never + pinned still comes back with `is_pinned = 1` (over-restored, not + under-restored — `day_assertion` never recorded *why* a row was asserted). +- a pinned row that collided with an exclusion at the same key loses its + `is_pinned` fact at 009's *upgrade* already (`INSERT OR REPLACE`, exclusion + wins), long before 010's downgrade runs, so there is nothing left to + recover it from. +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import date, datetime +from pathlib import Path + +from alembic import command +from alembic.config import Config + + +def _alembic_config(db_path: Path) -> Config: + cfg = Config("alembic.ini") + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + return cfg + + +def _seed_workspace_and_child(conn: sqlite3.Connection, now: str) -> tuple[str, str]: + ws_id = str(uuid.uuid4()) + child_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", (ws_id, "WS", now) + ) + conn.execute( + "INSERT INTO child (id, workspace_id, name, birth_date, " + "preschool_start_date, preschool_planning_days_per_year, created_at) " + "VALUES (?, ?, ?, ?, NULL, 0, ?)", + (child_id, ws_id, "Barn", date(2026, 5, 1).isoformat(), now), + ) + return ws_id, child_id + + +def _insert_planned_leave_day( + conn: sqlite3.Connection, + *, + workspace_id: str, + child_id: str, + parent_id: str, + day: date, + day_type: str, + is_pinned: bool, + wish_id: str | None, + now: str, +) -> str: + row_id = str(uuid.uuid4()) + conn.execute( + "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 (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)", + ( + row_id, + workspace_id, + child_id, + parent_id, + day.isoformat(), + day_type, + wish_id, + int(is_pinned), + now, + now, + ), + ) + return row_id + + +def _insert_exclusion( + conn: sqlite3.Connection, + *, + workspace_id: str, + child_id: str, + parent_id: str, + day: date, + now: str, +) -> None: + conn.execute( + "INSERT INTO planned_day_exclusion " + "(id, workspace_id, child_id, parent_personal_data_uuid, date, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), workspace_id, child_id, parent_id, day.isoformat(), now, now), + ) + + +def _planned_day(db_path: Path, row_id: str) -> sqlite3.Row: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + row = conn.execute("SELECT * FROM planned_leave_day WHERE id = ?", (row_id,)).fetchone() + assert row is not None + return row + finally: + conn.close() + + +def _exclusion_exists( + db_path: Path, *, workspace_id: str, child_id: str, parent_id: str, day: date +) -> bool: + conn = sqlite3.connect(db_path) + try: + row = conn.execute( + "SELECT 1 FROM planned_day_exclusion " + "WHERE workspace_id = ? AND child_id = ? AND parent_personal_data_uuid = ? " + "AND date = ?", + (workspace_id, child_id, parent_id, day.isoformat()), + ).fetchone() + return row is not None + finally: + conn.close() + + +def test_full_round_trip_reconstructs_pins_and_exclusions(tmp_path: Path) -> None: + db = tmp_path / "roundtrip.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + wish_id = str(uuid.uuid4()) + + pinned_day = date(2027, 1, 10) + pinned_id = _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=pinned_day, + day_type="lagsta", + is_pinned=True, + wish_id=wish_id, + now=now, + ) + + adhoc_day = date(2027, 1, 11) + adhoc_id = _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=adhoc_day, + day_type="vacation", + is_pinned=False, + wish_id=None, + now=now, + ) + + pinned_and_adhoc_day = date(2027, 1, 12) + pinned_and_adhoc_id = _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=pinned_and_adhoc_day, + day_type="sjukpenning", + is_pinned=True, + wish_id=None, + now=now, + ) + + derived_day = date(2027, 1, 13) + derived_id = _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=derived_day, + day_type="sjukpenning", + is_pinned=False, + wish_id=wish_id, + now=now, + ) + + plain_exclusion_day = date(2027, 1, 14) + _insert_exclusion( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=plain_exclusion_day, + now=now, + ) + + # A pinned row and a colliding exclusion at the same key (a historical + # anomaly the runtime never creates going forward, but old data might + # still hold) — 009's upgrade resolves this "exclusion wins". + collision_day = date(2027, 1, 15) + collision_id = _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=collision_day, + day_type="sjukpenning", + is_pinned=True, + wish_id=None, + now=now, + ) + _insert_exclusion( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=collision_day, + now=now, + ) + + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + command.downgrade(_alembic_config(db), "008") + + # -- pinned row: round-trips exactly ------------------------------------ + pinned_row = _planned_day(db, pinned_id) + assert bool(pinned_row["is_pinned"]) is True + assert pinned_row["day_type"] == "lagsta" + assert pinned_row["wish_id"] == wish_id + + # -- ad-hoc, never-pinned row: over-restored, documented and expected -- + # `day_assertion` never recorded whether a concrete assertion came from + # `is_pinned=1` or from `wish_id IS NULL` (009's own upgrade backfill + # already conflates them: `WHERE is_pinned = 1 OR wish_id IS NULL`), so + # this row comes back pinned even though it started unpinned. It is + # still exempt from the allocator via `wish_id IS NULL`, unaffected by + # this bit either way. + adhoc_row = _planned_day(db, adhoc_id) + assert bool(adhoc_row["is_pinned"]) is True + assert adhoc_row["wish_id"] is None + + # -- pinned AND ad-hoc: round-trips exactly (was already pinned) ------- + both_row = _planned_day(db, pinned_and_adhoc_id) + assert bool(both_row["is_pinned"]) is True + + # -- ordinary planner-derived row: never asserted, stays unpinned ------- + derived_row = _planned_day(db, derived_id) + assert bool(derived_row["is_pinned"]) is False + assert derived_row["wish_id"] == wish_id + + # -- plain exclusion: round-trips exactly -------------------------------- + assert _exclusion_exists( + db, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=plain_exclusion_day + ) + + # -- collision: exclusion wins, is_pinned is a KNOWN, documented loss --- + # 009's upgrade already discarded the pinned assertion at this key via + # `INSERT OR REPLACE` (exclusion wins) before 010 ever runs, so there is + # no assertion left for 010's downgrade to recover `is_pinned` from. + # This is not a bug in the downgrade under test — it is a fact already + # lost one migration earlier, and the exclusion itself is what survives. + collision_row = _planned_day(db, collision_id) + assert bool(collision_row["is_pinned"]) is False + assert _exclusion_exists( + db, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=collision_day + ) + + +def test_concrete_assertion_with_no_mirrored_row_does_not_error(tmp_path: Path) -> None: + """An assertion can outlive its `planned_leave_day` mirror (e.g. a later + + plan sync re-derives the day away). Reconstruction must not crash on it + — there is simply nothing to set `is_pinned` on. + """ + db = tmp_path / "orphan.db" + command.upgrade(_alembic_config(db), "008") + now = datetime(2026, 1, 1, 12, 0, 0).isoformat() + + conn = sqlite3.connect(db) + ws_id, child_id = _seed_workspace_and_child(conn, now) + parent_id = str(uuid.uuid4()) + day = date(2027, 2, 1) + row_id = _insert_planned_leave_day( + conn, + workspace_id=ws_id, + child_id=child_id, + parent_id=parent_id, + day=day, + day_type="vacation", + is_pinned=True, + wish_id=None, + now=now, + ) + conn.commit() + conn.close() + + command.upgrade(_alembic_config(db), "head") + + # Delete the mirrored row after the assertion was backfilled, simulating + # a later re-derivation that removed it — the assertion survives. + conn = sqlite3.connect(db) + conn.execute("DELETE FROM planned_leave_day WHERE id = ?", (row_id,)) + conn.commit() + conn.close() + + # Must downgrade cleanly, not raise. + command.downgrade(_alembic_config(db), "008") + + conn = sqlite3.connect(db) + try: + remaining = conn.execute( + "SELECT COUNT(*) FROM planned_leave_day WHERE workspace_id = ?", (ws_id,) + ).fetchone()[0] + finally: + conn.close() + assert remaining == 0 diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index 2667edb..1de7538 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -1024,30 +1024,26 @@ class TestLeaveWishNameMessageKeyRoundTrip: # --------------------------------------------------------------------------- -# 9) is_pinned / wish_id ROUND-TRIP, AND SURVIVAL THROUGH /api/plan/sync -# (routers/planned_days.py bug — both fields were accepted by the request -# schema and understood by PlanningService, but silently dropped by the -# router before ever reaching the service, so "pin this day" never -# persisted). One shared registration for both scenarios — see -# `TestLeaveWishNameMessageKeyRoundTrip` above for why: `/api/auth/register` -# is rate-limited to 10/minute and this module already registers a user per -# test class. +# 9) DayAssertion round-trip AND survival through /api/plan/sync +# (restructure step 3b — "pinned" moved off `planned_leave_day.is_pinned`, +# now dropped, onto `POST /api/day-assertions`; there is no +# `PUT /api/planned-days/{id}` any more, only reads). One shared +# registration for both scenarios — see `TestLeaveWishNameMessageKeyRoundTrip` +# above for why: `/api/auth/register` is rate-limited to 10/minute and this +# module already registers a user per test class. # --------------------------------------------------------------------------- -class TestPlannedDayIsPinnedRoundTripAndSyncSurvival: - """`PlannedLeaveDayCreate`/`Update` both carry `is_pinned`/`wish_id`, and +class TestDayAssertionRoundTripAndSyncSurvival: + """`POST /api/day-assertions` is the sole way to pin a day now (there is - `PlanningService.add_planned_day`/`.update` both accept them — but the - router never forwarded either field, so a client setting `is_pinned: - true` (the "pin this day so the allocator won't retype it" gesture) had - it silently discarded. Both scenarios (the raw POST/PUT round trip, and - the end-to-end "survives a re-sync" behaviour it exists for) share a - single registration, same reasoning as `TestLeaveWishNameMessageKeyRoundTrip` - above. + no `PUT /api/planned-days/{id}` any more). Both scenarios (the raw + create/clear round trip, and the end-to-end "survives a re-sync" + behaviour pinning exists for) share a single registration, same + reasoning as `TestLeaveWishNameMessageKeyRoundTrip` above. """ - async def test_is_pinned_round_trips_and_survives_a_subsequent_sync( + async def test_day_assertion_round_trips_and_survives_a_subsequent_sync( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "pin-roundtrip@example.com", "Alex") @@ -1074,57 +1070,61 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival: ) wish_id = wish["id"] - # --- part 1: raw POST/PUT round trip ----------------------------- + # --- part 1: raw POST/DELETE round trip -------------------------- + probe_date = _next_workday(date.today()) create_resp = await client.post( - "/api/planned-days", + "/api/day-assertions", headers=headers, json={ "workspace_id": str(ws_id), "child_id": str(child_id), "parent_personal_data_uuid": str(pd_uuid), - # An ad-hoc probe row for the is_pinned round trip below — - # the exact date is irrelevant except that it must be legal - # to plan as `vacation` (never a free day; see - # `PlanningService._reject_illegal_free_day`). - "date": _next_workday(date.today()).isoformat(), + # An ad-hoc probe assertion — the exact date is irrelevant + # except that it must be legal to plan as `vacation` (never + # a free day; see `app.services.day_guards. + # reject_illegal_free_day`). + "date": probe_date.isoformat(), "day_type": "vacation", "is_dubbeldag": False, "wish_id": wish_id, - "is_pinned": True, }, ) assert create_resp.status_code == 201, create_resp.text created = create_resp.json() - assert created["is_pinned"] is True, ( - "is_pinned must round-trip through POST /api/planned-days, not be silently " - "dropped by the router" - ) + assert created["day_type"] == "vacation" assert created["wish_id"] == wish_id - day_id = created["id"] + # The assertion mirrors immediately into `planned_leave_day` — no + # sync required to see it. + planned = await _list_planned_days(client, headers, child_id) + mirrored = next(d for d in planned if d["date"] == probe_date.isoformat()) + assert mirrored["is_pinned"] is True, ( + "a concrete DayAssertion must mirror into planned_leave_day immediately, " + "and the row must report is_pinned=true for it" + ) - # Flip both fields via PUT — a partial update — and confirm the new - # values, not the create-time ones, come back. - update_resp = await client.put( - f"/api/planned-days/{day_id}", + # Clear it (by natural key — DELETE /api/day-assertions takes no id) + # before part 2 so it can't be mistaken for one of the wish's own + # placed days below. + clear_resp = await client.request( + "DELETE", + "/api/day-assertions", headers=headers, - json={"is_pinned": False}, + params={ + "workspace_id": str(ws_id), + "child_id": str(child_id), + "parent_personal_data_uuid": str(pd_uuid), + "date": probe_date.isoformat(), + }, ) - assert update_resp.status_code == 200, update_resp.text - updated = update_resp.json() - assert updated["is_pinned"] is False, ( - "is_pinned must round-trip through PUT /api/planned-days/{id} too" + assert clear_resp.status_code == 204, clear_resp.text + after_clear = await _list_planned_days(client, headers, child_id) + assert not any(d["date"] == probe_date.isoformat() for d in after_clear), ( + "clearing the assertion must also remove the mirrored planned_leave_day row" ) - # wish_id was not mentioned in this PUT — must be left untouched. - assert updated["wish_id"] == wish_id - - # Delete the ad-hoc probe row before part 2 so it can't be mistaken - # for one of the wish's own placed days below. - del_resp = await client.delete(f"/api/planned-days/{day_id}", headers=headers) - assert del_resp.status_code == 204, del_resp.text # --- part 2: a pinned day survives a subsequent /api/plan/sync -- - # the end-to-end behaviour users actually rely on `is_pinned` for: + # the end-to-end behaviour users actually rely on pinning for: # "changing a day's funding type pins it so the allocator won't # retype it". @@ -1138,18 +1138,28 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival: pinned_day = wish_days[0] original_day_type = pinned_day["day_type"] # Retype it to whatever it currently is NOT, mirroring "the user - # hand-edited this day's funding type" — then pin it so a re-sync - # must leave it alone. + # hand-edited this day's funding type" — then assert it so a + # re-sync must leave it alone. new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation" - pin_resp = await client.put( - f"/api/planned-days/{pinned_day['id']}", + pin_resp = await client.post( + "/api/day-assertions", headers=headers, - json={"day_type": new_day_type, "is_pinned": True}, + json={ + "workspace_id": str(ws_id), + "child_id": str(child_id), + "parent_personal_data_uuid": str(pd_uuid), + "date": pinned_day["date"], + "day_type": new_day_type, + "wish_id": wish_id, + }, ) - assert pin_resp.status_code == 200, pin_resp.text - assert pin_resp.json()["is_pinned"] is True - assert pin_resp.json()["day_type"] == new_day_type + assert pin_resp.status_code == 201, pin_resp.text + + pinned_after_assert = await _list_planned_days(client, headers, child_id) + pinned_row = next(d for d in pinned_after_assert if d["date"] == pinned_day["date"]) + assert pinned_row["is_pinned"] is True + assert pinned_row["day_type"] == new_day_type # Re-sync: a fresh propose would want this date back at # `original_day_type` (the wish's natural funding choice) — the @@ -1157,7 +1167,7 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival: await _sync(client, headers, ws_id) after = await _list_planned_days(client, headers, child_id) - after_row = next(d for d in after if d["id"] == pinned_day["id"]) + after_row = next(d for d in after if d["date"] == pinned_day["date"]) assert after_row["is_pinned"] is True assert after_row["day_type"] == new_day_type, ( "a pinned day must survive a subsequent /api/plan/sync without being " diff --git a/backend/tests/unit/test_authz.py b/backend/tests/unit/test_authz.py index 358c85f..7759c12 100644 --- a/backend/tests/unit/test_authz.py +++ b/backend/tests/unit/test_authz.py @@ -194,6 +194,23 @@ class TestNoTokenIsUnauthenticated: resp = await anon_client.get(f"/api/vacation-days/workspace/{uuid4()}") assert resp.status_code == 401 + async def test_day_assertions_create_requires_auth(self, anon_client: AsyncClient) -> None: + resp = await anon_client.post( + "/api/day-assertions", + json={ + "workspace_id": str(uuid4()), + "child_id": str(uuid4()), + "parent_personal_data_uuid": str(uuid4()), + "date": "2030-01-01", + "day_type": "vacation", + }, + ) + assert resp.status_code == 401 + + async def test_day_assertions_list_requires_auth(self, anon_client: AsyncClient) -> None: + resp = await anon_client.get(f"/api/day-assertions/workspace/{uuid4()}") + assert resp.status_code == 401 + # --------------------------------------------------------------------------- # F3 — a real id from a DIFFERENT workspace must 404, never 403. @@ -504,21 +521,6 @@ class TestForeignWorkspaceIdIs404: resp = await client.get(f"/api/vacation-days/workspace/{two_workspaces.ws_b.id}") assert resp.status_code == 404 - async def test_planned_days_create_foreign_child( - self, client: AsyncClient, two_workspaces: _TwoWorkspaces - ) -> None: - resp = await client.post( - "/api/planned-days", - json={ - "workspace_id": str(two_workspaces.ws_a.id), - "child_id": str(two_workspaces.child_b.id), - "parent_personal_data_uuid": str(_CALLER_PD_UUID), - "date": "2030-06-01", - "day_type": "vacation", - }, - ) - assert resp.status_code == 404 - async def test_planned_days_list_by_child_foreign( self, client: AsyncClient, two_workspaces: _TwoWorkspaces ) -> None: @@ -550,38 +552,55 @@ class TestForeignWorkspaceIdIs404: resp = await client.get(f"/api/planned-days/workspace/{two_workspaces.ws_b.id}") assert resp.status_code == 404 - async def test_planned_days_update_foreign( - self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces + async def test_day_assertions_create_foreign_child( + self, client: AsyncClient, two_workspaces: _TwoWorkspaces ) -> None: - repo = SQLitePlannedLeaveRepository(connection) - row = await repo.create( - PlannedLeaveDay( - id=uuid4(), - workspace_id=two_workspaces.ws_b.id, - child_id=two_workspaces.child_b.id, - parent_personal_data_uuid=two_workspaces.pd_b.uuid, - date=date(2030, 6, 2), - day_type="vacation", - ) + resp = await client.post( + "/api/day-assertions", + json={ + "workspace_id": str(two_workspaces.ws_a.id), + "child_id": str(two_workspaces.child_b.id), + "parent_personal_data_uuid": str(_CALLER_PD_UUID), + "date": "2030-06-01", + "day_type": "vacation", + }, ) - resp = await client.put(f"/api/planned-days/{row.id}", json={"day_type": "sjukpenning"}) assert resp.status_code == 404 - async def test_planned_days_delete_foreign( - self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces + async def test_day_assertions_create_foreign_workspace( + self, client: AsyncClient, two_workspaces: _TwoWorkspaces ) -> None: - repo = SQLitePlannedLeaveRepository(connection) - row = await repo.create( - PlannedLeaveDay( - id=uuid4(), - workspace_id=two_workspaces.ws_b.id, - child_id=two_workspaces.child_b.id, - parent_personal_data_uuid=two_workspaces.pd_b.uuid, - date=date(2030, 6, 3), - day_type="vacation", - ) + resp = await client.post( + "/api/day-assertions", + json={ + "workspace_id": str(two_workspaces.ws_b.id), + "child_id": str(two_workspaces.child_b.id), + "parent_personal_data_uuid": str(two_workspaces.pd_b.uuid), + "date": "2030-06-01", + "day_type": "vacation", + }, + ) + assert resp.status_code == 404 + + async def test_day_assertions_list_foreign_workspace( + self, client: AsyncClient, two_workspaces: _TwoWorkspaces + ) -> None: + resp = await client.get(f"/api/day-assertions/workspace/{two_workspaces.ws_b.id}") + assert resp.status_code == 404 + + async def test_day_assertions_clear_foreign_workspace( + self, client: AsyncClient, two_workspaces: _TwoWorkspaces + ) -> None: + resp = await client.request( + "DELETE", + "/api/day-assertions", + params={ + "workspace_id": str(two_workspaces.ws_b.id), + "child_id": str(two_workspaces.child_b.id), + "parent_personal_data_uuid": str(two_workspaces.pd_b.uuid), + "date": "2030-06-01", + }, ) - resp = await client.delete(f"/api/planned-days/{row.id}") assert resp.status_code == 404 async def test_fetch_data_manual_foreign_workspace( diff --git a/backend/tests/unit/test_day_assertion.py b/backend/tests/unit/test_day_assertion.py new file mode 100644 index 0000000..7af85ae --- /dev/null +++ b/backend/tests/unit/test_day_assertion.py @@ -0,0 +1,511 @@ +"""Tests for the DayAssertion repository and service. + +Restructure step 3 (intent vs. derived output — see +`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`). +`TestDayAssertionServiceMirroring`/`TestDayAssertionServiceGuards` below +cover what step 3b's `DayAssertionService` newly took on from the deleted +`PlannedDayExclusionService`/`PlanningService.add_planned_day`: mirroring a +write into `planned_leave_day` and enforcing the free-day guard. +""" + +from datetime import date, datetime +from uuid import uuid4 + +import pytest + +from app.entities.day_assertion import DAY_TYPE_NONE, DayAssertion +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.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository +from app.services.day_assertion_service import DayAssertionService +from app.utils.errors import NotFoundError, ValidationError +from tests.conftest import SeededIds + +_DAY = date(2027, 2, 3) + + +def _make(seeded: SeededIds, **overrides) -> DayAssertion: + base = dict( + id=uuid4(), + workspace_id=seeded.workspace_id, + child_id=seeded.child_id, + parent_personal_data_uuid=seeded.personal_data_uuid, + date=_DAY, + day_type="sjukpenning", + is_dubbeldag=False, + wish_id=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + base.update(overrides) + return DayAssertion(**base) + + +class TestSQLiteDayAssertionRepository: + async def test_create_and_get_by_id( + self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds + ) -> None: + entity = _make(seeded_ids) + await day_assertion_repo.create(entity) + + fetched = await day_assertion_repo.get_by_id(entity.id) + assert fetched is not None + assert fetched.day_type == "sjukpenning" + assert fetched.wish_id is None + + async def test_get_by_natural_key( + self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds + ) -> None: + entity = _make(seeded_ids) + await day_assertion_repo.create(entity) + + found = await day_assertion_repo.get_by_natural_key( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + assert found is not None + assert found.id == entity.id + + assert ( + await day_assertion_repo.get_by_natural_key( + seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY + ) + is None + ) + + async def test_update_and_delete( + self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds + ) -> None: + entity = _make(seeded_ids) + await day_assertion_repo.create(entity) + + entity.day_type = "vacation" + entity.is_dubbeldag = True + updated = await day_assertion_repo.update(entity.id, entity) + assert updated.day_type == "vacation" + assert updated.is_dubbeldag is True + + await day_assertion_repo.delete(entity.id) + assert await day_assertion_repo.get_by_id(entity.id) is None + + with pytest.raises(NotFoundError): + await day_assertion_repo.delete(entity.id) + with pytest.raises(NotFoundError): + await day_assertion_repo.update(entity.id, entity) + + async def test_delete_by_natural_key_is_idempotent( + self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds + ) -> None: + entity = _make(seeded_ids) + await day_assertion_repo.create(entity) + + await day_assertion_repo.delete_by_natural_key( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + assert await day_assertion_repo.get_by_id(entity.id) is None + + # A no-op the second time — never raises. + await day_assertion_repo.delete_by_natural_key( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + + async def test_upsert_creates_then_overwrites( + self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds + ) -> None: + first = await day_assertion_repo.upsert(_make(seeded_ids, day_type="sjukpenning")) + second = await day_assertion_repo.upsert( + _make(seeded_ids, id=uuid4(), day_type="vacation", is_dubbeldag=True) + ) + + # Same natural key -> same underlying row, not a duplicate. + assert second.id == first.id + rows = await day_assertion_repo.list_by_workspace(seeded_ids.workspace_id) + assert len(rows) == 1 + assert rows[0].day_type == "vacation" + assert rows[0].is_dubbeldag is True + + async def test_list_by_workspace_is_date_ordered( + self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds + ) -> None: + await day_assertion_repo.create(_make(seeded_ids, date=date(2027, 2, 5))) + await day_assertion_repo.create(_make(seeded_ids, date=date(2027, 2, 1))) + + rows = await day_assertion_repo.list_by_workspace(seeded_ids.workspace_id) + assert [r.date for r in rows] == [date(2027, 2, 1), date(2027, 2, 5)] + assert await day_assertion_repo.list_by_workspace(uuid4()) == [] + + async def test_unsupported_filter_is_rejected( + self, day_assertion_repo: SQLiteDayAssertionRepository + ) -> None: + with pytest.raises(ValueError): + await day_assertion_repo.list(nonsense=1) + + +class TestDayAssertionService: + async def test_assert_day_is_idempotent_by_natural_key( + self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds + ) -> None: + first = await day_assertion_service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="lagsta", + ) + second = await day_assertion_service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + assert second.id == first.id + rows = await day_assertion_service.list_for_workspace(seeded_ids.workspace_id) + assert len(rows) == 1 + assert rows[0].day_type == "vacation" + + async def test_assert_none_is_the_exclusion_shape( + self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds + ) -> None: + created = await day_assertion_service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type=DAY_TYPE_NONE, + ) + assert created.day_type == "none" + + async def test_invalid_day_type_is_rejected( + self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds + ) -> None: + with pytest.raises(ValidationError): + await day_assertion_service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="not-a-real-type", + ) + + async def test_clear_is_idempotent( + self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds + ) -> None: + await day_assertion_service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="lagsta", + ) + await day_assertion_service.clear( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + assert await day_assertion_service.list_for_workspace(seeded_ids.workspace_id) == [] + + # A no-op the second time. + await day_assertion_service.clear( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + + async def test_get_is_workspace_scoped( + self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds + ) -> None: + created = await day_assertion_service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="lagsta", + ) + with pytest.raises(NotFoundError): + await day_assertion_service.get(created.id, workspace_id=uuid4()) + assert ( + await day_assertion_service.get(created.id, workspace_id=seeded_ids.workspace_id) + ).id == created.id + + +class TestDayAssertionServiceMirroring: + """Restructure step 3b: `assert_day`/`clear` mirror into + + `planned_leave_day` immediately — the same "true the moment the call + returns" guarantee `PlannedDayExclusionService.exclude()` used to + provide, now generalised to every day_type including a concrete pin. + """ + + async def test_assert_day_mirrors_a_concrete_pin_into_planned_leave_day( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) + + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + assert len(rows) == 1 + assert rows[0].date == _DAY + assert rows[0].day_type == "vacation" + + async def test_reasserting_a_different_day_type_replaces_the_mirrored_row( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="lagsta", + ) + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + assert len(rows) == 1 + assert rows[0].day_type == "vacation" + + async def test_asserting_none_deletes_the_mirrored_row( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type=DAY_TYPE_NONE, + ) + + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + assert rows == [] + + async def test_clear_deletes_the_mirrored_row_too( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + + await service.clear( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + assert rows == [] + # Idempotent — a second clear with nothing left is a no-op. + await service.clear( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + + +class TestDayAssertionServiceAtomicity: + """Adversarial-review fix #1: `assert_day`/`clear` each do an assertion + + write followed by a `planned_leave_day` mirror write — two statements + that must succeed or fail together. Before the fix, `self._repo.upsert` + (or `delete_by_natural_key`) committed immediately and the mirror step + ran as its own separate commit(s), so a crash mid-mirror left the + assertion table and `planned_leave_day` permanently diverged. These + tests inject a failure INTO the mirror step (not just the happy path) + and prove the assertion write itself was rolled back with it. + """ + + async def test_failure_during_mirror_leaves_no_assertion_committed( + self, + day_assertion_service_with_uow: DayAssertionService, + day_assertion_repo: SQLiteDayAssertionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + async def boom(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("simulated failure mid-mirror") + + monkeypatch.setattr(planned_leave_repo, "create", boom) + + with pytest.raises(RuntimeError): + await day_assertion_service_with_uow.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + + # Neither half of the write landed — the assertion table and + # `planned_leave_day` never diverged. + assert ( + await day_assertion_repo.get_by_natural_key( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + ) + is None + ) + assert ( + await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + == [] + ) + + async def test_failure_during_clear_mirror_leaves_the_original_assertion_intact( + self, + day_assertion_service_with_uow: DayAssertionService, + day_assertion_repo: SQLiteDayAssertionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + created = await day_assertion_service_with_uow.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + _DAY, + day_type="vacation", + ) + + async def boom(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("simulated failure mid-mirror") + + monkeypatch.setattr(planned_leave_repo, "delete_many", boom) + + with pytest.raises(RuntimeError): + await day_assertion_service_with_uow.clear( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + + # The assertion delete was rolled back along with the failed mirror + # delete — the original assertion (and its mirrored row) survive + # untouched, rather than the assertion vanishing while the row it + # was meant to keep in step with stays behind. + after = await day_assertion_repo.get_by_natural_key( + seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY + ) + assert after is not None + assert after.id == created.id + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + assert len(rows) == 1 + + +class TestDayAssertionServiceGuards: + """The free-day guard (`app.services.day_guards.reject_illegal_free_day`) + + is enforced here too now — the same rule `PlanningService. + add_planned_day`/`update` used to be the only enforcer of. + """ + + _SATURDAY = date(2027, 2, 6) + + async def test_vacation_on_a_free_day_is_rejected( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + workspace_repo: SQLiteWorkspaceRepository, + day_override_repo: SQLiteDayOverrideRepository, + seeded_ids: SeededIds, + ) -> None: + service = DayAssertionService( + day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo + ) + with pytest.raises(ValidationError): + await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + self._SATURDAY, + day_type="vacation", + ) + + async def test_sjukpenning_on_a_free_day_is_allowed_when_workspace_opts_in( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + workspace_repo: SQLiteWorkspaceRepository, + day_override_repo: SQLiteDayOverrideRepository, + seeded_ids: SeededIds, + ) -> None: + workspace = await workspace_repo.get_by_id(seeded_ids.workspace_id) + assert workspace is not None + workspace.take_pl_on_red_days = True + await workspace_repo.update(workspace.id, workspace) + + service = DayAssertionService( + day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo + ) + created = await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + self._SATURDAY, + day_type="sjukpenning", + ) + assert created.day_type == "sjukpenning" + + async def test_exclusion_is_always_allowed_on_a_free_day( + self, + day_assertion_repo: SQLiteDayAssertionRepository, + workspace_repo: SQLiteWorkspaceRepository, + day_override_repo: SQLiteDayOverrideRepository, + seeded_ids: SeededIds, + ) -> None: + """`day_type='none'` is never a "plan leave here" instruction, so the + + free-day guard (which only ever governs sjukpenning/lagsta/vacation) + must not block it. + """ + service = DayAssertionService( + day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo + ) + created = await service.assert_day( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + self._SATURDAY, + day_type=DAY_TYPE_NONE, + ) + assert created.day_type == DAY_TYPE_NONE diff --git a/backend/tests/unit/test_day_assertion_router.py b/backend/tests/unit/test_day_assertion_router.py new file mode 100644 index 0000000..2ba1934 --- /dev/null +++ b/backend/tests/unit/test_day_assertion_router.py @@ -0,0 +1,135 @@ +"""HTTP-level tests for `/api/day-assertions` — restructure step 3b. + +Replaces `TestPlannedDayExclusionRouter` (deleted along with the router it +tested). The create/list round trip and the foreign-workspace/foreign-child +rejections carry over; the delete shape changes from "by id" to "by natural +key" (see `app.routers.day_assertion.clear_day_assertion`'s docstring for +why), so this file's round-trip test clears by key instead of by id. +""" + +from datetime import date +from uuid import uuid4 + +from httpx import AsyncClient + +from tests.conftest import SeededIds + +_DAY = date(2027, 2, 3) + + +class TestDayAssertionRouter: + async def test_create_list_clear_round_trip( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + payload = { + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + "day_type": "vacation", + } + created = await authed_client.post("/api/day-assertions", json=payload) + assert created.status_code == 201, created.text + body = created.json() + assert body["date"] == _DAY.isoformat() + assert body["day_type"] == "vacation" + + listed = await authed_client.get(f"/api/day-assertions/workspace/{seeded_ids.workspace_id}") + assert listed.status_code == 200 + assert [r["id"] for r in listed.json()] == [body["id"]] + + clear_params = { + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + } + cleared = await authed_client.request("DELETE", "/api/day-assertions", params=clear_params) + assert cleared.status_code == 204 + + after = await authed_client.get(f"/api/day-assertions/workspace/{seeded_ids.workspace_id}") + assert after.json() == [] + + # Idempotent — clearing an already-empty slot is a no-op, never 404. + again = await authed_client.request("DELETE", "/api/day-assertions", params=clear_params) + assert again.status_code == 204 + + async def test_exclusion_shaped_assertion_is_accepted( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/day-assertions", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + "day_type": "none", + }, + ) + assert response.status_code == 201, response.text + assert response.json()["day_type"] == "none" + + async def test_invalid_day_type_is_rejected( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/day-assertions", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + "day_type": "not-a-real-type", + }, + ) + assert response.status_code == 422 + + async def test_a_foreign_workspace_create_is_rejected( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/day-assertions", + json={ + "workspace_id": str(uuid4()), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + "day_type": "vacation", + }, + ) + assert response.status_code == 404 + + async def test_a_foreign_child_create_is_rejected( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/day-assertions", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(uuid4()), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + "day_type": "vacation", + }, + ) + assert response.status_code == 404 + + async def test_a_foreign_list_is_rejected(self, authed_client: AsyncClient) -> None: + response = await authed_client.get(f"/api/day-assertions/workspace/{uuid4()}") + assert response.status_code == 404 + + async def test_a_foreign_workspace_clear_is_rejected( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.request( + "DELETE", + "/api/day-assertions", + params={ + "workspace_id": str(uuid4()), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": _DAY.isoformat(), + }, + ) + assert response.status_code == 404 diff --git a/backend/tests/unit/test_db_unit_of_work.py b/backend/tests/unit/test_db_unit_of_work.py index df0955f..38fb04d 100644 --- a/backend/tests/unit/test_db_unit_of_work.py +++ b/backend/tests/unit/test_db_unit_of_work.py @@ -184,12 +184,11 @@ class TestPlannedLeaveRepositoryBatchMethods: ) ) - await planned_leave_repo.update_metadata_many([(entity.id, wish.id, True)]) + await planned_leave_repo.update_metadata_many([(entity.id, wish.id)]) refreshed = await planned_leave_repo.get_by_id(entity.id) assert refreshed is not None assert refreshed.wish_id == wish.id - assert refreshed.is_pinned is True # day_type/is_dubbeldag are untouched by the metadata-only update. assert refreshed.day_type == "vacation" diff --git a/backend/tests/unit/test_leave_wish_repository.py b/backend/tests/unit/test_leave_wish_repository.py index 72ee6e5..7ef0606 100644 --- a/backend/tests/unit/test_leave_wish_repository.py +++ b/backend/tests/unit/test_leave_wish_repository.py @@ -10,9 +10,11 @@ from uuid import UUID, uuid4 import pytest +from app.entities.day_assertion import DayAssertion from app.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay from app.entities.workspace import Workspace +from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository @@ -1242,7 +1244,6 @@ class TestLeaveWishServiceDeleteCascade: seeded_ids: SeededIds, *, wish_id: UUID | None = None, - is_pinned: bool = False, day: str = "2027-02-03", ) -> PlannedLeaveDay: return PlannedLeaveDay( @@ -1254,7 +1255,6 @@ class TestLeaveWishServiceDeleteCascade: day_type="sjukpenning", is_dubbeldag=False, wish_id=wish_id, - is_pinned=is_pinned, created_at=datetime.now(), updated_at=datetime.now(), ) @@ -1284,25 +1284,57 @@ class TestLeaveWishServiceDeleteCascade: leave_wish_service: LeaveWishService, leave_wish_repo: SQLiteLeaveWishRepository, planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds, ) -> None: - """A pinned day dies with its wish rather than surviving as ad-hoc. + """A pinned day dies with its wish rather than surviving as ad-hoc — - Pinning only protects a day from being silently retyped/reassigned - by a later `sync_plan` re-plan — it is not a signal that the day - should outlive a period the user explicitly chose to delete. This - also matches the ad-hoc-period delete path in `Calendar.tsx` - (`handleDeletePeriod`'s no-`wishId` branch), which already deletes - every day in the band regardless of `is_pinned`. + and so does its `DayAssertion` (restructure step 3b). Pinning only + protects a day from being silently retyped/reassigned by a later + `sync_plan` re-plan — it is not a signal that the day should outlive + a period the user explicitly chose to delete. This also matches the + ad-hoc-period delete path in `Calendar.tsx` (`handleDeletePeriod`'s + no-`wishId` branch), which already deletes every day in the band + regardless of pin state. + + `LeaveWishRepository.delete_by_wish` itself never looked at + `is_pinned` (it deleted by `wish_id` alone even before that column + existed) — the row-level cascade was never in doubt. What step 3b + adds is `LeaveWishService._clear_assertions_for`, which is what this + test's `day_assertion_repo` assertion actually exercises: without + it, this assertion would survive with no row left to attach to, see + `TestWishDeleteClearsAssertions` in + test_services/test_allocation_day_assertion.py for the full + stray-slot scenario this prevents. """ wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id)) - pinned = await planned_leave_repo.create( - self._make_day(seeded_ids, wish_id=wish.id, is_pinned=True) + pinned = await planned_leave_repo.create(self._make_day(seeded_ids, wish_id=wish.id)) + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=pinned.date, + day_type=pinned.day_type, + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) ) await leave_wish_service.delete(wish.id) assert await planned_leave_repo.get_by_id(pinned.id) is None + assert ( + await day_assertion_repo.get_by_natural_key( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + pinned.date, + ) + is None + ) async def test_delete_leaves_a_different_wishs_days_untouched( self, @@ -1346,6 +1378,70 @@ class TestLeaveWishServiceDeleteCascade: assert survivor.wish_id is None +class TestLeaveWishServiceAtomicity: + """Bug 3 (adversarial-review-adjacent) — `LeaveWishService` previously had + + NO unit of work at all, so `delete()`'s multi-row loop (clear assertions + -> delete `planned_leave_day` rows -> delete the `LeaveWish` row, once + per targeted occurrence) committed each statement immediately. A crash + partway through a `scope='series'` delete left some years fully deleted + and others untouched. `leave_wish_service_with_uow` wires a real + `SQLiteUnitOfWork`, matching `PlanningService`'s established pattern — + this proves a failure mid-loop now rolls the WHOLE delete back. + """ + + async def test_failure_mid_series_delete_leaves_every_row_untouched( + self, + leave_wish_service_with_uow: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + seeded_ids: SeededIds, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + series_id = uuid4() + year1 = await leave_wish_repo.create( + _make(workspace_id=seeded_ids.workspace_id, first_year=2027, series_id=series_id) + ) + year2 = await leave_wish_repo.create( + _make(workspace_id=seeded_ids.workspace_id, first_year=2028, series_id=series_id) + ) + day1 = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2027, 6, 15), + day_type="sjukpenning", + wish_id=year1.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + real_delete = leave_wish_repo.delete + call_count = 0 + + async def flaky_delete(wish_id: UUID) -> None: + nonlocal call_count + call_count += 1 + if call_count == 2: + raise RuntimeError("simulated crash mid-series-delete") + await real_delete(wish_id) + + monkeypatch.setattr(leave_wish_repo, "delete", flaky_delete) + + with pytest.raises(RuntimeError): + await leave_wish_service_with_uow.delete(year1.id, scope="series") + + # Neither year's LeaveWish row nor its planned_leave_day row was + # actually removed — the first successful delete was rolled back + # along with the second (failed) one. + assert await leave_wish_repo.get_by_id(year1.id) is not None + assert await leave_wish_repo.get_by_id(year2.id) is not None + assert await planned_leave_repo.get_by_id(day1.id) is not None + + class TestLeaveWishSeriesMaterialisation: """A repeating wish is stored as one row per year, sharing a `series_id`. @@ -1588,7 +1684,6 @@ class TestLeaveWishSeriesMaterialisation: day_type="sjukpenning", is_dubbeldag=False, wish_id=dropped.id, - is_pinned=False, created_at=datetime.now(), updated_at=datetime.now(), ) @@ -1688,6 +1783,71 @@ class TestLeaveWishSeriesMaterialisation: assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == [] + async def test_delete_series_clears_every_occurrences_assertion_not_just_the_clicked_year( + self, + leave_wish_service: LeaveWishService, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + seeded_ids: SeededIds, + ) -> None: + """`scope='series'` deletes every occurrence's rows (restructure + + step 3b: and their assertions), not just the clicked year's — the + cleanup must not accidentally narrow to a single occurrence the way + the pre-materialisation single-row shape used to force. + """ + _, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids) + middle = next(r for r in rows if r.first_year == 2028) + + planned_by_year: dict[int, PlannedLeaveDay] = {} + for occurrence in rows: + day = date( + occurrence.first_year, occurrence.window_start_month, occurrence.window_start_day + ) + planned = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=day, + day_type="vacation", + wish_id=occurrence.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + planned_by_year[occurrence.first_year] = planned + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=day, + day_type="vacation", + wish_id=occurrence.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + await leave_wish_service.delete(middle.id, scope="series") + + for occurrence in rows: + planned = planned_by_year[occurrence.first_year] + assert await planned_leave_repo.get_by_id(planned.id) is None + assert ( + await day_assertion_repo.get_by_natural_key( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + planned.date, + ) + is None + ), f"assertion for {occurrence.first_year} survived a series-scoped delete" + async def test_delete_series_on_a_standalone_wish_removes_just_it( self, leave_wish_service: LeaveWishService, diff --git a/backend/tests/unit/test_leave_wish_router.py b/backend/tests/unit/test_leave_wish_router.py index f35ee4f..3ffda75 100644 --- a/backend/tests/unit/test_leave_wish_router.py +++ b/backend/tests/unit/test_leave_wish_router.py @@ -14,10 +14,14 @@ import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.dependencies import get_connection, get_current_user +from app.entities.child import Child +from app.entities.day_assertion import DayAssertion from app.entities.personal_data import PersonalData 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.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository @@ -554,3 +558,140 @@ class TestLeaveWishScopeQueryParam: response = await client.delete(f"/api/leave-wishes/{rows[0]['id']}?scope=everything") assert response.status_code == 422, response.text + + +class TestLeaveWishDeleteClearsDayAssertions: + """Bugs 2 & 3 (adversarial-review follow-ups) — deleting a wish must + + clear every `DayAssertion` attached to it, INCLUDING one whose mirrored + `planned_leave_day` row is already missing (a "dangling" assertion — + e.g. left behind by a crash between `DayAssertionService`'s own + assertion write and its mirror write, before that service had a `uow`). + Before the fix, `LeaveWishService._clear_assertions_for` only walked + SURVIVING `planned_leave_day` rows, so a dangling assertion was + invisible to it and outlived the wish as a `wish_id=NULL` ad-hoc + assertion that permanently locked its date. Also asserts the DELETE + itself returns its correct 2xx status (bug 3 — reported as a live 503 + despite the mutation succeeding). + """ + + async def _workspace_with_child( + self, connection: aiosqlite.Connection + ) -> tuple[Workspace, Child]: + now = datetime.now() + ws = await SQLiteWorkspaceRepository(connection).create( + Workspace(id=uuid4(), name="Familj", created_at=now) + ) + await SQLitePersonalDataRepository(connection).create( + PersonalData( + uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", created_at=now + ) + ) + child = await SQLiteChildRepository(connection).create( + Child( + id=uuid4(), + workspace_id=ws.id, + name="Barn", + birth_date=date(2024, 1, 1), + created_at=now, + ) + ) + return ws, child + + async def test_delete_clears_a_normally_mirrored_assertion( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + ws, child = await self._workspace_with_child(connection) + create = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "name": "Sommar", + "window_start_month": 7, + "window_start_day": 1, + "window_end_month": 7, + "window_end_day": 5, + "target_weeks": 1, + "first_year": date.today().year + 1, + }, + ) + assert create.status_code == 201, create.text + wish = create.json() + + day = f"{wish['first_year']}-07-01" + asserted = await client.post( + "/api/day-assertions", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "parent_personal_data_uuid": str(_CURRENT_USER_PD_UUID), + "date": day, + "day_type": "vacation", + "wish_id": wish["id"], + }, + ) + assert asserted.status_code == 201, asserted.text + + response = await client.delete(f"/api/leave-wishes/{wish['id']}") + assert response.status_code == 204, response.text + + remaining = await client.get(f"/api/day-assertions/workspace/{ws.id}") + assert remaining.status_code == 200 + assert remaining.json() == [] + + async def test_delete_clears_a_dangling_assertion_with_no_mirrored_row( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + """The assertion exists, but its `planned_leave_day` mirror row does + + not — e.g. left behind by a pre-uow-fix crash. `list_by_wish` (the + by-`wish_id` lookup, not a row walk) must still find and clear it. + """ + ws, child = await self._workspace_with_child(connection) + create = await client.post( + "/api/leave-wishes", + json={ + "workspace_id": str(ws.id), + "child_id": str(child.id), + "name": "Sommar", + "window_start_month": 7, + "window_start_day": 1, + "window_end_month": 7, + "window_end_day": 5, + "target_weeks": 1, + "first_year": date.today().year + 1, + }, + ) + assert create.status_code == 201, create.text + wish = create.json() + day = date(wish["first_year"], 7, 1) + + # A dangling assertion: inserted directly at the repository, with + # `wish_id` pointing at the wish, but NO `planned_leave_day` row — + # exactly the state a crash mid-mirror (bug 1, before its `uow` fix) + # would leave behind. + now = datetime.now() + await SQLiteDayAssertionRepository(connection).create( + DayAssertion( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + parent_personal_data_uuid=_CURRENT_USER_PD_UUID, + date=day, + day_type="vacation", + wish_id=wish["id"], + created_at=now, + updated_at=now, + ) + ) + + response = await client.delete(f"/api/leave-wishes/{wish['id']}") + assert response.status_code == 204, response.text + + remaining = await client.get(f"/api/day-assertions/workspace/{ws.id}") + assert remaining.status_code == 200 + assert remaining.json() == [], ( + "a dangling assertion (no mirrored row) must not outlive the wish it " + "was attached to as a permanently-locked, invisible ad-hoc assertion" + ) diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py index ed5334c..e49c17e 100644 --- a/backend/tests/unit/test_plan_router.py +++ b/backend/tests/unit/test_plan_router.py @@ -940,15 +940,16 @@ def _assert_domain_fields_reach_the_wire( ) -class TestPlanSyncEvictionPreviewOverTheWire: - """`POST /api/plan/sync`'s `eviction_choice` field and `eviction_preview` +class TestPlanSyncDisplacementOverTheWire: + """`POST /api/plan/sync`'s displacement reporting (`PlanChangeResponse. - response over the actual HTTP boundary -- the dataclass-level contract is - covered exhaustively by `tests/unit/test_planning_service_sync.py:: - TestSyncEvictionPreview`; this only needs to prove the wire shape - (`PlanSyncRequest.eviction_choice` / `PlanSyncResponse.eviction_preview`) - round-trips correctly and that an invalid choice 422s at the Pydantic - boundary rather than silently reaching the service as a no-op. + evicted`) over the actual HTTP boundary -- the dataclass-level contract + is covered exhaustively by `tests/unit/test_planning_service_sync.py:: + TestSyncDisplacement`; this only needs to prove the wire shape + round-trips correctly and that the sync always writes (no + `eviction_choice`/`eviction_preview` exists on the wire any more -- Step + 2 of the intent-vs-derived-output restructure made displacement + informational, never a refusal). Two non-overlapping windows sharing one sjukpenning pool (same recipe as the service-level tests): "Existing" (Jan 11-15) syncs first and spends @@ -956,7 +957,7 @@ class TestPlanSyncEvictionPreviewOverTheWire: steals it on the next sync. """ - async def test_sync_previews_then_resolves_an_eviction( + async def test_sync_writes_and_reports_displacement( self, client: AsyncClient, connection: aiosqlite.Connection, @@ -1022,7 +1023,7 @@ class TestPlanSyncEvictionPreviewOverTheWire: assert first.status_code == 200 first_body = first.json() assert first_body["apply_result"]["accepted"] is True - assert first_body["eviction_preview"]["would_evict"] is False + assert not any(c["evicted"] for c in first_body["changes"]) existing_count = first_body["apply_result"]["created_count"] assert existing_count > 0 @@ -1051,44 +1052,19 @@ class TestPlanSyncEvictionPreviewOverTheWire: ) assert create_new.status_code == 201 - # An invalid choice 422s at the Pydantic boundary. - invalid = await client.post( - "/api/plan/sync", - json={"workspace_id": str(ws.id), "eviction_choice": "not_a_real_choice"}, - ) - assert invalid.status_code == 422 - - # No choice -> preview only, nothing accepted/mutated. - preview = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)}) - assert preview.status_code == 200 - preview_body = preview.json() - assert preview_body["apply_result"]["accepted"] is False - assert ( - preview_body["apply_result"]["rejections"][0]["message_key"] - == "planSync.rejection.evictionChoiceRequired" - ) - assert preview_body["eviction_preview"]["would_evict"] is True - candidates = preview_body["eviction_preview"]["candidates"] - assert candidates - assert all(c["wish_name"] == "Existing" for c in candidates) - assert all(c["day_type"] == "sjukpenning" for c in candidates) - shortages = preview_body["eviction_preview"]["shortages"] - assert len(shortages) == 1 - assert shortages[0]["resource_kind"] == "parental_leave" - assert shortages[0]["tier"] == "sjukpenning" - assert shortages[0]["child_id"] == str(child.id) - assert shortages[0]["parent_personal_data_uuid"] == str(parent.uuid) - - # The chosen option resolves it: the edit lands, `Existing`'s stale - # days are gone. - resolved = await client.post( - "/api/plan/sync", - json={"workspace_id": str(ws.id), "eviction_choice": "drop_future_pl"}, - ) + # The sync writes immediately -- no separate preview round trip, no + # `eviction_choice` to pass. `Existing`'s stale days come back as + # 'removed' changes flagged `evicted: true`; `New`'s days land. + resolved = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)}) assert resolved.status_code == 200 resolved_body = resolved.json() - assert resolved_body["apply_result"]["accepted"] is True - assert resolved_body["eviction_preview"]["would_evict"] is True + assert resolved_body["apply_result"]["accepted"] is True, resolved_body["apply_result"] + + removed = [c for c in resolved_body["changes"] if c["kind"] == "removed"] + assert removed, "expected the crowded-out 'Existing' days to be reported as removed" + assert all(c["evicted"] is True for c in removed) + assert all(c["old_day_type"] == "sjukpenning" for c in removed) + wish_ids_written = {d["wish_id"] for d in resolved_body["apply_result"]["created_days"]} assert wish_ids_written == {create_new.json()["id"]} diff --git a/backend/tests/unit/test_plan_sync_with_wish_router.py b/backend/tests/unit/test_plan_sync_with_wish_router.py new file mode 100644 index 0000000..90d33f8 --- /dev/null +++ b/backend/tests/unit/test_plan_sync_with_wish_router.py @@ -0,0 +1,133 @@ +"""API-level test for `POST /api/plan/sync-with-wish`. + +Exercises the router + Pydantic wire models (not just the service directly, +which `tests/unit/test_planning_service_sync_with_wish.py` already covers) — +this catches the class of bug where the router/model layer drops or +mis-names a field on the way to `PlanningService.sync_with_wish`, or where +the "exactly one of wish_create/wish_update" request validation is wired +wrong. +""" + +from uuid import uuid4 + +from httpx import AsyncClient + +from tests.conftest import SeededIds + + +class TestSyncWithWishRequestValidation: + async def test_neither_wish_create_nor_wish_update_is_422( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/plan/sync-with-wish", json={"workspace_id": str(seeded_ids.workspace_id)} + ) + assert response.status_code == 422 + + async def test_both_wish_create_and_wish_update_is_422( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/plan/sync-with-wish", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "wish_create": { + "name": "X", + "window_start_month": 6, + "window_start_day": 1, + "window_end_month": 6, + "window_end_day": 7, + "target_weeks": 1, + "first_year": 2027, + }, + "wish_update": {"leave_wish_id": str(uuid4()), "patch": {}}, + }, + ) + assert response.status_code == 422 + + +class TestSyncWithWishCreatesAndFunds: + async def test_wish_create_round_trips_and_days_are_persisted( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + response = await authed_client.post( + "/api/plan/sync-with-wish", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "wish_create": { + "child_id": str(seeded_ids.child_id), + "name": "Sommar", + "window_start_month": 6, + "window_start_day": 1, + "window_end_month": 6, + "window_end_day": 7, + "target_weeks": 1, + "target_days": 7, + "first_year": 2027, + }, + }, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["wish"]["name"] == "Sommar" + assert body["sync"]["apply_result"]["accepted"] is True + + wish_id = body["wish"]["id"] + wishes = await authed_client.get(f"/api/leave-wishes/{seeded_ids.workspace_id}") + assert any(w["id"] == wish_id for w in wishes.json()) + + days = await authed_client.get( + "/api/planned-days", params={"child_id": str(seeded_ids.child_id)} + ) + assert any(d["wish_id"] == wish_id for d in days.json()) + + async def test_day_actions_attach_an_ad_hoc_pinned_day_to_the_new_wish( + self, authed_client: AsyncClient, seeded_ids: SeededIds + ) -> None: + # An ad-hoc, hand-pinned day (restructure step 3b: `POST + # /api/day-assertions`, which mirrors into `planned_leave_day` + # immediately) — the "day_actions" below need that mirrored row's + # id, not the assertion's own. + created_assertion = await authed_client.post( + "/api/day-assertions", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "child_id": str(seeded_ids.child_id), + "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), + "date": "2027-08-02", + "day_type": "vacation", + }, + ) + assert created_assertion.status_code == 201, created_assertion.text + + planned = await authed_client.get( + "/api/planned-days", params={"child_id": str(seeded_ids.child_id)} + ) + day_id = next(d["id"] for d in planned.json() if d["date"] == "2027-08-02") + + response = await authed_client.post( + "/api/plan/sync-with-wish", + json={ + "workspace_id": str(seeded_ids.workspace_id), + "wish_create": { + "child_id": str(seeded_ids.child_id), + "name": "Promoted", + "window_start_month": 8, + "window_start_day": 2, + "window_end_month": 8, + "window_end_day": 2, + "target_weeks": 1, + "target_days": 1, + "first_year": 2027, + "window_mode": "fixed", + }, + "day_actions": [{"planned_day_id": day_id, "action": "attach"}], + }, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["sync"]["apply_result"]["accepted"] is True + + day_after = await authed_client.get(f"/api/planned-days/{day_id}") + assert day_after.json()["wish_id"] == body["wish"]["id"] + assert day_after.json()["is_pinned"] is True diff --git a/backend/tests/unit/test_planned_day_exclusion.py b/backend/tests/unit/test_planned_day_exclusion.py deleted file mode 100644 index 7276426..0000000 --- a/backend/tests/unit/test_planned_day_exclusion.py +++ /dev/null @@ -1,367 +0,0 @@ -"""Tests for the PlannedDayExclusion repository, service and router. - -The core promise, in the user's words: "removing a single day should stay -permanent. Even if it's an auto added period day." `TestExclusionSurvivesSync` -is that promise — a day removed from a planner-placed period must still be -gone after the next `POST /api/plan/sync`, which is precisely what re-derives -it today. - -Follows the DayOverride reference pattern (arrange an entity, exercise the -repo/service, assert on the result). -""" - -from datetime import date, datetime -from uuid import uuid4 - -import pytest -from httpx import AsyncClient - -from app.entities.leave_wish import LeaveWish -from app.entities.planned_day_exclusion import PlannedDayExclusion -from app.entities.planned_leave import PlannedLeaveDay -from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository -from app.repositories_impl.sqlite.planned_day_exclusion_repository import ( - SQLitePlannedDayExclusionRepository, -) -from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository -from app.services.planned_day_exclusion_service import PlannedDayExclusionService -from app.services.planning_service import PlanningService -from app.utils.errors import NotFoundError -from tests.conftest import SeededIds - -_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below - - -def _make(seeded: SeededIds, **overrides) -> PlannedDayExclusion: - base = dict( - id=uuid4(), - workspace_id=seeded.workspace_id, - child_id=seeded.child_id, - parent_personal_data_uuid=seeded.personal_data_uuid, - date=_DAY, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - base.update(overrides) - return PlannedDayExclusion(**base) - - -def _make_wish(seeded: SeededIds) -> LeaveWish: - """A fixed, one-week February 2027 window, funded by the seeded parent.""" - return LeaveWish( - id=uuid4(), - workspace_id=seeded.workspace_id, - child_id=seeded.child_id, - name="Sportlov", - window_start_month=2, - window_start_day=1, - window_end_month=2, - window_end_day=7, - target_weeks=1, - first_year=2027, - last_year=None, - priority=1, - parent_mode="either", - parent_personal_data_uuid=None, - window_mode="fixed", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - - -class TestSQLitePlannedDayExclusionRepository: - async def test_create_and_get_by_id( - self, - planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, - seeded_ids: SeededIds, - ) -> None: - entity = _make(seeded_ids) - await planned_day_exclusion_repo.create(entity) - - fetched = await planned_day_exclusion_repo.get_by_id(entity.id) - assert fetched is not None - assert fetched.id == entity.id - assert fetched.date == _DAY - assert fetched.child_id == seeded_ids.child_id - assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid - - async def test_get_by_natural_key( - self, - planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, - seeded_ids: SeededIds, - ) -> None: - entity = _make(seeded_ids) - await planned_day_exclusion_repo.create(entity) - - found = await planned_day_exclusion_repo.get_by_natural_key( - seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY - ) - assert found is not None - assert found.id == entity.id - - # The parent IS part of the key — a different parent is a different row. - other_parent = await planned_day_exclusion_repo.get_by_natural_key( - seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY - ) - assert other_parent is None - - other_day = await planned_day_exclusion_repo.get_by_natural_key( - seeded_ids.workspace_id, - seeded_ids.child_id, - seeded_ids.personal_data_uuid, - date(2027, 2, 4), - ) - assert other_day is None - - async def test_update_and_delete( - self, - planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, - seeded_ids: SeededIds, - ) -> None: - entity = _make(seeded_ids) - await planned_day_exclusion_repo.create(entity) - - entity.date = date(2027, 2, 5) - updated = await planned_day_exclusion_repo.update(entity.id, entity) - assert updated.date == date(2027, 2, 5) - - await planned_day_exclusion_repo.delete(entity.id) - assert await planned_day_exclusion_repo.get_by_id(entity.id) is None - - with pytest.raises(NotFoundError): - await planned_day_exclusion_repo.delete(entity.id) - with pytest.raises(NotFoundError): - await planned_day_exclusion_repo.update(entity.id, entity) - - async def test_list_by_workspace_is_date_ordered( - self, - planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, - seeded_ids: SeededIds, - ) -> None: - await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 5))) - await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 1))) - - rows = await planned_day_exclusion_repo.list_by_workspace(seeded_ids.workspace_id) - assert [r.date for r in rows] == [date(2027, 2, 1), date(2027, 2, 5)] - - assert await planned_day_exclusion_repo.list_by_workspace(uuid4()) == [] - - async def test_unsupported_filter_is_rejected( - self, - planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, - ) -> None: - with pytest.raises(ValueError): - await planned_day_exclusion_repo.list(nonsense=1) - - -class TestPlannedDayExclusionService: - async def test_exclude_is_idempotent( - self, - planned_day_exclusion_service: PlannedDayExclusionService, - seeded_ids: SeededIds, - ) -> None: - first = await planned_day_exclusion_service.exclude( - seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY - ) - second = await planned_day_exclusion_service.exclude( - seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY - ) - assert second.id == first.id - rows = await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) - assert len(rows) == 1 - - async def test_exclude_removes_the_planned_day_immediately( - self, - planned_day_exclusion_service: PlannedDayExclusionService, - planned_leave_repo: SQLitePlannedLeaveRepository, - seeded_ids: SeededIds, - ) -> None: - await planned_leave_repo.create( - PlannedLeaveDay( - id=uuid4(), - workspace_id=seeded_ids.workspace_id, - child_id=seeded_ids.child_id, - parent_personal_data_uuid=seeded_ids.personal_data_uuid, - date=_DAY, - day_type="sjukpenning", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - ) - - await planned_day_exclusion_service.exclude( - seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY - ) - - rows = await planned_leave_repo.list_by_workspace_child( - seeded_ids.workspace_id, seeded_ids.child_id - ) - assert [r for r in rows if r.date == _DAY] == [] - - async def test_get_and_delete_are_workspace_scoped( - self, - planned_day_exclusion_service: PlannedDayExclusionService, - seeded_ids: SeededIds, - ) -> None: - created = await planned_day_exclusion_service.exclude( - seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY - ) - - with pytest.raises(NotFoundError): - await planned_day_exclusion_service.get(created.id, workspace_id=uuid4()) - with pytest.raises(NotFoundError): - await planned_day_exclusion_service.delete(created.id, workspace_id=uuid4()) - - await planned_day_exclusion_service.delete(created.id, workspace_id=seeded_ids.workspace_id) - assert await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) == [] - - async def test_excluded_keys_shape_matches_the_allocator_input( - self, - planned_day_exclusion_service: PlannedDayExclusionService, - seeded_ids: SeededIds, - ) -> None: - await planned_day_exclusion_service.exclude( - seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY - ) - keys = await planned_day_exclusion_service.get_excluded_keys(seeded_ids.workspace_id) - assert keys == frozenset({(seeded_ids.personal_data_uuid, seeded_ids.child_id, _DAY)}) - - -class TestExclusionSurvivesSync: - """The core promise: a removed day stays removed across re-syncs.""" - - async def test_removed_day_is_not_re_derived_by_the_next_sync( - self, - planning_service: PlanningService, - planned_day_exclusion_service: PlannedDayExclusionService, - planned_leave_repo: SQLitePlannedLeaveRepository, - leave_wish_repo: SQLiteLeaveWishRepository, - seeded_ids: SeededIds, - ) -> None: - ws, child, parent = ( - seeded_ids.workspace_id, - seeded_ids.child_id, - seeded_ids.personal_data_uuid, - ) - await leave_wish_repo.create(_make_wish(seeded_ids)) - - first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert first.apply_result.accepted is True - rows = await planned_leave_repo.list_by_workspace_child(ws, child) - assert _DAY in {r.date for r in rows}, "the planner should place the day to begin with" - - await planned_day_exclusion_service.exclude(ws, child, parent, _DAY) - - second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert second.apply_result.accepted is True, second.apply_result.rejections - rows = await planned_leave_repo.list_by_workspace_child(ws, child) - assert _DAY not in {r.date for r in rows} - # The rest of the week is still planned — only the one day went. - assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= { - r.date for r in rows - } - - # ...and a third sync must not quietly bring it back either. - third = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert third.apply_result.accepted is True - rows = await planned_leave_repo.list_by_workspace_child(ws, child) - assert _DAY not in {r.date for r in rows} - - async def test_removal_does_not_report_a_shortfall( - self, - planning_service: PlanningService, - planned_day_exclusion_service: PlannedDayExclusionService, - leave_wish_repo: SQLiteLeaveWishRepository, - seeded_ids: SeededIds, - ) -> None: - """A day the user removed is not a day the plan failed to fund. - - If it were counted as a gap, `sync_plan`'s eviction preview could - refuse the very sync that carries out the removal. - """ - ws, child, parent = ( - seeded_ids.workspace_id, - seeded_ids.child_id, - seeded_ids.personal_data_uuid, - ) - await leave_wish_repo.create(_make_wish(seeded_ids)) - await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - await planned_day_exclusion_service.exclude(ws, child, parent, _DAY) - - result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - - assert result.apply_result.accepted is True - assert result.eviction_preview is not None - assert result.eviction_preview.would_evict is False - assert [o.shortfall_days for o in result.proposal.outcomes] == [0] - - async def test_un_excluding_puts_the_day_back( - self, - planning_service: PlanningService, - planned_day_exclusion_service: PlannedDayExclusionService, - planned_leave_repo: SQLitePlannedLeaveRepository, - leave_wish_repo: SQLiteLeaveWishRepository, - seeded_ids: SeededIds, - ) -> None: - """There must be a way back — this is what the undo toast calls.""" - ws, child, parent = ( - seeded_ids.workspace_id, - seeded_ids.child_id, - seeded_ids.personal_data_uuid, - ) - await leave_wish_repo.create(_make_wish(seeded_ids)) - await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - created = await planned_day_exclusion_service.exclude(ws, child, parent, _DAY) - await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - - await planned_day_exclusion_service.delete(created.id, workspace_id=ws) - await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - - rows = await planned_leave_repo.list_by_workspace_child(ws, child) - assert _DAY in {r.date for r in rows} - - -class TestPlannedDayExclusionRouter: - async def test_create_list_delete_round_trip( - self, authed_client: AsyncClient, seeded_ids: SeededIds - ) -> None: - payload = { - "workspace_id": str(seeded_ids.workspace_id), - "child_id": str(seeded_ids.child_id), - "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), - "date": _DAY.isoformat(), - } - created = await authed_client.post("/api/planned-day-exclusions", json=payload) - assert created.status_code == 201, created.text - body = created.json() - assert body["date"] == _DAY.isoformat() - - listed = await authed_client.get( - f"/api/planned-day-exclusions/workspace/{seeded_ids.workspace_id}" - ) - assert listed.status_code == 200 - assert [r["id"] for r in listed.json()] == [body["id"]] - - deleted = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}") - assert deleted.status_code == 204 - - missing = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}") - assert missing.status_code == 404 - - async def test_a_foreign_workspace_is_rejected( - self, authed_client: AsyncClient, seeded_ids: SeededIds - ) -> None: - response = await authed_client.post( - "/api/planned-day-exclusions", - json={ - "workspace_id": str(uuid4()), - "child_id": str(seeded_ids.child_id), - "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), - "date": _DAY.isoformat(), - }, - ) - assert response.status_code == 404 - - async def test_a_foreign_list_is_rejected(self, authed_client: AsyncClient) -> None: - response = await authed_client.get(f"/api/planned-day-exclusions/workspace/{uuid4()}") - assert response.status_code == 404 diff --git a/backend/tests/unit/test_planning_service.py b/backend/tests/unit/test_planning_service.py index cecdfbd..818e4ab 100644 --- a/backend/tests/unit/test_planning_service.py +++ b/backend/tests/unit/test_planning_service.py @@ -64,14 +64,21 @@ class TestPlanningService: assert result.day_type == "vacation" assert result.is_dubbeldag is False - async def test_add_planned_day_with_wish_id_and_is_pinned( + async def test_add_planned_day_with_wish_id( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: - """Round-trips `wish_id`/`is_pinned` through the repository (create + - re-fetch), not just the in-memory object the create call returns.""" + """Round-trips `wish_id` through the repository (create + re-fetch), + + not just the in-memory object the create call returns. `is_pinned` + is no longer a `planned_leave_day` column (restructure step 3b) — a + freshly-added row is never pinned on its own; `PlanningService. + pinned_keys`/`is_pinned` compute that live from `DayAssertion`, see + `test_day_assertion.py` for that half of the guarantee this test + used to also cover. + """ wish = await leave_wish_repo.create( _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) ) @@ -83,14 +90,12 @@ class TestPlanningService: date=date(2026, 6, 15), day_type="vacation", wish_id=wish.id, - is_pinned=True, ) assert result.wish_id == wish.id - assert result.is_pinned is True + assert await planning_service.is_pinned(result) is False fetched = await planning_service.get(result.id) assert fetched.wish_id == wish.id - assert fetched.is_pinned is True async def test_add_planned_day_without_wish_id_defaults_to_none( self, @@ -105,7 +110,6 @@ class TestPlanningService: day_type="vacation", ) assert result.wish_id is None - assert result.is_pinned is False async def test_add_double_booking_raises( self, @@ -407,12 +411,17 @@ class TestPlanningService: assert updated.day_type == "sjukpenning" assert updated.is_dubbeldag is True - async def test_update_planned_day_wish_id_and_is_pinned( + async def test_update_planned_day_wish_id( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: + """`is_pinned` is not an `update()` parameter any more (restructure + + step 3b) — see `add_planned_day`'s docstring. Pinning this row is + now `DayAssertionService.assert_day`'s job (`test_day_assertion.py`). + """ wish = await leave_wish_repo.create( _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) ) @@ -428,14 +437,73 @@ class TestPlanningService: updated = await planning_service.update( created.id, wish_id=wish.id, - is_pinned=True, ) assert updated.wish_id == wish.id - assert updated.is_pinned is True refetched = await planning_service.get(created.id) assert refetched.wish_id == wish.id - assert refetched.is_pinned is True + + async def test_update_omitted_wish_id_leaves_it_unchanged( + self, + planning_service: PlanningService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Regression: an OMITTED `wish_id` (the caller doesn't pass it at + + all) must leave the row's existing wish attachment untouched — a + call that's only updating `day_type`, say, must not accidentally + detach the row from its wish. + """ + wish = await leave_wish_repo.create( + _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) + ) + created = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2026, 6, 15), + day_type="vacation", + ) + attached = await planning_service.update(created.id, wish_id=wish.id) + assert attached.wish_id == wish.id + + updated = await planning_service.update(created.id, day_type="lagsta") + assert updated.day_type == "lagsta" + assert updated.wish_id == wish.id # unchanged, not detached + + async def test_update_explicit_none_detaches_wish_id( + self, + planning_service: PlanningService, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """Regression: an explicit `wish_id=None` (`_clear_wish_id=True`) + + must actually detach the row from its wish, unlike a merely + OMITTED `wish_id` (see the sibling test above) — before the fix, + `if wish_id is not None: entity.wish_id = wish_id` silently ignored + both cases identically, so a day could never be detached through + `update()`. + """ + wish = await leave_wish_repo.create( + _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) + ) + created = await planning_service.add_planned_day( + workspace_id=seeded_ids.workspace_id, + child_id=seeded_ids.child_id, + parent_personal_data_uuid=seeded_ids.personal_data_uuid, + date=date(2026, 6, 15), + day_type="vacation", + ) + attached = await planning_service.update(created.id, wish_id=wish.id) + assert attached.wish_id == wish.id + + detached = await planning_service.update(created.id, _clear_wish_id=True) + assert detached.wish_id is None + + refetched = await planning_service.get(created.id) + assert refetched.wish_id is None async def test_update_missing_raises( self, diff --git a/backend/tests/unit/test_planning_service_apply.py b/backend/tests/unit/test_planning_service_apply.py index 6b3a403..48e30aa 100644 --- a/backend/tests/unit/test_planning_service_apply.py +++ b/backend/tests/unit/test_planning_service_apply.py @@ -257,13 +257,17 @@ class TestApplyProposedDaysCollisions: class TestApplyProposedDaysWishAttribution: - """`wish_id`/`is_pinned` are new fields on `ProposedDayInput` — the - keystone that lets the UI later click a multi-day band on the calendar - and edit it as one period. This covers them surviving the write path and - the "metadata-only update" carve-out from the collision rules. + """`wish_id` is a field on `ProposedDayInput` — the keystone that lets + + the UI later click a multi-day band on the calendar and edit it as one + period. This covers it surviving the write path and the "metadata-only + update" carve-out from the collision rules. `is_pinned` is no longer a + field here (restructure step 3b) — a freshly-applied day is never + pinned on its own; see `test_day_assertion.py` for that half of what + this test used to also cover. """ - async def test_wish_id_and_is_pinned_survive_create( + async def test_wish_id_survives_create( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, @@ -284,7 +288,6 @@ class TestApplyProposedDaysWishAttribution: child, "vacation", wish_id=wish_id, - is_pinned=True, ), ] @@ -293,14 +296,12 @@ class TestApplyProposedDaysWishAttribution: assert result.accepted is True assert result.created_count == 1 assert result.created[0].wish_id == wish_id - assert result.created[0].is_pinned is True rows = await planned_leave_repo.list_by_workspace_child( workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) ) assert len(rows) == 1 assert rows[0].wish_id == wish_id - assert rows[0].is_pinned is True async def test_ad_hoc_day_has_no_wish_id( self, @@ -318,7 +319,6 @@ class TestApplyProposedDaysWishAttribution: result = await planning_service.apply_proposed_days(ws, days) assert result.created[0].wish_id is None - assert result.created[0].is_pinned is False async def test_reapply_with_different_wish_id_updates_attribution_not_collision( self, diff --git a/backend/tests/unit/test_planning_service_free_days.py b/backend/tests/unit/test_planning_service_free_days.py index bead624..b7e8f61 100644 --- a/backend/tests/unit/test_planning_service_free_days.py +++ b/backend/tests/unit/test_planning_service_free_days.py @@ -24,7 +24,6 @@ import pytest_asyncio from app.entities.planned_leave import PlannedLeaveDay from app.entities.workspace import Workspace 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.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository @@ -51,7 +50,6 @@ async def guarded_planning_service( parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, planning_engine_service: PlanningEngineService, - leave_wish_repo: SQLiteLeaveWishRepository, workspace_repo: SQLiteWorkspaceRepository, day_override_repo: SQLiteDayOverrideRepository, ) -> PlanningService: @@ -67,7 +65,6 @@ async def guarded_planning_service( parental_leave_repo, vacation_repo, planning_engine_service, - leave_wish_repo=leave_wish_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo, ) @@ -258,14 +255,14 @@ class TestUpdateFreeDayGuard: seeded_ids: SeededIds, planned_leave_repo: SQLitePlannedLeaveRepository, ) -> None: - """Passing `is_pinned` alongside an illegal `day_type` must still + """Passing another field alongside an illegal `day_type` must still reject — the guard runs whenever `day_type` is part of the call, regardless of what else is being updated in the same call. """ row = await _create_row_directly(planned_leave_repo, seeded_ids, _SATURDAY, "lagsta") with pytest.raises(ValidationError): - await guarded_planning_service.update(row.id, day_type="vacation", is_pinned=True) + await guarded_planning_service.update(row.id, day_type="vacation", is_dubbeldag=False) class TestFreeDayGuardNoOp: diff --git a/backend/tests/unit/test_planning_service_sync.py b/backend/tests/unit/test_planning_service_sync.py index 74f3321..e4fbb39 100644 --- a/backend/tests/unit/test_planning_service_sync.py +++ b/backend/tests/unit/test_planning_service_sync.py @@ -7,25 +7,23 @@ diff surfaces each of its four kinds, sync is idempotent on an unchanged plan, and `/propose` stays read-only (persists nothing). """ -from datetime import date, datetime +from datetime import date, datetime, timedelta from uuid import uuid4 +from app.entities.day_assertion import DayAssertion from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData from app.entities.planned_leave import PlannedLeaveDay from app.entities.vacation_days import VacationDays +from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository 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_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.services.allocation_service import PlanningEngineService -from app.services.planning_service import ( - EVICTION_CHOICE_DROP_FUTURE_PL, - EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR, - PlanningService, -) +from app.services.planning_service import PlanningService from tests.conftest import SeededIds @@ -87,6 +85,7 @@ class TestSyncNeverOverwritesAPinnedDay: self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, vacation_repo: SQLiteVacationDaysRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, @@ -116,7 +115,19 @@ class TestSyncNeverOverwritesAPinnedDay: parent_personal_data_uuid=parent, date=pinned_date, day_type="lagsta", # deliberately NOT what a fresh plan would pick - is_pinned=True, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + # Restructure step 3b: "pinned" is a `DayAssertion` fact now. + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=pinned_date, + day_type="lagsta", created_at=datetime.now(), updated_at=datetime.now(), ) @@ -129,7 +140,7 @@ class TestSyncNeverOverwritesAPinnedDay: pinned_after = next(r for r in rows if r.date == pinned_date) assert pinned_after.id == pinned.id assert pinned_after.day_type == "lagsta" - assert pinned_after.is_pinned is True + assert await planning_service.is_pinned(pinned_after) is True # Every OTHER day in the window was free to be funded normally. other_days = [r for r in rows if r.date != pinned_date] @@ -140,6 +151,172 @@ class TestSyncNeverOverwritesAPinnedDay: assert all(c.date != pinned_date for c in result.changes) +class TestSyncPreservesPromotedLockedWish: + async def test_pinned_wish_attributed_days_untouched_and_debited_once( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """§promote-to-locked-wish — when the frontend "promotes" an ad-hoc + + period into a `LeaveWish` via the "locked" option, the existing + `planned_leave_day` rows are re-pointed at the new wish (`wish_id` + set) while a `DayAssertion` locks each date in place (restructure + step 3b — see `PlanningService._assert_pin`). `sync_plan` must treat + that state + exactly like any other pinned row for the write path (never moved, + retyped, reassigned, or deleted, per `TestSyncNeverOverwritesAPinnedDay` + above) -- but it must ALSO never double-spend its cost: `_Ledger. + _reserve_pinned_days` (allocation_service.py) debits a pinned day off + the top unconditionally, and `_fund_solo` separately must recognize + those same dates as already-occupied (`_existing_lookup`) and skip + funding them again, rather than spending a second unit of pool + capacity for a window whose days are already fully paid for. + + Rigged so the two failure modes are each independently visible: + - a moved/retyped/reassigned/deleted pinned row fails the row-level + assertions directly; + - a double-spend of the sjukpenning pool leaves too little of it for + the second, freshly-funded wish below, which would then come back + short (fewer than 5 persisted weekdays, or a different day_type) -- + exactly what a double debit of `_reserve_pinned_days` plus a + second spend during window-funding would cause. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + # No vacation budget -> tier-3 falls to FP, sjukpenning preferred + # (same reasoning as `TestSyncNeverOverwritesAPinnedDay` above). + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + # Wish A's 7-day window (Mon-Sun) is entirely pre-existing/pinned, so + # all 7 calendar days -- including the weekend -- were explicitly + # pinned to `sjukpenning` (a deliberate override of what would + # otherwise be a free/unpersisted weekend; `_reserve_pinned_days` + # debits a pinned day by `day_type` alone, never by whether the + # calendar date is otherwise free). Wish B's window is fresh: its + # own Sat/Sun are genuinely free (`is_free_day`) and never persisted, + # leaving 5 spendable weekdays to fund. Exactly enough sjukpenning + # for both (7 + 5 = 12), no spare -- a single extra debit anywhere + # is immediately visible as a shortfall on wish B. + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=12, lagsta_left=0) + ) + + # -- wish A: the "promoted, locked" wish. All 7 of its days are + # ALREADY persisted, pinned, and wish_id-attributed to it -- exactly + # the state the frontend's "locked" promotion produces, rather than + # something sync_plan itself would ever create in one call. + wish_a = await leave_wish_repo.create(_make_wish(ws, child, name="Promoted (locked)")) + pinned_dates = [date(2027, 2, 1) + timedelta(days=i) for i in range(7)] + pinned_rows = [ + await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=d, + day_type="sjukpenning", + wish_id=wish_a.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + for d in pinned_dates + ] + for d in pinned_dates: + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=d, + day_type="sjukpenning", + wish_id=wish_a.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + # -- wish B: an ordinary, not-yet-funded wish competing for the SAME + # pool. Only gets fully funded if wish A's pinned days were debited + # exactly once. + wish_b = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Fresh B", + priority=2, + window_start_month=3, + window_start_day=1, + window_end_month=3, + window_end_day=7, + ) + ) + + result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert result.apply_result.accepted is True + + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + rows_by_id = {r.id: r for r in rows} + + # -- 1. the pinned, wish-attributed rows: untouched ----------------- + assert len(pinned_rows) == 7 + for original in pinned_rows: + after = rows_by_id.get(original.id) + assert after is not None, "a pinned row was deleted by sync_plan" + assert after.date == original.date, "a pinned row was moved" + assert after.day_type == "sjukpenning", "a pinned row was retyped" + assert after.parent_personal_data_uuid == parent, "a pinned row changed parent" + assert after.wish_id == wish_a.id, "a pinned row was reassigned to a different wish" + assert await planning_service.is_pinned(after) is True + + # Exactly wish A's 7 original dates carry its wish_id -- no + # duplicate/extra row was created for its already-funded window. + wish_a_rows = [r for r in rows if r.wish_id == wish_a.id] + assert len(wish_a_rows) == 7 + assert {r.id for r in wish_a_rows} == {r.id for r in pinned_rows} + + # None of wish A's dates show up in the sync diff. + pinned_dates = {r.date for r in pinned_rows} + assert not any(c.date in pinned_dates for c in result.changes) + + # -- 2. cost debited exactly once ------------------------------------ + # 12 sjukpenning days existed: wish A's 7 pre-paid pinned days plus + # exactly the 5 weekday days wish B needs, nothing spare. If + # `_reserve_pinned_days` debited wish A's pinned days AND the + # allocator also funded wish A's (already-occupied) window a second + # time, the pool would run dry before wish B and it would come back + # short. Getting all 5 of its weekdays funded (its Sat/Sun stay free + # and unpersisted, like any fresh window's weekend) proves the debit + # happened exactly once. + wish_b_rows = [r for r in rows if r.wish_id == wish_b.id] + assert len(wish_b_rows) == 5, ( + f"expected wish B fully funded from the 5 remaining sjukpenning days, " + f"got {len(wish_b_rows)} -- looks like wish A's pinned days were debited twice" + ) + assert all(r.day_type == "sjukpenning" for r in wish_b_rows) + assert {r.date for r in wish_b_rows} == { + date(2027, 3, 1), + date(2027, 3, 2), + date(2027, 3, 3), + date(2027, 3, 4), + date(2027, 3, 5), + } + + class TestSyncChangeKinds: async def test_added_then_removed_when_the_wish_shrinks( self, @@ -301,7 +478,6 @@ class TestSyncChangeKinds: date=target_date, day_type="sjukpenning", wish_id=old_wish.id, - is_pinned=False, created_at=datetime.now(), updated_at=datetime.now(), ) @@ -439,20 +615,25 @@ class TestSyncCreditsFreedCapacityWithinSameCall: assert any(r.day_type == "sjukpenning" for r in winter_rows) -class TestSyncEvictionPreview: - """`sync_plan`'s eviction preview/resolution — the fix for the data-loss +class TestSyncDisplacement: + """`sync_plan`'s displacement handling -- Step 2 of the intent-vs- - bug where editing one wish silently deleted another wish's rows because - the shared pool went to the edited wish first (see this file's module - docstring / PLANNING_ENGINE_SPEC.md). Both scenarios below use two wishes - over the EXACT SAME window (Mon 11 - Fri 15 Jan 2027 -- five plain - workdays, no weekend, no Swedish red day in range) so a higher-priority - wish's fresh placement claims literally the same dates the lower-priority - wish already had persisted, guaranteeing full contention regardless of - the exact day-count the allocator computes. + derived-output restructure made this an informational outcome, never a + refusal: `sync_plan` always writes, and a genuinely evicted row (its OWN + wish still wants that day but lost the priority race for a shared pool + to a DIFFERENT wish's edit -- see `PlanningService._genuinely_evicted_ + rows`) comes back as a 'removed' `PlanChange` with `evicted=True` + instead of blocking the whole batch (see the fix for the data-loss bug + this replaces in this file's module docstring / PLANNING_ENGINE_SPEC.md). + The scenarios below use two wishes over the EXACT SAME window (Mon 11 - + Fri 15 Jan 2027 -- five plain workdays, no weekend, no Swedish red day in + range) so a higher-priority wish's fresh placement claims literally the + same dates the lower-priority wish already had persisted, guaranteeing + full contention regardless of the exact day-count the allocator + computes. """ - async def test_would_evict_returns_preview_and_mutates_nothing( + async def test_over_subscribed_sync_writes_and_reports_displacement( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, @@ -468,7 +649,7 @@ class TestSyncEvictionPreview: ) # No vacation this year, no lagsta -> the only lever is sjukpenning, # so a shortfall there can never be silently backstopped by anything - # else (it must show up as a genuine shortfall/eviction). + # else (it must show up as a genuine displacement). await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 @@ -497,16 +678,17 @@ class TestSyncEvictionPreview: existing_count = len(rows_after_first) assert existing_count > 0 assert all(r.wish_id == existing.id for r in rows_after_first) - assert first.eviction_preview.would_evict is False + assert not any(c.evicted for c in first.changes) # Shrink the shared pool to exactly what `existing` already spent (no - # slack), then a higher-priority wish over the SAME window steals it. + # slack), then a higher-priority wish over a DIFFERENT window steals + # it -- `existing`'s own window is now entirely unfunded. await parental_leave_repo.create( _make_fetch( parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 ) ) - await leave_wish_repo.create( + new_wish = await leave_wish_repo.create( _make_wish( ws, child, @@ -521,38 +703,30 @@ class TestSyncEvictionPreview: second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - # Nothing accepted, nothing changed, and the caller must be told why. - assert second.apply_result.accepted is False - assert second.changes == [] - assert second.apply_result.rejections - assert ( - second.apply_result.rejections[0].message_key - == "planSync.rejection.evictionChoiceRequired" + # The edit lands -- displacement is informational, never a refusal. + assert second.apply_result.accepted is True, second.apply_result.rejections + assert second.apply_result.rejections == [] + + removed = [c for c in second.changes if c.kind == "removed"] + assert removed, "expected the crowded-out 'Existing' days to be reported as removed" + assert all(c.evicted for c in removed), ( + "a row removed because a DIFFERENT wish's edit won the shared pool must be " + "flagged evicted=True, not folded in as an ordinary self-caused removal" ) + assert all(c.old_wish_id == existing.id for c in removed) + assert all(c.old_day_type == "sjukpenning" for c in removed) + assert all(c.child_id == child and c.parent_personal_data_uuid == parent for c in removed) - preview = second.eviction_preview - assert preview.would_evict is True - assert preview.candidates, "expected the evicted 'Existing' days to be listed" - assert all(c.wish_id == existing.id for c in preview.candidates) - assert all(c.wish_name == "Existing" for c in preview.candidates) - assert all(c.day_type == "sjukpenning" for c in preview.candidates) - assert all( - c.child_id == child and c.parent_personal_data_uuid == parent - for c in preview.candidates - ) + added = [c for c in second.changes if c.kind == "added"] + assert added, "expected the new wish's days to be reported as added" + assert all(c.new_wish_id == new_wish.id for c in added) - assert len(preview.shortages) == 1 - shortage = preview.shortages[0] - assert shortage.resource_kind == "parental_leave" - assert shortage.parent_personal_data_uuid == parent - assert shortage.tier == "sjukpenning" - assert shortage.child_id == child - assert shortage.year is None - assert shortage.days_evicted == len(preview.candidates) - - # Requirement: mutates NOTHING, not even the harmless parts. + # The write actually happened: `existing`'s rows are gone, `new`'s + # are there. rows_after_second = await planned_leave_repo.list_by_workspace_child(ws, child) - assert {r.id for r in rows_after_second} == {r.id for r in rows_after_first} + assert rows_after_second + assert all(r.wish_id == new_wish.id for r in rows_after_second) + assert not any(r.wish_id == existing.id for r in rows_after_second) async def test_shortfall_days_is_not_masked_when_a_higher_priority_wish_claims_the_same_dates( self, @@ -563,9 +737,9 @@ class TestSyncEvictionPreview: leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: - """Regression for a real bug this eviction-preview work uncovered in + """Regression for a real bug this displacement-handling work uncovered - `_Ledger._fund_solo` (`app/services/allocation_service.py`): when a + in `_Ledger._fund_solo` (`app/services/allocation_service.py`): when a higher-priority wish's placement lands on the EXACT SAME calendar dates a lower-priority wish already occupied (as opposed to merely exhausting a shared pool on different dates — the scenario the test @@ -579,19 +753,17 @@ class TestSyncEvictionPreview: `record_days()` calls started (`_pre_run_occupied`); a same-run claim by a different wish now correctly falls through unfunded. - NOTE — known, separately-scoped gap this test does NOT claim to fix: - because the two wishes here land on the identical (child, parent, - date) keys, `sync_plan`'s `stale_keys`/`_genuinely_evicted_rows` - (which only look at keys ABSENT from the fresh batch) never fire — - the row survives and is silently metadata-updated to the new wish's - `wish_id` (a 'reattributed' `PlanChange`, not a deletion), bypassing - `eviction_preview` entirely. That is a real, distinct hole in the - eviction-preview feature (same-date collisions between two DIFFERENT - wishes are not gated the way stale-key drops are) — flagged in this - session's handoff report as follow-up work, not fixed here. This - test only pins down that `shortfall_days`/`weeks_funded` — the - signal both the UI's "window funded" display AND - `_genuinely_evicted_rows` depend on — are no longer silently wrong. + Follow-up fix (this session): because the two wishes here land on + the identical (child, parent, date) keys, `sync_plan`'s + `stale_keys` (which only looks at keys ABSENT from the fresh batch) + never fires for them — the row survives and is metadata-updated to + the new wish's `wish_id`, i.e. a 'reattributed' `PlanChange`, not a + 'removed' one. That used to bypass the `evicted` flag entirely + (same-date collisions between two DIFFERENT wishes were not called + out as displacement the way stale-key drops are). `sync_plan` now + runs the same "does the row's own wish still have unmet shortfall + here" check `_genuinely_evicted_rows` applies to deleted stale rows + against 'reattributed' changes too — asserted below. """ ws, child, parent = ( seeded_ids.workspace_id, @@ -651,7 +823,21 @@ class TestSyncEvictionPreview: ) assert existing_outcome.weeks_funded == 0.0 - async def test_drop_future_pl_choice_frees_the_days_and_lets_the_edit_land( + reattributed = [c for c in second.changes if c.kind == "reattributed"] + assert len(reattributed) == existing_count, ( + "every day the higher-priority wish claimed from `existing` " + "should surface as a reattribution, not vanish silently" + ) + assert all(c.old_wish_id == existing.id for c in reattributed) + assert all(c.evicted is True for c in reattributed), ( + "a same-date collision that displaces a wish which still wants " + "that day must be flagged evicted=True, exactly like a " + "different-date eviction would be — a displaced day must never " + "go unreported to the user just because it happened to land on " + "the identical calendar slot" + ) + + async def test_over_subscribed_vacation_sync_writes_and_displaces_vacation_days( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, @@ -660,146 +846,12 @@ class TestSyncEvictionPreview: leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: - ws, child, parent = ( - seeded_ids.workspace_id, - seeded_ids.child_id, - seeded_ids.personal_data_uuid, - ) - await vacation_repo.create( - VacationDays( - id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 - ) - ) - await parental_leave_repo.create( - _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) - ) - existing = await leave_wish_repo.create( - _make_wish( - ws, - child, - name="Existing", - window_start_month=1, - window_start_day=11, - window_end_month=1, - window_end_day=15, - priority=5, - ) - ) - first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert first.apply_result.accepted is True - existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child)) + """Displacement isn't only a parental-leave-pool phenomenon -- - await parental_leave_repo.create( - _make_fetch( - parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 - ) - ) - new_wish = await leave_wish_repo.create( - _make_wish( - ws, - child, - name="New", - window_start_month=2, - window_start_day=1, - window_end_month=2, - window_end_day=5, - priority=1, - ) - ) - - preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert preview.eviction_preview.would_evict is True - - resolved = await planning_service.sync_plan( - ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL - ) - assert resolved.apply_result.accepted is True, resolved.apply_result.rejections - - rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) - assert rows_after, "expected the new wish's days to have been written" - assert all(r.wish_id == new_wish.id for r in rows_after) - assert not any(r.wish_id == existing.id for r in rows_after) - - async def test_wrong_choice_still_rejects_and_mutates_nothing( - self, - planning_service: PlanningService, - planned_leave_repo: SQLitePlannedLeaveRepository, - vacation_repo: SQLiteVacationDaysRepository, - parental_leave_repo: SQLiteParentalLeaveRepository, - leave_wish_repo: SQLiteLeaveWishRepository, - seeded_ids: SeededIds, - ) -> None: - """The vacation-eviction option can't free a parental-leave shortage - - -- `apply_proposed_days`'s own balance validation rejects the whole - batch exactly as it would with no choice at all, atomically. + vacation is budgeted per (parent, year) the same way, and an + over-subscribed vacation sync must write and report displacement + exactly like the sjukpenning case above. """ - ws, child, parent = ( - seeded_ids.workspace_id, - seeded_ids.child_id, - seeded_ids.personal_data_uuid, - ) - await vacation_repo.create( - VacationDays( - id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 - ) - ) - await parental_leave_repo.create( - _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) - ) - await leave_wish_repo.create( - _make_wish( - ws, - child, - name="Existing", - window_start_month=1, - window_start_day=11, - window_end_month=1, - window_end_day=15, - priority=5, - ) - ) - first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert first.apply_result.accepted is True - rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child) - existing_count = len(rows_after_first) - - await parental_leave_repo.create( - _make_fetch( - parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 - ) - ) - await leave_wish_repo.create( - _make_wish( - ws, - child, - name="New", - window_start_month=2, - window_start_day=1, - window_end_month=2, - window_end_day=5, - priority=1, - ) - ) - - resolved = await planning_service.sync_plan( - ws, - today=date(2027, 1, 1), - eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR, - ) - assert resolved.apply_result.accepted is False - rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) - assert {r.id for r in rows_after} == {r.id for r in rows_after_first} - - async def test_drop_latest_vacation_choice_frees_vacation_days( - self, - planning_service: PlanningService, - planned_leave_repo: SQLitePlannedLeaveRepository, - vacation_repo: SQLiteVacationDaysRepository, - parental_leave_repo: SQLiteParentalLeaveRepository, - leave_wish_repo: SQLiteLeaveWishRepository, - seeded_ids: SeededIds, - ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, @@ -850,39 +902,25 @@ class TestSyncEvictionPreview: ) ) - preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert preview.eviction_preview.would_evict is True - assert all(c.day_type == "vacation" for c in preview.eviction_preview.candidates) - assert len(preview.eviction_preview.shortages) == 1 - shortage = preview.eviction_preview.shortages[0] - assert shortage.resource_kind == "vacation" - assert shortage.year == 2027 - assert shortage.tier is None - assert shortage.child_id is None + second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - # The wrong option (PL) can't touch a vacation shortage. - wrong = await planning_service.sync_plan( - ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL - ) - assert wrong.apply_result.accepted is False - rows_still_original = await planned_leave_repo.list_by_workspace_child(ws, child) - assert {r.id for r in rows_still_original} == {r.id for r in rows_after_first} + assert second.apply_result.accepted is True, second.apply_result.rejections + removed = [c for c in second.changes if c.kind == "removed"] + assert removed, "expected the crowded-out 'Existing' vacation days to be reported" + assert all(c.evicted for c in removed) + assert all(c.old_day_type == "vacation" for c in removed) + assert all(c.old_wish_id == existing.id for c in removed) - resolved = await planning_service.sync_plan( - ws, - today=date(2027, 1, 1), - eviction_choice=EVICTION_CHOICE_DROP_LATEST_VACATION_THIS_YEAR, - ) - assert resolved.apply_result.accepted is True, resolved.apply_result.rejections rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) assert rows_after assert all(r.wish_id == new_wish.id for r in rows_after) assert not any(r.wish_id == existing.id for r in rows_after) - async def test_pinned_day_never_evicted_under_any_choice( + async def test_pinned_day_never_displaced( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, @@ -919,8 +957,23 @@ class TestSyncEvictionPreview: assert rows to_pin = rows[0] - pinned = await planning_service.update(to_pin.id, is_pinned=True) - assert pinned.is_pinned is True + # Restructure step 3b: "pinned" is a `DayAssertion` fact, not an + # `update()` parameter — see `PlanningService.update`'s docstring. + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=to_pin.workspace_id, + child_id=to_pin.child_id, + parent_personal_data_uuid=to_pin.parent_personal_data_uuid, + date=to_pin.date, + day_type=to_pin.day_type, + wish_id=to_pin.wish_id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + pinned = to_pin + assert await planning_service.is_pinned(pinned) is True await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 2), sjuk_left=len(rows), lagsta_left=0) @@ -938,32 +991,28 @@ class TestSyncEvictionPreview: ) ) - preview = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) - assert preview.eviction_preview.would_evict is True - assert all(c.date != pinned.date for c in preview.eviction_preview.candidates) - - resolved = await planning_service.sync_plan( - ws, today=date(2027, 1, 1), eviction_choice=EVICTION_CHOICE_DROP_FUTURE_PL - ) + resolved = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert resolved.apply_result.accepted is True, resolved.apply_result.rejections + assert all(c.date != pinned.date for c in resolved.changes if c.evicted) + rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) survivor = next((r for r in rows_after if r.id == pinned.id), None) - assert survivor is not None, "the pinned day must never be evicted" - assert survivor.is_pinned is True + assert survivor is not None, "the pinned day must never be displaced" + assert await planning_service.is_pinned(survivor) is True assert survivor.date == pinned.date assert survivor.day_type == pinned.day_type -class TestSyncEvictionPreviewAbsentWhenWithinBudget: - async def test_plain_sync_has_no_preview_friction( +class TestSyncNoDisplacementWithinBudget: + async def test_plain_sync_has_no_evicted_changes( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: - """Requirement 5 — a save that fits within budget applies straight + """A save that fits within budget applies straight through: no - through: no extra round trip, no empty-but-present friction either. + removed change is ever flagged `evicted` when nothing was displaced. """ ws, child, _parent = ( seeded_ids.workspace_id, @@ -975,9 +1024,7 @@ class TestSyncEvictionPreviewAbsentWhenWithinBudget: result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result.apply_result.accepted is True - assert result.eviction_preview.would_evict is False - assert result.eviction_preview.candidates == [] - assert result.eviction_preview.shortages == [] + assert not any(c.evicted for c in result.changes) class TestProposeStaysReadOnly: @@ -1000,3 +1047,247 @@ class TestProposeStaysReadOnly: rows = await planned_leave_repo.list_by_workspace_child(ws, child) assert rows == [] + + +class TestSyncPreservesElapsedAutoPlacedDays: + """§the-past-is-fact — a stale key is only ever "self-caused" or + + "genuinely evicted" when its own wish is still capable of re-claiming + the date at all. A date that has already elapsed can never come back + from the allocator (`_expand_all_windows`/`_expand_flexible_wish_years` + clip every window to `>= today`), so treating it as stale the moment it + elapses -- as `sync_plan` did before this fix -- silently deletes + already-taken leave on the very next load, since `PlanSyncProvider` + (frontend) syncs on every workspace load. + + Reproduces the reported scenario exactly: a fixed wish for Feb 1-7 + 2027 synced once with `today=2027-01-01` (creating auto-placed rows), + then synced again with `today=2027-02-04` -- Feb 1-3 must survive and + must not appear in `changes`. + """ + + async def test_past_autoplaced_rows_survive_later_sync( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + await leave_wish_repo.create(_make_wish(ws, child)) + + # Feb 1-7 2027: Feb 6-7 are a Sat/Sun and stay free/unpersisted (same + # reasoning as `TestSyncNeverOverwritesAPinnedDay`), so the wish + # auto-places exactly the 5 weekdays Feb 1-5. + result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert result_1.apply_result.accepted is True + rows_1 = await planned_leave_repo.list_by_workspace_child(ws, child) + assert {r.date for r in rows_1} >= {date(2027, 2, i) for i in range(1, 6)} + + # "Today" advances past Feb 1-3: the allocator's own window clipping + # now excludes those three dates from the fresh proposal entirely. + result_2 = await planning_service.sync_plan(ws, today=date(2027, 2, 4)) + assert result_2.apply_result.accepted is True + rows_2 = await planned_leave_repo.list_by_workspace_child(ws, child) + rows_2_dates = {r.date for r in rows_2} + + elapsed = {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 3)} + assert elapsed <= rows_2_dates, ( + f"elapsed rows were deleted by sync: missing {elapsed - rows_2_dates}" + ) + assert not any(c.date in elapsed for c in result_2.changes), ( + f"elapsed rows appeared in changes: " + f"{[c for c in result_2.changes if c.date in elapsed]}" + ) + + +class TestSyncTodayItselfIsStillReplannable: + """The boundary choice pinned: `today` itself is NOT frozen -- only a + + row dated strictly before `today` is "the past". A row dated exactly + `today` whose wish no longer wants it must still be droppable as + ordinary self-caused staleness, exactly like any other date the wish + stopped claiming. + """ + + async def test_todays_date_still_evicted_when_its_own_wish_drops_it( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + wish = await leave_wish_repo.create( + _make_wish(ws, child, window_start_day=1, window_end_day=5) + ) + + # Feb 1-5 2027 are all weekdays -> all five auto-placed. + result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert result_1.apply_result.accepted is True + rows_1 = await planned_leave_repo.list_by_workspace_child(ws, child) + assert {r.date for r in rows_1} == {date(2027, 2, i) for i in range(1, 6)} + + # The wish itself shrinks to drop Feb 1 -- its own edit, not a + # different wish stealing the day, so this is ordinary self-caused + # staleness, never "genuine eviction". + wish.window_start_day = 2 + await leave_wish_repo.update(wish.id, wish) + + # `today` is exactly Feb 1, the date being dropped. If the boundary + # were implemented as `<=` (today frozen too) this row would + # survive and never show up in `changes`, which is the wrong + # reading: today must stay fully re-plannable in the same call. + result_2 = await planning_service.sync_plan(ws, today=date(2027, 2, 1)) + assert result_2.apply_result.accepted is True + rows_2 = await planned_leave_repo.list_by_workspace_child(ws, child) + rows_2_dates = {r.date for r in rows_2} + + assert date(2027, 2, 1) not in rows_2_dates, ( + "today's own date was frozen even though its wish dropped it" + ) + assert any(c.kind == "removed" and c.date == date(2027, 2, 1) for c in result_2.changes), ( + "expected today's dropped date to be reported as a 'removed' change" + ) + # The rest of the (still-claimed) window is untouched. + assert {date(2027, 2, i) for i in range(2, 6)} <= rows_2_dates + + +class TestSyncDebitsElapsedDaysFromPools: + """§the-past-is-fact, second half — `AllocationInputLoader.load`'s + + `only_pinned_existing` mode hides every unpinned wish-attributed row so + the auto-placed portion can be re-derived, but a row dated before + `today` is never re-derived by the allocator at all (see + `TestSyncPreservesElapsedAutoPlacedDays`). Left out of the pool + netting too, it would be spent capacity the plan still reports as + available -- a competing wish would wrongly get funded from days that + are already gone. + + Rigged so any deviation is loud, not a silent off-by-one: the parent's + sjukpenning pool (per a stale FK snapshot that predates the already- + elapsed commitment) has exactly `elapsed + wish B's demand - 1` days + left. Correctly netting the 3 elapsed days leaves exactly 4 -- one + short of wish B's 5 weekdays -- so wish B's window must show 4 + `sjukpenning` rows and exactly 1 `lagsta` fallback row. If the elapsed + days are NOT netted, the pool has enough spare capacity to fund all 5 + of wish B's days as `sjukpenning`, and this test fails on the day-type + split rather than a fragile total-count check. + """ + + async def test_elapsed_rows_reduce_the_pool_a_competing_wish_can_draw_from( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + + # A wish whose window is entirely in the past by the time `sync_plan` + # runs below -- `_expand_all_windows` drops it outright, so nothing + # about its own funding matters here. What matters is the 3 rows + # already persisted against it: unpinned, wish-attributed, + # `sjukpenning`, dated before `today`. + elapsed_wish = await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Elapsed", + window_start_month=1, + window_start_day=4, + window_end_month=1, + window_end_day=6, + ) + ) + elapsed_dates = [date(2027, 1, 4), date(2027, 1, 5), date(2027, 1, 6)] + for d in elapsed_dates: + await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=d, + day_type="sjukpenning", + wish_id=elapsed_wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + # A stale FK snapshot that predates those 3 already-taken days: + # 7 sjukpenning left, plenty of lagsta so a shortfall never rejects + # the batch outright, it just falls back to a visibly different + # day_type once sjukpenning runs out. + await parental_leave_repo.create( + _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=7, lagsta_left=99) + ) + + # Wish B: 5 weekdays (Mon-Fri), competing for the SAME pool. + await leave_wish_repo.create( + _make_wish( + ws, + child, + name="Competing", + priority=2, + window_start_month=3, + window_start_day=1, + window_end_month=3, + window_end_day=5, + ) + ) + + result = await planning_service.sync_plan(ws, today=date(2027, 2, 1)) + assert result.apply_result.accepted is True + + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + # The elapsed rows themselves must survive untouched (covered in + # detail by `TestSyncPreservesElapsedAutoPlacedDays`; asserted here + # too since a bug that deletes them would otherwise free the pool + # back up and mask the netting bug this test targets). + assert {r.date for r in rows if r.date in elapsed_dates} == set(elapsed_dates) + + march_rows = {r.date: r.day_type for r in rows if r.date.year == 2027 and r.date.month == 3} + assert march_rows == { + date(2027, 3, 1): "sjukpenning", + date(2027, 3, 2): "sjukpenning", + date(2027, 3, 3): "sjukpenning", + date(2027, 3, 4): "sjukpenning", + date(2027, 3, 5): "lagsta", + }, ( + f"expected the elapsed days to have debited the sjukpenning pool down to " + f"4, leaving wish B's 5th weekday to fall back to lagsta; got {march_rows} " + f"-- looks like the elapsed rows were not netted out of the pool" + ) diff --git a/backend/tests/unit/test_planning_service_sync_with_wish.py b/backend/tests/unit/test_planning_service_sync_with_wish.py new file mode 100644 index 0000000..1b793fd --- /dev/null +++ b/backend/tests/unit/test_planning_service_sync_with_wish.py @@ -0,0 +1,212 @@ +"""Tests for `PlanningService.sync_with_wish` (POST /api/plan/sync-with-wish). + +This endpoint drives all four of the calendar's create/edit-then-sync paths +(see its docstring) but had NO test coverage at all before this file — +"930 backend tests pass" never once exercised it. Bug 4 (day-assertion +subsystem review) was reported as: open a pinned/locked day's Period modal, +set "Varje år" (yearly) + "Alla år" + "Tills barnet fyller 4 år", save — +`sync-with-wish` returns 200, but the FOLLOWING year shows no occurrence at +all, no band, no error. + +Investigation (see the review's report) found that a yearly wish — locked +or not — DOES materialise and fund every future year correctly as long as +there is enough balance; what silently swallows a sibling year is a genuine +funding shortfall (an ordinary, expected outcome under `sync_plan`'s +"shortfall, not failure" model) that the frontend's toast never surfaced for +any wish-year OTHER than the one just edited — fixed separately in +`Calendar.tsx`'s `buildSyncToastMessage` (see `WindowOutcome.series_id`). + +This file locks in the currently-correct backend behaviour as a regression +guard: a yearly (recurring) wish — including one whose CURRENT year is +locked/pinned — produces a funded occurrence in the following year given +sufficient balance, both generally (Bug 4's "recurrence generally" question) +and specifically for the locked case. +""" + +from datetime import date, datetime +from uuid import uuid4 + +from app.entities.parental_leave import ParentalLeaveFetch, TierData +from app.entities.vacation_days import VacationDays +from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository +from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository +from app.services.planning_service import DayLockAction, PlanningService, WishCreateInput +from tests.conftest import SeededIds + +_FIRST_YEAR = date.today().year + 1 +_SECOND_YEAR = _FIRST_YEAR + 1 + + +def _fetch( + personal_data_uuid, workspace_id, child_id, fetched_at, sjuk_left, lagsta_left +) -> ParentalLeaveFetch: + tier = TierData( + sjukpenning_total=390, + sjukpenning_used=390 - sjuk_left, + sjukpenning_left=sjuk_left, + lagsta_niva_total=90, + lagsta_niva_used=90 - lagsta_left, + lagsta_niva_left=lagsta_left, + ) + return ParentalLeaveFetch( + id=uuid4(), + personal_data_uuid=personal_data_uuid, + workspace_id=workspace_id, + child_id=child_id, + source="manual", + fetched_at=fetched_at, + total_days=480, + days_used=480 - sjuk_left - lagsta_left, + days_left=sjuk_left + lagsta_left, + reserved_days_total=90, + reserved_days_left=90, + transferable_days_left=sjuk_left + lagsta_left, + dubbeldagar_left=60, + tier_data=tier, + created_at=fetched_at, + updated_at=fetched_at, + ) + + +class TestSyncWithWishYearlyRecurrence: + async def _seed_balance( + self, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + for year in (_FIRST_YEAR, _SECOND_YEAR): + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=year, total_days=25 + ) + ) + await parental_leave_repo.create( + _fetch(parent, ws, child, datetime.now(), sjuk_left=300, lagsta_left=90) + ) + + async def test_yearly_recurrence_funds_the_following_year( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + """Bug 4's "does recurrence work generally" question — plain yearly + + recurrence, no locking at all, must fund BOTH years in one call. + """ + await self._seed_balance(vacation_repo, parental_leave_repo, seeded_ids) + + result = await planning_service.sync_with_wish( + seeded_ids.workspace_id, + wish_create=WishCreateInput( + child_id=seeded_ids.child_id, + name="Sommar", + name_message_key=None, + window_start_month=9, + window_start_day=1, + window_end_month=9, + window_end_day=10, + target_weeks=1, + target_days=None, + recurrence="yearly", + first_year=_FIRST_YEAR, + last_year=_SECOND_YEAR, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + window_mode="fixed", + series_id=None, + wants_dubbeldag=False, + ), + ) + assert result.sync.apply_result.accepted, result.sync.apply_result.rejections + + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + years_with_rows = {r.date.year for r in rows} + assert _FIRST_YEAR in years_with_rows + assert _SECOND_YEAR in years_with_rows, ( + "a plain yearly wish must fund BOTH years given sufficient balance" + ) + + async def test_yearly_recurrence_with_current_year_locked_still_funds_the_following_year( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + seeded_ids: SeededIds, + ) -> None: + """Bug 4's literal claim — a yearly wish whose CURRENT (first) year + + is locked/pinned must still produce a funded occurrence in the + following year, exactly as the unlocked case does. `day_actions` + only ever pins the specific `planned_day_id`s of the edited + occurrence (see `Calendar.tsx`'s `handleSavePeriod`) — the sibling + year is never itself locked, and per the review's design call, + should not need to be: it is planner-funded like any ordinary band. + """ + await self._seed_balance(vacation_repo, parental_leave_repo, seeded_ids) + + created = await planning_service.sync_with_wish( + seeded_ids.workspace_id, + wish_create=WishCreateInput( + child_id=seeded_ids.child_id, + name="Sommar", + name_message_key=None, + window_start_month=9, + window_start_day=1, + window_end_month=9, + window_end_day=10, + target_weeks=1, + target_days=None, + recurrence="yearly", + first_year=_FIRST_YEAR, + last_year=_SECOND_YEAR, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + window_mode="fixed", + series_id=None, + wants_dubbeldag=False, + ), + ) + assert created.sync.apply_result.accepted, created.sync.apply_result.rejections + + rows = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + year1_rows = [r for r in rows if r.date.year == _FIRST_YEAR] + assert year1_rows, "expected the first year to already be funded before locking it" + + from app.services.planning_service import WishUpdateInput + + locked = await planning_service.sync_with_wish( + seeded_ids.workspace_id, + wish_update=WishUpdateInput(leave_wish_id=created.wish.id, scope="occurrence"), + day_actions=[DayLockAction(planned_day_id=r.id, action="pin") for r in year1_rows], + ) + assert locked.sync.apply_result.accepted, locked.sync.apply_result.rejections + + rows_after = await planned_leave_repo.list_by_workspace_child( + seeded_ids.workspace_id, seeded_ids.child_id + ) + year2_rows_after = [r for r in rows_after if r.date.year == _SECOND_YEAR] + assert year2_rows_after, ( + "locking the current year's days must not prevent the following " + "year's occurrence from being (re-)funded by the same sync" + ) + # And the locked year's own days are exactly what they were before — + # locking must not have disturbed them either. + year1_rows_after = {r.id for r in rows_after if r.date.year == _FIRST_YEAR} + assert year1_rows_after == {r.id for r in year1_rows} diff --git a/backend/tests/unit/test_services/test_allocation_day_assertion.py b/backend/tests/unit/test_services/test_allocation_day_assertion.py new file mode 100644 index 0000000..d4d550f --- /dev/null +++ b/backend/tests/unit/test_services/test_allocation_day_assertion.py @@ -0,0 +1,354 @@ +"""Proves a `DayAssertion` is honoured by `sync_plan` exactly as the legacy + +signals it replaces — restructure step 3 +(`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`): + +- a pinned day (`PlannedLeaveDay.is_pinned=True`) -> §`TestAssertionActsLikeAPin`. +- a `PlannedDayExclusion` row -> §`TestAssertionActsLikeAnExclusion`. + +Both tests deliberately avoid the LEGACY signal they're replacing (no +`is_pinned=True` on the row in the pin test, no `planned_day_exclusion` row in +the exclusion test) so the assertion is proven to work on its own, not merely +alongside a fallback that would have produced the same result anyway. Reuses +the exact scenarios from `test_planning_service_sync.py:: +TestSyncNeverOverwritesAPinnedDay` and `test_planned_day_exclusion.py:: +TestExclusionSurvivesSync` so the equivalence is provable rather than +freshly invented. +""" + +from datetime import date, datetime +from uuid import uuid4 + +from app.entities.day_assertion import DayAssertion +from app.entities.leave_wish import LeaveWish +from app.entities.planned_leave import PlannedLeaveDay +from app.entities.vacation_days import VacationDays +from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository +from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository +from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository +from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository +from app.services.day_assertion_service import DayAssertionService +from app.services.leave_wish_service import LeaveWishService +from app.services.planning_service import PlanningService +from tests.conftest import SeededIds + +_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below + + +def _make_wish(seeded: SeededIds) -> LeaveWish: + """A fixed, one-week February 2027 window — same shape as the reference + scenarios in test_planning_service_sync.py / test_planned_day_exclusion.py. + """ + return LeaveWish( + id=uuid4(), + workspace_id=seeded.workspace_id, + child_id=seeded.child_id, + name="Sportlov", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=7, + target_weeks=1, + first_year=2027, + last_year=None, + priority=1, + parent_mode="either", + parent_personal_data_uuid=None, + window_mode="fixed", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +class TestAssertionActsLikeAPin: + async def test_a_concrete_assertion_alone_freezes_the_day_like_is_pinned_would( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + vacation_repo: SQLiteVacationDaysRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + # No vacation budget this year -> tier-3 falls through to FP, + # sjukpenning preferred -- so a freshly-funded interior day in this + # window would be 'sjukpenning', never 'lagsta' (see + # TestSyncNeverOverwritesAPinnedDay, the scenario this mirrors). + await vacation_repo.create( + VacationDays( + id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 + ) + ) + wish = await leave_wish_repo.create(_make_wish(seeded_ids)) + + # The row is deliberately NOT `is_pinned=True` and NOT ad-hoc + # (`wish_id` points at the real wish) -- under pre-step-3 rules this + # row is exactly the kind `only_pinned_existing` would hide and + # `sync_plan` would freely retype. Only the DayAssertion below should + # be what keeps it frozen. + row = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="lagsta", # deliberately NOT what a fresh plan would pick + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="lagsta", + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + + assert result.apply_result.accepted is True + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + frozen = next(r for r in rows if r.date == _DAY) + assert frozen.id == row.id + assert frozen.day_type == "lagsta" + + # Every OTHER day in the window was free to be funded normally. + other_days = [r for r in rows if r.date != _DAY] + assert other_days, "expected the rest of the window to be funded" + assert all(r.day_type == "sjukpenning" for r in other_days) + + # The frozen date never shows up as a changed slot. + assert all(c.date != _DAY for c in result.changes) + + +class TestAssertionActsLikeAnExclusion: + async def test_a_none_assertion_alone_keeps_the_day_out_with_no_exclusion_row( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await leave_wish_repo.create(_make_wish(seeded_ids)) + + first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert first.apply_result.accepted is True + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + assert _DAY in {r.date for r in rows}, "the planner should place the day to begin with" + + # No `PlannedDayExclusion` row anywhere -- only a raw DayAssertion. + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="none", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert second.apply_result.accepted is True, second.apply_result.rejections + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + assert _DAY not in {r.date for r in rows} + # The rest of the week is still planned — only the one day went. + assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= { + r.date for r in rows + } + # Not a reported eviction: this is a deliberate removal, not a lost + # priority race — see `_Ledger._excluded_window_dates`'s docstring. + assert not any(c.evicted for c in second.changes) + + async def test_clearing_the_exclusion_puts_the_day_back( + self, + planning_service: PlanningService, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """There must be a way back — this is what the undo toast calls. + + Ported from the deleted `test_planned_day_exclusion.py:: + TestExclusionSurvivesSync.test_un_excluding_puts_the_day_back`, now + exercised through `DayAssertionService.clear` instead of the deleted + `PlannedDayExclusionService.delete`. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + await leave_wish_repo.create(_make_wish(seeded_ids)) + await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + + service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) + await service.assert_day(ws, child, parent, _DAY, day_type="none") + await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + assert _DAY not in {r.date for r in rows} + + await service.clear(ws, child, parent, _DAY) + await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + assert _DAY in {r.date for r in rows} + + +class TestWishDeleteClearsAssertions: + """Restructure step 3b — deleting a wish must not strand a permanently + + locked slot behind. `LeaveWishService.delete()` clears the `DayAssertion` + at each deleted row's key (see that method's docstring for why this is + unconditional for a concrete assertion): otherwise the assertion + survives with no `planned_leave_day` row behind it (invisible to the + frontend, which only ever lists assertions via the rows/exclusions it + already knows about) while still silently pinning whatever a later sync + or hand-pin tries to place at that same (workspace, child, parent, date). + """ + + async def test_delete_clears_the_assertion_and_the_date_is_placeable_again( + self, + planning_service: PlanningService, + leave_wish_service: LeaveWishService, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + wish = await leave_wish_repo.create(_make_wish(seeded_ids)) + + # A locked/promoted day: a `planned_leave_day` row plus the + # `DayAssertion` that pins it, both attributed to `wish` — exactly + # what `PlanningService._assert_pin` produces for an 'attach'/'pin' + # day-lock action. + row = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="vacation", + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="vacation", + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + await leave_wish_service.delete(wish.id) + + assert await day_assertion_repo.get_by_natural_key(ws, child, parent, _DAY) is None + assert await planned_leave_repo.get_by_id(row.id) is None + + # A fresh wish covering the same date must be able to place a day + # there — nothing left invisibly pinning the slot. + new_wish = await leave_wish_repo.create(_make_wish(seeded_ids)) + result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) + assert result.apply_result.accepted is True, result.apply_result.rejections + + rows = await planned_leave_repo.list_by_workspace_child(ws, child) + placed = next((r for r in rows if r.date == _DAY), None) + assert placed is not None, "the freed date must be placeable again by a later sync" + assert placed.wish_id == new_wish.id + + async def test_delete_leaves_a_none_exclusion_untouched( + self, + leave_wish_service: LeaveWishService, + planned_leave_repo: SQLitePlannedLeaveRepository, + day_assertion_repo: SQLiteDayAssertionRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + seeded_ids: SeededIds, + ) -> None: + """An exclusion (`day_type='none'`) is a standing "never place leave + + here" instruction about the DATE, not about one period — it must + outlive the wish it happens to share a key with (matching the + restructure notes' already-accepted "exclusions outlive the wish + they were made against" behaviour). No current write path actually + produces a `'none'` assertion with a `wish_id` set (a + `planned_leave_day` row's `day_type` CHECK constraint never allows + `'none'`, so 'attach'/'pin' can never assert one) — this proves the + defensive exception in `LeaveWishService._clear_assertions_for` + anyway, in case a future caller does. + """ + ws, child, parent = ( + seeded_ids.workspace_id, + seeded_ids.child_id, + seeded_ids.personal_data_uuid, + ) + wish = await leave_wish_repo.create(_make_wish(seeded_ids)) + row = await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="vacation", + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws, + child_id=child, + parent_personal_data_uuid=parent, + date=_DAY, + day_type="none", + wish_id=wish.id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + ) + + await leave_wish_service.delete(wish.id) + + assert await planned_leave_repo.get_by_id(row.id) is None + survivor = await day_assertion_repo.get_by_natural_key(ws, child, parent, _DAY) + assert survivor is not None + assert survivor.day_type == "none" diff --git a/backend/tests/unit/test_services/test_allocation_excluded_days.py b/backend/tests/unit/test_services/test_allocation_excluded_days.py index 77b5fa9..6ffd51a 100644 --- a/backend/tests/unit/test_services/test_allocation_excluded_days.py +++ b/backend/tests/unit/test_services/test_allocation_excluded_days.py @@ -16,8 +16,8 @@ Two decisions are asserted here, both explained at length on A day the user removed is not a day the plan failed to fund, so `shortfall_days` must not grow (which would make the new shortfall remedies advise freeing resources for a day nobody wants, and would make - `sync_plan`'s eviction preview treat a deliberate removal as a lost - priority race). + `sync_plan` report a deliberate removal as a genuinely evicted/displaced + row instead of an ordinary self-caused one). """ from datetime import date @@ -129,8 +129,8 @@ class TestExcludedDayIsNeverPlaced: The user removed it; the plan did not fail to fund it. If this counted as a gap, `_build_remedies` would start proposing ways to - free up resources for a day nobody wants, and `sync_plan`'s eviction - preview would block the sync that carries out the removal. + free up resources for a day nobody wants, and `sync_plan` would + misreport the removal as a genuinely evicted/displaced row. """ _seed, parent, child = _solo_scenario() alloc_input, _p, _c = _solo_scenario( diff --git a/backend/tests/unit/test_services/test_allocation_input_loader.py b/backend/tests/unit/test_services/test_allocation_input_loader.py index b6ca3dd..e863b57 100644 --- a/backend/tests/unit/test_services/test_allocation_input_loader.py +++ b/backend/tests/unit/test_services/test_allocation_input_loader.py @@ -14,6 +14,7 @@ from uuid import uuid4 import pytest from app.entities.child import Child +from app.entities.day_assertion import DayAssertion from app.entities.day_override import DayOverride from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData @@ -23,6 +24,7 @@ from app.entities.preschool_planning_days import PreschoolPlanningDays from app.entities.vacation_days import VacationDays from app.entities.workspace import Workspace 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 @@ -46,6 +48,7 @@ def _make_loader( planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository | None = None, + day_assertion_repo: SQLiteDayAssertionRepository | None = None, ) -> AllocationInputLoader: return AllocationInputLoader( workspace_repo=workspace_repo, @@ -56,6 +59,7 @@ def _make_loader( planned_leave_repo=planned_leave_repo, leave_wish_repo=leave_wish_repo, preschool_planning_days_repo=preschool_planning_days_repo, + day_assertion_repo=day_assertion_repo, ) @@ -247,6 +251,7 @@ class TestAllocationInputLoader: vacation_repo: SQLiteVacationDaysRepository, planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, + day_assertion_repo: SQLiteDayAssertionRepository, ) -> None: """§fix (parent-reassignment moves dates, decision B) — `sync_plan` @@ -307,7 +312,19 @@ class TestAllocationInputLoader: parent_personal_data_uuid=parent.uuid, date=date(2027, 10, 1), day_type="vacation", - is_pinned=True, + ) + ) + # Restructure step 3b: "pinned" is a `DayAssertion` fact now. + await day_assertion_repo.create( + DayAssertion( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + parent_personal_data_uuid=parent.uuid, + date=pinned.date, + day_type="vacation", + created_at=now, + updated_at=now, ) ) unpinned_wish_row = await planned_leave_repo.create( @@ -319,7 +336,6 @@ class TestAllocationInputLoader: date=date(2027, 10, 4), day_type="vacation", wish_id=wish.id, - is_pinned=False, ) ) ad_hoc = await planned_leave_repo.create( @@ -331,7 +347,6 @@ class TestAllocationInputLoader: date=date(2027, 10, 5), day_type="vacation", wish_id=None, - is_pinned=False, ) ) @@ -343,6 +358,7 @@ class TestAllocationInputLoader: vacation_repo, planned_leave_repo, leave_wish_repo, + day_assertion_repo=day_assertion_repo, ) alloc_input = await loader.load(ws.id, today=date(2027, 1, 1), only_pinned_existing=True) diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 7eddb92..33a8eb7 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -181,6 +181,7 @@ def _build_input( day_overrides: dict[date, bool] | None = None, age4_savable_cap: int | None = None, take_pl_on_red_days: bool = False, + pinned_days: frozenset[tuple[UUID, UUID, date]] | None = None, excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None, preschool_reserve_by_year: dict[tuple[UUID, int], int] | None = None, vacation_forfeiting: dict[tuple[UUID, int], int] | None = None, @@ -207,6 +208,7 @@ def _build_input( day_overrides=day_overrides or {}, take_pl_on_red_days=take_pl_on_red_days, all_existing_planned=all_existing_planned, + pinned_days=pinned_days or frozenset(), excluded_days=excluded_days or frozenset(), preschool_reserve_by_year=preschool_reserve_by_year or {}, vacation_forfeiting=vacation_forfeiting or {}, @@ -220,7 +222,6 @@ def _planned( on: date, day_type: str = "vacation", wish_id: UUID | None = None, - is_pinned: bool = False, ) -> PlannedLeaveDay: """A persisted `planned_leave_day` row — the "already applied, before @@ -228,12 +229,13 @@ def _planned( flexible re-propose; extended for §fix parent-reassignment, decision B) test cases below. Only the fields `AllocationInput.existing_planned`/ `all_existing_planned` actually read (`parent_personal_data_uuid`, - `child_id`, `date`, `day_type`, `wish_id`, `is_pinned`) matter. `wish_id` - is what `_Ledger._persisted_by_wish` now matches a flexible wish's own - prior placement by — pass the wish's `.id` whenever a test relies on - that reuse mechanism finding these rows. `is_pinned=True` marks a date - the user locked in place — the Step 3 realignment escape hatch must - never move a window that contains one. + `child_id`, `date`, `day_type`, `wish_id`) matter. `wish_id` is what + `_Ledger._persisted_by_wish` now matches a flexible wish's own prior + placement by — pass the wish's `.id` whenever a test relies on that + reuse mechanism finding these rows. "Pinned" is no longer a field on + this entity (restructure step 3b — see `AllocationInput.pinned_days`); + a caller that needs a date to be immovable passes it via + `_build_input(pinned_days=...)` instead. """ return PlannedLeaveDay( id=uuid4(), @@ -243,7 +245,6 @@ def _planned( date=on, day_type=day_type, wish_id=wish_id, - is_pinned=is_pinned, ) @@ -1571,6 +1572,56 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides: assert not any(d.wish_id == flexible.id for d in proposal.days) +class TestFlexibleWishBoundEntirelyInThePast: + """§silent-failure gap — narrowing a flexible wish's bound to months that + + have since elapsed used to make `_expand_flexible_wish_years` drop the + wish-year entirely: no `WindowOutcome`, no shortfall, no 'unplaced' + signal. Unlike the horizon-future case (which will come back into range + on a later re-run) or a partially-past bound (already clipped and + reported as an ordinary shortfall), a bound that has fully elapsed can + never be revisited -- it must surface as an explicit 'unplaced' outcome + instead of silently vanishing from every future sync. + """ + + def test_fully_past_bound_reported_as_unplaced_not_dropped(self) -> None: + child = _child(birth_date=date(2020, 1, 1)) + parent = _parent(salary_type="monthly") + # VAR_2027 = ((3, 1), (5, 31)) -- fully elapsed by the `today` below. + wish = _wish( + child_id=child.id, + name="Flex", + start=VAR_2027[0], + end=VAR_2027[1], + weeks=1, + first_year=2027, + window_mode="flexible", + ) + alloc_input = _build_input( + today=date(2027, 6, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(90, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(50, 0, 0)}, + wishes=[wish], + ) + + proposal = Allocator().propose(alloc_input) + + # The wish must not vanish: exactly one outcome, reporting the wish + # as unplaced with its original (unclipped) bound. + assert len(proposal.outcomes) == 1 + outcome = proposal.outcomes[0] + assert outcome.wish_id == wish.id + assert outcome.strategy == STRATEGY_UNPLACED + assert outcome.weeks_funded == 0.0 + assert outcome.shortfall_days == 7 + assert outcome.start_date == date(2027, 3, 1) + assert outcome.end_date == date(2027, 5, 31) + assert not any(d.wish_id == wish.id for d in proposal.days) + + class TestCase21FlexibleYearlyWishTracksMovingEaster: def test_each_year_lands_on_that_years_easter_adjacent_dates(self) -> None: child = _child(birth_date=date(2020, 1, 1)) @@ -2014,6 +2065,7 @@ class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits: child: ChildFacts, parent: ParentFacts, existing: list[PlannedLeaveDay], + pinned_days: frozenset[tuple[UUID, UUID, date]] | None = None, ) -> AllocationInput: return _build_input( today=date(2027, 1, 1), @@ -2024,6 +2076,7 @@ class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits: vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=existing, + pinned_days=pinned_days, ) def test_a_misaligned_year_releases_onto_a_monday(self) -> None: @@ -2074,11 +2127,12 @@ class TestReuseAlreadyFundedWindowRealignsOrProtectsManualEdits: child_id=child.id, on=date(2027, 8, d), wish_id=wish.id, - is_pinned=(d == 3), # the user locked the first day in place ) for d in range(3, 10) ] - alloc_input = self._alloc_input(wish, child, parent, existing) + # The user locked the first day in place. + pinned_days = frozenset({(parent.uuid, child.id, date(2027, 8, 3))}) + alloc_input = self._alloc_input(wish, child, parent, existing, pinned_days=pinned_days) proposal = Allocator().propose(alloc_input) @@ -2138,7 +2192,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: parent_personal_data_uuid=parent.uuid, date=pinned_date, day_type="sjukpenning", - is_pinned=True, ) # A wish covering exactly this one date -- if the pinned day were # NOT fixed, this monthly-salaried parent's tier-3 policy (vacation @@ -2160,6 +2213,7 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[wish], existing_planned=[pinned], + pinned_days=frozenset({(parent.uuid, child.id, pinned_date)}), ) proposal = Allocator().propose(alloc_input) @@ -2192,7 +2246,6 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: parent_personal_data_uuid=parent.uuid, date=pinned_date, day_type="vacation", - is_pinned=True, ) alloc_input = _build_input( today=date(2027, 1, 1), @@ -2203,6 +2256,7 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, wishes=[], existing_planned=[pinned], + pinned_days=frozenset({(parent.uuid, child.id, pinned_date)}), ) proposal = Allocator().propose(alloc_input) @@ -2215,7 +2269,9 @@ class TestPinnedDayIsNeverRetypedAndDebitsThePool: # --------------------------------------------------------------------------- # Dubbeldagar count cap (settings.rules.max_dubbeldagar) reached mid-window # -> the allocator falls back to a parent swap for the leftover days -# (allocation_service.py:979, `if used >= _MAX_DUBBELDAGAR`). +# (`_fund_both`'s `used < max_dubbeldagar` check, reading +# `settings.rules.max_dubbeldagar` live -- see allocation_service.py; there +# is no `_MAX_DUBBELDAGAR` module constant any more). # # TestCase10DubbeldagOutsideWindow above covers the *15-month window* edge # (every day outside the window falls back to swap); TestDubbeldagSuccess in diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index 0a73024..af7d620 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -340,6 +340,129 @@ class TestValidateDubbeldagar: with pytest.raises(DubbeldagError): await service.validate_dubbeldagar(child.id, within_window) + async def test_allocator_and_validator_agree_when_cap_changes_at_runtime( + self, + child_repo: SQLiteChildRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + personal_data_repo: SQLitePersonalDataRepository, + workspace_repo: SQLiteWorkspaceRepository, + vacation_repo: SQLiteVacationDaysRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Regression: `allocation_service` used to snapshot + + `settings.rules.max_dubbeldagar` into a module-level constant at + import time, while `AnalyticsService.validate_dubbeldagar` always + read the setting live. If the setting changed after import (e.g. a + rule-year config reload) the two could disagree: the allocator would + keep proposing dubbeldagar past a newly-lowered cap that the + validator would then reject. Both must now read the SAME live + value. + + Lowers the cap to 2 at runtime -- well after `allocation_service` + was imported, so a stale snapshot would still show the higher + default -- and proves the allocator stops proposing dubbeldagar at + exactly the new cap, and that the validator independently agrees + the quota is exhausted at that same count. + """ + from app.config import settings + from app.entities.leave_wish import LeaveWish + from app.entities.personal_data import PersonalData + from app.entities.workspace import Workspace + from app.services.allocation_service import AllocationInputLoader, Allocator + from app.utils.errors import DubbeldagError + + monkeypatch.setattr( + settings, "rules", settings.rules.model_copy(update={"max_dubbeldagar": 2}) + ) + + now = datetime.now() + ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) + parent_a = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="A", created_at=now) + ) + parent_b = await personal_data_repo.create( + PersonalData(uuid=uuid4(), workspace_id=ws.id, name="B", created_at=now) + ) + birth = date(2027, 1, 1) + child = _make_child(ws.id, birth) + await child_repo.create(child) + + # Huge untouched pools so the FP pool itself never becomes the + # limiting factor -- only the (lowered) dubbeldagar quota should bind. + await parental_leave_repo.create( + _make_fetch(parent_a.uuid, ws.id, child.id, total=300, used=0) + ) + await parental_leave_repo.create( + _make_fetch(parent_b.uuid, ws.id, child.id, total=300, used=0) + ) + + await leave_wish_repo.create( + LeaveWish( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + name="Test", + window_start_month=2, + window_start_day=1, + window_end_month=2, + window_end_day=28, # clean month, 20 spendable weekdays -- well over the cap + target_weeks=4, + first_year=2027, + priority=1, + parent_mode="both", + parent_personal_data_uuid=None, + wants_dubbeldag=True, + ) + ) + + loader = AllocationInputLoader( + workspace_repo=workspace_repo, + child_repo=child_repo, + personal_data_repo=personal_data_repo, + parental_leave_repo=parental_leave_repo, + vacation_repo=vacation_repo, + planned_leave_repo=planned_leave_repo, + leave_wish_repo=leave_wish_repo, + ) + alloc_input = await loader.load(ws.id, today=date(2027, 1, 1)) + proposal = Allocator().propose(alloc_input) + + dubbel_dates = {d.date for d in proposal.days if d.is_dubbeldag} + # Allocator must honour the LOWERED live cap, not a higher + # import-time snapshot -- this is the headline regression. + assert len(dubbel_dates) == 2 + + # Persist exactly what the allocator proposed, then ask the + # validator (which always reads the setting live) whether one MORE + # dubbeldag is still allowed -- it must agree the quota is exhausted + # at the same count the allocator stopped at. + for d in dubbel_dates: + await planned_leave_repo.create( + PlannedLeaveDay( + id=uuid4(), + workspace_id=ws.id, + child_id=child.id, + parent_personal_data_uuid=parent_a.uuid, + date=d, + day_type="sjukpenning", + is_dubbeldag=True, + ) + ) + + service = await _make_service( + child_repo, + parental_leave_repo, + planned_leave_repo, + personal_data_repo, + workspace_repo, + vacation_repo, + ) + with pytest.raises(DubbeldagError): + await service.validate_dubbeldagar(child.id, date(2027, 2, 27)) + class TestGetOverview: async def test_overview_single_parent( diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index eac7ff9..c9d3c72 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -23,7 +23,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { apiCreatePlannedDay, apiGetContext, apiLogin, DEMO_EMAIL, DEMO_PASSWORD, waitForBackendHealth } from './support/api' +import { apiAssertDay, apiGetContext, apiLogin, DEMO_EMAIL, DEMO_PASSWORD, waitForBackendHealth } from './support/api' import { FIXTURE_PATH } from './support/fixture' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -70,7 +70,7 @@ export default async function globalSetup(): Promise { const band = computeBandDates() for (const date of band.dates) { - await apiCreatePlannedDay(token, { + await apiAssertDay(token, { workspace_id: context.workspace_id, child_id: child.id, parent_personal_data_uuid: context.personal_data_uuid, diff --git a/frontend/e2e/support/api.ts b/frontend/e2e/support/api.ts index 82486e7..b69db59 100644 --- a/frontend/e2e/support/api.ts +++ b/frontend/e2e/support/api.ts @@ -75,7 +75,7 @@ export async function apiGetContext(token: string): Promise { export type PlannedDayType = 'sjukpenning' | 'lagsta' | 'vacation' -export interface PlannedDayPayload { +export interface DayAssertionPayload { workspace_id: string child_id: string parent_personal_data_uuid: string @@ -84,14 +84,19 @@ export interface PlannedDayPayload { is_dubbeldag: boolean } -export async function apiCreatePlannedDay(token: string, payload: PlannedDayPayload): Promise { - const res = await fetch(`${API_BASE}/api/planned-days`, { +// `/api/planned-days` is read-only (restructure step 3b) — every write goes +// through `POST /api/day-assertions` instead, which mirrors a concrete +// assertion straight into `planned_leave_day` (see +// `app/services/day_assertion_service.py`), so this still materializes a +// band the calendar renders immediately. +export async function apiAssertDay(token: string, payload: DayAssertionPayload): Promise { + const res = await fetch(`${API_BASE}/api/day-assertions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(payload), }) if (!res.ok) { - throw new Error(`create planned day ${payload.date} failed: ${res.status} ${await res.text()}`) + throw new Error(`assert day ${payload.date} failed: ${res.status} ${await res.text()}`) } } diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index 95a3f7c..eca9ff8 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -106,14 +106,17 @@ test('dragging a range and pinning an exact type plans every day exactly, and de }) .toBe(1) - // Open it through its band — an ad-hoc band (no wishId) shows the plain - // date range and can only be deleted, not edited. `.first()` because the - // band may render as more than one DOM segment (see above); either one - // opens the same underlying period. + // Open it through its band — an ad-hoc band (no wishId) gets the full + // editor too (§"every calendar band is a real event"), seeded from its own + // pinned days; saving it would promote it to a real `LeaveWish`. This test + // only exercises delete, so it stops at the ad-hoc notice/seeded dates. + // `.first()` because the band may render as more than one DOM segment (see + // above); either one opens the same underlying period. await adhocBand.first().click() await expect(page.getByTestId('period-popover__content')).toBeVisible() await expect(page.getByTestId('period-adhoc-notice')).toBeVisible() - await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`) + await expect(page.getByTestId('period-start-input')).toHaveValue(firstDate) + await expect(page.getByTestId('period-end-input')).toHaveValue(lastDate) // todo.md #9 — the three delete scopes sit behind "Ta bort…". An ad-hoc // band has no recurring plan, so "every year" is disabled; "this day"/ @@ -183,7 +186,7 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t // Open the wish-linked band. Its own three-way remove: "this day" now // works on a planner-placed day too (the removal is recorded as a - // `PlannedDayExclusion` — see the next test); "this period" on a + // `DayAssertion` with `dayType: 'none'` — see the next test); "this period" on a // standalone (non-repeating) wish — one row, no series — removes the whole // thing, same as "every year". await wishBand.first().click() @@ -206,8 +209,8 @@ test('removing a single day out of a planner-placed period keeps it removed acro }) => { // The user's requirement: "removing a single day should stay permanent. // Even if it's an auto added period day." The wish behind the band still - // asks for the whole window, so without a stored `PlannedDayExclusion` the - // next `POST /api/plan/sync` — which the calendar runs on every load — + // asks for the whole window, so without a stored `DayAssertion` (`dayType: + // 'none'`) the next `POST /api/plan/sync` — which the calendar runs on every load — // would simply put the day back. await registerAndReachOnboarding(page, uniqueEmail('remove-day'), 'Robin Testsson') await fillChildStep(page, 'Nova') diff --git a/frontend/package.json b/frontend/package.json index 936c3e8..3345101 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently -k -n backend,web \"npm:dev:backend\" \"npm:dev:web\"", - "dev:backend": "cd ../backend && uv run uvicorn app.main:app --reload --port 8000", + "dev:backend": "cd ../backend && uv run uvicorn app.main:app --reload --reload-exclude '*.db' --reload-exclude '*.db-shm' --reload-exclude '*.db-wal' --reload-exclude '*.db-journal' --port 8000", "dev:web": "vite", "typecheck": "./node_modules/.bin/tsc --noEmit", "typecheck:e2e": "./node_modules/.bin/tsc -p e2e/tsconfig.json", @@ -18,7 +18,7 @@ "e2e": "playwright test", "e2e:ui": "playwright test --ui", "login": "concurrently -k -n backend,web \"npm:login:backend\" \"npm:login:web\"", - "login:backend": "cd ../backend && APP_SCRAPER_HEADLESS=true uv run uvicorn app.main:app --reload --port 8000", + "login:backend": "cd ../backend && APP_SCRAPER_HEADLESS=true uv run uvicorn app.main:app --reload --reload-exclude '*.db' --reload-exclude '*.db-shm' --reload-exclude '*.db-wal' --reload-exclude '*.db-journal' --port 8000", "login:web": "vite --open /dev/fetch" }, "dependencies": { diff --git a/frontend/src/components/balance/BalanceFlow.test.tsx b/frontend/src/components/balance/BalanceFlow.test.tsx index 4ab0e8c..1fce4e9 100644 --- a/frontend/src/components/balance/BalanceFlow.test.tsx +++ b/frontend/src/components/balance/BalanceFlow.test.tsx @@ -395,6 +395,34 @@ describe('BalanceFlow', () => { expect(await screen.findByText('Partnerns namn är ogiltigt.')).toBeInTheDocument() }) + it('shows a translated error when the workspace already has two parents', async () => { + const { ApiRequestError } = await import('@/services/errors') + vi.mocked(hooks.useScraperFetch).mockReturnValue( + makeScraperFetchResult({ + isTerminal: true, + pollData: makePollResponse({ status: 'done', result: {} }), + partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }), + }), + ) + vi.mocked(api.addPartner).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.add.maxReached', + message: 'max reached', + details: { messageKey: 'workspace.parents.add.maxReached' }, + }), + ) + const user = userEvent.setup() + render( + , + ) + await user.type(await screen.findByLabelText(/Den andra förälderns namn/), 'Kim') + await user.click(screen.getByRole('button', { name: 'Lägg till Kim' })) + + expect( + await screen.findByText('Går inte att lägga till fler föräldrar — den här familjen har redan två.'), + ).toBeInTheDocument() + }) + it('dismisses the deduced-partner prompt without adding anyone', async () => { vi.mocked(hooks.useScraperFetch).mockReturnValue( makeScraperFetchResult({ diff --git a/frontend/src/components/balance/BalanceFlow.tsx b/frontend/src/components/balance/BalanceFlow.tsx index ff04cb4..d587db2 100644 --- a/frontend/src/components/balance/BalanceFlow.tsx +++ b/frontend/src/components/balance/BalanceFlow.tsx @@ -42,6 +42,7 @@ type BalanceValues = ParentBalanceInitial & { reservedDaysLeft: number | null } const ADD_PARTNER_ERROR_KEYS: Record = { 'workspace.notFound': 'settings.partnerActionErrorNotFound', 'workspace.parents.invalidName': 'settings.partnerNameInvalid', + 'workspace.parents.add.maxReached': 'settings.partnerActionErrorMaxReached', } type Translate = (key: TranslationKey, params?: TranslationParams) => string diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index 4c6e3c6..95993fb 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { PeriodPopover } from './PeriodPopover' -import { makeLeaveWish } from '../../../tests/fixtures' +import { makeLeaveWish, makePlannedLeaveDay } from '../../../tests/fixtures' describe('PeriodPopover', () => { const mockOnClose = vi.fn() const mockOnSave = vi.fn() const mockOnDelete = vi.fn() + const mockOnSetLocked = vi.fn() const defaultProps = { isOpen: true, @@ -16,6 +17,7 @@ describe('PeriodPopover', () => { parents: [{ id: 'p1', name: 'Parent 1' }], onSave: mockOnSave, onDelete: mockOnDelete, + onSetLocked: mockOnSetLocked, onClose: mockOnClose, } @@ -64,17 +66,47 @@ describe('PeriodPopover', () => { }) }) - describe('ad-hoc band (no wish)', () => { - it('shows the ad-hoc notice and date range instead of editable fields', () => { + describe('ad-hoc band (no wish) — promotion to a real event', () => { + it('shows the ad-hoc notice alongside the full, editable form', () => { render() expect(screen.getByTestId('period-adhoc-notice')).toBeInTheDocument() - expect(screen.getByText('2028-06-15 – 2028-06-20')).toBeInTheDocument() - expect(screen.queryByTestId('period-name-input')).not.toBeInTheDocument() + expect(screen.getByTestId('period-name-input')).toHaveValue('') + expect(screen.getByTestId('period-name-input')).toHaveAttribute('placeholder', 'Manuellt tillagd period') + expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15') + expect(screen.getByTestId('period-end-input')).toHaveValue('2028-06-20') }) - it('does not render a save button', () => { + it('renders an enabled save button', () => { render() - expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument() + expect(screen.getByTestId('period-save-button')).not.toBeDisabled() + }) + + it('seeds the parent and "wants dubbeldag" from the band\'s own persisted rows, and shows them as already locked', () => { + render( + , + ) + expect(screen.getByTestId('period-parent-select')).toHaveValue('p1') + expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).toBeDisabled() // parentMode 'specific', not 'both' + // Ad-hoc rows are always hand-pinned already (the "Advanced" escape + // hatch) — the lock section reflects that instead of a ladder default. + expect(screen.getByTestId('period-lock-status')).toHaveTextContent('1 av 1 dag är låsta') + expect(screen.getByTestId('period-lock-toggle-button')).toHaveTextContent('Lås upp') + }) + + it('calls onSave with moveable: false and an empty name when left blank', async () => { + mockOnSave.mockResolvedValue(undefined) + render( + , + ) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ name: '', moveable: false })), + ) }) it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => { @@ -273,6 +305,13 @@ describe('PeriodPopover', () => { expect(screen.getByTestId('period-scope-series')).not.toBeChecked() }) + it('spells out that the default ("only this year") leaves other years untouched — the fix for the silent no-op trap', () => { + render() + expect(screen.getByTestId('period-scope-fieldset')).toHaveTextContent( + 'Övriga år i serien påverkas inte.', + ) + }) + it('does not offer it for a one-off period, where there is nothing to choose between', () => { render() expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument() @@ -344,10 +383,114 @@ describe('PeriodPopover', () => { scope: 'occurrence', lastYear: null, wantsDubbeldag: false, + // A fixed wish defaults to "not moveable" — see the toggle tests + // below — with the bound seeded from the wish's own window months. + moveable: false, + boundStartMonth: 6, + boundEndMonth: 7, }) }) }) + describe('the moveable toggle and month bound', () => { + it('defaults off for a fixed wish, seeding the bound from the window months', () => { + render() + expect(screen.getByTestId('period-moveable-checkbox')).not.toBeChecked() + expect(screen.queryByTestId('period-bound-fields')).not.toBeInTheDocument() + }) + + it('defaults on for a flexible wish and shows the seeded month bound', () => { + render( + , + ) + expect(screen.getByTestId('period-moveable-checkbox')).toBeChecked() + expect(screen.getByTestId('period-bound-from-select')).toHaveValue('3') + expect(screen.getByTestId('period-bound-to-select')).toHaveValue('5') + }) + + it('sends moveable: true and the chosen month bound once turned on', async () => { + mockOnSave.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-moveable-checkbox')) + fireEvent.change(screen.getByTestId('period-bound-from-select'), { target: { value: '3' } }) + fireEvent.change(screen.getByTestId('period-bound-to-select'), { target: { value: '5' } }) + fireEvent.click(screen.getByTestId('period-save-button')) + await waitFor(() => + expect(mockOnSave).toHaveBeenCalledWith( + expect.objectContaining({ moveable: true, boundStartMonth: 3, boundEndMonth: 5 }), + ), + ) + }) + + it('shows the target length as the days between the edited dates', () => { + render() + fireEvent.click(screen.getByTestId('period-moveable-checkbox')) + expect(screen.getByTestId('period-bound-length-hint')).toHaveTextContent('7 dagar') + }) + }) + + describe('locking and unlocking the band\'s days', () => { + it('shows how many of the band\'s persisted days are locked', () => { + render( + , + ) + expect(screen.getByTestId('period-lock-status')).toHaveTextContent('1 av 2 dagar är låsta') + expect(screen.getByTestId('period-lock-toggle-button')).toHaveTextContent('Lås dagarna') + }) + + it('calls onSetLocked(true) when not every day is locked yet', () => { + render( + , + ) + fireEvent.click(screen.getByTestId('period-lock-toggle-button')) + expect(mockOnSetLocked).toHaveBeenCalledWith(true) + }) + + it('offers "Lås upp" and calls onSetLocked(false) once every day is already locked', () => { + render( + , + ) + expect(screen.getByTestId('period-lock-toggle-button')).toHaveTextContent('Lås upp') + fireEvent.click(screen.getByTestId('period-lock-toggle-button')) + expect(mockOnSetLocked).toHaveBeenCalledWith(false) + }) + + it('hides the lock section for an empty band (nothing placed yet)', () => { + render() + expect(screen.queryByTestId('period-lock-fieldset')).not.toBeInTheDocument() + }) + + it('disables the lock button while a lock/unlock write is in flight', () => { + render( + , + ) + expect(screen.getByTestId('period-lock-toggle-button')).toBeDisabled() + expect(screen.getByTestId('period-lock-toggle-button')).toHaveTextContent('Uppdaterar…') + }) + }) + it('sends lastYear: null when switched to Never even if a last year was set', async () => { mockOnSave.mockResolvedValue(undefined) render() @@ -431,8 +574,8 @@ describe('PeriodPopover', () => { // Intent changed (was: '"this day" is disabled for a wish-linked band'). // A single day removed from a planner-placed period is now recorded as a - // `PlannedDayExclusion`, so it no longer reappears on the next sync and - // the option is live — see Calendar.tsx's `excludeDay`. + // `DayAssertion` with `dayType: 'none'`, so it no longer reappears on the + // next sync and the option is live — see Calendar.tsx's `excludeDay`. it('enables "this day" for a wish-linked band and explains what it does', () => { render() openDeleteMenu() diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index a791067..468caa1 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -1,6 +1,6 @@ import { ReactNode, useEffect, useState } from 'react' -import type { LeaveWish, LeaveWishScope } from '@/types' -import { useTranslation, type TranslationKey } from '@/i18n' +import type { LeaveWish, LeaveWishScope, PlannedLeaveDay } from '@/types' +import { useTranslation, type TranslationKey, type Locale } from '@/i18n' import { resolveWishName } from '@/utils/wishName' import { Icon } from '@/components/common/Icon' import './period-popover.css' @@ -29,12 +29,27 @@ export interface PeriodPopoverSavePayload { // `parentMode === 'both'`. See `LeaveWish.wantsDubbeldag` and // dubbeldagar.md at the repo root. wantsDubbeldag: boolean + // §Planering (restructure step 4) — replaces the old three-way "Planering" + // ladder with a wish-level toggle. `false` (default): `windowMode: 'fixed'` + // — the edited dates ARE the placement, exactly as before. `true`: + // `windowMode: 'flexible'` — the edited dates set the wanted LENGTH only; + // the planner searches for the best-costing placement inside + // `boundStartMonth'..'boundEndMonth` (see below). Locking specific days' + // types is now a separate action (`onSetLocked`), not a mode here. + moveable: boolean + // The month bound the planner may search, inclusive of both ends — only + // meaningful (and only read by the caller) when `moveable` is `true`. + // `boundEndMonth < boundStartMonth` means the bound wraps into the next + // year (e.g. Dec -> Jan), mirroring `windowWraps`. + boundStartMonth: number + boundEndMonth: number } /** todo.md #9 — the three-way remove. `'day'` removes one date out of the * clicked band — for an ad-hoc band by deleting the row, for a planner-placed - * one by recording a `PlannedDayExclusion` so the next sync leaves it alone - * (see Calendar.tsx's `handleDeletePeriod`); `'year'` removes this + * one by writing a `DayAssertion` with `dayType: 'none'` (see + * `useDayAssertions`'s `assertDay`) so the next sync leaves it alone (see + * Calendar.tsx's `handleDeletePeriod`/`excludeDay`); `'year'` removes this * occurrence/year only (for an ad-hoc band, the whole contiguous range); * `'allYears'` removes every occurrence of the wish. */ export type PeriodDeleteScope = 'day' | 'year' | 'allYears' @@ -42,8 +57,10 @@ export type PeriodDeleteScope = 'day' | 'year' | 'allYears' export interface PeriodPopoverProps { isOpen: boolean /** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band - * (planned days with no `wishId`) — those can only be deleted, not edited, - * since there is no wish to persist name/parent/repeat changes against. */ + * (planned days with no `wishId`). An ad-hoc band gets the same full + * editor as a wish-linked one — saving it PROMOTES it to a real + * `LeaveWish` (see Calendar.tsx's `handleSavePeriod`); only the title and + * the `periodAdHocNotice` differ while it's still unsaved. */ wish: LeaveWish | null /** The clicked band's actual placed dates — always shown, and the only * source of truth for an ad-hoc band. */ @@ -59,11 +76,28 @@ export interface PeriodPopoverProps { * loud rather than leaving the user to guess. `null` when the band's parent * isn't among the known members (stale data, removed partner). */ removeDayParentName?: string | null | undefined + /** The band's own persisted `planned_leave_day` rows — for a wish-linked + * band, that occurrence's rows; for an ad-hoc band, the ad-hoc rows the + * clicked band is made of (see Calendar.tsx's `bandDays`). Seeds the + * ad-hoc form (name/parent/dubbeldagar) and the lock section's "N of M + * days locked" count. Omitted defaults to empty — a defensive fallback, + * since a rendered band should always have at least one row. */ + bandDays?: PlannedLeaveDay[] parents: Array<{ id: string; name: string }> isSaving?: boolean | undefined error?: string | null | undefined onSave: (payload: PeriodPopoverSavePayload) => Promise onDelete: (scope: PeriodDeleteScope) => Promise + /** Locks ("Lås dagarna") or unlocks ("Lås upp") every persisted day in + * `bandDays` at once — restructure step 4: an ACTION on the band's real + * days (writes/clears a `DayAssertion` per day, see Calendar.tsx's + * `handleSetPeriodLocked`), not a form field bundled into `onSave`. This is + * what lets the lock state be partial (some days locked, some not) — a + * fact the old three-way "Planering" mode could never express — and is why + * there is no more "disabled once the dates drift" guard: you lock the + * days that exist, not a mode about dates that might not. */ + onSetLocked: (locked: boolean) => Promise + isLockUpdating?: boolean | undefined onClose: () => void } @@ -77,18 +111,9 @@ interface FormState { scope: LeaveWishScope lastYear: number | null wantsDubbeldag: boolean -} - -const EMPTY_FORM: FormState = { - name: '', - startDate: '', - endDate: '', - parentMode: 'either', - parentPersonalDataUuid: null, - recurrence: 'once', - scope: 'occurrence', - lastYear: null, - wantsDubbeldag: false, + moveable: boolean + boundStartMonth: number + boundEndMonth: number } /** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py): @@ -106,6 +131,38 @@ function parseISO(date: string): { year: number; month: number; day: number } { return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 } } +/** Inclusive day count between two ISO dates — the target LENGTH the + * planner fills, whether the placement is fixed or moveable. `NaN`-safe: + * an unparseable date (e.g. the start/end input mid-edit) falls back to 1 + * rather than propagating `NaN` into the hint text. */ +function dayCountBetween(start: string, end: string): number { + const s = Date.parse(`${start}T00:00:00Z`) + const e = Date.parse(`${end}T00:00:00Z`) + if (Number.isNaN(s) || Number.isNaN(e)) return 1 + return Math.round((e - s) / 86_400_000) + 1 +} + +// Locale-aware "Januari".."December" for the month-bound setForm((prev) => ({ ...prev, name: e.target.value }))} - className="field__control" - disabled={isSaving} - data-testid="period-name-input" - aria-label={t('calendar.periodNameAria')} - /> - +
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + className="field__control" + disabled={isSaving} + placeholder={t('wishEditor.manualPeriodName')} + data-testid="period-name-input" + aria-label={t('calendar.periodNameAria')} + /> +
-
- - setForm((prev) => ({ ...prev, startDate: e.target.value }))} - className="field__control" - disabled={isSaving} - data-testid="period-start-input" - /> -
+
+ + setForm((prev) => ({ ...prev, startDate: e.target.value }))} + className="field__control" + disabled={isSaving} + data-testid="period-start-input" + /> +
-
- - setForm((prev) => ({ ...prev, endDate: e.target.value }))} - className="field__control" - disabled={isSaving} - data-testid="period-end-input" - /> -
+
+ + setForm((prev) => ({ ...prev, endDate: e.target.value }))} + className="field__control" + disabled={isSaving} + data-testid="period-end-input" + /> +
-
- - { + const value = e.target.value + if (value === 'either' || value === 'both') { + setForm((prev) => ({ ...prev, parentMode: value, parentPersonalDataUuid: null })) + } else { + setForm((prev) => ({ ...prev, parentMode: 'specific', parentPersonalDataUuid: value })) + } + }} + className="field__control" + disabled={isSaving} + data-testid="period-parent-select" + > + + + {parents.map((p) => ( + + ))} + +
+ +
+ +

+ {form.parentMode === 'both' + ? t('calendar.wantsDubbeldagHint') + : t('calendar.wantsDubbeldagRequiresBothHint')} +

+
+ +
+ {t('calendar.periodMoveableLegend')} + +

+ {form.moveable ? t('calendar.periodMoveableHint') : t('calendar.periodFixedHint')} +

+ + {form.moveable && ( +
+ + +

+ {t('calendar.periodBoundLengthHint', { count: dayCount })} +

+
+ )} +
+ + {bandDays.length > 0 && ( +
+ {t('calendar.periodLockLegend')} +

+ {t('calendar.periodLockStatus', { locked: lockedCount, total: bandDays.length })} +

+
+ {isLockUpdating + ? t('calendar.periodLockUpdatingButton') + : allLocked + ? t('calendar.periodUnlockButton') + : t('calendar.periodLockButton')} + + + )} -
+
+ {t('calendar.periodRepeatLabel')} + + +
+ + {showScopeChoice && ( +
+ {t('calendar.periodEditScopeLabel')} + + {/* §Planering (step 4, item C) — the previously-observed "silent + no-op" trap: this option is the DEFAULT, so its limiting + consequence must be as explicit as the alternative's, not + left implicit by omission. Without this line, a user who + saves here and later checks a different year sees no + change and has no way to tell whether that was the plan or + a bug. */} +

{t('calendar.periodEditScopeYearHint')}

+ +

{t('calendar.periodEditScopeHint')}

+
+ )} + + {form.recurrence === 'yearly' && ( +
-

- {form.parentMode === 'both' - ? t('calendar.wantsDubbeldagHint') - : t('calendar.wantsDubbeldagRequiresBothHint')} -

-
+ {lastYearLocked && ( +

+ {t('calendar.periodLastYearNeedsSeriesHint')} +

+ )} -
- {t('calendar.periodRepeatLabel')} - - -
- - {showScopeChoice && ( -
- {t('calendar.periodEditScopeLabel')} -
- )} - - {form.recurrence === 'yearly' && ( -
- - {lastYearLocked && ( -

- {t('calendar.periodLastYearNeedsSeriesHint')} -

- )} - - {form.lastYear !== null && ( - - )} -
- )} -
- )} + )} + + )} +
@@ -521,17 +715,15 @@ export function PeriodPopover({ > {t('calendar.periodCancelButton')} - {wish && ( - - )} + {isDeleteMenuOpen && (
+ {/* §Planering — "not moveable": exact dates, an ad-hoc band's rows are + re-pointed at the new wish (kept pinned exactly as they already + are) rather than handed back to the planner. */} + + {/* §Planering — "moveable": hands the placement back to the search + (`windowMode: 'flexible'`, a month bound), and (for an ad-hoc band) + deletes its existing rows rather than re-pointing them. */} + + {/* Exercises the `parentMode: 'specific'` branch of the save payload — + the buttons above all send 'either', which never reaches it. Used + against both a wish-linked band and an ad-hoc band. */} + + {/* Exercises the ad-hoc promotion's blank-name fallback, its yearly + `lastYear` forwarding, and 'both'-parentMode `wantsDubbeldag` + forwarding all in one payload (Calendar.tsx's + `handlePromoteAdhocBand`). */} + {/* §todo9 — the three-way remove. */} + {/* §Planering (step 4) — a wrapping month bound (Dec -> Feb), to exercise + the year-rollover arithmetic that decides the bound's end day + (leap-year-sensitive for February). */} + + {/* §Planering (step 4) — the lock/unlock action, independent of onSave. */} + + @@ -285,14 +428,14 @@ describe('Calendar', () => { const mockLeaveWishes = makeLeaveWishesResult() const mockMembers = makeWorkspaceMembersResult() const mockSync = makePlanSyncResult() - const mockExclusions = makePlannedDayExclusionsResult() + const mockAssertions = makeDayAssertionsResult() beforeEach(() => { vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow) vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes) vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers) vi.mocked(usePlanSync).mockReturnValue(mockSync) - vi.mocked(usePlannedDayExclusions).mockReturnValue(mockExclusions) + vi.mocked(useDayAssertions).mockReturnValue(mockAssertions) vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams]) @@ -536,8 +679,8 @@ describe('Calendar', () => { describe('§Task7 saving a day pins it', () => { it('pins a brand-new day created through the day popover', async () => { - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) render() const calendarDays = screen.getAllByTestId('calendar-day') @@ -545,16 +688,18 @@ describe('Calendar', () => { fireEvent.click(currentMonthDay!) fireEvent.click(screen.getByTestId('modal-save-pinned')) - await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }))) + await waitFor(() => + expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' })), + ) }) - it('pins an existing day edited through the day popover', async () => { + it('pins an existing day edited through the day popover (assertDay is an upsert)', async () => { const today = todayDateString() - const update = vi.fn().mockResolvedValue(makePlannedLeaveDay()) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], - update, }), ) @@ -565,13 +710,15 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('modal-save-pinned')) await waitFor(() => - expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })), + expect(assertDay).toHaveBeenCalledWith( + expect.objectContaining({ dayType: 'vacation', parentPersonalDataUuid: 'person-1', date: today }), + ), ) }) it('shows a generic error when saving a day rejects with a non-Error value', async () => { - const add = vi.fn().mockRejectedValue('boom') - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) + const assertDay = vi.fn().mockRejectedValue('boom') + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) render() const calendarDays = screen.getAllByTestId('calendar-day') @@ -581,17 +728,17 @@ describe('Calendar', () => { // The mocked modal doesn't surface `error`, but the save must not // crash and must leave the modal open with the day still selected. - await waitFor(() => expect(add).toHaveBeenCalled()) + await waitFor(() => expect(assertDay).toHaveBeenCalled()) expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('shows a generic error when deleting a day rejects with a non-Error value', async () => { const today = todayDateString() - const remove = vi.fn().mockRejectedValue('boom') + const clear = vi.fn().mockRejectedValue('boom') + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], - remove, }), ) @@ -601,7 +748,7 @@ describe('Calendar', () => { fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-delete')) - await waitFor(() => expect(remove).toHaveBeenCalled()) + await waitFor(() => expect(clear).toHaveBeenCalled()) expect(screen.getByTestId('modal')).toBeInTheDocument() }) }) @@ -743,13 +890,53 @@ describe('Calendar', () => { expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'null') }) - it('saving a period edit updates the wish and re-syncs', async () => { + it('bandDays falls back to empty for a wish-linked band if plannedDaysHook.data becomes unavailable', () => { + // Defensive fallback in the `bandDays` memo — a background refetch + // error can null out `plannedDaysHook.data` while the popover the user + // already opened is still showing. const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) - const update = vi.fn().mockResolvedValue(wish) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + const { rerender } = render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-band-days-count', '1') + + vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: null })) + rerender() + + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-band-days-count', '0') + }) + + it('bandDays falls back to empty for an ad-hoc band if plannedDaysHook.data becomes unavailable', () => { + const today = todayDateString() + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + const { rerender } = render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-band-days-count', '1') + + vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: null })) + rerender() + + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-band-days-count', '0') + }) + + it('saving a period edit updates the wish and re-syncs, in one atomic call', async () => { + const today = todayDateString() + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -761,31 +948,35 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-save')) await waitFor(() => - expect(update).toHaveBeenCalledWith( - 'lw-1', - expect.objectContaining({ name: 'Edited name' }), - 'occurrence', + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + scope: 'occurrence', + patch: expect.objectContaining({ name: 'Edited name' }), + }), + }), ), ) - await waitFor(() => expect(sync).toHaveBeenCalled()) expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument() }) - it('saving a period edit pins exact dates: windowMode flips to fixed with a matching target', async () => { - // §bug — a preset-born wish is `windowMode: 'flexible'` (a bounding - // range the planner searches inside, not a placement). Once the user - // has dragged/typed exact dates in the popover, those dates ARE the - // placement, so the save must flip the row to 'fixed' with a target - // matching the edited span. Leaving it 'flexible' turns the (now - // week-long) bound into a single flexible candidate, which repeats the - // same calendar date every year on a drifting weekday instead of - // preserving the weekday. + it('saving a period edit with moveable: false flips windowMode to fixed with a matching target', async () => { + // §Planering — a preset-born wish is `windowMode: 'flexible'` (a + // bounding range the planner searches inside, not a placement). Once + // the user has dragged/typed exact dates in the popover and left the + // "planner may move it" toggle off, those dates ARE the placement, so + // the save must flip the row to 'fixed' with a target matching the + // edited span. Leaving it 'flexible' turns the (now week-long) bound + // into a single flexible candidate, which repeats the same calendar + // date every year on a drifting weekday instead of preserving the + // weekday. See the complementary "moveable: true" test right below. const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' }) - const update = vi.fn().mockResolvedValue(wish) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-05'), mockSetSearchParams]) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ @@ -807,14 +998,384 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-save')) await waitFor(() => - expect(update).toHaveBeenCalledWith( - 'lw-1', - expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }), - 'occurrence', + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + scope: 'occurrence', + patch: expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }), + }), + }), ), ) }) + it('saving a period edit with moveable: true no longer forces windowMode to fixed', async () => { + // §Planering — the old ladder ALWAYS flipped an edited wish to + // `windowMode: 'fixed'` (see the test above). Turning "Planeraren får + // flytta perioden" on hands the placement back to the search across + // the chosen month bound instead, so the row must stay/become + // `'flexible'`. + const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: todayDateString(), wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-auto')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + patch: expect.objectContaining({ windowMode: 'flexible' }), + }), + }), + ), + ) + }) + + it('a wrapping month bound (Dec -> Feb) spans into next year, honouring that year\'s leap day', async () => { + // §Planering (step 4) — 2028 is a leap year, so a bound ending in + // February 2028 (the year the clicked 2027-12 band's Dec->Feb bound + // wraps into) must end on the 29th, not the 28th. Getting the wrap + // year wrong here would silently shrink or misplace the search bound. + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2027-12-15'), mockSetSearchParams]) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: '2027-12-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-wrap-bound')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + patch: expect.objectContaining({ + windowMode: 'flexible', + windowStartMonth: 12, + windowStartDay: 1, + windowEndMonth: 2, + windowEndDay: 29, + }), + }), + }), + ), + ) + }) + + it('saving an existing wish-linked band never sends dayActions any more — locking is a separate action', async () => { + // §Planering (step 4) — the old ladder bundled pin/unpin into the same + // atomic call as the wish update; that's gone. `handleSetPeriodLocked` + // (below) is the only path that writes/clears a `DayAssertion` now. + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + isPinned: false, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save')) + + await waitFor(() => expect(syncWithWish).toHaveBeenCalled()) + expect(syncWithWish.mock.calls[0]![0]).not.toHaveProperty('dayActions') + }) + + describe('§Planering (step 4) — locking/unlocking the clicked band\'s days', () => { + // Bug fix (live-testing review): a wish-backed band's lock/unlock used + // to write/clear a `DayAssertion` per row directly. `clear` deletes the + // mirrored `planned_leave_day` row outright (not just the pin), so + // unlocking made the whole band disappear until some unrelated sync + // happened to re-place the wish's still-open demand — and the + // subsequent per-row loop had no per-call success check, so a toast + // claiming success could fire even after a row failed. Both are fixed + // by routing a wish-backed band's lock/unlock through the same atomic + // `syncWithWish` call (`dayActions: 'pin'/'unpin'`) the rest of the + // Planering ladder already uses — one call, one gated result. + it('locks every currently-unpinned row via sync-with-wish, attached to the wish, then closes the popover', async () => { + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + dayType: 'vacation', + isDubbeldag: false, + isPinned: false, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-on')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + wishUpdate: { leaveWishId: 'lw-1', patch: {} }, + dayActions: [{ plannedDayId: 'pd-1', action: 'pin' }], + }), + ) + expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument() + }) + + it('unlocks every currently-pinned row via sync-with-wish\'s unpin action, never the raw DayAssertion clear', async () => { + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + const clear = vi.fn().mockResolvedValue(undefined) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + isPinned: true, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-off')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + wishUpdate: { leaveWishId: 'lw-1', patch: {} }, + dayActions: [{ plannedDayId: 'pd-1', action: 'unpin' }], + }), + ) + // The regression this guards against: unlocking a wish-backed band + // must never fall back to the per-row `DELETE /api/day-assertions` + // path, which deletes the mirrored row outright instead of handing + // it back to the planner. + expect(clear).not.toHaveBeenCalled() + }) + + it('does nothing when every row is already in the target state', async () => { + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + isPinned: true, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-on')) + + await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()) + expect(syncWithWish).not.toHaveBeenCalled() + }) + + it('undo re-runs sync-with-wish with the opposite day action', async () => { + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + isPinned: false, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-on')) + + await waitFor(() => expect(syncWithWish).toHaveBeenCalledTimes(1)) + const undoButton = await screen.findByTestId('calendar-toast-undo') + fireEvent.click(undoButton) + + await waitFor(() => + expect(syncWithWish).toHaveBeenLastCalledWith({ + workspaceId: 'ws-1', + wishUpdate: { leaveWishId: 'lw-1', patch: {} }, + dayActions: [{ plannedDayId: 'pd-1', action: 'unpin' }], + }), + ) + }) + + // Bug fix — the old per-row loop showed a hardcoded success toast once + // every awaited call resolved, with nothing checking whether the sync + // itself actually landed. `applyWishSyncResult` gates on `sync. + // applyResult.accepted`, so a rejected day-lock action (still HTTP 2xx + // -- a structured rejection, not a thrown error) must render the + // rejection, not a "days locked" success message, and must not offer + // an undo (there's nothing to undo -- the whole call rolled back + // server-side). + it('does not show a success toast (or an undo) when the sync-with-wish call is rejected', async () => { + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish, + sync: makePlanSyncResponse({ + changes: [], + applyResult: makePlanApplyResponse({ + accepted: false, + rejections: [makeRejection({ messageKey: 'planApply.rejection.insufficientVacationBalance' })], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + isPinned: true, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-off')) + + await waitFor(() => expect(syncWithWish).toHaveBeenCalled()) + const toast = await screen.findByTestId('calendar-toast') + expect(toast).not.toHaveTextContent('toastDaysUnlocked') + expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() + }) + + // Bug fix — same claim, for the network/exception path: a rejected + // promise (matching what a mid-request dev-server reload produces, see + // Bug 3) must surface as an error, never as the success toast. + it('does not show a success toast when sync-with-wish itself throws', async () => { + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockRejectedValue(new Error('503 Service Unavailable')) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: 'lw-1', + parentPersonalDataUuid: 'person-1', + isPinned: true, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-off')) + + await waitFor(() => expect(syncWithWish).toHaveBeenCalled()) + expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() + // The popover stays open (mirrors every other failed-save path in + // this file) so the error can render inline instead of the change + // silently vanishing behind a closed popover. + expect(screen.getByTestId('period-popover')).toBeInTheDocument() + }) + + it('an ad-hoc band (no wish) still locks/unlocks via the direct DayAssertion write/clear', async () => { + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: todayDateString(), + wishId: null, + parentPersonalDataUuid: 'person-1', + dayType: 'vacation', + isDubbeldag: false, + isPinned: false, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-lock-on')) + + await waitFor(() => + expect(assertDay).toHaveBeenCalledWith( + expect.objectContaining({ + parentPersonalDataUuid: 'person-1', + date: todayDateString(), + dayType: 'vacation', + isDubbeldag: false, + wishId: null, + }), + ), + ) + }) + }) + it('saving one year of a repeating wish writes that year and keeps the scope narrow', async () => { // The band clicked here is the 2028 row of a series. Its own year IS // what gets written — the old shape had to keep the wish's anchor year @@ -822,11 +1383,10 @@ describe('Calendar', () => { // writing `firstYear: 2028` would have dropped the earlier occurrences. // There are no "earlier occurrences" on this row to drop any more. const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' }) - const update = vi.fn().mockResolvedValue(wish) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams]) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -839,23 +1399,26 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-save')) await waitFor(() => - expect(update).toHaveBeenCalledWith( - 'lw-1', - expect.objectContaining({ firstYear: 2028 }), - 'occurrence', + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + scope: 'occurrence', + patch: expect.objectContaining({ firstYear: 2028 }), + }), + }), ), ) // `lastYear` is series-level, so a this-year-only edit must not carry it. - expect(update.mock.calls[0]![1]).not.toHaveProperty('lastYear') + expect(syncWithWish.mock.calls[0]![0].wishUpdate.patch).not.toHaveProperty('lastYear') }) it('saving "every year" widens the scope and carries the series end year', async () => { const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' }) - const update = vi.fn().mockResolvedValue(wish) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams]) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -867,10 +1430,14 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-save-series')) await waitFor(() => - expect(update).toHaveBeenCalledWith( - 'lw-1', - expect.objectContaining({ firstYear: 2028, lastYear: 2031 }), - 'series', + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + scope: 'series', + patch: expect.objectContaining({ firstYear: 2028, lastYear: 2031 }), + }), + }), ), ) }) @@ -881,11 +1448,10 @@ describe('Calendar', () => { // the siblings go. That is series-wide by definition, so the popover // sends the narrow scope and the backend still drops the siblings. const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' }) - const update = vi.fn().mockResolvedValue(wish) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams]) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -897,10 +1463,14 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-save-once')) await waitFor(() => - expect(update).toHaveBeenCalledWith( - 'lw-1', - expect.objectContaining({ firstYear: 2028, recurrence: 'once' }), - 'occurrence', + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + scope: 'occurrence', + patch: expect.objectContaining({ firstYear: 2028, recurrence: 'once' }), + }), + }), ), ) }) @@ -976,15 +1546,15 @@ describe('Calendar', () => { expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() }) - it('deleting an ad-hoc period removes the underlying planned days directly (no sync)', async () => { + it('deleting an ad-hoc period clears the underlying day assertions directly (no sync)', async () => { const today = todayDateString() - const remove = vi.fn().mockResolvedValue(undefined) + const clear = vi.fn().mockResolvedValue(undefined) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], - remove, }), ) @@ -992,11 +1562,18 @@ describe('Calendar', () => { fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete-year')) - await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1')) - // An ad-hoc delete removes the planned day directly and never syncs - // (it isn't wish-driven) — Calendar itself performs no auto-sync of - // its own any more (see the "§Task7 auto-sync moved to - // PlanSyncProvider" describe block below). + await waitFor(() => + expect(clear).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', + date: today, + }), + ) + // An ad-hoc delete clears the assertion directly and never syncs (it + // isn't wish-driven) — Calendar itself performs no auto-sync of its + // own any more (see the "§Task7 auto-sync moved to PlanSyncProvider" + // describe block below). expect(sync).not.toHaveBeenCalled() }) @@ -1016,42 +1593,47 @@ describe('Calendar', () => { }) describe('§Task7 preset rail', () => { - it('creates the selected preset as a flexible, yearly wish', async () => { + it('creates the selected preset as a flexible, yearly wish, funded in the same atomic call', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => - expect(add).toHaveBeenCalledWith(expect.objectContaining({ windowMode: 'flexible', recurrence: 'yearly' })), + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + wishCreate: expect.objectContaining({ windowMode: 'flexible', recurrence: 'yearly' }), + }), + ), ) - await waitFor(() => expect(sync).toHaveBeenCalled()) }) it('defaults a solo household preset to "either" parent', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) - await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parentMode: 'either' }))) + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ wishCreate: expect.objectContaining({ parentMode: 'either' }) }), + ), + ) }) it('defaults a two-parent household preset to "both" parents', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [ @@ -1060,25 +1642,31 @@ describe('Calendar', () => { ], }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) - await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parentMode: 'both' }))) + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ wishCreate: expect.objectContaining({ parentMode: 'both' }) }), + ), + ) }) it('navigates the month view to the resolved placement and flashes the band', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [makePlanChange({ newWishId: 'lw-preset', date: '2030-07-10' })], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [makePlanChange({ newWishId: 'lw-preset', date: '2030-07-10' })], + }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1090,8 +1678,9 @@ describe('Calendar', () => { }) it('shows an error toast when creating the preset fails', async () => { - const add = vi.fn().mockRejectedValue(new Error('no priority left')) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) + const syncWithWish = vi.fn().mockRejectedValue(new Error('no priority left')) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1107,12 +1696,16 @@ describe('Calendar', () => { // machinery through the preset rail instead. it('shows a toast summarizing what changed after a sync with changes', async () => { const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' }), makePlanChange({ newWishId: 'lw-preset' })] }), + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [makePlanChange({ newWishId: 'lw-preset' }), makePlanChange({ newWishId: 'lw-preset' })], + }), + }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [created] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1123,41 +1716,44 @@ describe('Calendar', () => { it('shows no toast when the sync produces no changes', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] })) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ wish: created, sync: makePlanSyncResponse({ changes: [] }) }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) - await waitFor(() => expect(add).toHaveBeenCalled()) + await waitFor(() => expect(syncWithWish).toHaveBeenCalled()) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('explains a fully-starved sync (no changes, nothing funded) instead of showing nothing', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [], - proposal: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-preset', - startDate: '2026-06-01', - endDate: '2026-06-14', - weeksWished: 2, - weeksFunded: 0, - shortfallDays: 10, - data: { wish_name: 'Sommar', wish_name_message_key: null }, - }), - ], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-preset', + startDate: '2026-06-01', + endDate: '2026-06-14', + weeksWished: 2, + weeksFunded: 0, + shortfallDays: 10, + data: { wish_name: 'Sommar', wish_name_message_key: null }, + }), + ], + }), }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1180,27 +1776,29 @@ describe('Calendar', () => { // creates a fresh wish with `targetDays` anymore. const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1', name: 'Ny period', targetDays: 7, targetWeeks: 1 }) - const update = vi.fn().mockResolvedValue(wish) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [], - proposal: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-1', - startDate: '2026-09-17', - endDate: '2026-09-23', - weeksWished: 3, - weeksFunded: 0, - shortfallDays: 5, - data: { wish_name: 'Ny period', wish_name_message_key: null }, - }), - ], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-1', + startDate: '2026-09-17', + endDate: '2026-09-23', + weeksWished: 3, + weeksFunded: 0, + shortfallDays: 5, + data: { wish_name: 'Ny period', wish_name_message_key: null }, + }), + ], + }), }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -1220,46 +1818,47 @@ describe('Calendar', () => { it('shows no toast when a sync with no changes also has no outcome for the touched wish (genuine no-op)', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [], - proposal: makePlanProposal({ outcomes: [] }), + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [], proposal: makePlanProposal({ outcomes: [] }) }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) - await waitFor(() => expect(add).toHaveBeenCalled()) + await waitFor(() => expect(syncWithWish).toHaveBeenCalled()) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('reports both what changed and what fell short on a partially-funded sync', async () => { const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [makePlanChange({ newWishId: 'lw-preset' })], - proposal: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-preset', - startDate: '2026-06-01', - endDate: '2026-06-14', - weeksWished: 2, - weeksFunded: 1, - shortfallDays: 5, - data: { wish_name: 'Sommar', wish_name_message_key: null }, - }), - ], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [makePlanChange({ newWishId: 'lw-preset' })], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-preset', + startDate: '2026-06-01', + endDate: '2026-06-14', + weeksWished: 2, + weeksFunded: 1, + shortfallDays: 5, + data: { wish_name: 'Sommar', wish_name_message_key: null }, + }), + ], + }), }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [created] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1274,23 +1873,25 @@ describe('Calendar', () => { it('shows a toast when a flexible window auto-shifted to catch extra red days', async () => { const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [makePlanChange({ newWishId: 'lw-preset' })], - proposal: makePlanProposal({ - outcomes: [ - makeWindowOutcome({ - wishId: 'lw-preset', - shiftGainDays: 2, - data: { wish_name: 'Sommar', wish_name_message_key: null }, - }), - ], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [makePlanChange({ newWishId: 'lw-preset' })], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-preset', + shiftGainDays: 2, + data: { wish_name: 'Sommar', wish_name_message_key: null }, + }), + ], + }), }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [created] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1307,44 +1908,48 @@ describe('Calendar', () => { // `changes` membership is what distinguishes the two, not // `shiftGainDays > 0` alone. const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [], - proposal: makePlanProposal({ - outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })], + }), }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) - await waitFor(() => expect(add).toHaveBeenCalled()) + await waitFor(() => expect(syncWithWish).toHaveBeenCalled()) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ - changes: [], - applyResult: makePlanApplyResponse({ - accepted: false, - rejections: [ - makeRejection({ - messageKey: 'planApply.rejection.insufficientVacationBalance', - data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 5, remaining: 2 }, - }), - ], + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + applyResult: makePlanApplyResponse({ + accepted: false, + rejections: [ + makeRejection({ + messageKey: 'planApply.rejection.insufficientVacationBalance', + data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 5, remaining: 2 }, + }), + ], + }), }), }), ) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }), ) @@ -1357,15 +1962,57 @@ describe('Calendar', () => { expect(toast.textContent).not.toContain('pd-a') }) + // §Step 1 (intent-vs-derived-output restructure) — the wish create, the + // day-lock/detach list, and the funding sync are now one atomic backend + // call (`PlanningService.sync_with_wish`): a rejected sync is rolled + // back server-side, so the frontend performs NO compensating rollback + // of its own any more (no `wishesHook.remove` call) — it just shows the + // rejection message with no "Undo" (there is nothing to undo). + it('shows the rejection with no undo and performs no client-side rollback when the sync is rejected (nothing written)', async () => { + const created = makeLeaveWish({ id: 'lw-preset' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + applyResult: makePlanApplyResponse({ + accepted: false, + rejections: [ + makeRejection({ messageKey: 'planApply.rejection.insufficientBalance', data: { tier: 'sjukpenning', needed: 1, remaining: 0 } }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + fireEvent.click(screen.getByTestId('preset-click')) + + const toast = await screen.findByTestId('calendar-toast') + expect(toast.textContent).toBeTruthy() + expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() + expect(remove).not.toHaveBeenCalled() + }) + it('undo re-syncs after reverting a period edit to its previous state', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' }) const update = vi.fn().mockResolvedValue(wish) - // Every call reports the same non-empty diff — the point of this test - // is the UNDO's re-sync, not what any particular sync call returns. + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }), + }), + ) + // The plain sync hook is what undo's re-sync calls — every call + // reports the same non-empty diff, since the point of this test is + // the UNDO's re-sync, not what any particular sync call returns. const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync, syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -1378,27 +2025,31 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-save')) const undoButton = await screen.findByTestId('calendar-toast-undo') - const syncCallsBeforeUndo = sync.mock.calls.length fireEvent.click(undoButton) await waitFor(() => - expect(update).toHaveBeenLastCalledWith( + expect(update).toHaveBeenCalledWith( 'lw-1', expect.objectContaining({ name: 'Sommar' }), 'occurrence', ), ) - // Undo re-syncs (days are derived — reverting the wish and re-syncing - // is the correct undo, not touching planned days directly). - await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) + // Undo re-syncs via the plain sync hook (days are derived — reverting + // the wish and re-syncing is the correct undo, not touching planned + // days directly). + await waitFor(() => expect(sync).toHaveBeenCalled()) }) it('dismisses the toast on demand', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] })) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [created] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1410,32 +2061,36 @@ describe('Calendar', () => { it('undo after selecting a preset deletes the wish and re-syncs, leaving day cleanup to the backend', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) const remove = vi.fn().mockResolvedValue(undefined) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] })) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }), + }), + ) + const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [created] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync, syncWithWish })) - const removeDay = vi.fn().mockResolvedValue(undefined) - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ remove: removeDay })) + const clearAssertion = vi.fn().mockResolvedValue(undefined) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear: clearAssertion })) render() fireEvent.click(screen.getByTestId('preset-click')) const undoButton = await screen.findByTestId('calendar-toast-undo') - const syncCallsBeforeUndo = sync.mock.calls.length fireEvent.click(undoButton) // The preset is a repeating wish, so undo takes the whole series. await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset', 'series')) - await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) - expect(removeDay).not.toHaveBeenCalled() + await waitFor(() => expect(sync).toHaveBeenCalled()) + expect(clearAssertion).not.toHaveBeenCalled() }) it('undo after deleting an ad-hoc period recreates the deleted days', async () => { const today = todayDateString() - const remove = vi.fn().mockResolvedValue(undefined) - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ @@ -1449,8 +2104,6 @@ describe('Calendar', () => { isPinned: true, }), ], - remove, - add, }), ) @@ -1462,15 +2115,14 @@ describe('Calendar', () => { fireEvent.click(undoButton) await waitFor(() => - expect(add).toHaveBeenCalledWith( + expect(assertDay).toHaveBeenCalledWith( expect.objectContaining({ - workspace_id: 'ws-1', - child_id: 'child-1', - parent_personal_data_uuid: 'person-1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', date: today, - day_type: 'vacation', - is_dubbeldag: false, - is_pinned: true, + dayType: 'vacation', + isDubbeldag: false, }), ), ) @@ -1581,13 +2233,13 @@ describe('Calendar', () => { expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes') }) - it('deleting an existing day through the popover removes it', async () => { + it('deleting an existing day through the popover clears its assertion', async () => { const today = todayDateString() - const remove = vi.fn().mockResolvedValue(undefined) + const clear = vi.fn().mockResolvedValue(undefined) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], - remove, }), ) render() @@ -1595,7 +2247,14 @@ describe('Calendar', () => { const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-delete')) - await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1')) + await waitFor(() => + expect(clear).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', + date: today, + }), + ) }) it('closes the day popover via its close button', () => { @@ -1608,29 +2267,32 @@ describe('Calendar', () => { expect(screen.queryByTestId('modal')).not.toBeInTheDocument() }) - it('shows a generic error toast for a non-Error rejection while syncing', async () => { - const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockRejectedValue('boom') - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + it('shows a generic error toast for a non-Error rejection from the atomic sync-with-wish call', async () => { + const syncWithWish = vi.fn().mockRejectedValue('boom') + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => { const toastEl = screen.getByTestId('calendar-toast') - expect(toastEl).toHaveTextContent('Det gick inte att synka planen') + expect(toastEl).toHaveTextContent('Det gick inte att skapa perioden') }) }) it('shows a generic error toast for a non-Error rejection on undo', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) - const update = vi.fn().mockResolvedValueOnce(wish).mockRejectedValueOnce('boom') - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] })) + const update = vi.fn().mockRejectedValueOnce('boom') + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }), + }), + ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -1647,13 +2309,373 @@ describe('Calendar', () => { await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att ångra ändringen')) }) - it('does not save a period edit for an ad-hoc band (no wishId)', async () => { + describe('§Every calendar band is a real event — saving an ad-hoc band promotes it', () => { + it('promotes via ONE atomic syncWithWish call carrying wishCreate (there is no existing wish row, and no separate wishesHook.add/update)', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const add = vi.fn() + const update = vi.fn() + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, update, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ wishCreate: expect.any(Object) }), + ), + ) + expect(add).not.toHaveBeenCalled() + expect(update).not.toHaveBeenCalled() + }) + + it('moveable: false keeps the exact days: an "attach" dayAction re-points and pins the band\'s existing rows, in the same atomic call, deleting none', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishCreate: expect.objectContaining({ targetDays: 1, targetWeeks: 1, windowMode: 'fixed', recurrence: 'once' }), + dayActions: [{ plannedDayId: 'pd-1', action: 'attach' }], + }), + ), + ) + }) + + it('moveable: true hands the days back to the planner: a "detach" dayAction removes the existing rows, and syncs with windowMode flexible', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-auto')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishCreate: expect.objectContaining({ windowMode: 'flexible' }), + dayActions: [{ plannedDayId: 'pd-1', action: 'detach' }], + }), + ), + ) + }) + + it('undo removes the created wish and restores the original ad-hoc rows', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const remove = vi.fn().mockResolvedValue(undefined) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-promoted' })] }), + }), + ) + const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync, syncWithWish })) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: today, + wishId: null, + parentPersonalDataUuid: 'person-1', + dayType: 'vacation', + isDubbeldag: false, + isPinned: true, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + const undoButton = await screen.findByTestId('calendar-toast-undo') + fireEvent.click(undoButton) + + await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-promoted', 'series')) + await waitFor(() => + expect(assertDay).toHaveBeenCalledWith( + expect.objectContaining({ + parentPersonalDataUuid: 'person-1', + date: today, + dayType: 'vacation', + isDubbeldag: false, + }), + ), + ) + }) + + it('forwards parentMode: "specific" to the created wish', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-specific')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishCreate: expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }), + }), + ), + ) + }) + + it('falls back to the manual-period name, forwards a yearly lastYear, and forwards wantsDubbeldag for "both"', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked-yearly-both-blank-name')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishCreate: expect.objectContaining({ + name: 'Manuellt tillagd period', + nameMessageKey: 'wishEditor.manualPeriodName', + recurrence: 'yearly', + lastYear: 2030, + parentMode: 'both', + wantsDubbeldag: true, + }), + }), + ), + ) + }) + + it('shows the Error message directly when promoting an ad-hoc band fails', async () => { + const today = todayDateString() + const syncWithWish = vi.fn().mockRejectedValue(new Error('promotion failed')) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + await waitFor(() => + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'promotion failed'), + ) + }) + + it('shows a generic error when promoting an ad-hoc band fails with a non-Error value', async () => { + const today = todayDateString() + const syncWithWish = vi.fn().mockRejectedValue('boom') + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + await waitFor(() => { + const attr = screen.getByTestId('period-popover').getAttribute('data-error') + expect(attr).not.toBe('') + expect(attr).not.toBe('boom') + }) + }) + + // §Step 1 (intent-vs-derived-output restructure) — the wish create, + // the day-lock/detach list, and the funding sync are now one atomic + // backend call: a rejected sync rolls back everything server-side, so + // the frontend performs NO compensating rollback of its own (no + // `wishesHook.remove`/`plannedDaysHook.add` calls) — it just shows + // the rejection with no "Undo". + it('shows the rejection with no undo and performs no client-side rollback when the follow-up sync is rejected (nothing written)', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const remove = vi.fn().mockResolvedValue(undefined) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + applyResult: makePlanApplyResponse({ + accepted: false, + rejections: [ + makeRejection({ messageKey: 'planApply.rejection.insufficientBalance', data: { tier: 'sjukpenning', needed: 1, remaining: 0 } }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + makePlannedLeaveDay({ + id: 'pd-1', + date: today, + wishId: null, + parentPersonalDataUuid: 'person-1', + dayType: 'vacation', + isDubbeldag: false, + isPinned: true, + }), + ], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + // The rejection banner shows... + const toast = await screen.findByTestId('calendar-toast') + expect(toast.textContent).toBeTruthy() + // ...nothing was actually saved, so there is nothing for the user to + // undo, and no client-side compensation runs. + expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() + expect(remove).not.toHaveBeenCalled() + expect(assertDay).not.toHaveBeenCalled() + }) + + it('does not roll back the promoted wish when the follow-up sync succeeds', async () => { + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-promoted' })] }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + await screen.findByTestId('calendar-toast-undo') + expect(remove).not.toHaveBeenCalled() + }) + + it('keeps a promoted wish that synced successfully but was only underfunded', async () => { + // `accepted: true` with `shortfallDays > 0` is a legitimate, + // expected shortfall — the wish IS written, just underfunded — and + // must never be rolled back like a genuine rejection. + const today = todayDateString() + const created = makeLeaveWish({ id: 'lw-promoted' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-promoted', + weeksWished: 1, + weeksFunded: 0, + shortfallDays: 3, + data: { wish_name: 'Manuellt tillagd period', wish_name_message_key: null }, + }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + await screen.findByTestId('calendar-toast') + expect(remove).not.toHaveBeenCalled() + }) + }) + + it('does not save a period edit when the wish is no longer present locally', async () => { const today = todayDateString() const update = vi.fn() + // The band references `lw-1`, but the local wish list (e.g. after a + // concurrent refresh) no longer has it — same defensive shape as the + // analogous delete test below. vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ update, data: [] })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ - data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) @@ -1664,11 +2686,142 @@ describe('Calendar', () => { expect(update).not.toHaveBeenCalled() }) + it('forwards parentMode: "specific" to the wish patch', async () => { + const today = todayDateString() + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-specific')) + + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishUpdate: expect.objectContaining({ + leaveWishId: 'lw-1', + patch: expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }), + }), + }), + ), + ) + }) + + // §Step 1 (intent-vs-derived-output restructure) — this path patches an + // EXISTING wish rather than creating one; the patch and the funding sync + // are one atomic call, so a rejected sync rolls the patch back + // server-side too — the frontend performs no client-side "revert the + // patch" compensation of its own any more. + it('shows the rejection with no undo, and never touches wishesHook.update, when the follow-up sync is rejected (nothing written)', async () => { + const today = todayDateString() + const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' }) + const update = vi.fn() + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish, + sync: makePlanSyncResponse({ + changes: [], + applyResult: makePlanApplyResponse({ + accepted: false, + rejections: [ + makeRejection({ messageKey: 'planApply.rejection.insufficientBalance', data: { tier: 'sjukpenning', needed: 1, remaining: 0 } }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save')) + + // The rejection banner shows... + const toast = await screen.findByTestId('calendar-toast') + expect(toast.textContent).toBeTruthy() + // ...nothing was actually saved (rolled back server-side), so there is + // nothing left for the user to undo, and no client-side "revert the + // patch" call runs. + expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() + expect(update).not.toHaveBeenCalled() + }) + + it('displays the Error message directly when saving a period edit rejects with an Error', async () => { + const today = todayDateString() + const wish = makeLeaveWish({ id: 'lw-1' }) + const syncWithWish = vi.fn().mockRejectedValue(new Error('conflict')) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save')) + + await waitFor(() => + expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'conflict'), + ) + }) + + it('does not save a period edit for an ad-hoc band when the workspace is unavailable', async () => { + const today = todayDateString() + const add = vi.fn() + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + expect(add).not.toHaveBeenCalled() + }) + + it('does not promote an ad-hoc band when there is no selected child', async () => { + const today = todayDateString() + const add = vi.fn() + vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ selectedChildId: null })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], + }), + ) + + render() + fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) + fireEvent.click(screen.getByTestId('period-save-locked')) + + expect(add).not.toHaveBeenCalled() + }) + it('shows a generic error when saving a period edit rejects with a non-Error value', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) - const update = vi.fn().mockRejectedValue('boom') - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) + const syncWithWish = vi.fn().mockRejectedValue('boom') + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], @@ -1704,12 +2857,10 @@ describe('Calendar', () => { await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete')) }) - it('shows the Error message directly when a sync rejects with an Error', async () => { - const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockRejectedValue(new Error('workspace not found')) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + it('shows the Error message directly when the atomic sync-with-wish call rejects with an Error', async () => { + const syncWithWish = vi.fn().mockRejectedValue(new Error('workspace not found')) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1717,12 +2868,10 @@ describe('Calendar', () => { await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('workspace not found')) }) - it('does not navigate or flash a band when the preset is created but the follow-up sync fails', async () => { - const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockRejectedValue(new Error('sync failed')) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + it('does not navigate or flash a band when creating the preset fails', async () => { + const syncWithWish = vi.fn().mockRejectedValue(new Error('sync failed')) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() @@ -1742,12 +2891,16 @@ describe('Calendar', () => { configurable: true, }) const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi - .fn() - .mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset', date: '2031-03-05' })] })) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [makePlanChange({ newWishId: 'lw-preset', date: '2031-03-05' })], + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1779,13 +2932,15 @@ describe('Calendar', () => { it('summarizes a change for a wish not present in the local wish list as a generic period', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue( - makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'some-other-wish-id' })] }), + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'some-other-wish-id' })] }), + }), ) // The wish list doesn't (yet) include the wish the change refers to. - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() fireEvent.click(screen.getByTestId('preset-click')) @@ -1795,24 +2950,22 @@ describe('Calendar', () => { }) describe('§Task7 drag-select range save writes every dragged day', () => { - it('writes every dragged date as its own planned day (add for new dates, update for existing ones), all pinned', async () => { + it('asserts every dragged date (an upsert — a pre-existing day is overwritten in place, never skipped or duplicated)', async () => { // Render once first to read off the actual dates the drag will cover // (independent of which day of the month the suite happens to run // on), then re-render with a pre-existing planned day on the first - // of those dates — the save must update it in place, not skip or + // of those dates — the save must assert it in place, not skip or // duplicate it. const { unmount } = render() const probeDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled')) const firstDate = probeDays[0]!.getAttribute('aria-label')!.slice(0, 10) unmount() - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) - const update = vi.fn().mockResolvedValue(makePlannedLeaveDay()) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-existing', date: firstDate, parentPersonalDataUuid: 'person-1' })], - add, - update, }), ) @@ -1829,11 +2982,9 @@ describe('Calendar', () => { expect(modal).toHaveAttribute('data-day-count', '3') fireEvent.click(screen.getByTestId('modal-save-pinned')) - // The pre-existing day is updated (pinned), the other two are added - // (also pinned) — never a LeaveWish, never a sync. - await waitFor(() => expect(update).toHaveBeenCalledWith('pd-existing', expect.objectContaining({ is_pinned: true }))) - await waitFor(() => expect(add).toHaveBeenCalledTimes(2)) - expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true })) + // All 3 dragged dates are asserted — never a LeaveWish, never a sync. + await waitFor(() => expect(assertDay).toHaveBeenCalledTimes(3)) + expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' })) await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument()) }) }) @@ -1872,16 +3023,16 @@ describe('Calendar', () => { it('pinning a Mon–Sun range as vacation creates 5 rows, skipping the weekend', async () => { mockFreeWeekend() - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) selectMonToSun() const modal = await screen.findByTestId('modal') expect(modal).toHaveAttribute('data-day-count', '7') fireEvent.click(screen.getByTestId('modal-save-pinned')) - await waitFor(() => expect(add).toHaveBeenCalledTimes(5)) - const pinnedDates = add.mock.calls.map((call) => (call[0] as { date: string }).date) + await waitFor(() => expect(assertDay).toHaveBeenCalledTimes(5)) + const pinnedDates = assertDay.mock.calls.map((call) => (call[0] as { date: string }).date) expect(pinnedDates).not.toContain(SAT_DATE) expect(pinnedDates).not.toContain(SUN_DATE) }) @@ -1895,21 +3046,21 @@ describe('Calendar', () => { workspace: makeWorkspace({ takePlOnRedDays: true }), }), ) - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) selectMonToSun() const modal = await screen.findByTestId('modal') expect(modal).toHaveAttribute('data-day-count', '7') fireEvent.click(screen.getByTestId('modal-save-pinned-sjukpenning')) - await waitFor(() => expect(add).toHaveBeenCalledTimes(7)) + await waitFor(() => expect(assertDay).toHaveBeenCalledTimes(7)) }) it('shows an error and saves nothing when every selected date is free and ineligible', async () => { mockFreeWeekend() - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${SAT_DATE}`), mockSetSearchParams]) render() @@ -1925,7 +3076,7 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('modal-save-pinned')) await waitFor(() => expect(screen.getByTestId('modal')).toBeInTheDocument()) - expect(add).not.toHaveBeenCalled() + expect(assertDay).not.toHaveBeenCalled() }) }) @@ -2025,8 +3176,8 @@ describe('Calendar', () => { describe('todo.md #14 — two parents cannot both be given the same days', () => { it('saving the pinned/advanced path targets the modal-selected parent, never the logged-in one', async () => { const today = todayDateString() - const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) - const update = vi.fn().mockResolvedValue(makePlannedLeaveDay()) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion()) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })], @@ -2046,8 +3197,6 @@ describe('Calendar', () => { isPinned: true, }), ], - add, - update, }), ) @@ -2068,14 +3217,18 @@ describe('Calendar', () => { // Now select vacation for B and save through the advanced/pinned path. fireEvent.click(screen.getByTestId('modal-save-pinned')) - // A's row must never be touched — the old bug called `update` on it. - await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' }))) - expect(update).not.toHaveBeenCalled() + // A's row must never be touched — assertDay is only ever called once, + // for B. + await waitFor(() => + expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ parentPersonalDataUuid: 'pd-b', dayType: 'vacation' })), + ) + expect(assertDay).toHaveBeenCalledTimes(1) }) - it('deleting through the advanced path removes the currently-selected parent\'s day, not the other parent\'s', async () => { + it('deleting through the advanced path clears the currently-selected parent\'s assertion, not the other parent\'s', async () => { const today = todayDateString() - const remove = vi.fn().mockResolvedValue(undefined) + const clear = vi.fn().mockResolvedValue(undefined) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })], @@ -2087,7 +3240,6 @@ describe('Calendar', () => { makePlannedLeaveDay({ id: 'pd-a-day', date: today, parentPersonalDataUuid: 'person-1', dayType: 'sjukpenning' }), makePlannedLeaveDay({ id: 'pd-b-day', date: today, parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }), ], - remove, }), ) @@ -2098,19 +3250,87 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('modal-change-parent-b')) fireEvent.click(screen.getByTestId('modal-delete')) - await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-b-day')) + await waitFor(() => + expect(clear).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'pd-b', + date: today, + }), + ) + }) + }) + + describe('advanced/pinned save errors never leak a raw backend message_key', () => { + it('translates a structured message_key from the save call instead of showing it raw', async () => { + const today = todayDateString() + const assertDay = vi.fn().mockRejectedValue( + new ApiRequestError(422, { + code: 'planning.rejection.vacationOnFreeDay', + message: 'planning.rejection.vacationOnFreeDay', + details: { messageKey: 'planning.rejection.vacationOnFreeDay', data: { date: today } }, + }), + ) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))! + fireEvent.click(todayCell) + fireEvent.click(screen.getByTestId('modal-save-pinned')) + + await waitFor(() => { + const shown = screen.getByTestId('modal').getAttribute('data-error') + expect(shown).not.toBe('planning.rejection.vacationOnFreeDay') + expect(shown).toContain(today) + }) + }) + + it('falls back to the raw message_key when this build does not recognise it', async () => { + const today = todayDateString() + const assertDay = vi.fn().mockRejectedValue( + new ApiRequestError(422, { + code: 'planning.rejection.somethingNew', + message: 'planning.rejection.somethingNew', + details: { messageKey: 'planning.rejection.somethingNew', data: {} }, + }), + ) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))! + fireEvent.click(todayCell) + fireEvent.click(screen.getByTestId('modal-save-pinned')) + + await waitFor(() => + expect(screen.getByTestId('modal')).toHaveAttribute('data-error', 'planning.rejection.somethingNew'), + ) + }) + + it('falls back to a plain Error message for a non-API error', async () => { + const today = todayDateString() + const assertDay = vi.fn().mockRejectedValue(new Error('network down')) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))! + fireEvent.click(todayCell) + fireEvent.click(screen.getByTestId('modal-save-pinned')) + + await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-error', 'network down')) }) }) describe('todo.md #10/#11 — the manual-add modal defaults to a planner-funded wish', () => { - it('a click/drag by default creates a fixed, once-off wish (not a pinned day) for the chosen parent mode', async () => { + it('a click/drag by default creates a fixed, once-off wish (not a pinned day) for the chosen parent mode, funded in the same atomic call', async () => { const created = makeLeaveWish({ id: 'lw-manual' }) - const add = vi.fn().mockResolvedValue(created) - const addDay = vi.fn() - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) - vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add: addDay })) + const assertDay = vi.fn() + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay })) render() const calendarDays = screen.getAllByTestId('calendar-day') @@ -2119,21 +3339,26 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('modal-save-wish-both')) await waitFor(() => - expect(add).toHaveBeenCalledWith( - expect.objectContaining({ windowMode: 'fixed', recurrence: 'once', parentMode: 'both', wantsDubbeldag: false }), + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishCreate: expect.objectContaining({ + windowMode: 'fixed', + recurrence: 'once', + parentMode: 'both', + wantsDubbeldag: false, + }), + }), ), ) - await waitFor(() => expect(sync).toHaveBeenCalled()) - // The default path never touches planned days directly. - expect(addDay).not.toHaveBeenCalled() + // The default path never touches day assertions directly. + expect(assertDay).not.toHaveBeenCalled() }) it('forwards wantsDubbeldag only when the wish targets both parents', async () => { const created = makeLeaveWish({ id: 'lw-manual' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() const calendarDays = screen.getAllByTestId('calendar-day') @@ -2141,15 +3366,18 @@ describe('Calendar', () => { fireEvent.click(currentMonthDay) fireEvent.click(screen.getByTestId('modal-save-wish-both-dubbeldag')) - await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true }))) + await waitFor(() => + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ wishCreate: expect.objectContaining({ wantsDubbeldag: true }) }), + ), + ) }) it('never sends wantsDubbeldag for a specific-parent wish, even if the modal tried to', async () => { const created = makeLeaveWish({ id: 'lw-manual' }) - const add = vi.fn().mockResolvedValue(created) - const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) - vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + const syncWithWish = vi.fn().mockResolvedValue(makePlanSyncWithWishResponse({ wish: created })) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) render() const calendarDays = screen.getAllByTestId('calendar-day') @@ -2158,27 +3386,239 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('modal-save-wish-specific-dubbeldag-attempt')) await waitFor(() => - expect(add).toHaveBeenCalledWith( - expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false }), + expect(syncWithWish).toHaveBeenCalledWith( + expect.objectContaining({ + wishCreate: expect.objectContaining({ + parentMode: 'specific', + parentPersonalDataUuid: 'pd-b', + wantsDubbeldag: false, + }), + }), ), ) }) + + // §Step 1 (intent-vs-derived-output restructure) — the wish create and + // its funding sync are now one atomic backend call: a rejected sync + // rolls the wish create back server-side, so the frontend performs no + // client-side rollback of its own (no `wishesHook.remove` call). + it('shows the rejection with no undo and performs no client-side rollback when the follow-up sync is rejected (nothing written)', async () => { + const created = makeLeaveWish({ id: 'lw-manual' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + applyResult: makePlanApplyResponse({ + accepted: false, + rejections: [ + makeRejection({ messageKey: 'planApply.rejection.insufficientBalance', data: { tier: 'sjukpenning', needed: 1, remaining: 0 } }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))! + fireEvent.click(currentMonthDay) + fireEvent.click(screen.getByTestId('modal-save-wish-both')) + + // The rejection banner shows... + const toast = await screen.findByTestId('calendar-toast') + expect(toast.textContent).toBeTruthy() + // ...nothing was actually saved, so there is nothing left for the user + // to undo, and no client-side rollback runs. + expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() + expect(remove).not.toHaveBeenCalled() + }) + + it('does not roll back the created wish when the follow-up sync succeeds', async () => { + const created = makeLeaveWish({ id: 'lw-manual' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-manual' })] }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))! + fireEvent.click(currentMonthDay) + fireEvent.click(screen.getByTestId('modal-save-wish-both')) + + // A manual "Undo" affordance is offered (the normal, successful path)... + await screen.findByTestId('calendar-toast-undo') + // ...and nothing was rolled back automatically. + expect(remove).not.toHaveBeenCalled() + }) + + // Step 2 (intent-vs-derived-output restructure) — displacement is now an + // informational outcome, never a refusal: `sync_plan` always writes, and + // a genuinely evicted row (a DIFFERENT wish's edit won a shared pool + // ahead of it) comes back as a 'removed' change with `evicted: true` + // rather than blocking the whole batch. The toast must call it out + // distinctly from an ordinary "N days changed" — and Undo must still be + // offered, since nothing was rejected. + it('reports a displaced (evicted) wish\'s removed days distinctly in the toast, and still offers Undo', async () => { + const created = makeLeaveWish({ id: 'lw-manual' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [ + makePlanChange({ newWishId: 'lw-manual' }), + makePlanChange({ + kind: 'removed', + date: '2028-01-11', + oldWishId: 'lw-existing', + newWishId: null, + oldDayType: 'sjukpenning', + newDayType: null, + oldIsDubbeldag: false, + newIsDubbeldag: null, + evicted: true, + }), + ], + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))! + fireEvent.click(currentMonthDay) + fireEvent.click(screen.getByTestId('modal-save-wish-both')) + + const toast = await screen.findByTestId('calendar-toast') + // The ordinary change ("N dagar ändrades") and the displaced-days + // warning ("N dagar togs bort ...") are both present and distinct — + // displacement is never folded into the same silent count. + expect(toast).toHaveTextContent('ändrades') + expect(toast).toHaveTextContent('togs bort') + + // Undo is still offered — this was a write, not a rejection. + await screen.findByTestId('calendar-toast-undo') + expect(remove).not.toHaveBeenCalled() + }) + + it('keeps (does not roll back) a wish that synced successfully but was only partially/never funded', async () => { + // `accepted: true` with a `shortfallDays > 0` outcome is a legitimate, + // expected result — the wish IS written, just underfunded — and must + // never be treated the same as a genuine rejection. + const created = makeLeaveWish({ id: 'lw-manual' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-manual', + weeksWished: 1, + weeksFunded: 0, + shortfallDays: 5, + data: { wish_name: 'Manuellt tillagd period', wish_name_message_key: null }, + }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))! + fireEvent.click(currentMonthDay) + fireEvent.click(screen.getByTestId('modal-save-wish-both')) + + await screen.findByTestId('calendar-toast') + expect(remove).not.toHaveBeenCalled() + }) + + it('surfaces a sibling year\'s total shortfall too, not just the edited occurrence\'s own', async () => { + // Bug 4 (day-assertion-subsystem review) — a repeating wish is one + // `LeaveWish` row PER YEAR, each with its own `wishId`, sharing a + // `seriesId`. Funding a series-wide edit can fully fund the edited + // year while a SIBLING year comes up 100% short in the very same + // sync — before the fix, the toast only ever checked the edited + // occurrence's own outcome, so that sibling's shortfall was silent: + // `accepted: true`, no rejection, an empty band, no warning. + const created = makeLeaveWish({ id: 'lw-anchor', seriesId: 'series-1' }) + const remove = vi.fn().mockResolvedValue(undefined) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish: created, + sync: makePlanSyncResponse({ + changes: [], + proposal: makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw-anchor', + seriesId: 'series-1', + shortfallDays: 0, + data: { wish_name: 'Sommar', wish_name_message_key: null }, + }), + makeWindowOutcome({ + wishId: 'lw-sibling-year-2', + seriesId: 'series-1', + year: 2028, + weeksWished: 1, + weeksFunded: 0, + shortfallDays: 5, + data: { wish_name: 'Sommar', wish_name_message_key: null }, + }), + ], + }), + }), + }), + ) + vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ remove, data: [] })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish })) + + render() + const calendarDays = screen.getAllByTestId('calendar-day') + const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))! + fireEvent.click(currentMonthDay) + fireEvent.click(screen.getByTestId('modal-save-wish-both')) + + const toast = await screen.findByTestId('calendar-toast') + // The sibling year's total-shortfall message ("inget ... kunde + // finansieras — 5 dagar saknas") must be present, not just silence. + expect(toast).toHaveTextContent('5') + expect(toast).toHaveTextContent('saknas') + }) }) describe('todo.md #9 — the three-way remove', () => { // Intent changed (was: '"day" scope is a no-op for a wish-linked band'). // The user asked for it to work: a single day removed from a - // planner-placed period is now recorded as a `PlannedDayExclusion`, which - // is what stops the next sync re-deriving it. + // planner-placed period is now recorded as a `DayAssertion` with + // `dayType: 'none'`, which is what stops the next sync re-deriving it. it('"day" scope records an exclusion for a wish-linked band, keyed on the band\'s parent', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) const remove = vi.fn().mockResolvedValue(undefined) const update = vi.fn().mockResolvedValue(wish) - const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-1' })) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion({ id: 'da-1' })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update })) - vi.mocked(usePlannedDayExclusions).mockReturnValue( - makePlannedDayExclusionsResult({ exclude }), + vi.mocked(useDayAssertions).mockReturnValue( + makeDayAssertionsResult({ assertDay }), ) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ @@ -2199,11 +3639,12 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-delete-day')) await waitFor(() => - expect(exclude).toHaveBeenCalledWith({ + expect(assertDay).toHaveBeenCalledWith({ workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'person-1', date: today, + dayType: 'none', }), ) // The wish itself is untouched — only this one date goes. @@ -2215,11 +3656,11 @@ describe('Calendar', () => { it('offers an undo that restores the removed day', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) - const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-9' })) - const restore = vi.fn().mockResolvedValue(undefined) + const assertDay = vi.fn().mockResolvedValue(makeDayAssertion({ id: 'da-9' })) + const clear = vi.fn().mockResolvedValue(undefined) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) - vi.mocked(usePlannedDayExclusions).mockReturnValue( - makePlannedDayExclusionsResult({ exclude, restore }), + vi.mocked(useDayAssertions).mockReturnValue( + makeDayAssertionsResult({ assertDay, clear }), ) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ @@ -2242,25 +3683,34 @@ describe('Calendar', () => { await waitFor(() => expect(screen.getByTestId('calendar-toast-undo')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('calendar-toast-undo')) - await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-9')) + await waitFor(() => + expect(clear).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', + date: today, + }), + ) }) it('offers the removed day back from the day popover, long after the toast is gone', async () => { const today = todayDateString() - const restore = vi.fn().mockResolvedValue(undefined) + const clear = vi.fn().mockResolvedValue(undefined) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })], }), ) - vi.mocked(usePlannedDayExclusions).mockReturnValue( - makePlannedDayExclusionsResult({ - restore, + vi.mocked(useDayAssertions).mockReturnValue( + makeDayAssertionsResult({ + clear, data: [ - makePlannedDayExclusion({ - id: 'ex-7', + makeDayAssertion({ + id: 'da-7', date: today, parentPersonalDataUuid: 'person-1', + childId: 'child-1', + dayType: 'none', }), ], }), @@ -2272,11 +3722,18 @@ describe('Calendar', () => { .find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) - const restoreButton = await screen.findByTestId('modal-restore-excluded-ex-7') + const restoreButton = await screen.findByTestId('modal-restore-excluded-da-7') expect(restoreButton).toHaveAttribute('data-parent-name', 'Alex') fireEvent.click(restoreButton) - await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-7')) + await waitFor(() => + expect(clear).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', + date: today, + }), + ) }) it('tells the popover which date and which parent "this day" would remove', async () => { @@ -2312,11 +3769,11 @@ describe('Calendar', () => { it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => { const today = todayDateString() - const removeDay = vi.fn() + const clear = vi.fn() + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], - remove: removeDay, }), ) @@ -2325,18 +3782,18 @@ describe('Calendar', () => { fireEvent.click(screen.getByTestId('period-delete-all-years')) await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()) - expect(removeDay).not.toHaveBeenCalled() + expect(clear).not.toHaveBeenCalled() }) it('"day" scope on an ad-hoc band removes only the clicked occurrence\'s first date', async () => { - const removeDay = vi.fn().mockResolvedValue(undefined) + const clear = vi.fn().mockResolvedValue(undefined) + vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ clear })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ makePlannedLeaveDay({ id: 'pd-1', date: '2027-03-10', wishId: null, parentPersonalDataUuid: 'person-1' }), makePlannedLeaveDay({ id: 'pd-2', date: '2027-03-11', wishId: null, parentPersonalDataUuid: 'person-1' }), ], - remove: removeDay, }), ) vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2027-03-10'), mockSetSearchParams]) @@ -2345,8 +3802,15 @@ describe('Calendar', () => { fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete-day')) - await waitFor(() => expect(removeDay).toHaveBeenCalledWith('pd-1')) - expect(removeDay).not.toHaveBeenCalledWith('pd-2') + await waitFor(() => + expect(clear).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', + date: '2027-03-10', + }), + ) + expect(clear).not.toHaveBeenCalledWith(expect.objectContaining({ date: '2027-03-11' })) }) describe('"year" scope on a recurring wish — one row, one delete', () => { @@ -2478,12 +3942,20 @@ describe('Calendar', () => { it('undo after editing one year of a series restores that row without re-materialising', async () => { const wish = seriesWish(2028) + // The forward save now goes through the atomic `syncWithWish` call; + // `wishesHook.update` is only ever called by the undo closure below. const update = vi.fn().mockResolvedValue(wish) + const syncWithWish = vi.fn().mockResolvedValue( + makePlanSyncWithWishResponse({ + wish, + sync: makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] }), + }), + ) const sync = vi .fn() .mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) - vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) + vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync, syncWithWish })) renderWithBand(wish, '2028-06-15') fireEvent.click(screen.getByTestId('period-save')) @@ -2491,8 +3963,8 @@ describe('Calendar', () => { const undoButton = await screen.findByTestId('calendar-toast-undo') fireEvent.click(undoButton) - await waitFor(() => expect(update).toHaveBeenCalledTimes(2)) - const [, undoPatch, undoScope] = update.mock.calls[1]! + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + const [, undoPatch, undoScope] = update.mock.calls[0]! expect(undoScope).toBe('occurrence') // The row was already part of a series, so its repeat-ness is restored // but the series-level end year is left alone (the backend rejects it diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index d369942..97d9316 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -10,7 +10,7 @@ import { useLeaveWishes, useWorkspaceMembers, usePlanSync, - usePlannedDayExclusions, + useDayAssertions, } from '@/hooks' import type { PlannedLeaveDay, @@ -22,6 +22,7 @@ import type { PersonalData, PlanChange, PlanSyncResponse, + PlanSyncWithWishResponse, WindowOutcome, } from '@/types' import { Layout } from '@/components/layout/Layout' @@ -35,6 +36,7 @@ import { Icon } from '@/components/common/Icon' import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName' import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates' import { renderRejection, type Translate } from '@/utils/rejectionMessages' +import { isApiError } from '@/services/errors' import { useTranslation } from '@/i18n' import './calendar.css' @@ -59,6 +61,55 @@ function splitISODate(date: string): { year: number; month: number; day: number return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 } } +/** The last calendar day of `month` (1-12) in `year` — Feb-aware via the + * "day 0 of next month" trick. Used to turn a §Planering month-bound pick + * (PeriodPopover's `boundStartMonth`/`boundEndMonth`) into the exact + * `window_end_day` `LeaveWish` stores (see `app/entities/leave_wish.py`'s + * `clamped_date`, which this mirrors on the frontend). */ +function lastDayOfMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate() +} + +/** §Planering (restructure step 4) — the four `window_start_*`/`window_end_*` + * fields to save, from the popover's `moveable` toggle: + * - off (`fixed`): the edited dates ARE the placement, unchanged from today. + * - on (`flexible`): the whole of `boundStartMonth`..`boundEndMonth` is the + * bounding range the allocator searches (§10.2) — day 1 of the first month + * through the last day of the second, wrapping into `start.year + 1` when + * `boundEndMonth < boundStartMonth` exactly like a Dec->Jan window does. + * `targetDays`/`targetWeeks` (set by the caller from the same edited dates) + * is what actually bounds the placement's length; this is only the search + * space. */ +function windowFieldsForSave( + payload: PeriodPopoverSavePayload, + start: { year: number; month: number; day: number }, + end: { year: number; month: number; day: number }, +): { + windowMode: 'fixed' | 'flexible' + windowStartMonth: number + windowStartDay: number + windowEndMonth: number + windowEndDay: number +} { + if (!payload.moveable) { + return { + windowMode: 'fixed', + windowStartMonth: start.month, + windowStartDay: start.day, + windowEndMonth: end.month, + windowEndDay: end.day, + } + } + const boundEndYear = payload.boundEndMonth < payload.boundStartMonth ? start.year + 1 : start.year + return { + windowMode: 'flexible', + windowStartMonth: payload.boundStartMonth, + windowStartDay: 1, + windowEndMonth: payload.boundEndMonth, + windowEndDay: lastDayOfMonth(boundEndYear, payload.boundEndMonth), + } +} + const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ /** Reads the `?date=` search param the dashboard's "years ahead" bands @@ -152,21 +203,46 @@ function patchFieldsFromWish(w: LeaveWish): LeaveWishUpdateRequest { /** Groups a sync's `changes` by the wish (or "adhoc") they belong to and * renders one "{name}: N dagar ändrades" fragment per group — house style - * is structured data in, composed copy out; the backend never sends prose. */ + * is structured data in, composed copy out; the backend never sends prose. + * + * A `removed` change with `evicted: true` — a row removed because a + * *different* wish's edit won a shared FP/vacation pool ahead of it, not + * because its own wish moved (see `PlanChange.evicted`'s doc comment) — is + * reported separately, grouped by `oldWishId` (the wish that lost the + * days), with its own "{name}: N dagar togs bort ..." fragment instead of + * being folded into the same silent "N dagar ändrades" count an ordinary + * change gets. `sync_plan` always writes now, so this is the only place + * displacement is surfaced — there is no separate rejection/preview to ask + * about it. */ function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Translate): string | null { if (changes.length === 0) return null + const ordinary = changes.filter((c) => !c.evicted) + const evicted = changes.filter((c) => c.evicted) + const parts: string[] = [] + const counts = new Map() - for (const change of changes) { + for (const change of ordinary) { const key = change.newWishId ?? change.oldWishId ?? 'adhoc' counts.set(key, (counts.get(key) ?? 0) + 1) } - const parts: string[] = [] for (const [wishId, count] of counts) { const wish = wishes.find((w) => w.id === wishId) const name = wish ? resolveWishName(wish, t) : t('calendar.toastGenericPeriod') parts.push(t('calendar.toastChangedDays', { name, count })) } - return parts.join(' · ') + + const evictedCounts = new Map() + for (const change of evicted) { + const key = change.oldWishId ?? 'adhoc' + evictedCounts.set(key, (evictedCounts.get(key) ?? 0) + 1) + } + for (const [wishId, count] of evictedCounts) { + const wish = wishes.find((w) => w.id === wishId) + const name = wish ? resolveWishName(wish, t) : t('calendar.toastGenericPeriod') + parts.push(t('calendar.toastDisplacedDays', { name, count })) + } + + return parts.length > 0 ? parts.join(' · ') : null } /** Renders a `WindowOutcome`'s shortfall for the touched wish, from @@ -242,10 +318,27 @@ function buildSyncToastMessage( if (changesMessage) parts.push(changesMessage) if (watchWishId) { - const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId) - if (outcome && outcome.shortfallDays > 0) { - const funded = result.changes.some((c) => c.newWishId === watchWishId || c.oldWishId === watchWishId) - const wish = watchWish ?? wishes.find((w) => w.id === watchWishId) + // Bug 4 fix (day-assertion-subsystem review) — a repeating wish + // materialises into ONE `LeaveWish` row PER YEAR, each with its own + // `wishId`, so a `sync_with_wish` call that (re-)projects a whole + // series funds/re-funds every sibling year in the SAME batch, not just + // `watchWishId`'s own occurrence. Checking only `watchWishId`'s outcome + // left a sibling year that came up 100% short completely silent: synced + // with `accepted: true`, no rejection, an empty band, and — because + // this loop never looked at it — no warning either, indistinguishable + // from "nothing changed". Match every outcome sharing `watchWish`'s + // `seriesId` (falling back to the exact wish id for a standalone, + // non-repeating wish, where there is no series to widen the match to). + const watchSeriesId = watchWish?.seriesId ?? wishes.find((w) => w.id === watchWishId)?.seriesId + const siblingOutcomes = result.proposal.outcomes.filter((o) => + watchSeriesId != null ? o.seriesId === watchSeriesId : o.wishId === watchWishId, + ) + for (const outcome of siblingOutcomes) { + if (outcome.shortfallDays <= 0) continue + const funded = result.changes.some( + (c) => c.newWishId === outcome.wishId || c.oldWishId === outcome.wishId, + ) + const wish = outcome.wishId === watchWishId ? watchWish : wishes.find((w) => w.id === outcome.wishId) parts.push(summarizeOutcomeShortfall(outcome, funded, wish, t)) } } @@ -277,9 +370,53 @@ function buildSyncToastMessage( return parts.length > 0 ? parts.join(' · ') : null } +/** Turns a caught error from `handleSavePinnedDay`/`handleSaveWish`/ + * `handleDeletePlannedDay` below into a translated message, instead of the + * raw `message_key` `err.message` would otherwise carry. + * + * `handleSavePinnedDay`'s and `handleDeletePlannedDay`'s endpoint + * (`POST`/`DELETE /api/day-assertions`) enforces the same guards the old + * `/api/planned-days` write used to (`app.services.day_guards. + * reject_illegal_free_day` for a hand-pin on a weekend/red day, + * `AnalyticsService.validate_dubbeldagar` for a dubbeldag), now inside + * `DayAssertionService`. A rejection raises a structured `message_key` + + * `data` pair directly as the `HTTPException` detail (§3.5) — decoded by + * `api.ts`'s `apiFetch` into `ApiRequestError.error.details.messageKey`/ + * `data`. `handleSaveWish`'s endpoint (`POST /api/plan/sync-with-wish`) + * never produces a `messageKey`, so its errors always fall through to the + * generic fallback below. Reuses `renderRejection`'s own lookup table (the + * same `messageKey`s the plan apply/sync toast above renders) rather than a + * second copy, so an unrecognised key still degrades to showing the raw + * key (same as an unrecognised plan-apply rejection) instead of crashing — + * never a hardcoded English fallback for a key this build simply doesn't + * know yet. A non-API error (network failure, etc.) falls back to + * `err.message`/`fallback` exactly as before this existed. + */ +function describePlannedDayError( + err: unknown, + t: Translate, + members: PersonalData[], + fallback: string, +): string { + if (isApiError(err)) { + const details = err.error.details as { messageKey?: string; data?: Record } | undefined + if (details?.messageKey) { + return renderRejection({ messageKey: details.messageKey, data: details.data ?? {} }, t, members) + } + } + return err instanceof Error ? err.message : fallback +} + export function Calendar(): ReactNode { const { t, locale } = useTranslation() - const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace() + const { + isLoading: wsLoading, + selectedChildId, + workspace, + personalDataUuid, + parentName, + bumpPlanVersion, + } = useWorkspace() const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined) const [showWeekNumbers] = useShowWeekNumbers() const [searchParams] = useSearchParams() @@ -311,6 +448,7 @@ export function Calendar(): ReactNode { const [isPeriodOpen, setIsPeriodOpen] = useState(false) const [isPeriodSaving, setIsPeriodSaving] = useState(false) const [periodError, setPeriodError] = useState(null) + const [isLockUpdating, setIsLockUpdating] = useState(false) const [isCreatingPeriod, setIsCreatingPeriod] = useState(false) const [flashWishId, setFlashWishId] = useState(null) const [toast, setToast] = useState(null) @@ -331,9 +469,15 @@ export function Calendar(): ReactNode { const wishesHook = useLeaveWishes(workspace?.id ?? null) const membersHook = useWorkspaceMembers(workspace?.id ?? null) const syncHook = usePlanSync() - // The days the user removed by hand. Read for the calendar's badges, and - // written by the popover's "this day" remove — see `excludeDay`. - const exclusionsHook = usePlannedDayExclusions(workspace?.id ?? null) + // Pins, ad-hoc days and exclusions (restructure step 3b — see + // `app/entities/day_assertion.py`). Read for the calendar's "removed day" + // badges, and written by the Advanced hand-pin path and the popover's + // "this day" remove — see `handleSavePinnedDay`/`excludeDay`. + const assertionsHook = useDayAssertions(workspace?.id ?? null) + const excludedAssertions = useMemo( + () => assertionsHook.data.filter((a) => a.dayType === 'none'), + [assertionsHook.data], + ) const calendarDaysByDate = useMemo(() => { const map = new Map() @@ -347,10 +491,36 @@ export function Calendar(): ReactNode { // took a day out here", which is what stops the hole reading as a bug. Whose // day it was is shown in the day popover, where there is room to say it. const excludedDates = useMemo( - () => new Set(exclusionsHook.data.map((e) => e.date)), - [exclusionsHook.data], + () => new Set(excludedAssertions.map((a) => a.date)), + [excludedAssertions], ) + // §Every band is a real event — the currently active band's own persisted + // `planned_leave_day` rows: for a wish-linked band, that occurrence's rows + // (`wishId` is unique per year, see `PlannedLeaveDay.wishId`); for an + // ad-hoc band, its rows by parent + date range (same filter + // `deleteAdhocRange` below uses). Feeds the popover's ad-hoc seed/lock-guard + // (`bandDays` prop) AND the pin/unpin + promotion logic in + // `handleSavePeriod` — one source of truth for "what does this band + // actually persist right now". Memoized so its identity stays stable + // across renders that don't touch `activeBand`/`plannedDaysHook.data` — + // PeriodPopover reseeds its form whenever this prop's identity changes. + const bandDays = useMemo(() => { + if (!activeBand) return [] + if (activeBand.wishId) { + return (plannedDaysHook.data ?? []).filter( + (pd) => pd.wishId === activeBand.wishId && pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid, + ) + } + const bandDates = new Set(datesInRange(activeBand.startDate, activeBand.endDate)) + return (plannedDaysHook.data ?? []).filter( + (pd) => + pd.wishId === null && + pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid && + bandDates.has(pd.date), + ) + }, [activeBand, plannedDaysHook.data]) + /** The display name for a band's owner. Used wherever the UI has to say * WHOSE day something is — above all the "this day" remove, which takes one * parent's day and leaves the partner's. Falls back the same way @@ -421,6 +591,55 @@ export function Calendar(): ReactNode { [syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast], ) + // §Step 1 (intent-vs-derived-output restructure) — the shared tail of the + // calendar's four create/edit-then-sync paths (drag-select, promoting an + // ad-hoc band, editing a wish-backed band, picking a preset): the wish + // create-or-update, the day-lock/detach list, and the sync now all run in + // ONE atomic backend call (`syncHook.syncWithWish` -> `POST /api/plan/ + // sync-with-wish` -> `PlanningService.sync_with_wish`), so there is no + // more separate "create the wish, then re-point/delete the days, then + // sync" sequence to compensate for if it fails partway. This refreshes the + // caches that call doesn't itself trigger a refetch for and renders the + // same toast `runSync` does; `result.wish` is used both to "watch" that + // wish's own outcome in the toast and as the argument to `buildUndo` + // (called only when the sync was actually accepted — a rejected call + // rolled back server-side, so there is nothing left to undo). Never + // throws itself — `syncWithWish` may throw (validation/network), which + // each caller still catches, exactly as the old wishesHook.add()/ + // plannedDaysHook.update() calls did. + const applyWishSyncResult = useCallback( + async ( + result: PlanSyncWithWishResponse, + buildUndo?: (wish: LeaveWish) => CalendarToast['undo'], + // Prepended to an accepted sync's own message (or shown alone when the + // sync produced none, e.g. a pure pin/unpin day action with no date/ + // type `PlanChange`) — used by `handleSetPeriodLocked`, where the + // click itself is the confirmation the user is waiting for, unlike a + // plain re-save with nothing actually placed differently. Never + // shown on a rejection; `buildSyncToastMessage`'s own rendering of + // `applyResult.rejections` already covers that case. + fallbackMessage?: string, + ): Promise => { + await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch(), wishesHook.refresh()]) + bumpPlanVersion() + const accepted = result.sync.applyResult.accepted + const syncMessage = buildSyncToastMessage( + result.sync, + wishesHook.data, + membersHook.data, + accepted ? result.wish.id : undefined, + t, + result.wish, + ) + const message = accepted + ? [fallbackMessage, syncMessage].filter((part): part is string => Boolean(part)).join(' · ') || null + : syncMessage + if (message) showToast(message, accepted && buildUndo ? buildUndo(result.wish) : null) + return result + }, + [plannedDaysHook, calendarHook, wishesHook, membersHook.data, t, showToast, bumpPlanVersion], + ) + // The one-time-per-workspace auto-sync that used to live here (so wishes // seeded/created elsewhere showed up as bands without a manual edit // first) is now `PlanSyncProvider`'s job — it runs globally, for every @@ -585,8 +804,11 @@ export function Calendar(): ReactNode { } // The "Advanced" escape hatch — pins an exact day type for an exact - // parent, writing `planned_leave_day` rows directly (today's pre-existing - // behaviour, unchanged except for the §todo14 lookup fix above). + // parent, via `POST /api/day-assertions` (restructure step 3b — this used + // to write `planned_leave_day` rows directly; the assertion now mirrors + // into that same table immediately, so the calendar still updates without + // waiting for a sync). `assertDay` is an upsert, so there is no more + // separate add/update branch — see §todo14's lookup fix, now moot. const handleSavePinnedDay = async (payload: { dayType: 'sjukpenning' | 'lagsta' | 'vacation' parentId: string @@ -619,33 +841,22 @@ export function Calendar(): ReactNode { setLastLeaveType(payload.dayType) for (const date of eligibleDates) { - const existingDay = getExistingDay(date, payload.parentId) - - if (existingDay) { - // §Task7 — a hand-edit through the day popover pins the day so a - // later `POST /api/plan/sync` never silently retypes it. - await plannedDaysHook.update(existingDay.id, { - day_type: payload.dayType, - is_dubbeldag: payload.isDubbeldag, - is_pinned: true, - }) - } else { - await plannedDaysHook.add({ - workspace_id: workspace.id, - child_id: selectedChildId, - parent_personal_data_uuid: payload.parentId, - date, - day_type: payload.dayType, - is_dubbeldag: payload.isDubbeldag, - is_pinned: true, - }) - } + // §Task7 — a hand-edit through the day popover asserts the day so a + // later `POST /api/plan/sync` never silently retypes it. + await assertionsHook.assertDay({ + workspaceId: workspace.id, + childId: selectedChildId, + parentPersonalDataUuid: payload.parentId, + date, + dayType: payload.dayType, + isDubbeldag: payload.isDubbeldag, + }) } setIsModalOpen(false) setSelectedDates(new Set()) } catch (err) { - setModalError(err instanceof Error ? err.message : t('calendar.saveError')) + setModalError(describePlannedDayError(err, t, membersHook.data, t('calendar.saveError'))) } finally { setIsSaving(false) } @@ -654,7 +865,10 @@ export function Calendar(): ReactNode { // §todo10/11 default path — the planner decides funding. Builds a fixed, // one-off `LeaveWish` spanning every selected date and lets the existing // sync machinery place it, exactly like `handleSelectPreset` below (same - // add-then-sync-then-undo shape). + // create-then-sync shape). The wish create and its funding sync are one + // atomic backend call (see `applyWishSyncResult`'s doc comment) — a + // rejected sync is rolled back server-side, so there is no client-side + // compensation left to do here. const handleSaveWish = async (payload: { parentMode: 'either' | 'both' | 'specific' parentPersonalDataUuid: string | null @@ -669,47 +883,46 @@ export function Calendar(): ReactNode { const start = splitISODate(first) const end = splitISODate(last) const dayCount = orderedDates.length + const workspaceId = workspace.id try { setIsSaving(true) setModalError(null) - const created = await wishesHook.add({ - workspaceId: workspace.id, - childId: selectedChildId, - name: t('wishEditor.manualPeriodName'), - nameMessageKey: 'wishEditor.manualPeriodName', - windowStartMonth: start.month, - windowStartDay: start.day, - windowEndMonth: end.month, - windowEndDay: end.day, - targetWeeks: Math.max(1, Math.ceil(dayCount / 7)), - targetDays: dayCount, - recurrence: 'once', - firstYear: start.year, - priority: wishesHook.data.length + 1, - parentMode: payload.parentMode, - parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null, - windowMode: 'fixed', - wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false, + const result = await syncHook.syncWithWish({ + workspaceId, + wishCreate: { + childId: selectedChildId, + name: t('wishEditor.manualPeriodName'), + nameMessageKey: 'wishEditor.manualPeriodName', + windowStartMonth: start.month, + windowStartDay: start.day, + windowEndMonth: end.month, + windowEndDay: end.day, + targetWeeks: Math.max(1, Math.ceil(dayCount / 7)), + targetDays: dayCount, + recurrence: 'once', + firstYear: start.year, + priority: wishesHook.data.length + 1, + parentMode: payload.parentMode, + parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null, + windowMode: 'fixed', + wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false, + }, }) setIsModalOpen(false) setSelectedDates(new Set()) - await runSync(workspace.id, { - watchWishId: created.id, - watchWish: created, - undo: { - label: t('calendar.undoButton'), - run: async () => { - await wishesHook.remove(created.id) - await runSync(workspace.id, { silent: true }) - }, + await applyWishSyncResult(result, (wish) => ({ + label: t('calendar.undoButton'), + run: async () => { + await wishesHook.remove(wish.id) + await runSync(workspaceId, { silent: true }) }, - }) + })) } catch (err) { - setModalError(err instanceof Error ? err.message : t('calendar.saveError')) + setModalError(describePlannedDayError(err, t, membersHook.data, t('calendar.saveError'))) } finally { setIsSaving(false) } @@ -727,14 +940,21 @@ export function Calendar(): ReactNode { if (!date) return const existingDay = getExistingDay(date, advancedParentId) - if (existingDay) { - await plannedDaysHook.remove(existingDay.id) + if (existingDay && workspace?.id) { + // Clears the assertion (not merely unpins it) and its mirrored row — + // restructure step 3b, see `useDayAssertions.clear`. + await assertionsHook.clear({ + workspaceId: workspace.id, + childId: existingDay.childId, + parentPersonalDataUuid: existingDay.parentPersonalDataUuid, + date: existingDay.date, + }) } setIsModalOpen(false) setSelectedDates(new Set()) } catch (err) { - setModalError(err instanceof Error ? err.message : t('calendar.deleteError')) + setModalError(describePlannedDayError(err, t, membersHook.data, t('calendar.deleteError'))) } finally { setIsSaving(false) } @@ -752,8 +972,94 @@ export function Calendar(): ReactNode { setPeriodError(null) } + // §Every calendar band is a real event — an ad-hoc band (no wish behind + // it) is PROMOTED here: a new `LeaveWish` is created from the edited + // fields, and the band's existing (ad-hoc, already-pinned — the "Advanced" + // hand-pin escape hatch) rows either become that wish's exact days + // (`moveable: false`, an 'attach' day action re-points them and keeps them + // pinned exactly as they already are) or are handed back to the planner to + // search fresh inside the chosen month bound (`moveable: true`, a 'detach' + // day action — the old exact placement can't survive a widened bound). + // Locking/unlocking specific days is a separate action now (see + // `handleSetPeriodLocked`), not bundled into this save. The wish create, + // the day-lock/detach list, and the funding sync are one atomic backend + // call — see `applyWishSyncResult`'s doc comment. + const handlePromoteAdhocBand = async (payload: PeriodPopoverSavePayload, workspaceId: string) => { + if (!selectedChildId) return + + const start = splitISODate(payload.startDate) + const end = splitISODate(payload.endDate) + const dayCount = datesInRange(payload.startDate, payload.endDate).length + const windowFields = windowFieldsForSave(payload, start, end) + const snapshot = bandDays.map((pd) => ({ ...pd })) + + try { + setIsPeriodSaving(true) + setPeriodError(null) + + const result = await syncHook.syncWithWish({ + workspaceId, + wishCreate: { + childId: selectedChildId, + name: payload.name || t('wishEditor.manualPeriodName'), + nameMessageKey: payload.name ? null : 'wishEditor.manualPeriodName', + ...windowFields, + targetWeeks: Math.max(1, Math.ceil(dayCount / 7)), + targetDays: dayCount, + recurrence: payload.recurrence, + firstYear: start.year, + ...(payload.recurrence === 'yearly' ? { lastYear: payload.lastYear } : {}), + priority: wishesHook.data.length + 1, + parentMode: payload.parentMode, + parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null, + wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false, + }, + dayActions: snapshot.map((pd) => ({ + plannedDayId: pd.id, + action: payload.moveable ? 'detach' : 'attach', + })), + }) + + closePeriodPopover() + + await applyWishSyncResult(result, (wish) => ({ + label: t('calendar.undoButton'), + run: async () => { + // Deleting the wish also deletes every `planned_leave_day` row + // still pointing at it (see `LeaveWishService.delete`) — for the + // 'locked' path that includes the rows just re-pointed above, so + // this alone undoes the reassignment; either way the ad-hoc rows + // are then recreated fresh, exactly as `deleteAdhocRange`'s undo + // does. + await wishesHook.remove(wish.id, 'series') + for (const pd of snapshot) { + await assertionsHook.assertDay({ + workspaceId: pd.workspaceId, + childId: pd.childId, + parentPersonalDataUuid: pd.parentPersonalDataUuid, + date: pd.date, + dayType: pd.dayType, + isDubbeldag: pd.isDubbeldag, + }) + } + await runSync(workspaceId, { silent: true }) + }, + })) + } catch (err) { + setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError')) + } finally { + setIsPeriodSaving(false) + } + } + const handleSavePeriod = async (payload: PeriodPopoverSavePayload) => { - if (!activeBand?.wishId || !workspace?.id) return + if (!workspace?.id || !activeBand) return + + if (activeBand.wishId === null) { + await handlePromoteAdhocBand(payload, workspace.id) + return + } + const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId) if (!previousWish) return @@ -767,27 +1073,23 @@ export function Calendar(): ReactNode { // the clicked year into `firstYear` would have dropped the earlier // occurrences; there is nothing to preserve now. // - // The popover always hands back exact dates — once the user has - // typed/dragged them, those dates ARE the placement, not a range for the - // planner to search inside. So this save must also flip the row to - // `windowMode: 'fixed'` with a target matching the edited span; leaving - // it `'flexible'` (its state from a preset chip) turns the now - // week-long bound into a single flexible candidate, which repeats the - // same calendar dates every year on a drifting weekday instead of - // preserving the weekday. + // §Planering (step 4) — `moveable: false` (the popover's default) pins + // the edited dates exactly, `'fixed'` with a target matching the edited + // span, unchanged from before. `moveable: true` hands the placement back + // to the search across the chosen month bound (`'flexible'`) — the + // edited dates now only set the wanted LENGTH. Locking/unlocking day + // TYPES is a separate action (`onSetLocked`/`handleSetPeriodLocked`), no + // longer bundled into this save at all. + const windowFields = windowFieldsForSave(payload, start, end) const patch: LeaveWishUpdateRequest = { name: payload.name, nameMessageKey: null, - windowStartMonth: start.month, - windowStartDay: start.day, - windowEndMonth: end.month, - windowEndDay: end.day, + ...windowFields, firstYear: start.year, recurrence: payload.recurrence, parentMode: payload.parentMode, parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null, wantsDubbeldag: payload.wantsDubbeldag, - windowMode: 'fixed', targetDays: dayCount, targetWeeks: Math.max(1, Math.ceil(dayCount / 7)), } @@ -802,27 +1104,30 @@ export function Calendar(): ReactNode { try { setIsPeriodSaving(true) setPeriodError(null) - await wishesHook.update(previousWish.id, patch, payload.scope) - closePeriodPopover() - await runSync(workspace.id, { - watchWishId: previousWish.id, - undo: { - label: t('calendar.undoButton'), - run: async () => { - // Restores this row's own fields at the same scope the edit used. - // A series-wide edit's undo re-projects the siblings from these - // values, which is as faithful as the shape allows: per-year - // divergence the user had before an "every year" save is gone - // once that save lands. - await wishesHook.update( - previousWish.id, - patchFromWish(previousWish, payload.scope), - payload.scope, - ) - await runSync(workspace.id, { silent: true }) - }, - }, + + const result = await syncHook.syncWithWish({ + workspaceId: workspace.id, + wishUpdate: { leaveWishId: previousWish.id, scope: payload.scope, patch }, }) + + closePeriodPopover() + + await applyWishSyncResult(result, () => ({ + label: t('calendar.undoButton'), + run: async () => { + // Restores this row's own fields at the same scope the edit used. + // A series-wide edit's undo re-projects the siblings from these + // values, which is as faithful as the shape allows: per-year + // divergence the user had before an "every year" save is gone + // once that save lands. + await wishesHook.update( + previousWish.id, + patchFromWish(previousWish, payload.scope), + payload.scope, + ) + await runSync(workspace.id, { silent: true }) + }, + })) } catch (err) { setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError')) } finally { @@ -830,6 +1135,122 @@ export function Calendar(): ReactNode { } } + // §Planering (step 4) — locks or unlocks every one of the clicked band's + // own persisted rows at once. This is the replacement for the old "Låst" + // radio: an ACTION on the days that actually exist, not a mode about a + // selection that might have changed — so it needs no `lockedDisabled` + // guard, and can freely leave a band PARTIALLY locked. Closes the popover + // on success (like `excludeDay`/`deleteAdhocRange`/`handleDeletePlannedDay` + // elsewhere in this file): `bandDays` changing underneath an open popover + // would otherwise reseed the whole form and discard any unsaved edit. + // + // Wish-backed band (`activeBand.wishId` set): routed through the SAME + // atomic `POST /api/plan/sync-with-wish` call `handleSavePeriod`/ + // `handlePromoteAdhocBand` use, with `dayActions` of 'pin'/'unpin' — + // *not* a loop of individual `useDayAssertions.assertDay`/`clear` calls. + // Two reasons this isn't optional: + // - `clear` (`DELETE /api/day-assertions`) doesn't just retract the + // pin, it deletes the mirrored `planned_leave_day` row outright (see + // `DayAssertionService.clear`) — so "unlock" reads as "delete the + // row", and the band vanishes from the calendar until some UNRELATED + // sync happens to run and re-place the wish's still-open demand. + // `sync_with_wish`'s 'unpin' action only retracts the assertion + // (`PlanningService._clear_pin`) and runs `sync_plan` in the SAME + // unit of work, so the freed capacity is re-funded (or honestly + // reported as a shortfall) immediately, atomically, every time. + // - a bare loop of awaited calls with a toast at the end has no + // per-call success check (see the toast below) — one atomic call + // collapses that to a single response `applyWishSyncResult` already + // knows how to gate a toast on. + // Ad-hoc band (no wish): unchanged direct `DayAssertion` write/clear — + // there is no wish demand behind these hand-placed rows for a sync to + // re-derive, so "unlock" really does mean "delete the day" here, same as + // before. + const handleSetPeriodLocked = async (locked: boolean) => { + if (!workspace?.id) return + const workspaceId = workspace.id + const toChange = bandDays.filter((pd) => pd.isPinned !== locked) + if (toChange.length === 0) { + closePeriodPopover() + return + } + const wishId = activeBand?.wishId ?? null + + try { + setIsLockUpdating(true) + setPeriodError(null) + + if (wishId) { + const runLockSync = (action: 'pin' | 'unpin') => + syncHook.syncWithWish({ + workspaceId, + wishUpdate: { leaveWishId: wishId, patch: {} }, + dayActions: toChange.map((pd) => ({ plannedDayId: pd.id, action })), + }) + + const result = await runLockSync(locked ? 'pin' : 'unpin') + closePeriodPopover() + await applyWishSyncResult( + result, + () => ({ + label: t('calendar.undoButton'), + run: async () => { + const undone = await runLockSync(locked ? 'unpin' : 'pin') + await Promise.all([ + plannedDaysHook.refresh(), + calendarHook.refetch(), + wishesHook.refresh(), + ]) + bumpPlanVersion() + if (!undone.sync.applyResult.accepted) { + throw new Error(t('calendar.undoError')) + } + }, + }), + t(locked ? 'calendar.toastDaysLocked' : 'calendar.toastDaysUnlocked', { count: toChange.length }), + ) + } else { + const lockRow = (pd: PlannedLeaveDay) => + assertionsHook.assertDay({ + workspaceId: pd.workspaceId, + childId: pd.childId, + parentPersonalDataUuid: pd.parentPersonalDataUuid, + date: pd.date, + dayType: pd.dayType, + isDubbeldag: pd.isDubbeldag, + wishId: pd.wishId, + }) + const unlockRow = (pd: PlannedLeaveDay) => + assertionsHook.clear({ + workspaceId: pd.workspaceId, + childId: pd.childId, + parentPersonalDataUuid: pd.parentPersonalDataUuid, + date: pd.date, + }) + + for (const pd of toChange) { + await (locked ? lockRow(pd) : unlockRow(pd)) + } + closePeriodPopover() + showToast( + t(locked ? 'calendar.toastDaysLocked' : 'calendar.toastDaysUnlocked', { count: toChange.length }), + { + label: t('calendar.undoButton'), + run: async () => { + for (const pd of toChange) { + await (locked ? unlockRow(pd) : lockRow(pd)) + } + }, + }, + ) + } + } catch (err) { + setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError')) + } finally { + setIsLockUpdating(false) + } + } + // §todo9 "this period, every year" — removes every row of the series (or // just the one row, when the period does not repeat). Undo recreates the // repeating wish from the clicked occurrence's fields; the rows get fresh @@ -869,7 +1290,12 @@ export function Calendar(): ReactNode { ) const snapshot = toDelete.map((pd) => ({ ...pd })) for (const pd of toDelete) { - await plannedDaysHook.remove(pd.id) + await assertionsHook.clear({ + workspaceId: pd.workspaceId, + childId: pd.childId, + parentPersonalDataUuid: pd.parentPersonalDataUuid, + date: pd.date, + }) } closePeriodPopover() if (snapshot.length > 0) { @@ -877,14 +1303,13 @@ export function Calendar(): ReactNode { label: t('calendar.undoButton'), run: async () => { for (const pd of snapshot) { - await plannedDaysHook.add({ - workspace_id: pd.workspaceId, - child_id: pd.childId, - parent_personal_data_uuid: pd.parentPersonalDataUuid, + await assertionsHook.assertDay({ + workspaceId: pd.workspaceId, + childId: pd.childId, + parentPersonalDataUuid: pd.parentPersonalDataUuid, date: pd.date, - day_type: pd.dayType, - is_dubbeldag: pd.isDubbeldag, - is_pinned: pd.isPinned, + dayType: pd.dayType, + isDubbeldag: pd.isDubbeldag, }) } }, @@ -914,13 +1339,13 @@ export function Calendar(): ReactNode { /** §"removing a single day should stay permanent" — the planner-placed * counterpart of `deleteAdhocRange`. An auto-placed day cannot simply be * deleted: the wish behind it still asks for the whole window, so the next - * sync would re-derive it. Recording a `PlannedDayExclusion` is what makes - * the removal outlive the sync. + * sync would re-derive it. Asserting `dayType: 'none'` is what makes the + * removal outlive the sync (restructure step 3b). * * Keyed on the band's own parent, so a both-parents period loses only the * band that was clicked and the partner keeps their day — the toast names - * the parent so that choice is never a silent one. Undo deletes the - * exclusion and re-syncs, which puts the day back where it was. + * the parent so that choice is never a silent one. Undo clears the + * assertion and re-syncs, which puts the day back where it was. */ const excludeDay = async ( workspaceId: string, @@ -936,11 +1361,12 @@ export function Calendar(): ReactNode { return } - const created = await exclusionsHook.exclude({ + await assertionsHook.assertDay({ workspaceId, childId, parentPersonalDataUuid: band.parentPersonalDataUuid, date: band.clickedDate, + dayType: 'none', }) closePeriodPopover() await runSync(workspaceId, { silent: true }) @@ -949,7 +1375,12 @@ export function Calendar(): ReactNode { showToast(t('calendar.toastDayExcluded', { date: dateLabel, parent: parentLabel }), { label: t('calendar.undoButton'), run: async () => { - await exclusionsHook.restore(created.id) + await assertionsHook.clear({ + workspaceId, + childId, + parentPersonalDataUuid: band.parentPersonalDataUuid, + date: band.clickedDate, + }) await runSync(workspaceId, { silent: true }) showToast(t('calendar.toastDayRestored', { date: dateLabel, parent: parentLabel }), null) }, @@ -961,20 +1392,24 @@ export function Calendar(): ReactNode { * button. See `excludeDay` for the other direction. */ const handleRestoreExcluded = async (exclusionId: string) => { if (!workspace?.id) return - const entry = exclusionsHook.data.find((e) => e.id === exclusionId) - await exclusionsHook.restore(exclusionId) + const entry = excludedAssertions.find((a) => a.id === exclusionId) + if (!entry) return + await assertionsHook.clear({ + workspaceId: workspace.id, + childId: entry.childId, + parentPersonalDataUuid: entry.parentPersonalDataUuid, + date: entry.date, + }) setIsModalOpen(false) setSelectedDates(new Set()) await runSync(workspace.id, { silent: true }) - if (entry) { - showToast( - t('calendar.toastDayRestored', { - date: formatDateForDisplay(entry.date, locale), - parent: resolveParentName(entry.parentPersonalDataUuid), - }), - null, - ) - } + showToast( + t('calendar.toastDayRestored', { + date: formatDateForDisplay(entry.date, locale), + parent: resolveParentName(entry.parentPersonalDataUuid), + }), + null, + ) } const handleDeletePeriod = async (scope: PeriodDeleteScope) => { @@ -1029,50 +1464,50 @@ export function Calendar(): ReactNode { const handleSelectPreset = async (preset: SeasonPreset) => { if (!workspace?.id || !selectedChildId) return + const workspaceId = workspace.id try { setIsCreatingPeriod(true) - const created = await wishesHook.add({ - workspaceId: workspace.id, - childId: selectedChildId, - name: t(preset.labelKey), - nameMessageKey: preset.labelKey, - windowStartMonth: preset.startMonth, - windowStartDay: preset.startDay, - windowEndMonth: preset.endMonth, - windowEndDay: preset.endDay, - targetWeeks: preset.defaultWeeks, - recurrence: 'yearly', - firstYear: new Date().getFullYear(), - priority: wishesHook.data.length + 1, - parentMode: membersHook.data.length >= 2 ? 'both' : 'either', - windowMode: 'flexible', - }) - const result = await runSync(workspace.id, { - watchWishId: created.id, - watchWish: created, - undo: { - label: t('calendar.undoButton'), - run: async () => { - // The preset is a repeating wish, so `created` is only its first - // year — undo has to take the whole series with it. - await wishesHook.remove(created.id, 'series') - await runSync(workspace.id, { silent: true }) - }, + const result = await syncHook.syncWithWish({ + workspaceId, + wishCreate: { + childId: selectedChildId, + name: t(preset.labelKey), + nameMessageKey: preset.labelKey, + windowStartMonth: preset.startMonth, + windowStartDay: preset.startDay, + windowEndMonth: preset.endMonth, + windowEndDay: preset.endDay, + targetWeeks: preset.defaultWeeks, + recurrence: 'yearly', + firstYear: new Date().getFullYear(), + priority: wishesHook.data.length + 1, + parentMode: membersHook.data.length >= 2 ? 'both' : 'either', + windowMode: 'flexible', }, }) - if (result) { - const placedDates = result.changes - .filter((c) => c.newWishId === created.id) + await applyWishSyncResult(result, (wish) => ({ + label: t('calendar.undoButton'), + run: async () => { + // The preset is a repeating wish, so `wish` is only its first + // year — undo has to take the whole series with it. + await wishesHook.remove(wish.id, 'series') + await runSync(workspaceId, { silent: true }) + }, + })) + + if (result.sync.applyResult.accepted) { + const placedDates = result.sync.changes + .filter((c) => c.newWishId === result.wish.id) .map((c) => c.date) .sort() const earliest = placedDates[0] if (earliest) { const { year: placedYear, month: placedMonth } = splitISODate(earliest) setCurrentMonth(new Date(placedYear, placedMonth - 1, 1)) - setFlashWishId(created.id) + setFlashWishId(result.wish.id) const calendarEl = document.querySelector('[data-testid="calendar"]') if (calendarEl && typeof calendarEl.scrollIntoView === 'function') { calendarEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) @@ -1287,7 +1722,7 @@ export function Calendar(): ReactNode { onSaveWish={handleSaveWish} onSavePinned={handleSavePinnedDay} onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined} - excludedEntries={exclusionsHook.data + excludedEntries={excludedAssertions .filter((e) => e.date === firstSelectedDate) .map((e) => ({ id: e.id, parentName: resolveParentName(e.parentPersonalDataUuid) }))} onRestoreExcluded={handleRestoreExcluded} @@ -1311,6 +1746,7 @@ export function Calendar(): ReactNode { wish={activeWish} startDate={activeBand.startDate} endDate={activeBand.endDate} + bandDays={bandDays} removeDayLabel={formatDateForDisplay(activeBand.clickedDate, locale)} removeDayParentName={resolveParentName(activeBand.parentPersonalDataUuid)} parents={parentsForModals} @@ -1318,6 +1754,8 @@ export function Calendar(): ReactNode { error={periodError} onSave={handleSavePeriod} onDelete={handleDeletePeriod} + onSetLocked={handleSetPeriodLocked} + isLockUpdating={isLockUpdating} onClose={closePeriodPopover} /> )} diff --git a/frontend/src/pages/Onboarding.test.tsx b/frontend/src/pages/Onboarding.test.tsx index 3d27d89..2775cf8 100644 --- a/frontend/src/pages/Onboarding.test.tsx +++ b/frontend/src/pages/Onboarding.test.tsx @@ -260,6 +260,26 @@ describe('Onboarding', () => { expect(screen.getByTestId('onboarding-step-family')).toBeInTheDocument() }) + it('shows a translated message when the workspace already has two parents', async () => { + const user = await advanceToFamilyStep() + const api = await import('@/services/api') + vi.mocked(api.addPartner).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.add.maxReached', + message: 'max reached', + details: { messageKey: 'workspace.parents.add.maxReached' }, + }) + ) + + await user.click(screen.getByTestId('onboarding-family-couple')) + await user.type(screen.getByLabelText(/Den andra förälderns namn/), 'Sam') + await user.click(screen.getByRole('button', { name: 'Fortsätt' })) + + expect( + await screen.findByText('Går inte att lägga till fler föräldrar — den här familjen har redan två.'), + ).toBeInTheDocument() + }) + it('falls back to the generic add-partner error for an unrecognised structured error', async () => { const user = await advanceToFamilyStep() const api = await import('@/services/api') diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx index 7c48835..9111744 100644 --- a/frontend/src/pages/Onboarding.tsx +++ b/frontend/src/pages/Onboarding.tsx @@ -23,6 +23,7 @@ type Translate = (key: TranslationKey, params?: TranslationParams) => string const ADD_PARTNER_ERROR_KEYS: Record = { 'workspace.notFound': 'settings.partnerActionErrorNotFound', 'workspace.parents.invalidName': 'settings.partnerNameInvalid', + 'workspace.parents.add.maxReached': 'settings.partnerActionErrorMaxReached', } function describeAddPartnerError(err: unknown, t: Translate, fallback: string): string { diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index 0f6bd8c..ce6b670 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -23,7 +23,6 @@ import { makeParentYearBalance, makeVacationYearBalance, makePlanSyncContextValue, - makeEvictionPreview, } from '../../tests/fixtures' vi.mock('@/hooks') @@ -555,6 +554,22 @@ describe('Settings', () => { expect(message).toHaveTextContent('Partnerns namn är ogiltigt.') }) + it('shows a translated add-partner error when the workspace already has two parents', async () => { + vi.mocked(api.addPartner).mockRejectedValue( + new ApiRequestError(422, { + code: 'workspace.parents.add.maxReached', + message: 'workspace.parents.add.maxReached', + details: { messageKey: 'workspace.parents.add.maxReached', data: {} }, + }), + ) + renderSettings() + fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } }) + fireEvent.click(screen.getByTestId('add-partner-button')) + + const message = await screen.findByTestId('save-message') + expect(message).toHaveTextContent('Går inte att lägga till fler föräldrar — den här familjen har redan två.') + }) + describe('workspace members', () => { beforeEach(() => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( @@ -954,9 +969,11 @@ describe('Settings', () => { // Simulates `PlanSyncProvider`'s background sync (triggered by the // `bumpPlanVersion()` call above) completing: its context value now - // carries a newer `latestProposalVersion` and no eviction. + // carries a newer `latestProposalVersion`. `sync_plan` always writes + // (displacement is informational, never a refusal), so a landed sync + // is always reported as a success. vi.mocked(usePlanSyncContextOptional).mockReturnValue( - makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: null }), + makePlanSyncContextValue({ latestProposalVersion: 1 }), ) rerender() @@ -966,31 +983,14 @@ describe('Settings', () => { expect(screen.getByTestId('recompute-plan-button')).toHaveTextContent('Räkna om planen') }) - it('reports that nothing was saved when the triggered sync would evict', async () => { - const { rerender } = renderSettings() - - fireEvent.click(screen.getByTestId('recompute-plan-button')) - - vi.mocked(usePlanSyncContextOptional).mockReturnValue( - makePlanSyncContextValue({ - latestProposalVersion: 1, - pendingEvictionPreview: makeEvictionPreview({ wouldEvict: true }), - }), - ) - rerender() - - const message = await screen.findByTestId('save-message') - expect(message).toHaveTextContent('Omräkningen skulle ta bort redan inplanerade dagar') - }) - - it('ignores a version bump/eviction from an unrelated sync while idle', () => { + it('ignores a version bump from an unrelated sync while idle', () => { const { rerender } = renderSettings() // No recompute click happened — `latestProposalVersion` moving on its // own (e.g. a vacation-day autosave elsewhere bumped `planVersion`) // must not surface a message this button never triggered. vi.mocked(usePlanSyncContextOptional).mockReturnValue( - makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: null }), + makePlanSyncContextValue({ latestProposalVersion: 1 }), ) rerender() diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index cddd81e..5c2067e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -50,6 +50,7 @@ const PARTNER_ERROR_KEYS: Record = { 'workspace.parents.invalidName': 'settings.partnerNameInvalid', 'workspace.parents.remove.self': 'settings.removePartnerErrorSelf', 'workspace.parents.remove.hasLogin': 'settings.removePartnerErrorHasLogin', + 'workspace.parents.add.maxReached': 'settings.partnerActionErrorMaxReached', } // `hasDependentData`'s `data.blockers` is a subset of these four kinds — @@ -280,7 +281,6 @@ export function Settings(): ReactNode { // `PlanSyncProvider.tsx`. const planSyncContext = usePlanSyncContextOptional() const latestProposalVersion = planSyncContext?.latestProposalVersion ?? 0 - const pendingEvictionPreview = planSyncContext?.pendingEvictionPreview ?? null // The `latestProposalVersion` the recompute button's own triggered sync // should land past — `null` while no recompute is in flight, so a sync // started by something else entirely (e.g. a vacation-day autosave @@ -306,16 +306,9 @@ export function Settings(): ReactNode { recomputeTimeoutRef.current = null } setIsRecomputing(false) - if (pendingEvictionPreview) { - // `wouldEvict === true` means the sync this button triggered wrote - // NOTHING (see `EvictionPreview`'s doc comment) — never report that - // as a success. - setSaveMessage({ type: 'error', text: t('settings.recomputeEvicted') }) - } else { - setSaveMessage({ type: 'success', text: t('settings.recomputeSuccess') }) - } + setSaveMessage({ type: 'success', text: t('settings.recomputeSuccess') }) setTimeout(() => setSaveMessage(null), 3000) - }, [latestProposalVersion, pendingEvictionPreview, t]) + }, [latestProposalVersion, t]) // Settings isn't scoped to a single child the way Onboarding/Dashboard are // — the balance flow needs somewhere to attach a saved balance even before @@ -599,9 +592,10 @@ export function Settings(): ReactNode { // Manual recompute: bumping `planVersion` is the whole implementation — // `PlanSyncProvider` (mounted app-wide) turns the bump into a // `POST /api/plan/sync` itself. Calling `api.syncPlan` directly here - // instead would bypass that provider's burst-coalescing and its - // `pendingEvictionPreview` handling. See the effect above for how the - // outcome (success vs. the sync writing nothing) gets reported. + // instead would bypass that provider's burst-coalescing. `sync_plan` + // always writes now (displacement is informational, never a refusal — + // see `PlanChange.evicted`), so the effect above only ever reports + // success once the triggered sync's proposal version lands. const handleRecompute = async () => { recomputeBaselineRef.current = latestProposalVersion setIsRecomputing(true) diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css index f8e65bb..543977a 100644 --- a/frontend/src/pages/calendar.css +++ b/frontend/src/pages/calendar.css @@ -351,9 +351,9 @@ color: var(--c-text-muted); } -/* A day the user removed from the plan by hand (see PlannedDayExclusion): - * marked, not hidden — an empty day in the middle of an otherwise-funded - * week would read as a planner bug. */ +/* A day the user removed from the plan by hand (a DayAssertion with + * dayType: 'none'): marked, not hidden — an empty day in the middle of an + * otherwise-funded week would read as a planner bug. */ .calendar-day--excluded .calendar-day__number { text-decoration: line-through; color: var(--c-text-muted); diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index c5506a9..30af8e6 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -617,81 +617,102 @@ describe('API response mappers (snake_case → camelCase)', () => { }) }) - describe('planned-day exclusions', () => { - it('posts snake_case and maps the created exclusion back to camelCase', async () => { + describe('day assertions', () => { + it('posts snake_case and maps the created assertion back to camelCase', async () => { fetchMock.mockResolvedValue({ ok: true, status: 201, json: async () => ({ - id: 'ex-1', + id: 'da-1', workspace_id: 'ws1', child_id: 'child-1', parent_personal_data_uuid: 'parent-1', date: '2027-02-03', + day_type: 'vacation', + is_dubbeldag: false, + wish_id: null, created_at: '2027-01-01T00:00:00', updated_at: '2027-01-01T00:00:00', }), }) - const result = await api.createPlannedDayExclusion({ + const result = await api.assertDay({ workspaceId: 'ws1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2027-02-03', + dayType: 'vacation', }) const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toContain('/planned-day-exclusions') + expect(url).toContain('/day-assertions') expect(options.method).toBe('POST') expect(JSON.parse(options.body as string)).toEqual({ workspace_id: 'ws1', child_id: 'child-1', parent_personal_data_uuid: 'parent-1', date: '2027-02-03', + day_type: 'vacation', + is_dubbeldag: false, + wish_id: null, }) expect(result).toEqual({ - id: 'ex-1', + id: 'da-1', workspaceId: 'ws1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2027-02-03', + dayType: 'vacation', + isDubbeldag: false, + wishId: null, createdAt: '2027-01-01T00:00:00', updatedAt: '2027-01-01T00:00:00', }) }) - it('maps a listed exclusion to camelCase', async () => { + it('maps a listed assertion to camelCase', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => [ { - id: 'ex-2', + id: 'da-2', workspace_id: 'ws1', child_id: 'child-1', parent_personal_data_uuid: 'parent-2', date: '2027-02-04', + day_type: 'none', + is_dubbeldag: false, + wish_id: null, created_at: null, updated_at: null, }, ], }) - const result = await api.listPlannedDayExclusionsByWorkspace('ws1') + const result = await api.listDayAssertionsByWorkspace('ws1') const [url] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toContain('/planned-day-exclusions/workspace/ws1') + expect(url).toContain('/day-assertions/workspace/ws1') expect(result[0]!.parentPersonalDataUuid).toBe('parent-2') + expect(result[0]!.dayType).toBe('none') expect(result[0]!.createdAt).toBeNull() }) - it('deletes an exclusion by id (the way back)', async () => { + it('clears an assertion by natural key (the way back)', async () => { fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => null }) - await api.deletePlannedDayExclusion('ex-1') + await api.clearDayAssertion({ + workspaceId: 'ws1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2027-02-03', + }) const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toContain('/planned-day-exclusions/ex-1') + expect(url).toContain('/day-assertions?') + expect(url).toContain('workspace_id=ws1') + expect(url).toContain('date=2027-02-03') expect(options.method).toBe('DELETE') }) }) @@ -735,7 +756,12 @@ describe('apiFetch (shared request/response handling)', () => { it('returns undefined for a 204 No Content response', async () => { fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) }) - const result = await api.deletePlannedDay('pd1') + const result = await api.clearDayAssertion({ + workspaceId: 'ws1', + childId: 'child1', + parentPersonalDataUuid: 'parent1', + date: '2027-02-03', + }) expect(result).toBeUndefined() }) @@ -1735,6 +1761,7 @@ describe('endpoint functions (representative coverage)', () => { new_is_dubbeldag: false, old_wish_id: 'lw1', new_wish_id: 'lw1', + evicted: false, }, { kind: 'added', @@ -1747,13 +1774,9 @@ describe('endpoint functions (representative coverage)', () => { new_is_dubbeldag: false, old_wish_id: null, new_wish_id: 'lw1', + evicted: false, }, ], - eviction_preview: { - would_evict: false, - candidates: [], - shortages: [], - }, }), }) @@ -1790,6 +1813,7 @@ describe('endpoint functions (representative coverage)', () => { newIsDubbeldag: false, oldWishId: 'lw1', newWishId: 'lw1', + evicted: false, }) expect(result.changes[1]).toEqual({ kind: 'added', @@ -1802,11 +1826,11 @@ describe('endpoint functions (representative coverage)', () => { newIsDubbeldag: false, oldWishId: null, newWishId: 'lw1', + evicted: false, }) - expect(result.evictionPreview).toEqual({ wouldEvict: false, candidates: [], shortages: [] }) }) - it('syncPlan maps a would-evict eviction_preview with candidates and shortages', async () => { + it('syncPlan maps a removed change flagged as evicted (a different wish won a shared pool)', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, @@ -1821,74 +1845,50 @@ describe('endpoint functions (representative coverage)', () => { age4_projections: [], }, apply_result: { - accepted: false, + accepted: true, created_count: 0, skipped_identical_count: 0, skipped_free_count: 0, replaced_count: 0, collisions: [], - rejections: [ - { - message_key: 'planSync.rejection.evictionChoiceRequired', - data: { count: 1 }, - }, - ], + rejections: [], created_days: [], }, - changes: [], - eviction_preview: { - would_evict: true, - candidates: [ - { - date: '2028-06-20', - parent_personal_data_uuid: 'pd1', - child_id: 'child1', - day_type: 'vacation', - is_dubbeldag: false, - wish_id: 'lw1', - wish_name: 'Sommar', - }, - ], - shortages: [ - { - resource_kind: 'vacation', - parent_personal_data_uuid: 'pd1', - tier: null, - child_id: null, - year: 2028, - days_evicted: 1, - }, - ], - }, + changes: [ + { + kind: 'removed', + date: '2028-06-20', + parent_personal_data_uuid: 'pd1', + child_id: 'child1', + old_day_type: 'vacation', + new_day_type: null, + old_is_dubbeldag: false, + new_is_dubbeldag: null, + old_wish_id: 'lw1', + new_wish_id: null, + evicted: true, + }, + ], }), }) const result = await api.syncPlan('ws1') - expect(result.evictionPreview).toEqual({ - wouldEvict: true, - candidates: [ - { - date: '2028-06-20', - parentPersonalDataUuid: 'pd1', - childId: 'child1', - dayType: 'vacation', - isDubbeldag: false, - wishId: 'lw1', - wishName: 'Sommar', - }, - ], - shortages: [ - { - resourceKind: 'vacation', - parentPersonalDataUuid: 'pd1', - tier: null, - childId: null, - year: 2028, - daysEvicted: 1, - }, - ], - }) + expect(result.changes).toEqual([ + { + kind: 'removed', + date: '2028-06-20', + parentPersonalDataUuid: 'pd1', + childId: 'child1', + oldDayType: 'vacation', + newDayType: null, + oldIsDubbeldag: false, + newIsDubbeldag: null, + oldWishId: 'lw1', + newWishId: null, + evicted: true, + }, + ]) }) it('listWorkspaceMembers maps snake_case members to camelCase', async () => { @@ -1961,64 +1961,50 @@ describe('endpoint functions (representative coverage)', () => { expect(lastRequest()).toEqual({ url: '/api/planned-days', method: undefined }) }) - it('createPlannedDay posts the payload and maps the response', async () => { + it('assertDay posts the payload and maps the response', async () => { fetchMock.mockResolvedValue({ ok: true, - status: 200, + status: 201, json: async () => ({ - id: 'pld1', + id: 'da1', workspace_id: 'ws1', child_id: 'child1', parent_personal_data_uuid: 'pd1', date: '2026-06-01', day_type: 'vacation', is_dubbeldag: false, + wish_id: null, created_at: '2026-01-01', updated_at: '2026-01-01', }), }) - const result = await api.createPlannedDay({ - workspace_id: 'ws1', - child_id: 'child1', - parent_personal_data_uuid: 'pd1', + const result = await api.assertDay({ + workspaceId: 'ws1', + childId: 'child1', + parentPersonalDataUuid: 'pd1', date: '2026-06-01', - day_type: 'vacation', + dayType: 'vacation', }) - expect(lastRequest()).toEqual({ url: '/api/planned-days', method: 'POST' }) + expect(lastRequest()).toEqual({ url: '/api/day-assertions', method: 'POST' }) expect(result.dayType).toBe('vacation') }) - it('updatePlannedDay PUTs a partial payload and maps the response', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - id: 'pld1', - workspace_id: 'ws1', - child_id: 'child1', - parent_personal_data_uuid: 'pd1', - date: '2026-06-01', - day_type: 'sjukpenning', - is_dubbeldag: true, - created_at: '2026-01-01', - updated_at: '2026-01-01', - }), - }) - - const result = await api.updatePlannedDay('pld1', { is_dubbeldag: true }) - - expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'PUT' }) - expect(result.isDubbeldag).toBe(true) - }) - - it('deletePlannedDay issues a DELETE', async () => { + it('clearDayAssertion issues a DELETE with the natural key as query params', async () => { fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) }) - await api.deletePlannedDay('pld1') + await api.clearDayAssertion({ + workspaceId: 'ws1', + childId: 'child1', + parentPersonalDataUuid: 'pd1', + date: '2026-06-01', + }) - expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'DELETE' }) + const request = lastRequest() + expect(request.method).toBe('DELETE') + expect(request.url).toContain('/api/day-assertions?') + expect(request.url).toContain('date=2026-06-01') }) it('startScraperSession, pollScraperSession and cancelScraperSession hit the fetch-data endpoints', async () => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e06dab7..49e77e1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -23,6 +23,8 @@ import type { WorkspaceUpdateRequest, WorkspaceOverview, PlanSyncResponse, + SyncPlanWithWishRequest, + PlanSyncWithWishResponse, PreschoolPlanningDaysPlannedRequest, PreschoolPlanningDaysTakenRequest, CreateInvitationResponse, @@ -408,6 +410,7 @@ interface RawWindowOutcome { cost_breakdown: Record remedies: RawShortfallRemedy[] shift_gain_days: number + series_id: string | null } interface RawPlanWarning { @@ -500,6 +503,7 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal { daysFreed: r.days_freed, })), shiftGainDays: o.shift_gain_days, + seriesId: o.series_id, })), warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })), poolBalances: raw.pool_balances.map((p) => ({ @@ -614,25 +618,29 @@ function toDayOverride(raw: RawDayOverride): import('@/types').DayOverride { } } -interface RawPlannedDayExclusion { +interface RawDayAssertion { id: string workspace_id: string child_id: string parent_personal_data_uuid: string date: string + day_type: 'sjukpenning' | 'lagsta' | 'vacation' | 'none' + is_dubbeldag: boolean + wish_id: string | null created_at: string | null updated_at: string | null } -function toPlannedDayExclusion( - raw: RawPlannedDayExclusion, -): import('@/types').PlannedDayExclusion { +function toDayAssertion(raw: RawDayAssertion): import('@/types').DayAssertion { return { id: raw.id, workspaceId: raw.workspace_id, childId: raw.child_id, parentPersonalDataUuid: raw.parent_personal_data_uuid, date: raw.date, + dayType: raw.day_type, + isDubbeldag: raw.is_dubbeldag, + wishId: raw.wish_id, createdAt: raw.created_at, updatedAt: raw.updated_at, } @@ -1104,6 +1112,7 @@ interface RawPlanChange { new_is_dubbeldag: boolean | null old_wish_id: string | null new_wish_id: string | null + evicted: boolean } function toPlanChange(raw: RawPlanChange): PlanChange { @@ -1118,54 +1127,7 @@ function toPlanChange(raw: RawPlanChange): PlanChange { newIsDubbeldag: raw.new_is_dubbeldag, oldWishId: raw.old_wish_id, newWishId: raw.new_wish_id, - } -} - -interface RawEvictionCandidate { - date: string - parent_personal_data_uuid: string - child_id: string - day_type: string - is_dubbeldag: boolean - wish_id: string - wish_name: string | null -} - -interface RawResourceShortage { - resource_kind: string - parent_personal_data_uuid: string - tier: string | null - child_id: string | null - year: number | null - days_evicted: number -} - -interface RawEvictionPreview { - would_evict: boolean - candidates: RawEvictionCandidate[] - shortages: RawResourceShortage[] -} - -function toEvictionPreview(raw: RawEvictionPreview): import('@/types').EvictionPreview { - return { - wouldEvict: raw.would_evict, - candidates: raw.candidates.map((c) => ({ - date: c.date, - parentPersonalDataUuid: c.parent_personal_data_uuid, - childId: c.child_id, - dayType: c.day_type as import('@/types').EvictionCandidate['dayType'], - isDubbeldag: c.is_dubbeldag, - wishId: c.wish_id, - wishName: c.wish_name, - })), - shortages: raw.shortages.map((s) => ({ - resourceKind: s.resource_kind, - parentPersonalDataUuid: s.parent_personal_data_uuid, - tier: s.tier, - childId: s.child_id, - year: s.year, - daysEvicted: s.days_evicted, - })), + evicted: raw.evicted, } } @@ -1173,7 +1135,14 @@ interface RawPlanSyncResponse { proposal: RawPlanProposal apply_result: RawPlanApplyResponse changes: RawPlanChange[] - eviction_preview: RawEvictionPreview +} + +function toPlanSyncResponse(raw: RawPlanSyncResponse): PlanSyncResponse { + return { + proposal: toPlanProposal(raw.proposal), + applyResult: toPlanApplyResponse(raw.apply_result), + changes: raw.changes.map(toPlanChange), + } } /** POST /api/plan/sync — the direct-manipulation calendar's one call: @@ -1188,11 +1157,97 @@ export async function syncPlan(workspaceId: string): Promise { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }), })) as RawPlanSyncResponse + return toPlanSyncResponse(raw) +} + +interface RawPlanSyncWithWishResponse { + wish: RawLeaveWish + sync: RawPlanSyncResponse +} + +/** POST /api/plan/sync-with-wish — one atomic backend call for the + * calendar's four create/edit-then-sync paths: create-or-update the + * `LeaveWish`, apply the day-lock/detach list, and sync, all in one unit of + * work server-side (`PlanningService.sync_with_wish`). Replaces the old + * create/patch-the-wish -> re-point-or-delete-the-days -> `syncPlan()` + * 3-phase sequence (and the client-side compensating rollback it needed) — + * a rejected sync now rolls back the wish/day-lock writes with it, so this + * call is either fully applied or leaves no trace, never half-applied. + */ +export async function syncPlanWithWish( + payload: SyncPlanWithWishRequest, +): Promise { + const body: Record = { workspace_id: payload.workspaceId } + + if (payload.wishCreate) { + const w = payload.wishCreate + body.wish_create = { + child_id: w.childId ?? null, + name: w.name, + name_message_key: w.nameMessageKey ?? null, + window_start_month: w.windowStartMonth, + window_start_day: w.windowStartDay, + window_end_month: w.windowEndMonth, + window_end_day: w.windowEndDay, + target_weeks: w.targetWeeks, + target_days: w.targetDays ?? null, + recurrence: w.recurrence, + first_year: w.firstYear, + last_year: w.lastYear ?? null, + priority: w.priority ?? 1, + parent_mode: w.parentMode ?? 'either', + parent_personal_data_uuid: w.parentPersonalDataUuid ?? null, + window_mode: w.windowMode ?? 'fixed', + series_id: w.seriesId ?? null, + wants_dubbeldag: w.wantsDubbeldag ?? false, + } + } + + if (payload.wishUpdate) { + const patch = payload.wishUpdate.patch + const patchBody: Record = {} + if (patch.childId !== undefined) patchBody.child_id = patch.childId + if (patch.name !== undefined) patchBody.name = patch.name + if (patch.nameMessageKey !== undefined) patchBody.name_message_key = patch.nameMessageKey + if (patch.windowStartMonth !== undefined) patchBody.window_start_month = patch.windowStartMonth + if (patch.windowStartDay !== undefined) patchBody.window_start_day = patch.windowStartDay + if (patch.windowEndMonth !== undefined) patchBody.window_end_month = patch.windowEndMonth + if (patch.windowEndDay !== undefined) patchBody.window_end_day = patch.windowEndDay + if (patch.targetWeeks !== undefined) patchBody.target_weeks = patch.targetWeeks + if (patch.targetDays !== undefined) patchBody.target_days = patch.targetDays + if (patch.recurrence !== undefined) patchBody.recurrence = patch.recurrence + if (patch.firstYear !== undefined) patchBody.first_year = patch.firstYear + if (patch.lastYear !== undefined) patchBody.last_year = patch.lastYear + if (patch.priority !== undefined) patchBody.priority = patch.priority + if (patch.parentMode !== undefined) patchBody.parent_mode = patch.parentMode + if (patch.parentPersonalDataUuid !== undefined) { + patchBody.parent_personal_data_uuid = patch.parentPersonalDataUuid + } + if (patch.windowMode !== undefined) patchBody.window_mode = patch.windowMode + if (patch.wantsDubbeldag !== undefined) patchBody.wants_dubbeldag = patch.wantsDubbeldag + + body.wish_update = { + leave_wish_id: payload.wishUpdate.leaveWishId, + scope: payload.wishUpdate.scope ?? 'occurrence', + patch: patchBody, + } + } + + if (payload.dayActions && payload.dayActions.length > 0) { + body.day_actions = payload.dayActions.map((a) => ({ + planned_day_id: a.plannedDayId, + action: a.action, + })) + } + + const raw = (await apiFetch('/plan/sync-with-wish', { + method: 'POST', + body: JSON.stringify(body), + })) as RawPlanSyncWithWishResponse + return { - proposal: toPlanProposal(raw.proposal), - applyResult: toPlanApplyResponse(raw.apply_result), - changes: raw.changes.map(toPlanChange), - evictionPreview: toEvictionPreview(raw.eviction_preview), + wish: toLeaveWish(raw.wish), + sync: toPlanSyncResponse(raw.sync), } } @@ -1266,7 +1321,10 @@ export async function getParentalLeave( } // --------------------------------------------------------------------------- -// Planned Days +// Planned Days — READ ONLY (restructure step 3b). `planned_leave_day` is +// purely derived output now; every write goes through `POST /api/plan/ +// sync(-with-wish)` (services/api.ts's syncPlan/syncPlanWithWish) or +// `POST`/`DELETE /api/day-assertions` (assertDay/clearDayAssertion below). // --------------------------------------------------------------------------- export async function getPlannedDays(childId?: string): Promise { @@ -1275,46 +1333,6 @@ export async function getPlannedDays(childId?: string): Promise { - const raw = (await apiFetch(`/planned-days`, { - method: 'POST', - body: JSON.stringify(payload), - })) as RawPlannedLeaveDay - return toPlannedLeaveDay(raw) -} - -export interface UpdatePlannedDayPayload { - day_type?: 'sjukpenning' | 'lagsta' | 'vacation' - is_dubbeldag?: boolean - // See `CreatePlannedDayPayload.is_pinned`. - is_pinned?: boolean -} - -export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise { - const raw = (await apiFetch(`/planned-days/${id}`, { - method: 'PUT', - body: JSON.stringify(payload), - })) as RawPlannedLeaveDay - return toPlannedLeaveDay(raw) -} - -export async function deletePlannedDay(id: string): Promise { - return apiFetch(`/planned-days/${id}`, { method: 'DELETE' }) -} - // --------------------------------------------------------------------------- // Day overrides (calendar exceptions) // --------------------------------------------------------------------------- @@ -1344,36 +1362,49 @@ export async function deleteDayOverride(id: string): Promise { } // --------------------------------------------------------------------------- -// Planned-day exclusions — the days the user removed by hand and the planner -// must therefore leave empty on every subsequent sync. +// Day assertions — restructure step 3b. Replaces the write side of the +// legacy planned-days (hand-pin/ad-hoc) and planned-day-exclusions +// endpoints: `assertDay` expresses a pin (`dayType` concrete), an ad-hoc day +// (`wishId` omitted), or a removal (`dayType: 'none'`) — the planner must +// therefore leave that slot empty on every subsequent sync. Mirrors +// immediately into `planned_leave_day` (visible via `getPlannedDays` without +// waiting for the next sync). `clearDayAssertion` retracts by natural key — +// there is no id-based delete, see `DayAssertionKey`. // --------------------------------------------------------------------------- -export async function createPlannedDayExclusion( - req: import('@/types').PlannedDayExclusionCreateRequest, -): Promise { - const raw = (await apiFetch('/planned-day-exclusions', { +export async function assertDay( + req: import('@/types').DayAssertionCreateRequest, +): Promise { + const raw = (await apiFetch('/day-assertions', { method: 'POST', body: JSON.stringify({ workspace_id: req.workspaceId, child_id: req.childId, parent_personal_data_uuid: req.parentPersonalDataUuid, date: req.date, + day_type: req.dayType, + is_dubbeldag: req.isDubbeldag ?? false, + wish_id: req.wishId ?? null, }), - })) as RawPlannedDayExclusion - return toPlannedDayExclusion(raw) + })) as RawDayAssertion + return toDayAssertion(raw) } -export async function listPlannedDayExclusionsByWorkspace( +export async function listDayAssertionsByWorkspace( workspaceId: string, -): Promise { - const raw = (await apiFetch( - `/planned-day-exclusions/workspace/${workspaceId}`, - )) as RawPlannedDayExclusion[] - return raw.map(toPlannedDayExclusion) +): Promise { + const raw = (await apiFetch(`/day-assertions/workspace/${workspaceId}`)) as RawDayAssertion[] + return raw.map(toDayAssertion) } -export async function deletePlannedDayExclusion(id: string): Promise { - return apiFetch(`/planned-day-exclusions/${id}`, { method: 'DELETE' }) +export async function clearDayAssertion(key: import('@/types').DayAssertionKey): Promise { + const query = new URLSearchParams({ + workspace_id: key.workspaceId, + child_id: key.childId, + parent_personal_data_uuid: key.parentPersonalDataUuid, + date: key.date, + }) + return apiFetch(`/day-assertions?${query.toString()}`, { method: 'DELETE' }) } // --------------------------------------------------------------------------- diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 3de3444..51bae28 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -138,30 +138,6 @@ export type LeaveWishUpdateRequest = Partial + wishUpdate?: { + leaveWishId: string + scope?: LeaveWishScope + patch: LeaveWishUpdateRequest + } + // What to do, before the sync runs, to an already-persisted + // `planned_leave_day` row this same call's wish concerns: + // - 'attach' — re-point the row at this call's wish and pin it (promoting + // an ad-hoc band with the "locked" control: keep these exact days). + // - 'pin' / 'unpin' — flip `isPinned` only, `wishId` untouched (editing an + // existing wish-backed band's Planering ladder). + // - 'detach' — delete the row outright (hand it back to the planner). + dayActions?: { plannedDayId: string; action: 'attach' | 'pin' | 'unpin' | 'detach' }[] +} + +export interface PlanSyncWithWishResponse { + // The wish as created/updated — present even when `sync.applyResult. + // accepted` is `false`: in that case nothing was actually persisted (the + // whole point of the atomic call), but the id/fields are still useful to + // match against `sync.proposal.outcomes` for toast rendering. Always + // check `sync.applyResult.accepted` before treating `wish` as saved. + wish: import('./entities').LeaveWish + sync: PlanSyncResponse +} + +// Day assertions — POST/DELETE /api/day-assertions (restructure step 3b). +export interface DayAssertionCreateRequest { workspaceId: string childId: string parentPersonalDataUuid: string date: string // ISO-8601 date + dayType: 'sjukpenning' | 'lagsta' | 'vacation' | 'none' + isDubbeldag?: boolean + // Attachment only (band naming) — omitted/null means ad-hoc. + wishId?: string | null +} + +// The natural key `DELETE /api/day-assertions` clears by — no id needed, +// see `app.routers.day_assertion.clear_day_assertion`'s docstring. +export interface DayAssertionKey { + workspaceId: string + childId: string + parentPersonalDataUuid: string + date: string } export interface DayOverrideCreateRequest { diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index d8ef5e4..344f63d 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -99,6 +99,15 @@ export interface LeaveWish { * * Structured data only, house style — the frontend composes the toast copy * ("Sommar 2028 flyttades 2 dagar") itself rather than rendering backend prose. + * + * `evicted` is only ever `true` on a `kind === 'removed'` change: it marks a + * row removed because a *different* wish's edit won a shared FP/vacation + * pool ahead of it, as opposed to the ordinary case of a row removed + * because its OWN wish moved/shrank/was deleted. `sync_plan` always writes + * (displacement is informational, never a refusal — see + * `backend/app/services/planning_service.py`'s `PlanChange` docstring), so + * this is what lets the UI call displacement out distinctly in the sync + * toast instead of folding it into the same silent "N days changed" count. */ export interface PlanChange { kind: 'added' | 'removed' | 'retyped' | 'reattributed' @@ -111,45 +120,7 @@ export interface PlanChange { newIsDubbeldag: boolean | null oldWishId: string | null newWishId: string | null -} - -/** One day a `POST /api/plan/sync` eviction preview would remove — see - * `EvictionPreview`'s docstring. Structured data only, same house style as - * `PlanChange`: the frontend composes the warning copy itself. */ -export interface EvictionCandidate { - date: string // ISO-8601 date - parentPersonalDataUuid: string - childId: string - dayType: 'sjukpenning' | 'lagsta' | 'vacation' - isDubbeldag: boolean - wishId: string - wishName: string | null -} - -/** One exhausted resource behind an eviction preview. - * `resourceKind === 'parental_leave'` carries `tier`/`childId` (`year` is - * `null`); `resourceKind === 'vacation'` carries `year` instead (`tier`/ - * `childId` are `null`). */ -export interface ResourceShortage { - resourceKind: string - parentPersonalDataUuid: string - tier: string | null - childId: string | null - year: number | null - daysEvicted: number -} - -/** Always present on `PlanSyncResponse` — `wouldEvict: false` with empty - * lists in the common (fits-within-budget) case (see - * `backend/app/services/planning_service.py`'s `EvictionPreview`). When - * `wouldEvict` is `true`, `sync_plan` wrote NOTHING at all (unless the - * caller passed `eviction_choice`, which this app's frontend never does - * yet) — a caller receiving this must surface it for a user decision, never - * silently discard it and assume the plan was written. */ -export interface EvictionPreview { - wouldEvict: boolean - candidates: EvictionCandidate[] - shortages: ResourceShortage[] + evicted: boolean } export interface TierData { @@ -224,18 +195,23 @@ export interface DayOverride { updatedAt: string | null } -// One date the planner may not place leave on, for one parent and one child -// — POST /api/planned-day-exclusions. Written when the user removes a single -// day out of a planner-placed period ("this day" in the calendar's three-way -// remove); without it the next plan sync would simply re-derive the day from -// the wish. Deliberately separate from `DayOverride`, which answers the -// unrelated *calendar* question "is this a working day for this workspace". -export interface PlannedDayExclusion { +// One user assertion about one calendar day — POST/DELETE +// /api/day-assertions (restructure step 3b). Replaces THREE previously +// separate concepts: a pinned `PlannedLeaveDay` (`dayType` concrete, +// `wishId` set), an ad-hoc day (`dayType` concrete, `wishId` null), and a +// removed/excluded day (`dayType: 'none'` — the planner may not place leave +// here; `PlannedDayExclusion` used to be its own entity for this). +// Deliberately separate from `DayOverride`, which answers the unrelated +// *calendar* question "is this a working day for this workspace". +export interface DayAssertion { id: string workspaceId: string childId: string parentPersonalDataUuid: string date: string // ISO-8601 date + dayType: 'sjukpenning' | 'lagsta' | 'vacation' | 'none' + isDubbeldag: boolean + wishId: string | null createdAt: string | null updatedAt: string | null } @@ -444,6 +420,13 @@ export interface WindowOutcome { // allocation_service.py). 0 for a fixed window or a reused flexible one — // those never auto-shift, so there is nothing to report. shiftGainDays: number + // The same id every sibling occurrence of a repeating wish shares (`null` + // for a standalone wish) — see `WindowOutcome.series_id` in + // allocation_service.py. A materialised series is one `LeaveWish` row PER + // YEAR, each with its own `wishId`, so this is what lets a caller find + // every OTHER outcome in the same sync batch that belongs to the same + // series as one occurrence it already knows. + seriesId: string | null } export interface PlanWarning { diff --git a/frontend/src/utils/rejectionMessages.test.ts b/frontend/src/utils/rejectionMessages.test.ts index 7a74351..757d769 100644 --- a/frontend/src/utils/rejectionMessages.test.ts +++ b/frontend/src/utils/rejectionMessages.test.ts @@ -35,6 +35,9 @@ describe('renderRejection', () => { 'planApply.rejection.outsideEligibleWindow', 'planApply.rejection.insufficientBalance', 'planApply.rejection.insufficientVacationBalance', + 'planApply.rejection.deleteKeyConflict', + 'planning.rejection.vacationOnFreeDay', + 'planning.rejection.parentalLeaveOnFreeDayDisabled', ] for (const messageKey of knownKeys) { const rendered = renderRejection(rejection({ messageKey }), t, []) @@ -90,6 +93,39 @@ describe('renderRejection', () => { expect(rendered).not.toContain('pd-a') }) + it('resolves the parent uuid in deleteKeyConflict to a name, not the raw uuid', () => { + const rendered = renderRejection( + rejection({ + messageKey: 'planApply.rejection.deleteKeyConflict', + data: { date: '2026-06-15', child_id: 'child-1', parent_personal_data_uuid: 'pd-a' }, + }), + t, + [member({ uuid: 'pd-a', name: 'Alex' })], + ) + expect(rendered).toContain('Alex') + expect(rendered).not.toContain('pd-a') + }) + + it('interpolates the date into the free-day rejections', () => { + expect( + renderRejection( + rejection({ messageKey: 'planning.rejection.vacationOnFreeDay', data: { date: '2026-06-06' } }), + t, + [], + ), + ).toContain('2026-06-06') + expect( + renderRejection( + rejection({ + messageKey: 'planning.rejection.parentalLeaveOnFreeDayDisabled', + data: { date: '2026-06-06' }, + }), + t, + [], + ), + ).toContain('2026-06-06') + }) + it('leaves data with no parent uuid untouched', () => { const rendered = renderRejection( rejection({ diff --git a/frontend/src/utils/rejectionMessages.ts b/frontend/src/utils/rejectionMessages.ts index e29646b..df48fc2 100644 --- a/frontend/src/utils/rejectionMessages.ts +++ b/frontend/src/utils/rejectionMessages.ts @@ -37,6 +37,21 @@ const REJECTION_KEYS: Record = { 'planApply.rejection.outsideEligibleWindow': 'planResults.rejectionOutsideEligibleWindow', 'planApply.rejection.insufficientBalance': 'planResults.rejectionInsufficientBalance', 'planApply.rejection.insufficientVacationBalance': 'planResults.rejectionInsufficientVacationBalance', + // Structural guard in `apply_proposed_days` — a key `sync_plan` wants to + // both delete (a stale row) and write (a fresh one) in the same batch. + // `sync_plan` computes `delete_keys` as exactly the stale keys NOT in its + // own fresh batch, so this should never actually fire today; kept here + // (rather than left unmapped) purely as defense-in-depth, since a rejected + // batch still reaches this same rendering path if it ever does. + 'planApply.rejection.deleteKeyConflict': 'planResults.rejectionDeleteKeyConflict', + // The hand-pin "Advanced" path's authoritative guard + // (`app.services.day_guards.reject_illegal_free_day`, enforced by + // `DayAssertionService`), forwarded by `POST /api/day-assertions` — + // reached via `describePlannedDayError` in `Calendar.tsx`, not the + // plan-apply/sync toast, but the same lookup. + 'planning.rejection.vacationOnFreeDay': 'planResults.rejectionVacationOnFreeDay', + 'planning.rejection.parentalLeaveOnFreeDayDisabled': + 'planResults.rejectionParentalLeaveOnFreeDayDisabled', } /** Renders one rejection, never leaking `data.parent_personal_data_uuid` as diff --git a/frontend/tests/fixtures/hooks.ts b/frontend/tests/fixtures/hooks.ts index f5a16d2..fee29cb 100644 --- a/frontend/tests/fixtures/hooks.ts +++ b/frontend/tests/fixtures/hooks.ts @@ -19,7 +19,7 @@ import type { usePlannedDays } from '@/hooks/usePlannedDays' import type { useScraperFetch } from '@/hooks/useScraperFetch' import type { useWorkspaceMembers } from '@/hooks/useWorkspaceMembers' import type { useOptimizationTips } from '@/hooks/useOptimizationTips' -import type { usePlannedDayExclusions } from '@/hooks/usePlannedDayExclusions' +import type { useDayAssertions } from '@/hooks/useDayAssertions' import type { useDayOverrides } from '@/hooks/useDayOverrides' import type { useLeaveWishes } from '@/hooks/useLeaveWishes' import type { usePlanProposal } from '@/hooks/usePlanProposal' @@ -68,8 +68,6 @@ export function makePlanSyncContextValue( overrides: Partial> = {}, ): ReturnType { return { - pendingEvictionPreview: null, - clearPendingEvictionPreview: vi.fn(), latestProposal: null, latestProposalVersion: 0, ...overrides, @@ -126,9 +124,6 @@ export function makePlannedDaysResult( isLoading: false, error: null, refresh: vi.fn(), - add: vi.fn(), - update: vi.fn(), - remove: vi.fn(), ...overrides, } } @@ -190,15 +185,15 @@ export function makeDayOverridesResult( } } -export function makePlannedDayExclusionsResult( - overrides: Partial> = {}, -): ReturnType { +export function makeDayAssertionsResult( + overrides: Partial> = {}, +): ReturnType { return { data: [], isLoading: false, error: null, - exclude: vi.fn(), - restore: vi.fn(), + assertDay: vi.fn(), + clear: vi.fn(), ...overrides, } } @@ -239,6 +234,7 @@ export function makePlanSyncResult( error: null, lastChanges: null, sync: vi.fn(), + syncWithWish: vi.fn(), ...overrides, } } diff --git a/frontend/tests/fixtures/mockData.ts b/frontend/tests/fixtures/mockData.ts index fab74cd..9f2f094 100644 --- a/frontend/tests/fixtures/mockData.ts +++ b/frontend/tests/fixtures/mockData.ts @@ -10,6 +10,7 @@ import type { Child, + DayAssertion, DayOverride, LeaveWish, ParentalLeaveFetch, @@ -19,7 +20,6 @@ import type { Tip, VacationDays, Workspace, - PlannedDayExclusion, } from '@/types' // --------------------------------------------------------------------------- @@ -197,15 +197,16 @@ export function makeDayOverride(overrides: Partial = {}): DayOverri } } -export function makePlannedDayExclusion( - overrides: Partial = {}, -): PlannedDayExclusion { +export function makeDayAssertion(overrides: Partial = {}): DayAssertion { return { - id: 'ex-1', + id: 'da-1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2027-02-03', + dayType: 'none', + isDubbeldag: false, + wishId: null, createdAt: '2027-01-01T00:00:00', updatedAt: '2027-01-01T00:00:00', ...overrides, diff --git a/frontend/tests/fixtures/plan.ts b/frontend/tests/fixtures/plan.ts index 0495335..c02ac58 100644 --- a/frontend/tests/fixtures/plan.ts +++ b/frontend/tests/fixtures/plan.ts @@ -6,24 +6,22 @@ import type { CostBreakdown, DayCollision, - EvictionCandidate, - EvictionPreview, PlanAge4Projection, PlanApplyResponse, PlanChange, PlanProposal, PlanSyncResponse, + PlanSyncWithWishResponse, PlanWarning, PoolBalance, ProposedDay, - ProposedDayApplyItem, Rejection, - ResourceShortage, ShortfallRemedy, SurplusAdvice, VacationBalance, WindowOutcome, } from '@/types' +import { makeLeaveWish } from './mockData' export function makeCostBreakdown(overrides: Partial = {}): CostBreakdown { return { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0, ...overrides } @@ -52,20 +50,6 @@ export function makeProposedDay(overrides: Partial = {}): ProposedD } } -/** The `/api/plan/apply` request-side counterpart of `ProposedDay` — no - * `wishId`/`reason`, and `dayType` additionally allows `'free'`. */ -export function makeProposedDayApplyItem( - overrides: Partial = {}, -): ProposedDayApplyItem { - return { - date: '2026-06-15', - parentPersonalDataUuid: 'pd-a', - childId: 'child-1', - dayType: 'sjukpenning', - ...overrides, - } -} - export function makeWindowOutcome(overrides: Partial = {}): WindowOutcome { return { wishId: 'lw-1', @@ -83,6 +67,7 @@ export function makeWindowOutcome(overrides: Partial = {}): Windo costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }), remedies: [], shiftGainDays: 0, + seriesId: null, ...overrides, } } @@ -201,43 +186,7 @@ export function makePlanChange(overrides: Partial = {}): PlanChange newIsDubbeldag: false, oldWishId: null, newWishId: 'lw-1', - ...overrides, - } -} - -export function makeEvictionCandidate(overrides: Partial = {}): EvictionCandidate { - return { - date: '2028-06-20', - parentPersonalDataUuid: 'pd-a', - childId: 'child-1', - dayType: 'vacation', - isDubbeldag: false, - wishId: 'lw-1', - wishName: 'Sommar', - ...overrides, - } -} - -export function makeResourceShortage(overrides: Partial = {}): ResourceShortage { - return { - resourceKind: 'vacation', - parentPersonalDataUuid: 'pd-a', - tier: null, - childId: null, - year: 2028, - daysEvicted: 1, - ...overrides, - } -} - -/** Always present on `PlanSyncResponse`. Defaults to the common (nothing - * evicted) case — pass `{ wouldEvict: true, candidates: [...], shortages: - * [...] }` to build the "nothing was written, ask the user" case. */ -export function makeEvictionPreview(overrides: Partial = {}): EvictionPreview { - return { - wouldEvict: false, - candidates: [], - shortages: [], + evicted: false, ...overrides, } } @@ -247,7 +196,20 @@ export function makePlanSyncResponse(overrides: Partial = {}): proposal: makePlanProposal(), applyResult: makePlanApplyResponse(), changes: [], - evictionPreview: makeEvictionPreview(), + ...overrides, + } +} + +/** `POST /api/plan/sync-with-wish`'s response — the wish as created/updated + * plus the same `PlanSyncResponse` shape `syncPlan()` returns. See + * `PlanSyncWithWishResponse`'s doc comment (src/types/api.ts): `wish` is + * present even when `sync.applyResult.accepted` is `false`. */ +export function makePlanSyncWithWishResponse( + overrides: Partial = {}, +): PlanSyncWithWishResponse { + return { + wish: makeLeaveWish(), + sync: makePlanSyncResponse(), ...overrides, } } diff --git a/ideas.md b/ideas.md index 008ad94..a8ef9b7 100644 --- a/ideas.md +++ b/ideas.md @@ -17,7 +17,7 @@ birthdays mean two cliffs. **Suggestion:** decide whether multi-child is a supported scenario. If yes, the timeline on Översikt is the natural place to show both; if no, say so in the UI rather than half-supporting it. -## 2. The planner cannot explain itself +## 2. The planner cannot explain itself --> in progress Every funded day already carries a `reason` code (`vacation_wrap_red_day`, `fp_interior`, `swap_first_half`, `dubbeldag`, …) and every window produces a `WindowOutcome` with a cost @@ -76,169 +76,6 @@ the app's premise is optimising the two *together*. assumption is that Swedish multiples rules add days at the sjukpenning tier, so the derivation still holds — worth confirming before anyone builds twins support. -## 7. Vacation forfeiture: the planner detects the loss and then does nothing about it (full plan) - -**The problem, quantified.** The seeded demo workspace shows 46 forfeiting vacation days for one -parent. This is not a demo artifact — the mechanism that would prevent it structurally cannot fire -for most of a typical year, for a reason explained below. `PLANNING_ENGINE_SPEC.md` §9.2 records -that the very first demo run had the same shape of bug: "finishing every year with 8–14 unused -semesterdagar" even after §Task 2's bias was designed. Whatever the exact number, the pattern is -structural, not incidental. - -**Root cause: the allocator only ever writes inside a wish's window.** `Allocator.propose` -(`backend/app/services/allocation_service.py:2773`) has exactly two passes: fixed wish windows -(`_expand_all_windows`, line 598) and flexible wish windows (`_expand_flexible_wish_years`, line -647). There is no third pass, and `sync_plan` (`backend/app/services/planning_service.py:814`) -calls nothing but `PlanningEngineService.propose` (line 883), which wraps this same `Allocator`. -Every `ProposedDay` the engine ever emits traces back to a date inside `[wish.window_start, -wish.window_end]`. A date with no wish covering it is never visited by `fund_window`, -`_assign_tier3`, or anything else — it simply never enters the loop. My suspicion going in (per the -task brief) was that this is the root cause, and having read `propose()` end to end, it is: there -is no code path capable of placing a day the user did not, in some form, ask a wish to cover. - -**Why §Task 2's bias doesn't save you.** The forfeiture-avoidance bias lives in `_assign_tier3` -(`allocation_service.py:1203`, decision at 1242-1250): when a parent's (parent, year) pair is -forecast to forfeit vacation (`AllocationInput.vacation_forfeiting`, computed by -`AllocationInputLoader` from `AnalyticsService._vacation_year_series`'s FIFO carry-forward bank — -same simulation the Analytics tab and the `use_vacation_before_expiry` tip both read, so the -figures agree), it reorders which resource pays for a day *that is already being funded* — vacation -first instead of FP first, even for an hourly parent who'd normally spend FP first. That is all it -does. It is a payment-method tiebreak for goal-dependent interior days (§3.3 tier 3) inside a window -a wish already created. It cannot manufacture a leave day where no wish asked for one. A parent -whose wishes cover 6-8 weeks a year (typical: summer + jullov + a few long weekends, per -`scripts/seed_demo.py`) has ~44-46 weeks of workdays every year where the allocator's placement -logic literally never runs, and that is exactly where unused vacation lives. - -**The other half of the existing machinery is purely advisory.** `WARNING_VACATION_EXPIRING` -(`allocation_service.py:2686`, `_vacation_expiry_warnings`) and the `use_vacation_before_expiry` -tip (`backend/app/services/tips_service.py:112`) both correctly compute the forecast loss and tell -the user a number and a year-end date. Neither places a day. `REMEDY_CARRY_VACATION_FORWARD` -(`allocation_service.py:1833`, `_carry_vacation_forward_remedy`) looks adjacent but solves the -opposite problem: it tells a *shortfall* ("this window is short 12 days") that spare vacation from -an earlier year *could* cover it — it's a funding-source suggestion for an under-filled wish, not a -forfeiture fix. `SURPLUS_USE_LESS_VACATION` (line ~2591) is the FP-surplus mirror, also advisory. So -today: the system knows the exact number, knows the exact deadline (year-end), and communicates -both — a nag, correctly computed, that nobody acts on before the days are gone. - -**Proposed mechanism: a third, non-wish placement pass that proposes rescue days.** After passes 1 -and 2 in `Allocator.propose()`, for every `(parent, year)` where `year == alloc_input.today.year` -(see below for why not future years) and the post-placement forfeiture is still > 0 — i.e. re-run -exactly the subtraction `_vacation_expiry_warnings` already does (line 2686-2744) rather than a -second forecast — generate up to that many candidate workdays between `today` and Dec 31 of that -year that are: not already a free day (`is_free_day`), not already occupied -(`_existing_lookup`/this-run's own placements), not in `excluded_days`. Rank candidates by adjacency -to a red-day cluster (`_red_day_cluster_dates`, line 510 — reuse it, don't reinvent it) so a rescue -day preferentially extends an existing weekend/holiday into a real day off instead of landing as an -isolated Tuesday with no value; among ties, prefer later dates (closer to the deadline leaves more -runway for the user's own wishes to claim the day first on a later sync). Emit these as -`ProposedDay`s with a **new** reason code (e.g. `REASON_VACATION_RESCUE`) and `wish_id = None` — do -not attach them to a synthetic wish, and do not silently fold them into an existing `WindowOutcome`. -They need to be visually and semantically distinct from a wish-funded day: the user didn't ask for -this date off, the planner is telling them "spend it here or lose it." - -**Do not auto-commit these to the plan.** Treat them exactly like `surplus_advice` today: a proposal -the user acts on, not a fact the sync silently writes. This matches how `_use_less_vacation_advice` -and `_carry_vacation_forward_remedy` already work (data, not `ProposedDay`s) — but rescue days are -concrete *dates*, not just a count, so they belong closer to `ProposedDay`/`WindowOutcome` shape than -to `SurplusAdvice`'s abstract `days_involved`. Suggest a new top-level `PlanProposal.vacation_rescue: -list[ProposedDay]` field, kept separate from `days` (line 460), so nothing downstream that iterates -`days` (balance netting, calendar rendering, exports) picks them up by accident before the user -accepts. Accepting one (or "accept all") writes it through the *same* ad-hoc write path a user -manually clicking a calendar day already uses today (`wish_id is None`, noted at -`planning_service.py:53` and the restructure notes' "promoting an ad-hoc band" at line 1050) — no -new persistence mechanism needed for acceptance, only new generation logic. - -**Why only `today.year`, not the whole horizon.** `vacation_forfeiting` is populated for every year -from `today.year` through `horizon_end.year` (`allocation_service.py:313-325`), and it would be easy -to "fix" all of them at once. Don't. A future year's forecast is provisional — it assumes the -household adds no more wishes between now and then, which is false by construction (that's the -whole point of `sync_plan` re-running on every edit). Suggesting concrete October-2028 rescue dates -in September 2026 is manufacturing false precision about a number that will legitimately keep -changing. Only the current year has a real, non-negotiable deadline (Dec 31 of *this* year) with no -more wish-adding time left after it passes. Keep `WARNING_VACATION_EXPIRING` as the informational -signal for future years — it already does that job correctly — and reserve the new mechanism for -the year where the deadline is actually imminent. - -**Interaction with the wish model.** This deliberately does not touch how wishes work. A wish is -"I want to be home for N weeks in this window" — a positive intent. A rescue day is the opposite -shape: "you have a day you're about to lose, spend it or don't." Bolting it onto the wish model (a -synthetic auto-generated wish) would force it through `LeaveWishService`'s materialisation, -priority ordering, and repeat-year machinery for something that is a one-shot, current-year-only, -non-negotiable nudge — that's a lot of borrowed complexity for a suggestion the user might reject -outright. Keeping it as its own small pass in the allocator, emitting its own output shape, is -smaller and doesn't risk `_expand_all_windows`/`_expand_flexible_wish_years` needing to know about -"wishes nobody made." - -**Interaction with restructure step 3 (`day_assertion`).** Genuinely easier after step 3, not -harder, but land it *after*, not concurrently. The accept-a-rescue-day write is, in both worlds, -"insert an ad-hoc leave commitment for this date" — today that's an ad-hoc `planned_leave_day` row -(`wish_id NULL`); after step 3 it's a `day_assertion` row with `wish_id NULL`, which is explicitly -called out as a first-class case in the restructure notes ("ad-hoc = assertion with `wish_id` -NULL"). The generation logic (the new pass inside `Allocator.propose()`) never touches persistence -at all — it's pure, like the rest of the allocator — so it is *not* coupled to step 3's schema -change and doesn't need to be redone once step 3 lands. The only coupling is the acceptance -endpoint, which is a few lines either way. Given the restructure's own rule — "Run STRICTLY -SERIALLY — all steps touch Calendar.tsx/planning_service.py/allocation_service.py" — and that this -feature touches exactly those same three files, sequence it after step 3 (or at minimum, not -overlapping any in-flight step) purely to avoid conflicting edits to `allocation_service.py`, not -because of any deeper technical dependency. - -**What's genuinely uncertain — needs a product-owner call, not a guess:** -- *Auto-apply vs. suggest-only.* Restructure step 2 turns eviction into "informational warning + - Undo" rather than a blocking choice. The same UX pattern — place the rescue days automatically and - let the user undo — is arguably more consistent with that direction than a separate accept/reject - UI, and would actually solve "vacation gets lost" instead of adding a fourth nag next to three - existing ones. But it also means the app puts dates on someone's calendar they didn't request, - which is a bigger behavioral commitment than anything else the planner does today (every other - auto-placement traces back to a wish the user created). I'd want this decided explicitly, not - inferred. -- *Ranking heuristic.* "Prefer extending a red-day cluster" is my judgment call, not a rule found in - the repo. A reasonable alternative is "spread evenly so no month gets clustered" or "prefer Fridays - to build long weekends without touching a holiday cluster at all." Not a legal question, a taste - question — flagging rather than deciding unilaterally. -- No new Swedish-law figure is needed. `statutory_vacation_days_per_year` (25), - `min_vacation_days_per_year` floor (20), and `vacation_carry_forward_years` (5) — all in - `backend/app/config.py:60-73`, all already cited to semesterlagen in the surrounding comments — - are exactly the inputs this mechanism needs and none are new. The one caveat already on record - (ideas.md §5): collectively-bargained extra days and non-statutory carry-over rules aren't modelled - at all, so a rescue suggestion inherits whatever fidelity gap already exists there. Not something - this plan should try to fix in passing. - -**Staged implementation, smallest-useful-first:** -1. Nothing in the allocator yet. Improve the existing `use_vacation_before_expiry` tip and - `WARNING_VACATION_EXPIRING` to name the actual last-safe-to-act date (year-end) more prominently - in the UI copy than they do today — cheap, ships this week, and makes the existing correct - forecast easier to act on manually. Almost certainly insufficient alone (it's the status quo's - mechanism, and it hasn't worked), but it's free and worth doing regardless of what follows. -2. Add the rescue-day generation pass to the pure `Allocator`, gated behind a feature flag/config - default-off, current year only, emitted on `PlanProposal.vacation_rescue`, not written anywhere. - Fully testable in isolation (same pure-function guarantee as the rest of the engine) before any - UI exists to show it — this is most of the actual engineering risk and can be validated with unit - tests against the existing `§Task 2` test fixtures before touching the frontend at all. -3. Surface it in the calendar/dashboard as a distinct visual affordance (not a normal band — the - user didn't ask for it) with one-click accept-one / accept-all / dismiss. Dismiss should behave - like the tip-dismissal system already does (§4 above) — expire when the underlying number - changes, not forever. -4. Only after 1-3 are live and observed for a season: revisit whether default behaviour should shift - toward auto-apply-with-undo, per the open question above. Do not decide that up front from a - description of the problem alone. - -**What I would NOT do:** -- Would not make the existing `_assign_tier3` bias more aggressive (e.g. spending vacation on - free-standing days by loosening what counts as "interior"). It's already correctly scoped to - "inside a window a wish created" — the bug isn't that the bias is weak, it's that most of the - calendar year never reaches it. Turning up a dial on a mechanism that structurally can't reach the - problem wastes effort and risks unrelated regressions in wish-funded placement, which today is - working correctly. -- Would not model employer-approval friction for a rescue day (whether the household could actually - get that specific date off work). Out of scope by the same abstraction line CLAUDE.md already - draws around money/SGI — this app doesn't model employer scheduling constraints anywhere else - either, and a rescue *suggestion* the user still has to act on doesn't need to. -- Would not extend this to future horizon years (see above) or to FP-surplus-at-age-4 (already has - its own, better-suited mirror mechanism in `SurplusAdvice` — no need to unify the two; they differ - in urgency shape: age-4 surplus is years away and gradual, vacation forfeiture is a hard Dec 31 - cliff). - --- ## Deliberately not proposed