From 8cfb9c82c617678b5bc9fa4b20afb7b068dce5bc Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 16:05:17 +0200 Subject: [PATCH] Let a day removed from a period stay removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking one day out of a period the planner placed did nothing that lasted: the period still asked for a full week, so the next sync put the day straight back. The option was disabled rather than lying about it. A removed day is now recorded as its own thing — no leave for this parent, this child, this date — and the planner honours it on every later sync. It is kept apart from the working-day overrides, which answer a different question about the calendar rather than about the plan. The day belongs to the parent whose band was clicked, so removing one parent's day leaves their partner's alone; where both parents were to be off on one funding arrangement, the partner is simply funded on their own instead. A removed day shortens what the period is asking for rather than counting as a day the plan failed to find, so the advice about closing a shortfall does not start arguing for a day nobody wants. Nothing is removed without a way back: the toast undoes it, and the day itself is marked in the calendar and offers to restore. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- backend/app/db/connection.py | 19 + backend/app/dependencies.py | 33 ++ backend/app/entities/planned_day_exclusion.py | 40 ++ backend/app/main.py | 2 + backend/app/models/planned_day_exclusion.py | 42 ++ .../planned_day_exclusion_repository.py | 28 ++ .../planned_day_exclusion_repository.py | 133 +++++++ backend/app/routers/planned_day_exclusions.py | 64 +++ backend/app/services/allocation_service.py | 139 ++++++- .../services/planned_day_exclusion_service.py | 132 +++++++ .../versions/005_planned_day_exclusion.py | 66 ++++ backend/tests/conftest.py | 26 ++ .../tests/unit/test_planned_day_exclusion.py | 367 ++++++++++++++++++ .../test_allocation_excluded_days.py | 270 +++++++++++++ .../test_services/test_allocation_service.py | 2 + frontend/e2e/tests/drag-create-delete.spec.ts | 62 ++- .../calendar/PeriodPopover.test.tsx | 41 +- .../src/components/calendar/PeriodPopover.tsx | 36 +- .../components/common/CalendarDay.test.tsx | 32 ++ .../src/components/common/CalendarDay.tsx | 19 + .../components/common/CalendarWeek.test.tsx | 35 ++ .../src/components/common/CalendarWeek.tsx | 31 +- .../modals/PlannedDayModal.test.tsx | 54 +++ .../src/components/modals/PlannedDayModal.tsx | 35 ++ .../components/modals/planned-day-modal.css | 18 + frontend/src/hooks/index.ts | 1 + .../src/hooks/usePlannedDayExclusions.test.ts | 98 +++++ frontend/src/hooks/usePlannedDayExclusions.ts | 75 ++++ frontend/src/i18n/en/calendar.ts | 15 +- frontend/src/i18n/sv/calendar.ts | 15 +- frontend/src/pages/Calendar.test.tsx | 147 ++++++- frontend/src/pages/Calendar.tsx | 108 +++++- frontend/src/pages/calendar.css | 19 + frontend/src/services/api.test.ts | 79 ++++ frontend/src/services/api.ts | 57 +++ frontend/src/types/api.ts | 7 + frontend/src/types/entities.ts | 16 + frontend/tests/fixtures/hooks.ts | 14 + frontend/tests/fixtures/mockData.ts | 16 + 39 files changed, 2362 insertions(+), 31 deletions(-) create mode 100644 backend/app/entities/planned_day_exclusion.py create mode 100644 backend/app/models/planned_day_exclusion.py create mode 100644 backend/app/repositories/planned_day_exclusion_repository.py create mode 100644 backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py create mode 100644 backend/app/routers/planned_day_exclusions.py create mode 100644 backend/app/services/planned_day_exclusion_service.py create mode 100644 backend/migrations/versions/005_planned_day_exclusion.py create mode 100644 backend/tests/unit/test_planned_day_exclusion.py create mode 100644 backend/tests/unit/test_services/test_allocation_excluded_days.py create mode 100644 frontend/src/hooks/usePlannedDayExclusions.test.ts create mode 100644 frontend/src/hooks/usePlannedDayExclusions.ts diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index 414579c..1fb2c4b 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -180,6 +180,23 @@ 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 ( + 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 +); + CREATE TABLE IF NOT EXISTS tip_dismissal ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, @@ -225,6 +242,8 @@ 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); """ diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 8378afb..049eb9d 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -27,6 +27,7 @@ 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.tip_dismissal_repository import TipDismissalRepository from app.repositories.unit_of_work import UnitOfWork @@ -41,6 +42,9 @@ from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverri 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.tip_dismissal_repository import SQLiteTipDismissalRepository from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork @@ -58,6 +62,7 @@ from app.services.child_service import ChildService 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.tips_service import TipsService from app.services.vacation_days_service import VacationDaysService @@ -184,6 +189,17 @@ 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: + if settings.database_type == "sqlite": + return SQLitePlannedDayExclusionRepository(conn) + raise ValueError(f"Unsupported database_type: {settings.database_type}") + + +PlannedDayExclusionRepositoryDep = Annotated[ + PlannedDayExclusionRepository, Depends(get_planned_day_exclusion_repository) +] + + def get_day_override_repository(conn: ConnectionDep) -> DayOverrideRepository: if settings.database_type == "sqlite": return SQLiteDayOverrideRepository(conn) @@ -246,6 +262,21 @@ 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) @@ -339,6 +370,7 @@ def get_allocation_input_loader( planned_leave_repo: PlannedLeaveRepositoryDep, leave_wish_repo: LeaveWishRepositoryDep, day_override_repo: DayOverrideRepositoryDep, + exclusion_repo: PlannedDayExclusionRepositoryDep, ) -> AllocationInputLoader: return AllocationInputLoader( workspace_repo=workspace_repo, @@ -349,6 +381,7 @@ 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, ) diff --git a/backend/app/entities/planned_day_exclusion.py b/backend/app/entities/planned_day_exclusion.py new file mode 100644 index 0000000..32bafab --- /dev/null +++ b/backend/app/entities/planned_day_exclusion.py @@ -0,0 +1,40 @@ +"""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/main.py b/backend/app/main.py index 83b73b8..a6696c0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ 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.settings import child_router, settings_child_router, tips_router from app.routers.workspace import _inv_router as invitations_router @@ -126,6 +127,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(vacation_days.router) app.include_router(day_overrides_router) app.include_router(calendar_router) diff --git a/backend/app/models/planned_day_exclusion.py b/backend/app/models/planned_day_exclusion.py new file mode 100644 index 0000000..033bcfb --- /dev/null +++ b/backend/app/models/planned_day_exclusion.py @@ -0,0 +1,42 @@ +"""API schemas (Pydantic) for PlannedDayExclusion. + +Schemas are the API boundary — they validate request bodies and shape +responses, kept separate from the domain entity so the wire format can +evolve independently. +""" + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel + +from app.entities.planned_day_exclusion import PlannedDayExclusion + + +class PlannedDayExclusionCreate(BaseModel): + workspace_id: UUID + child_id: UUID + parent_personal_data_uuid: UUID + date: date + + +class PlannedDayExclusionResponse(BaseModel): + id: UUID + workspace_id: UUID + child_id: UUID + parent_personal_data_uuid: UUID + date: date + created_at: datetime | None + updated_at: datetime | None + + @classmethod + def from_entity(cls, entity: PlannedDayExclusion) -> "PlannedDayExclusionResponse": + 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, + created_at=entity.created_at, + updated_at=entity.updated_at, + ) diff --git a/backend/app/repositories/planned_day_exclusion_repository.py b/backend/app/repositories/planned_day_exclusion_repository.py new file mode 100644 index 0000000..0d72629 --- /dev/null +++ b/backend/app/repositories/planned_day_exclusion_repository.py @@ -0,0 +1,28 @@ +"""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_impl/sqlite/planned_day_exclusion_repository.py b/backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py new file mode 100644 index 0000000..b4440c7 --- /dev/null +++ b/backend/app/repositories_impl/sqlite/planned_day_exclusion_repository.py @@ -0,0 +1,133 @@ +"""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/routers/planned_day_exclusions.py b/backend/app/routers/planned_day_exclusions.py new file mode 100644 index 0000000..67b0e7c --- /dev/null +++ b/backend/app/routers/planned_day_exclusions.py @@ -0,0 +1,64 @@ +"""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/services/allocation_service.py b/backend/app/services/allocation_service.py index 5c4c714..21d5221 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -32,6 +32,7 @@ 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.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import WorkspaceRepository @@ -267,6 +268,19 @@ 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 + # §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). + excluded_days: frozenset[tuple[UUID, UUID, date]] = frozenset() # --------------------------------------------------------------------------- @@ -929,6 +943,78 @@ class _Ledger: # -- resource spending ------------------------------------------------ + # -- user-removed days (§remove-this-day) ------------------------------ + + def _is_excluded(self, parent_uuid: UUID, child_id: UUID | None, d: date) -> bool: + """Whether the user removed THIS date from THIS parent's plan. + + The single reader of `AllocationInput.excluded_days`. A wish with no + resolved child (`child_id is None`) can never match: an exclusion is + always recorded from a real `planned_leave_day`, which always has a + child. + """ + if child_id is None or not self._input.excluded_days: + return False + return (parent_uuid, child_id, d) in self._input.excluded_days + + def _excluded_window_dates( + self, wish: LeaveWish, responsible: _Responsible, dates: list[date] + ) -> 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 + parents that window puts on leave per date: + + - 'dubbeldag' (both parents off on the same dates): removing one + parent's day must leave the partner's standing, so a date only + drops out when BOTH parents were removed from it. The single-parent + case is handled inside `_fund_both`/`_fund_solo`, which skip just + the excluded parent. + - 'solo'/'swap' (exactly one parent on leave per date): ANY + responsible parent's exclusion empties the date. Without the `any`, + `_fund_swap`'s leftover hand-off would hand the removed date + straight to the partner — silently refilling the hole the user just + made, under a different name, which is precisely the "it came + back" failure this whole feature exists to prevent. + + Dropping these dates from the list `fund_window` funds (and from the + list it measures) is also the shortfall decision: a day the user + removed reduces the window's target rather than counting as a day the + plan failed to fund. It is not a gap the planner could do anything + about, `_build_remedies` must not propose freeing resources for it, + and `sync_plan`'s eviction preview (which fires on a stale row whose + own wish still reports `shortfall_days > 0`) must not mistake a + deliberate removal for a lost priority race. + """ + if not self._input.excluded_days or not responsible.parents: + return set() + child = self._resolve_wish_child(wish) + if child is None: + return set() + quantifier = all if responsible.mode == "dubbeldag" else any + return { + d + for d in dates + if quantifier(self._is_excluded(p.uuid, child.id, d) for p in responsible.parents) + } + + def _window_dates( + self, wish: LeaveWish, responsible: _Responsible, start: date, end: date + ) -> list[date]: + """`[start, end]` minus the dates the user removed from it. + + Shared by `fund_window` (the real placement) and + `_candidate_shortfall` (§Decision C's budget-aware probe) so the two + keep measuring the same window — see `_shortfall_and_weeks_funded`'s + docstring for why they must never silently disagree. + """ + all_dates = _dates_in(start, end) + excluded = self._excluded_window_dates(wish, responsible, all_dates) + if not excluded: + return all_dates + return [d for d in all_dates if d not in excluded] + def _spend_vacation(self, parent_uuid: UUID, d: date) -> bool: key = (parent_uuid, d.year) budget = self._vacation.get(key) @@ -1079,6 +1165,17 @@ class _Ledger: pool_key = (parent.uuid, child_id) if child_id is not None else None for d in dates: + if self._is_excluded(parent.uuid, child_id, d): + # §remove-this-day — the user removed this exact date for + # this exact parent. Never re-derive it, and emit nothing at + # all (not even a 'free' day): `fund_window`/ + # `_candidate_shortfall` have already taken the date out of + # the window's target wherever no responsible parent may take + # it, so it is neither funded nor a gap. This is also what + # stops `_fund_swap`'s leftover hand-off from handing a + # parent a date they were removed from. + continue + key = (parent.uuid, child_id, d) existing_type = self._existing_lookup.get(key) if existing_type is not None: @@ -1336,6 +1433,10 @@ class _Ledger: else REASON_FREE_WEEKEND ) for red_parent, red_pool in ((parent_a, pool_a), (parent_b, pool_b)): + if self._is_excluded(red_parent.uuid, child.id, d): + # Removed for this parent — the partner still gets + # their free day for this date. + continue fp_on_red_day = ( self._spend_fp((red_parent.uuid, child.id), prefer="sjukpenning") if self._input.take_pl_on_red_days and red_pool is not None @@ -1363,6 +1464,19 @@ class _Ledger: ) continue + if self._is_excluded(parent_a.uuid, child.id, d) or self._is_excluded( + parent_b.uuid, child.id, d + ): + # §remove-this-day — one parent was removed from this date + # (never both: `fund_window` already dropped those dates from + # `dates` entirely). Dubbeldagar and co-leave each spend for + # BOTH parents, so neither can fund a date only one parent may + # take. Hand it to the swap path, which funds it for whichever + # parent is still allowed to have it — `_fund_solo` skips the + # excluded one — so the partner keeps their day. + leftover_for_swap.append(d) + continue + funded = False if ( @@ -1473,7 +1587,13 @@ class _Ledger: wish = w.wish responsible = self._resolve_parents(wish) - all_dates = _dates_in(w.start, w.end) + # §remove-this-day — the window's dates minus the ones the user + # removed outright (see `_excluded_window_dates`, which also explains + # why an excluded date shrinks the target instead of counting as a + # shortfall). The red-day cluster is still scanned over the FULL + # range: klämdag detection is a property of the calendar, not of what + # this window happens to fund. + all_dates = self._window_dates(wish, responsible, w.start, w.end) red_cluster = _red_day_cluster_dates( w.start, w.end, self._input.holidays, self._input.day_overrides ) @@ -1826,7 +1946,7 @@ class _Ledger: candidate is funded for real afterwards through the ordinary `fund_window()` path, exactly as before this fix. """ - dates = _dates_in(candidate_start, candidate_end) + dates = self._window_dates(wish, responsible, candidate_start, candidate_end) red_cluster = _red_day_cluster_dates( candidate_start, candidate_end, self._input.holidays, self._input.day_overrides ) @@ -2490,6 +2610,7 @@ class AllocationInputLoader: planned_leave_repo: PlannedLeaveRepository, leave_wish_repo: LeaveWishRepository, day_override_repo: DayOverrideRepository | None = None, + exclusion_repo: PlannedDayExclusionRepository | None = None, ) -> None: self._workspace_repo = workspace_repo self._children_repo = child_repo @@ -2499,6 +2620,7 @@ 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 async def load( self, @@ -2686,6 +2808,18 @@ class AllocationInputLoader: overrides = await self._day_override_repo.list_by_workspace(workspace_id) day_overrides = {o.date: o.is_working_day for o in overrides} + # §remove-this-day — the dates the user removed by hand. Loaded + # unconditionally (never narrowed by `only_pinned_existing`): a + # 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 + ) + return AllocationInput( today=today, horizon_end=horizon_end, @@ -2700,6 +2834,7 @@ class AllocationInputLoader: day_overrides=day_overrides, take_pl_on_red_days=ws.take_pl_on_red_days, all_existing_planned=all_existing_planned, + excluded_days=excluded_days, ) diff --git a/backend/app/services/planned_day_exclusion_service.py b/backend/app/services/planned_day_exclusion_service.py new file mode 100644 index 0000000..2aa25c1 --- /dev/null +++ b/backend/app/services/planned_day_exclusion_service.py @@ -0,0 +1,132 @@ +"""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/migrations/versions/005_planned_day_exclusion.py b/backend/migrations/versions/005_planned_day_exclusion.py new file mode 100644 index 0000000..c342425 --- /dev/null +++ b/backend/migrations/versions/005_planned_day_exclusion.py @@ -0,0 +1,66 @@ +"""Add planned_day_exclusion: one date the planner may not place leave on. + +Removing a single day out of a planner-placed period used to be impossible: +the wish still said "a full week", so the next `POST /api/plan/sync` simply +re-derived the day. This table is where "not this one date" is written down, +per (workspace, child, parent, date). + +Deliberately a table of its own rather than a row in `day_override`: that +one answers a *calendar* question ("is this a working day for this +workspace?") and is workspace-wide, this one answers a *planning* question +("may a leave day be placed here?") and is per parent. See +`app/entities/planned_day_exclusion.py`. + +A plain `CREATE TABLE` suffices — nothing existing changes, and there is +nothing to backfill: before this migration no exclusion could be expressed, +so the correct history is "none". + +Must stay byte-for-byte equivalent to the `planned_day_exclusion` table in +`app/db/connection.py`'s `_FULL_SCHEMA` — +`tests/integration/test_schema_parity.py` asserts exactly that. + +Revision ID: 005 +Revises: 004 +Create Date: 2026-09-19 +""" + +from __future__ import annotations + +from alembic import op + +revision: str = "005" +down_revision: str | None = "004" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute( + """ + 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 + ) + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_planned_day_exclusion_workspace + ON planned_day_exclusion (workspace_id) + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_planned_day_exclusion_workspace") + op.execute("DROP TABLE IF EXISTS planned_day_exclusion") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4abfb75..ff98edc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -27,6 +27,9 @@ from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverri 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.tip_dismissal_repository import SQLiteTipDismissalRepository from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork @@ -44,6 +47,7 @@ from app.services.auth_service import AuthService from app.services.child_service import ChildService 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.tips_service import TipsService from app.services.vacation_days_service import VacationDaysService @@ -299,6 +303,26 @@ async def day_override_service( return DayOverrideService(day_override_repo) +# --------------------------------------------------------------------------- +# PlannedDayExclusion (user-removed days — see app/entities/planned_day_exclusion.py) +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def planned_day_exclusion_repo( + connection: aiosqlite.Connection, +) -> SQLitePlannedDayExclusionRepository: + return SQLitePlannedDayExclusionRepository(connection) + + +@pytest_asyncio.fixture +async def planned_day_exclusion_service( + planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, +) -> PlannedDayExclusionService: + return PlannedDayExclusionService(planned_day_exclusion_repo, planned_leave_repo) + + @pytest_asyncio.fixture async def child_service( child_repo: SQLiteChildRepository, @@ -329,6 +353,7 @@ async def allocation_input_loader( planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, day_override_repo: SQLiteDayOverrideRepository, + planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, ) -> AllocationInputLoader: return AllocationInputLoader( workspace_repo=workspace_repo, @@ -339,6 +364,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, ) diff --git a/backend/tests/unit/test_planned_day_exclusion.py b/backend/tests/unit/test_planned_day_exclusion.py new file mode 100644 index 0000000..7276426 --- /dev/null +++ b/backend/tests/unit/test_planned_day_exclusion.py @@ -0,0 +1,367 @@ +"""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_services/test_allocation_excluded_days.py b/backend/tests/unit/test_services/test_allocation_excluded_days.py new file mode 100644 index 0000000..0529f7a --- /dev/null +++ b/backend/tests/unit/test_services/test_allocation_excluded_days.py @@ -0,0 +1,270 @@ +"""The allocator must never re-place a day the user removed by hand. + +User's requirement, verbatim: "removing a single day should stay permanent. +Even if it's an auto added period day." The wish still says "a full week", so +without `AllocationInput.excluded_days` the next `POST /api/plan/sync` simply +re-derives the removed date — which is exactly what these tests pin down. + +Two decisions are asserted here, both explained at length on +`_Ledger._excluded_window_dates`: + +1. **The parent is part of the key.** A both-parents window places one day + per parent on the same date; removing one parent's day leaves the + partner's standing. A date only leaves the window entirely when no + responsible parent may take it. +2. **An excluded date shrinks the window's target; it is not a shortfall.** + 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). +""" + +from datetime import date + +from app.services.allocation_service import Allocator, DayPool, VacationBudget +from tests.unit.test_services.test_allocation_service import _build_input, _child, _parent, _wish + +# Feb 2027 is the suite's clean "no red-day interference" month; Feb 1-7 2027 +# is Mon-Sun, so 5 spendable days + Sat 6 + Sun 7. +_WINDOW_START = (2, 1) +_WINDOW_END = (2, 7) +_REMOVED = date(2027, 2, 3) # a Wednesday in the middle of the window + + +def _solo_scenario(*, excluded: frozenset | None = None, reuse=None): + """A one-parent, one-week fixed window. + + `reuse` takes a `(child, parent)` pair from an earlier call so a test can + build two inputs that are identical down to the uuids — needed both for a + before/after comparison and for the determinism check, since `_Ledger` + mutates its copies of the pool/vacation dicts in place and every + `propose()` run therefore needs its own fresh input. + """ + if reuse is not None: + child, parent = reuse + else: + child, parent = _child(birth_date=date(2025, 6, 1)), _parent(name="Solo") + wish = _wish( + child_id=child.id, + name="Sportlov", + start=_WINDOW_START, + end=_WINDOW_END, + weeks=1, + first_year=2027, + parent_mode="specific", + parent_uuid=parent.uuid, + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent], + pools={(parent.uuid, child.id): DayPool(200, 90, 60)}, + vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)}, + wishes=[wish], + excluded_days=excluded, + ) + return alloc_input, parent, child + + +def _both_scenario(*, excluded: frozenset | None = None, reuse=None): + """A both-parents ("co-leave") one-week fixed window; `reuse` as above.""" + if reuse is not None: + child, parent_a, parent_b = reuse + else: + child = _child(birth_date=date(2025, 6, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + wish = _wish( + child_id=child.id, + name="Tillsammans", + start=_WINDOW_START, + end=_WINDOW_END, + weeks=1, + first_year=2027, + parent_mode="both", + ) + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent_a, parent_b], + pools={ + (parent_a.uuid, child.id): DayPool(200, 90, 60), + (parent_b.uuid, child.id): DayPool(200, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(25, 0, 0), + (parent_b.uuid, 2027): VacationBudget(25, 0, 0), + }, + wishes=[wish], + excluded_days=excluded, + ) + return alloc_input, parent_a, parent_b, child + + +class TestExcludedDayIsNeverPlaced: + def test_solo_window_skips_the_removed_date(self) -> None: + baseline_input, parent, child = _solo_scenario() + baseline = Allocator().propose(baseline_input) + assert _REMOVED in {d.date for d in baseline.days if d.day_type != "free"} + + alloc_input, parent, child = _solo_scenario( + excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent) + ) + proposal = Allocator().propose(alloc_input) + + assert _REMOVED not in {d.date for d in proposal.days} + # The rest of the window is untouched. + assert {d.date for d in proposal.days if d.day_type != "free"} == { + date(2027, 2, 1), + date(2027, 2, 2), + date(2027, 2, 4), + date(2027, 2, 5), + } + + def test_removed_date_is_not_reported_as_a_shortfall(self) -> None: + """Decision: an excluded date reduces the window's target. + + 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. + """ + _seed, parent, child = _solo_scenario() + alloc_input, _p, _c = _solo_scenario( + excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent) + ) + proposal = Allocator().propose(alloc_input) + + outcome = proposal.outcomes[0] + assert outcome.shortfall_days == 0 + assert outcome.remedies == [] + # `weeks_funded` still tells the truth about what was actually + # placed: 6 of the window's 7 days (4 funded + Sat + Sun). + assert outcome.weeks_funded == round(6 / 7, 2) + + def test_an_unrelated_parents_exclusion_does_not_touch_this_window(self) -> None: + _seed, parent, child = _solo_scenario() + alloc_input, _p, _c = _solo_scenario( + excluded=frozenset({(_parent(name="Stranger").uuid, child.id, _REMOVED)}), + reuse=(child, parent), + ) + proposal = Allocator().propose(alloc_input) + + assert _REMOVED in {d.date for d in proposal.days if d.day_type != "free"} + assert proposal.outcomes[0].shortfall_days == 0 + + +class TestExclusionIsKeyedPerParent: + def test_both_parents_window_keeps_the_partners_day(self) -> None: + """The parent-keying decision, stated as a test. + + Removing "this day" from A's band must not silently cancel B's leave + on the same date — B never asked for that, and on a shared plan they + would only discover it by noticing the hole. + """ + alloc_input, parent_a, parent_b, child = _both_scenario() + baseline = Allocator().propose(alloc_input) + baseline_on_date = [d for d in baseline.days if d.date == _REMOVED] + assert {d.parent_personal_data_uuid for d in baseline_on_date} == { + parent_a.uuid, + parent_b.uuid, + } + + alloc_input, parent_a, parent_b, child = _both_scenario( + excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}), + reuse=(child, parent_a, parent_b), + ) + proposal = Allocator().propose(alloc_input) + + on_date = [d for d in proposal.days if d.date == _REMOVED] + assert [d.parent_personal_data_uuid for d in on_date] == [parent_b.uuid] + assert on_date[0].day_type != "free" + + def test_the_date_leaves_the_window_only_when_both_parents_are_removed(self) -> None: + _seed, parent_a, parent_b, child = _both_scenario() + alloc_input, _a, _b, _c = _both_scenario( + excluded=frozenset( + { + (parent_a.uuid, child.id, _REMOVED), + (parent_b.uuid, child.id, _REMOVED), + } + ), + reuse=(child, parent_a, parent_b), + ) + proposal = Allocator().propose(alloc_input) + + assert _REMOVED not in {d.date for d in proposal.days} + assert proposal.outcomes[0].shortfall_days == 0 + + def test_one_parents_exclusion_is_not_a_shortfall_for_the_window(self) -> None: + _seed, parent_a, parent_b, child = _both_scenario() + alloc_input, _a, _b, _c = _both_scenario( + excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}), + reuse=(child, parent_a, parent_b), + ) + proposal = Allocator().propose(alloc_input) + + assert proposal.outcomes[0].shortfall_days == 0 + + +class TestSwapWindowDoesNotRefillTheHole: + def test_the_removed_date_is_not_handed_to_the_other_parent(self) -> None: + """`_fund_swap`'s leftover hand-off must not undo the removal. + + In a swap window exactly one parent is on leave per date, so a date + one parent is removed from would otherwise fall through as "unfunded + leftover" and be offered to the partner — the day would visibly come + back, just in the other parent's colour. + """ + child = _child(birth_date=date(2025, 6, 1)) + parent_a = _parent(name="A") + parent_b = _parent(name="B") + wish = _wish( + child_id=child.id, + name="Sommar", + start=_WINDOW_START, + end=_WINDOW_END, + weeks=1, + first_year=2027, + parent_mode="either", + ) + # `parent_mode='either'` with two parents resolves to a swap window. + alloc_input = _build_input( + today=date(2027, 1, 1), + horizon_end=date(2027, 12, 31), + children=[child], + parents=[parent_a, parent_b], + pools={ + (parent_a.uuid, child.id): DayPool(200, 90, 60), + (parent_b.uuid, child.id): DayPool(200, 90, 60), + }, + vacation={ + (parent_a.uuid, 2027): VacationBudget(25, 0, 0), + (parent_b.uuid, 2027): VacationBudget(25, 0, 0), + }, + wishes=[wish], + excluded_days=frozenset({(parent_a.uuid, child.id, _REMOVED)}), + ) + proposal = Allocator().propose(alloc_input) + + assert _REMOVED not in {d.date for d in proposal.days} + assert proposal.outcomes[0].shortfall_days == 0 + + +class TestDeterminismIsPreserved: + def test_same_input_same_output_with_exclusions(self) -> None: + """§5 test #11 — the purity guarantee still holds with exclusions.""" + _seed_input, parent, child = _solo_scenario() + excluded = frozenset({(parent.uuid, child.id, _REMOVED)}) + first_input, _p, _c = _solo_scenario(excluded=excluded, reuse=(child, parent)) + second_input, _p2, _c2 = _solo_scenario(excluded=excluded, reuse=(child, parent)) + + first = Allocator().propose(first_input) + second = Allocator().propose(second_input) + + assert [(d.date, d.day_type, d.reason) for d in first.days] == [ + (d.date, d.day_type, d.reason) for d in second.days + ] diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index d5c2711..14ecee3 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, + excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None, ) -> AllocationInput: years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years} if age4_savable_cap is None: @@ -204,6 +205,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, + excluded_days=excluded_days or frozenset(), ) diff --git a/frontend/e2e/tests/drag-create-delete.spec.ts b/frontend/e2e/tests/drag-create-delete.spec.ts index 130c526..958ab83 100644 --- a/frontend/e2e/tests/drag-create-delete.spec.ts +++ b/frontend/e2e/tests/drag-create-delete.spec.ts @@ -176,13 +176,14 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t await expect(wishBand.first()).toBeVisible() - // Open the wish-linked band. Its own three-way remove: "this day" cannot - // be expressed for a wish (it would just reappear on the next sync) and - // is disabled; "this period" on a standalone (non-repeating) wish — one - // row, no series — removes the whole thing, same as "every year". + // 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 + // standalone (non-repeating) wish — one row, no series — removes the whole + // thing, same as "every year". await wishBand.first().click() await expect(page.getByTestId('period-popover__content')).toBeVisible() - await expect(page.getByTestId('period-delete-day-button')).toBeDisabled() + await expect(page.getByTestId('period-delete-day-button')).toBeEnabled() await page.getByTestId('period-delete-year-button').click() await expect(page.getByTestId('period-delete-confirm')).toBeVisible() @@ -193,3 +194,54 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t await goToCalendarMonth(page, month) await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0) }) + +test('removing a single day out of a planner-placed period keeps it removed across syncs', async ({ + page, +}) => { + // 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 — + // would simply put the day back. + await registerAndReachOnboarding(page, uniqueEmail('remove-day'), 'Robin Testsson') + await fillChildStep(page, 'Nova') + await continueSoloAndSkipBalances(page) + + const { month, dates } = targetRange() + await goToCalendarMonth(page, month) + + const wishBand = page.locator('[data-testid^="calendar-band-wish:"]') + await expect(wishBand).toHaveCount(0) + + await dragSelectDates(page, dates) + await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar') + await page.getByTestId('save-button').click() + await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible() + await expect(wishBand.first()).toBeVisible() + + // Clicking the band itself targets its first placed date; clicking one of + // its day slots would target that date instead (see CalendarWeek's + // `clickedDate`). Either way the popover's "this day" removes exactly one. + await wishBand.first().click() + await expect(page.getByTestId('period-popover__content')).toBeVisible() + await page.getByTestId('period-delete-day-button').click() + await expect(page.getByTestId('period-delete-confirm')).toBeVisible() + await page.getByTestId('period-delete-confirm-button').click() + await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible() + + // There is a way back, offered immediately. + await expect(page.getByTestId('calendar-toast-undo')).toBeVisible() + + // The removed date is marked rather than silently empty — a hole in an + // otherwise-funded week would read as a planner bug. + await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1) + // The rest of the period survives: this removed one day, not the wish. + await expect(wishBand.first()).toBeVisible() + + // The real assertion: a reload re-syncs the plan from the wish, and the + // day must still be gone afterwards. + await page.reload() + await goToCalendarMonth(page, month) + await expect(page.getByTestId('calendar-day-excluded-badge')).toHaveCount(1) + await expect(page.locator('[data-testid^="calendar-band-wish:"]').first()).toBeVisible() +}) diff --git a/frontend/src/components/calendar/PeriodPopover.test.tsx b/frontend/src/components/calendar/PeriodPopover.test.tsx index b12048a..d42f8e0 100644 --- a/frontend/src/components/calendar/PeriodPopover.test.tsx +++ b/frontend/src/components/calendar/PeriodPopover.test.tsx @@ -388,17 +388,52 @@ describe('PeriodPopover', () => { }) }) - it('disables "this day" and shows why, for a wish-linked band (would just reappear on the next sync)', () => { + // 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`. + it('enables "this day" for a wish-linked band and explains what it does', () => { render() - expect(screen.getByTestId('period-delete-day-button')).toBeDisabled() + expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled() expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute( 'title', - "Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.", + 'Bara den dagen tas bort. Resten av perioden ligger kvar, och planeraren lägger inte tillbaka dagen.', ) expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled() expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled() }) + it('calls onDelete with the "day" scope for a wish-linked band after confirmation', async () => { + mockOnDelete.mockResolvedValue(undefined) + render() + fireEvent.click(screen.getByTestId('period-delete-day-button')) + fireEvent.click(screen.getByTestId('period-delete-confirm-button')) + await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day')) + }) + + it('names the date and the parent in the "this day" confirmation', () => { + render( + , + ) + fireEvent.click(screen.getByTestId('period-delete-day-button')) + expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent( + 'Ta bort 17 juni 2028 för Alex?', + ) + }) + + it('falls back to the generic confirmation when the parent is unknown', () => { + render() + fireEvent.click(screen.getByTestId('period-delete-day-button')) + expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent( + 'Är du säker på att du vill ta bort den här dagen?', + ) + }) + it('displays a generic local error when onDelete rejects with a non-Error value', async () => { mockOnDelete.mockRejectedValue('boom') render() diff --git a/frontend/src/components/calendar/PeriodPopover.tsx b/frontend/src/components/calendar/PeriodPopover.tsx index e738965..0faa7fe 100644 --- a/frontend/src/components/calendar/PeriodPopover.tsx +++ b/frontend/src/components/calendar/PeriodPopover.tsx @@ -30,12 +30,12 @@ export interface PeriodPopoverSavePayload { wantsDubbeldag: boolean } -/** todo.md #9 — the three-way remove. `'day'` removes just the clicked - * occurrence's first date (ad-hoc bands only — see Calendar.tsx's - * `handleDeletePeriod`); `'year'` removes this occurrence/year only (for a - * yearly wish, narrows or splits `firstYear`/`lastYear`; for an ad-hoc band, - * removes the whole contiguous range, same as today); `'allYears'` removes - * every occurrence (today's only wish-branch behaviour, unchanged). */ +/** 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 + * 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' export interface PeriodPopoverProps { @@ -48,6 +48,16 @@ export interface PeriodPopoverProps { * source of truth for an ad-hoc band. */ startDate: string endDate: string + /** The single date "this day" would remove, already formatted for display + * (the day the user clicked inside the band, falling back to the band's + * first date). Shown in the confirm so it can never be ambiguous which day + * is about to go. */ + removeDayLabel?: string | undefined + /** Whose day "this day" would remove. A removal is per parent — the + * partner's day on the same date stays — so the confirm says the name out + * 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 parents: Array<{ id: string; name: string }> isSaving?: boolean | undefined error?: string | null | undefined @@ -132,6 +142,8 @@ export function PeriodPopover({ wish, startDate, endDate, + removeDayLabel, + removeDayParentName = null, parents, isSaving = false, error = null, @@ -208,7 +220,13 @@ export function PeriodPopover({ } const deleteConfirmMessage: Record = { - day: t('calendar.periodDeleteConfirmDay'), + day: + removeDayLabel && removeDayParentName + ? t('calendar.periodDeleteConfirmDayNamed', { + date: removeDayLabel, + parent: removeDayParentName, + }) + : t('calendar.periodDeleteConfirmDay'), year: t('calendar.periodDeleteConfirmYear'), allYears: t('calendar.periodDeleteConfirmAllYears'), } @@ -483,8 +501,8 @@ export function PeriodPopover({ type="button" className="btn btn--danger" onClick={() => handleDeleteClick('day')} - disabled={isSaving || wish !== null} - title={wish !== null ? t('calendar.periodDeleteDayDisabledHint') : undefined} + disabled={isSaving} + title={t('calendar.periodDeleteDayHint')} data-testid="period-delete-day-button" > {t('calendar.periodDeleteDayButton')} diff --git a/frontend/src/components/common/CalendarDay.test.tsx b/frontend/src/components/common/CalendarDay.test.tsx index 106a6f8..1273d10 100644 --- a/frontend/src/components/common/CalendarDay.test.tsx +++ b/frontend/src/components/common/CalendarDay.test.tsx @@ -270,4 +270,36 @@ describe('CalendarDay', () => { expect(day.getAttribute('aria-label')).toContain('arbetsdag') }) }) + + describe('user-removed days (PlannedDayExclusion)', () => { + it('marks a removed day with its own class, badge and aria descriptor', () => { + const { getByTestId } = render() + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--excluded') + expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument() + expect(day.getAttribute('aria-label')).toContain('borttagen dag') + }) + + it('leaves an ordinary day unmarked', () => { + const { getByTestId, queryByTestId } = render() + expect(getByTestId('calendar-day')).not.toHaveClass('calendar-day--excluded') + expect(queryByTestId('calendar-day-excluded-badge')).not.toBeInTheDocument() + }) + + it('can carry both an override and a removal on the same date', () => { + const { getByTestId } = render( + , + ) + const day = getByTestId('calendar-day') + expect(day).toHaveClass('calendar-day--override') + expect(day).toHaveClass('calendar-day--excluded') + expect(getByTestId('calendar-day-override-badge')).toBeInTheDocument() + expect(getByTestId('calendar-day-excluded-badge')).toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/components/common/CalendarDay.tsx b/frontend/src/components/common/CalendarDay.tsx index 4c7a60a..75295f8 100644 --- a/frontend/src/components/common/CalendarDay.tsx +++ b/frontend/src/components/common/CalendarDay.tsx @@ -21,6 +21,11 @@ export interface CalendarDayProps { isOverride?: boolean | undefined /** The override's value when `isOverride` is true (true = marked as a working day). */ overrideIsWorkingDay?: boolean | null | undefined + /** True iff the user removed this date from the plan by hand (see + * `PlannedDayExclusion`). Without a mark, a day the planner deliberately + * skipped inside an otherwise-funded period just looks like a hole in the + * week. */ + isExcluded?: boolean | undefined } export function CalendarDay({ @@ -37,6 +42,7 @@ export function CalendarDay({ holidayName, isOverride = false, overrideIsWorkingDay = null, + isExcluded = false, }: CalendarDayProps): ReactNode { const { t } = useTranslation() const dateObj = new Date(date + 'T00:00:00Z') @@ -82,6 +88,9 @@ export function CalendarDay({ overrideIsWorkingDay ? t('calendarDay.overrideWorkingAria') : t('calendarDay.overrideFreeAria'), ) } + if (isExcluded) { + descriptors.push(t('calendarDay.excludedAria')) + } const baseLabel = events.length > 0 ? t('calendarDay.ariaWithEvents', { date, count: events.length }) : date const ariaLabel = descriptors.length > 0 ? `${baseLabel}, ${descriptors.join(', ')}` : baseLabel @@ -97,6 +106,7 @@ export function CalendarDay({ dayType === 'klamdag' ? 'calendar-day--klamdag' : '', isPlainSaturday ? 'calendar-day--weekend' : '', isOverride ? 'calendar-day--override' : '', + isExcluded ? 'calendar-day--excluded' : '', ] .filter(Boolean) .join(' ') @@ -143,6 +153,15 @@ export function CalendarDay({ {overrideIsWorkingDay ? '⚒' : '✓'} )} + {isExcluded && ( + + )} {events.length > 0 && (
{events.map((event, idx) => ( diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx index 1844ae5..38d531c 100644 --- a/frontend/src/components/common/CalendarWeek.test.tsx +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -34,6 +34,7 @@ vi.mock('./CalendarDay', () => ({ holidayName, isOverride, overrideIsWorkingDay, + isExcluded, }: CalendarDayProps) => (
({ data-holiday-name={holidayName ?? ''} data-is-override={isOverride ? 'true' : 'false'} data-override-is-working-day={overrideIsWorkingDay === null || overrideIsWorkingDay === undefined ? '' : String(overrideIsWorkingDay)} + data-is-excluded={isExcluded ? 'true' : 'false'} onClick={onClick} onPointerDown={() => onPointerDown?.(date)} onPointerEnter={() => onPointerEnter?.(date)} @@ -481,9 +483,41 @@ describe('CalendarWeek', () => { parentPersonalDataUuid: 'parent-1', startDate: '2026-01-05', endDate: '2026-01-05', + // Clicking the band itself (not one of its day slots) reports the + // band's first date, which is what "this day" then removes. + clickedDate: '2026-01-05', }) }) + it('reports the clicked day slot as clickedDate, not the band start', () => { + const onBandClick: (info: BandClickInfo) => void = vi.fn() + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', parentPersonalDataUuid: 'parent-1' }), + ] + const { container } = render( + , + ) + const daySlots = container.querySelectorAll('.calendar-band__day') + fireEvent.click(daySlots[1]!) + expect(onBandClick).toHaveBeenCalledWith( + expect.objectContaining({ clickedDate: '2026-01-06', startDate: '2026-01-05' }), + ) + }) + + it('marks a user-removed date in the grid so the hole is explained', () => { + render() + expect(screen.getByTestId('calendar-day-2026-01-06')).toHaveAttribute( + 'data-is-excluded', + 'true', + ) + expect(screen.getByTestId('calendar-day-2026-01-05')).toHaveAttribute( + 'data-is-excluded', + 'false', + ) + }) + it('calls onBandClick with a null wishId for an ad-hoc (non-wish) band', () => { const onBandClick: (info: BandClickInfo) => void = vi.fn() const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })] @@ -708,6 +742,7 @@ describe('CalendarWeek', () => { parentPersonalDataUuid: 'parent-1', startDate: '2026-01-05', endDate: '2026-01-05', + clickedDate: '2026-01-05', }) // Clicking the band must not also trigger the day cell's own click. expect(onDayClick).not.toHaveBeenCalled() diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index 954bcfa..c3a09c9 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -14,6 +14,11 @@ export interface BandClickInfo { parentPersonalDataUuid: string startDate: string endDate: string + /** The single date the user actually clicked inside the band, which is what + * the popover's "this day" removes. Clicking the band anywhere else (or + * activating it from the keyboard) reports the band's own start date, so + * this is never empty. */ + clickedDate: string } export interface CalendarWeekProps { @@ -42,6 +47,11 @@ export interface CalendarWeekProps { onPointerEnter: (date: string) => void /** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */ calendarDaysByDate?: Map | undefined + /** Dates the user has removed from the plan by hand (see + * `PlannedDayExclusion`). A date the planner deliberately skipped inside an + * otherwise-funded period is invisible otherwise, and the hole in the week + * looks like a bug. */ + excludedDates?: Set | undefined /** Opens the period editor for the clicked band's underlying period. */ onBandClick?: ((info: BandClickInfo) => void) | undefined /** §Task7 — the wishId of a just-created/just-edited period to briefly @@ -283,6 +293,7 @@ export function CalendarWeek({ onPointerDown, onPointerEnter, calendarDaysByDate, + excludedDates, onBandClick, flashWishId, monthMaxLaneCount, @@ -352,6 +363,7 @@ export function CalendarWeek({ holidayName={calendarInfo?.name ?? null} isOverride={calendarInfo?.isOverride ?? false} overrideIsWorkingDay={calendarInfo?.overrideIsWorkingDay ?? null} + isExcluded={excludedDates?.has(date) ?? false} // Every cell is clickable/drag-selectable regardless of which // month it visually belongs to — `dayIsCurrentMonth` only // controls the dimmed `calendar-day--disabled` styling above. @@ -394,12 +406,15 @@ export function CalendarWeek({ .filter(Boolean) .join(' ') - const fireClick = () => + const fireClick = (clickedDate?: string) => onBandClick?.({ wishId: segment.wishId, parentPersonalDataUuid: segment.parentUuid, startDate: segment.overallStart, endDate: segment.overallEnd, + // Falls back to the band's first date when the click didn't land + // on a specific day slot (the band's padding, or the keyboard). + clickedDate: clickedDate ?? segment.overallStart, }) return ( @@ -417,7 +432,7 @@ export function CalendarWeek({ aria-label={`${label} · ${typeLabel}, ${segment.overallStart} – ${segment.overallEnd}`} role={onBandClick ? 'button' : undefined} tabIndex={onBandClick ? 0 : undefined} - onClick={onBandClick ? fireClick : undefined} + onClick={onBandClick ? () => fireClick() : undefined} onKeyDown={ onBandClick ? (e) => { @@ -443,6 +458,18 @@ export function CalendarWeek({ style={info.isGap ? undefined : { backgroundColor: getColorForType(info.dayType ?? 'vacation') }} title={info.isPinned ? t('calendarDay.pinnedAria') : undefined} data-testid={info.isPinned ? 'calendar-band-day-pinned' : undefined} + // Clicking one day slot opens the same popover as clicking + // the band, but tells it WHICH date was clicked — "this + // day" must be able to remove a day in the middle of a + // period, not only its first. + onClick={ + onBandClick + ? (e) => { + e.stopPropagation() + fireClick(info.date) + } + : undefined + } /> ))}
diff --git a/frontend/src/components/modals/PlannedDayModal.test.tsx b/frontend/src/components/modals/PlannedDayModal.test.tsx index 80b2878..9067e09 100644 --- a/frontend/src/components/modals/PlannedDayModal.test.tsx +++ b/frontend/src/components/modals/PlannedDayModal.test.tsx @@ -622,4 +622,58 @@ describe('PlannedDayModal', () => { expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument() }) }) + + describe('removed days (PlannedDayExclusion) — the way back', () => { + it('names the parent whose day was removed and offers to restore it', async () => { + const onRestoreExcluded = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + expect(screen.getByTestId('excluded-notice')).toHaveTextContent( + 'Du har tagit bort den här dagen för Alex.', + ) + await userEvent.click(screen.getByTestId('restore-excluded-ex-1')) + await waitFor(() => expect(onRestoreExcluded).toHaveBeenCalledWith('ex-1')) + }) + + it('lists one notice per parent who lost that date', () => { + render( + , + ) + expect(screen.getAllByTestId('excluded-notice')).toHaveLength(2) + }) + + it('shows nothing when the date has no removals', () => { + render() + expect(screen.queryByTestId('excluded-notice')).not.toBeInTheDocument() + }) + + it('hides the notice in range mode, where it would be ambiguous which date it means', () => { + render( + , + ) + expect(screen.queryByTestId('excluded-notice')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx index c48974a..6b15cb1 100644 --- a/frontend/src/components/modals/PlannedDayModal.tsx +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -23,6 +23,12 @@ export interface PlannedDayModalSavePinnedPayload { isDubbeldag: boolean } +/** One day the user removed by hand, ready to be offered back. */ +export interface PlannedDayExclusionEntry { + id: string + parentName: string +} + export interface PlannedDayModalProps { date: string isOpen: boolean @@ -39,6 +45,11 @@ export interface PlannedDayModalProps { onSaveWish: (payload: PlannedDayModalSaveWishPayload) => Promise onSavePinned: (payload: PlannedDayModalSavePinnedPayload) => Promise onDelete?: (() => Promise) | undefined + /** Removals recorded for THIS date (see `PlannedDayExclusion`), one per + * parent. Shown with a way back: a day the user can't restore is a trap, + * and the undo toast is gone as soon as it times out. */ + excludedEntries?: PlannedDayExclusionEntry[] | undefined + onRestoreExcluded?: ((exclusionId: string) => Promise) | undefined onClose: () => void isSaving?: boolean | undefined error?: string | null | undefined @@ -57,6 +68,8 @@ export function PlannedDayModal({ onSaveWish, onSavePinned, onDelete, + excludedEntries = [], + onRestoreExcluded, onClose, isSaving = false, error = null, @@ -217,6 +230,28 @@ export function PlannedDayModal({
)} + {!isRangeMode && + excludedEntries.map((entry) => ( +
+ {t('plannedDayModal.excludedNotice', { parent: entry.parentName })} + {onRestoreExcluded && ( + + )} +
+ ))} +
{!forcedAdvanced && hasMultipleParents && (
diff --git a/frontend/src/components/modals/planned-day-modal.css b/frontend/src/components/modals/planned-day-modal.css index e37d4c1..ce226f9 100644 --- a/frontend/src/components/modals/planned-day-modal.css +++ b/frontend/src/components/modals/planned-day-modal.css @@ -36,6 +36,24 @@ margin: calc(var(--sp-2) * -1) 0 var(--sp-4) 0; } +/* "You removed this day" + the way back. Wraps and sizes itself by content + * so both the Swedish and the English string fit without clipping the + * button. */ +.planned-day-modal__excluded-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); + font-size: var(--fs-xs); + color: var(--c-text-muted); + margin-bottom: var(--sp-4); +} + +.planned-day-modal__excluded-notice > span { + flex: 1 1 12rem; + min-width: 0; +} + .planned-day-modal__override-switch { display: flex; gap: var(--sp-4); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 5347390..63b7d97 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -11,5 +11,6 @@ export { useLatestBalance } from './useLatestBalance' export { usePlannedDays } from './usePlannedDays' export { useVacationDays } from './useVacationDays' export { useDayOverrides } from './useDayOverrides' +export { usePlannedDayExclusions } from './usePlannedDayExclusions' export { useCalendarDays } from './useCalendarDays' export { useScraperFetch } from './useScraperFetch' diff --git a/frontend/src/hooks/usePlannedDayExclusions.test.ts b/frontend/src/hooks/usePlannedDayExclusions.test.ts new file mode 100644 index 0000000..6d63b89 --- /dev/null +++ b/frontend/src/hooks/usePlannedDayExclusions.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { usePlannedDayExclusions } from './usePlannedDayExclusions' +import * as api from '@/services/api' +import { useWorkspace } from '@/context' +import { makePlannedDayExclusion, makeWorkspaceContextValue } from '../../tests/fixtures' + +vi.mock('@/services/api') +vi.mock('@/context') + +describe('usePlannedDayExclusions', () => { + const mockExclusions = [ + makePlannedDayExclusion({ id: 'ex-1', date: '2027-02-03' }), + makePlannedDayExclusion({ id: 'ex-2', date: '2027-02-04', parentPersonalDataUuid: 'parent-2' }), + ] + let bumpPlanVersion: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + bumpPlanVersion = vi.fn() + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }), + ) + vi.mocked(api.listPlannedDayExclusionsByWorkspace).mockResolvedValue(mockExclusions) + vi.mocked(api.createPlannedDayExclusion).mockResolvedValue(mockExclusions[0]!) + vi.mocked(api.deletePlannedDayExclusion).mockResolvedValue(undefined) + }) + + it('fetches the removed days on mount', async () => { + const { result } = renderHook(() => usePlannedDayExclusions('ws-1')) + expect(result.current.isLoading).toBe(true) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockExclusions) + expect(api.listPlannedDayExclusionsByWorkspace).toHaveBeenCalledWith('ws-1') + }) + + it('does not fetch without a workspace', async () => { + renderHook(() => usePlannedDayExclusions(null)) + await waitFor(() => { + expect(api.listPlannedDayExclusionsByWorkspace).not.toHaveBeenCalled() + }) + }) + + it('excludes a day, refetches, and returns the created row so undo can restore it', async () => { + const { result } = renderHook(() => usePlannedDayExclusions('ws-1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + const created = await result.current.exclude({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2027-02-03', + }) + + expect(api.createPlannedDayExclusion).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'parent-1', + date: '2027-02-03', + }) + expect(created.id).toBe('ex-1') + expect(api.listPlannedDayExclusionsByWorkspace).toHaveBeenCalledTimes(2) + expect(bumpPlanVersion).toHaveBeenCalledTimes(1) + }) + + it('restores a removed day and refetches', async () => { + const { result } = renderHook(() => usePlannedDayExclusions('ws-1')) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + await result.current.restore('ex-1') + + expect(api.deletePlannedDayExclusion).toHaveBeenCalledWith('ex-1') + expect(api.listPlannedDayExclusionsByWorkspace).toHaveBeenCalledTimes(2) + expect(bumpPlanVersion).toHaveBeenCalledTimes(1) + }) + + it('sets an error when the fetch fails', async () => { + vi.mocked(api.listPlannedDayExclusionsByWorkspace).mockRejectedValueOnce( + new Error('Network error'), + ) + + const { result } = renderHook(() => usePlannedDayExclusions('ws-1')) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBe('Network error') + expect(result.current.data).toEqual([]) + }) + + it('falls back to a generic message when a non-Error value is thrown', async () => { + vi.mocked(api.listPlannedDayExclusionsByWorkspace).mockRejectedValueOnce('boom') + + const { result } = renderHook(() => usePlannedDayExclusions('ws-1')) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error).toBe('Failed to load removed days') + }) +}) diff --git a/frontend/src/hooks/usePlannedDayExclusions.ts b/frontend/src/hooks/usePlannedDayExclusions.ts new file mode 100644 index 0000000..e009741 --- /dev/null +++ b/frontend/src/hooks/usePlannedDayExclusions.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from 'react' +import * as api from '@/services/api' +import { useLatestRequest } from './useLatestRequest' +import { useWorkspace } from '@/context' +import type { PlannedDayExclusion, PlannedDayExclusionCreateRequest } from '@/types' + +interface UsePlannedDayExclusionsResult { + data: PlannedDayExclusion[] + isLoading: boolean + error: string | null + /** Remove one day permanently: the planner stops placing leave here. */ + exclude: (req: PlannedDayExclusionCreateRequest) => Promise + /** The way back — what the undo toast and the day modal's restore call. */ + restore: (id: string) => Promise +} + +/** The days the user removed by hand (see `PlannedDayExclusion`). + * + * Shaped after `useDayOverrides`: fetch on workspace change, refetch after + * every write, and bump the shared plan version so other hooks reading + * plan-derived data refetch too. `exclude` returns the created row so the + * caller can build an undo that restores exactly it. + */ +export function usePlannedDayExclusions(workspaceId: string | null): UsePlannedDayExclusionsResult { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const { start, isCurrent } = useLatestRequest() + const { bumpPlanVersion } = useWorkspace() + + const fetch = useCallback(async () => { + const requestId = start() + if (!workspaceId) { + setData([]) + return + } + setIsLoading(true) + setError(null) + try { + const result = await api.listPlannedDayExclusionsByWorkspace(workspaceId) + if (!isCurrent(requestId)) return + setData(result) + } catch (err: unknown) { + if (!isCurrent(requestId)) return + setError(err instanceof Error ? err.message : 'Failed to load removed days') + } finally { + if (isCurrent(requestId)) setIsLoading(false) + } + }, [workspaceId, start, isCurrent]) + + useEffect(() => { + void fetch() + }, [fetch]) + + const exclude = useCallback( + async (req: PlannedDayExclusionCreateRequest) => { + const created = await api.createPlannedDayExclusion(req) + await fetch() + bumpPlanVersion() + return created + }, + [fetch, bumpPlanVersion], + ) + + const restore = useCallback( + async (id: string) => { + await api.deletePlannedDayExclusion(id) + await fetch() + bumpPlanVersion() + }, + [fetch, bumpPlanVersion], + ) + + return { data, isLoading, error, exclude, restore } +} diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index c4a6bda..a0b2882 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -31,6 +31,7 @@ export const calendar = { 'calendar.weekdayWed': 'Wed', 'calendar.you': 'You', 'calendarDay.ariaWithEvents': '{date} with {count} event(s)', + 'calendarDay.excludedAria': 'removed day — the planner skips it', 'calendarDay.klamdagAria': 'bridge day', 'calendarDay.overrideFreeAria': 'marked as a day off (exception)', 'calendarDay.overrideWorkingAria': 'marked as a working day (exception)', @@ -52,6 +53,8 @@ export const calendar = { 'plannedDayModal.newTitle': 'Plan a Day', 'plannedDayModal.parentLabel': 'Parent', 'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.', + 'plannedDayModal.excludedNotice': 'You removed this day for {parent}.', + 'plannedDayModal.restoreExcludedButton': 'Put the day back', 'plannedDayModal.dayStatusLabel': 'This day is:', 'plannedDayModal.rangeTitle': 'Plan {count} days', 'plannedDayModal.parentModeLabel': 'Who is this for?', @@ -138,10 +141,18 @@ export const calendar = { 'calendar.periodDeleteDayButton': 'This day', 'calendar.periodDeleteYearButton': 'This period', 'calendar.periodDeleteAllYearsButton': 'This period, every year', - 'calendar.periodDeleteDayDisabledHint': - "Can't remove a single day from a recurring plan — the planner will add it back on the next sync. Delete the period instead.", + // "This day" now works on planner-placed days too: the removal is stored + // as a `PlannedDayExclusion` and survives every subsequent sync. + 'calendar.periodDeleteDayHint': + 'Only that day is removed. The rest of the period stays, and the planner will not add the day back.', 'calendar.periodDeleteAllYearsDisabledHint': 'This period is not linked to a recurring plan.', 'calendar.periodDeleteConfirmDay': 'Are you sure you want to remove this day?', + // Names both the date and the parent: a day is removed for ONE parent, and + // the partner's day on the same date stays (see PlannedDayExclusionService). + 'calendar.periodDeleteConfirmDayNamed': + 'Remove {date} for {parent}? The day stays removed until you restore it.', + 'calendar.toastDayExcluded': '{date} removed for {parent}', + 'calendar.toastDayRestored': '{date} restored for {parent}', 'calendar.periodDeleteConfirmYear': 'Are you sure you want to remove this period (this year only)?', 'calendar.periodDeleteConfirmAllYears': 'Are you sure you want to remove the period — every year?', 'calendar.wantsDubbeldagLabel': 'Allow double days (dubbeldagar)', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index 7c97d37..01a3350 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -31,6 +31,7 @@ export const calendar = { 'calendar.weekdayWed': 'Ons', 'calendar.you': 'Du', 'calendarDay.ariaWithEvents': '{date} med {count} händelse(r)', + 'calendarDay.excludedAria': 'borttagen dag – planeraren hoppar över den', 'calendarDay.klamdagAria': 'klämdag', 'calendarDay.overrideFreeAria': 'markerad som ledig dag (undantag)', 'calendarDay.overrideWorkingAria': 'markerad som arbetsdag (undantag)', @@ -52,6 +53,8 @@ export const calendar = { 'plannedDayModal.newTitle': 'Planera en dag', 'plannedDayModal.parentLabel': 'Förälder', 'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.', + 'plannedDayModal.excludedNotice': 'Du har tagit bort den här dagen för {parent}.', + 'plannedDayModal.restoreExcludedButton': 'Lägg tillbaka dagen', 'plannedDayModal.dayStatusLabel': 'Den här dagen är:', 'plannedDayModal.rangeTitle': 'Planera {count} dagar', 'plannedDayModal.parentModeLabel': 'Vem gäller det?', @@ -139,10 +142,18 @@ export const calendar = { 'calendar.periodDeleteDayButton': 'Den här dagen', 'calendar.periodDeleteYearButton': 'Den här perioden', 'calendar.periodDeleteAllYearsButton': 'Den här perioden, varje år', - 'calendar.periodDeleteDayDisabledHint': - 'Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.', + // "Den här dagen" gäller numera även planerarens egna dagar: borttagningen + // sparas som en `PlannedDayExclusion` och överlever varje ny synk. + 'calendar.periodDeleteDayHint': + 'Bara den dagen tas bort. Resten av perioden ligger kvar, och planeraren lägger inte tillbaka dagen.', 'calendar.periodDeleteAllYearsDisabledHint': 'Den här perioden är inte kopplad till en återkommande plan.', 'calendar.periodDeleteConfirmDay': 'Är du säker på att du vill ta bort den här dagen?', + // Namnger både datumet och föräldern: en dag tas bort för EN förälder, och + // partnerns dag samma datum ligger kvar (se PlannedDayExclusionService). + 'calendar.periodDeleteConfirmDayNamed': + 'Ta bort {date} för {parent}? Dagen stannar borta tills du återställer den.', + 'calendar.toastDayExcluded': '{date} togs bort för {parent}', + 'calendar.toastDayRestored': '{date} lades tillbaka för {parent}', 'calendar.periodDeleteConfirmYear': 'Är du säker på att du vill ta bort den här perioden (bara det här året)?', 'calendar.periodDeleteConfirmAllYears': 'Är du säker på att du vill ta bort perioden — alla år?', 'calendar.wantsDubbeldagLabel': 'Tillåt dubbeldagar', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index 300c9b2..b90432c 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -13,6 +13,7 @@ import { useLeaveWishes, useWorkspaceMembers, usePlanSync, + usePlannedDayExclusions, } from '@/hooks' import { LanguageProvider } from '@/i18n' import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal' @@ -35,6 +36,8 @@ import { makeRejection, makePlanProposal, makePlanApplyResponse, + makePlannedDayExclusionsResult, + makePlannedDayExclusion, } from '../../tests/fixtures' import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar' @@ -61,6 +64,7 @@ vi.mock('@/hooks', () => ({ useLeaveWishes: vi.fn(), useWorkspaceMembers: vi.fn(), usePlanSync: vi.fn(), + usePlannedDayExclusions: vi.fn(), })) vi.mock('@/components/layout/Layout', () => ({ @@ -149,6 +153,16 @@ vi.mock('@/components/modals/PlannedDayModal', () => ({ delete )} + {(props.excludedEntries ?? []).map((entry) => ( + + ))} @@ -164,6 +178,8 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({ data-wish-id={props.wish?.id ?? 'null'} data-start={props.startDate} data-end={props.endDate} + data-remove-day-label={props.removeDayLabel ?? ''} + data-remove-day-parent={props.removeDayParentName ?? ''} data-error={props.error ?? ''} >