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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user