Spend vacation on the days worth having, and name the ones about to expire

The planner knew exactly how many semesterdagar a parent was about to
forfeit and never placed a single day to prevent it. It also had no
weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on
every tier-3 interior vacation day, including a lone Tuesday in February.

Two changes.

Spend better. A workday adjacent to a free stretch, or in the middle of a
gap of three or fewer fenced by free days, now takes vacation --
_vacation_bridge_reason, consulted for every parent type. This departs
from spec 6.1's "wrapping suppressed for hourly": the FP-preservation
argument is salary-type independent, only the pay argument was hourly-moot.
In a both-parents window, both now take vacation on such a date rather
than one paying with FP. After funding, reallocate_vacation_by_value swaps
vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold
vacation improves while how many never drops -- greedy first, re-timed
after, never withheld. An invariant test asserts the count can only rise.

Name the losses. A new pass proposes concrete dates for vacation that would
otherwise expire, current year only, ranked red-day bridge then weekend
bridge then interior, later dates first. They ride on
PlanProposal.rescue_days, kept off `days` so nothing downstream commits
them, and surface on the Dashboard with accept-one, accept-all (one
batched /api/plan/apply, not N) and dismiss. Dismissal reuses the
tip_dismissal table, keyed on (parent, year) identity rather than the
group's dates -- those are recomputed from the whole plan's residual on
every propose, so date-hashing made dismissal self-defeating. Dismiss does
not bump the plan version; it changes no data and must not trigger a write.

Measured on the seeded demo: 12 forfeiting days for one parent now yield 12
proposed dates, all Fridays or Mondays. Total vacation placed is unchanged
at 203 days, confirming the reallocation only re-times.

Three defects here were found by driving a browser, while 2273 unit tests
passed -- all three lived in the seam between layers that unit tests mock
away. Adds API-level tests over the real request sequence. Rate limiting is
now off in tests (one file consumed the whole registration budget) with the
suite's first real 429 assertion.

PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along
the way: Dashboard figures swing on the first sync with no user action.

Backend 946 tests, frontend 1341, coverage above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
This commit is contained in:
2026-09-21 07:00:18 +02:00
parent fc90cf9a5f
commit 014503e89b
35 changed files with 5023 additions and 99 deletions
+5 -2
View File
@@ -425,8 +425,11 @@ def get_allocation_input_loader(
AllocationInputLoaderDep = Annotated[AllocationInputLoader, Depends(get_allocation_input_loader)]
def get_planning_engine_service(loader: AllocationInputLoaderDep) -> PlanningEngineService:
return PlanningEngineService(loader)
def get_planning_engine_service(
loader: AllocationInputLoaderDep,
dismissal_repo: TipDismissalRepositoryDep,
) -> PlanningEngineService:
return PlanningEngineService(loader, dismissal_repo=dismissal_repo)
PlanningEngineServiceDep = Annotated[PlanningEngineService, Depends(get_planning_engine_service)]
+47
View File
@@ -73,6 +73,23 @@ class SurplusAdviceResponse(BaseModel):
data: dict[str, object]
class RescueDayResponse(BaseModel):
"""§vacation-rescue — a suggest-only concrete date to spend a vacation
day that would otherwise expire this year — see `RescueDay`
(app/services/allocation_service.py) for why this rides on its own
`PlanProposalResponse.rescue_days` field rather than joining `days`: it
has no wish behind it and must never be picked up by anything that
iterates the proposal's `days` before the user explicitly accepts it.
"""
date: date
parent_personal_data_uuid: UUID
child_id: UUID
year: int
reason: str
class WindowOutcomeResponse(BaseModel):
wish_id: UUID
wish_name: str
@@ -153,6 +170,7 @@ class PlanProposalResponse(BaseModel):
vacation_balances: list[VacationBalanceResponse]
surplus_advice: list[SurplusAdviceResponse]
age4_projections: list[Age4ProjectionResponse]
rescue_days: list[RescueDayResponse]
@classmethod
def from_domain(cls, proposal: PlanProposal) -> "PlanProposalResponse":
@@ -246,6 +264,16 @@ class PlanProposalResponse(BaseModel):
)
for p in proposal.age4_projections
],
rescue_days=[
RescueDayResponse(
date=r.date,
parent_personal_data_uuid=r.parent_personal_data_uuid,
child_id=r.child_id,
year=r.year,
reason=r.reason,
)
for r in proposal.rescue_days
],
)
@@ -486,6 +514,25 @@ class EvictionPreviewResponse(BaseModel):
)
# ---------------------------------------------------------------------------
# POST /api/plan/rescue-days/dismiss
# ---------------------------------------------------------------------------
class RescueDayDismissRequest(BaseModel):
"""Dismiss one (parent, year) group of suggested vacation-rescue days —
see `PlanningEngineService.dismiss_rescue_days`. No `date`/`reason` list
here on purpose: the service recomputes the currently-suggested group
itself rather than trusting a client-supplied date list (same shape as
`TipDismissalCreate`/`TipsService.dismiss`).
"""
workspace_id: UUID
parent_personal_data_uuid: UUID
year: int
class PlanSyncResponse(BaseModel):
proposal: PlanProposalResponse
apply_result: PlanApplyResponse
+25
View File
@@ -17,6 +17,7 @@ from app.models.plan import (
PlanProposeRequest,
PlanSyncRequest,
PlanSyncResponse,
RescueDayDismissRequest,
)
from app.utils.errors import NotFoundError, ValidationError
@@ -103,3 +104,27 @@ async def sync_plan(
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return PlanSyncResponse.from_domain(result)
@router.post("/rescue-days/dismiss", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_rescue_days(
payload: RescueDayDismissRequest,
service: PlanningEngineServiceDep,
scope: WorkspaceScopeDep,
) -> None:
"""Dismiss a suggested vacation-rescue group (§vacation-rescue) so it
stops appearing in `/propose`/`/sync` — until the underlying dates
change, at which point it resurfaces (see
`PlanningEngineService.dismiss_rescue_days`).
"""
scope.check_workspace(payload.workspace_id)
# The parent must belong to the caller's own workspace too — otherwise a
# foreign parent_personal_data_uuid could dismiss (or probe for) another
# tenant's rescue-day suggestion even though workspace_id itself checks out.
await scope.check_member(payload.parent_personal_data_uuid)
try:
await service.dismiss_rescue_days(
payload.workspace_id, payload.parent_personal_data_uuid, payload.year
)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
File diff suppressed because it is too large Load Diff