Let a day removed from a period stay removed

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 16:05:17 +02:00
parent 6d92b87bf3
commit 8cfb9c82c6
39 changed files with 2362 additions and 31 deletions
+19
View File
@@ -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);
"""
+33
View File
@@ -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,
)
@@ -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
+2
View File
@@ -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)
@@ -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,
)
@@ -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/<backend>/.
"""
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."""
@@ -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)
@@ -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
+137 -2
View File
@@ -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,
)
@@ -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)