Merge branch 'vacation-rescue-and-wrap'

Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.

Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:

- plan.py: both import sets; main's sync-with-wish handler kept next to the
  branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
  applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
  ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
  `is_pinned` flag that no longer exists: on main a pin IS a day assertion
  (see PlanningService.add_planned_day's docstring), and an assertion is
  what keeps a later sync from retyping the day. useRescueDayActions,
  its tests, VacationRescueTip's tests and the backend integration flow
  follow that contract; applyPlan stays as a client for the live endpoint
  minus the dropped field. The hook also gets the security branch's
  useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
  buildSyncToastMessage (the branch's new parameter); the branch's
  swap-visibility toast tests drive the preset path through syncWithWish,
  which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
  isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:48:33 +02:00
42 changed files with 5531 additions and 103 deletions
+5 -2
View File
@@ -439,8 +439,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
@@ -69,6 +69,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
@@ -154,6 +171,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":
@@ -248,6 +266,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
],
)
@@ -406,6 +434,25 @@ class PlanChangeResponse(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
+1 -1
View File
@@ -127,7 +127,7 @@ async def get_context(
children = await child_repo.list_by_workspace(pd.workspace_id)
workspace = await ws_repo.get_by_id(pd.workspace_id)
if workspace is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No workspace found")
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "No workspace found")
return _UserContext(
personal_data_uuid=str(pd.uuid),
workspace_id=str(pd.workspace_id),
+25
View File
@@ -19,6 +19,7 @@ from app.models.plan import (
PlanSyncResponse,
PlanSyncWithWishRequest,
PlanSyncWithWishResponse,
RescueDayDismissRequest,
)
from app.utils.errors import NotFoundError, ValidationError
@@ -149,3 +150,27 @@ async def sync_plan_with_wish(
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
return PlanSyncWithWishResponse.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