Keep plan warnings in step with the plan they describe
Warnings such as "vacation days at risk of being lost" kept showing after the planner had already resolved them. The planner itself was firing correctly -- PlanSyncProvider syncs on every plan-input change -- so the fault was in the warnings, in two independent places. The vacation-expiry warning was computed before allocation. It read `vacation_forfeiting`, which the loader derives from already-committed usage, while `_assign_tier3` deliberately spends at-risk vacation days first precisely to avoid that forfeiture. The figure therefore counted days the plan was in the middle of saving. `_vacation_expiry_warnings` now subtracts the vacation days this run actually placed for that parent and year, clamped at zero -- sound because the bank is FIFO, so a day spent in year Y retires one expiring day one-for-one. The full carry-forward simulation is still not duplicated inside the ledger. The Dashboard then never refreshed them. Its proposal came from a one-shot effect guarded by `proposal === null`, with no planVersion dependency, so the card kept rendering the pre-replan warning until remount -- while `syncPlan` was already returning a fresh proposal that both call sites discarded. PlanSyncProvider now exposes that proposal behind a monotonic counter, and usePlanProposal prefers it once any sync has completed, keeping `propose()` for the first load. Also adds a manual recompute button to Settings, which only bumps planVersion and reports what the resulting sync did, and fixes partner add/remove never bumping planVersion at all -- that gap meant gaining or losing a parent never re-ran the allocator, though it changes who days can be assigned to. Rename stays cosmetic. Verified in the browser: a 2031 warning of 15 days dropped to 12 after three vacation days were planned, without a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
This commit is contained in:
@@ -328,6 +328,11 @@ class TestWarnings:
|
||||
# `has_vacation_near_expiry` heuristic, so a hand-built pure
|
||||
# `AllocationInput` supplies that figure directly rather than a
|
||||
# `VacationBudget` shape the heuristic used to infer it from.
|
||||
#
|
||||
# Also the "structurally unresolvable" regression case for the
|
||||
# post-allocation subtraction added below: no wishes means this run
|
||||
# places zero vacation days anywhere, so nothing can reduce the
|
||||
# forecast and the warning must still fire for the full figure.
|
||||
parent = _parent()
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
@@ -347,6 +352,96 @@ class TestWarnings:
|
||||
assert expiry_warnings[0].data["expiring_days"] == 5
|
||||
assert expiry_warnings[0].data["year"] == 2027
|
||||
|
||||
def test_vacation_expiring_warning_reduced_by_placed_vacation_days(self) -> None:
|
||||
# §Task 2 follow-up — `_assign_tier3` (family-wide, 0-children wishes
|
||||
# are vacation-only regardless of its bias) spends vacation to fund
|
||||
# this window, so the pre-placement forecast must be reduced by
|
||||
# however many vacation days THIS run actually placed in that same
|
||||
# year before the warning is shown — the FIFO argument in
|
||||
# `_vacation_expiry_warnings`'s docstring.
|
||||
parent = _parent()
|
||||
wish = _wish(child_id=None, start=(2, 1), end=(2, 7), weeks=1)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[],
|
||||
parents=[parent],
|
||||
pools={},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
|
||||
wishes=[wish],
|
||||
vacation_forfeiting={(parent.uuid, 2027): 8},
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
placed_vacation_2027 = sum(
|
||||
1 for d in proposal.days if d.day_type == "vacation" and d.date.year == 2027
|
||||
)
|
||||
assert placed_vacation_2027 == 5 # Mon 1 Feb - Fri 5 Feb, the whole eligible window
|
||||
|
||||
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
||||
assert len(expiry_warnings) == 1
|
||||
assert expiry_warnings[0].data["expiring_days"] == 3 # 8 forecast - 5 placed
|
||||
assert expiry_warnings[0].data["year"] == 2027
|
||||
|
||||
def test_vacation_expiring_warning_suppressed_when_fully_covered(self) -> None:
|
||||
# M >= N: the plan places at least as many vacation days in the
|
||||
# forfeiting year as were forecast to expire, so no warning at all.
|
||||
parent = _parent()
|
||||
wish = _wish(child_id=None, start=(2, 1), end=(2, 7), weeks=1)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[],
|
||||
parents=[parent],
|
||||
pools={},
|
||||
vacation={(parent.uuid, 2027): VacationBudget(25, 5, 0)},
|
||||
wishes=[wish],
|
||||
vacation_forfeiting={(parent.uuid, 2027): 5},
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
placed_vacation_2027 = sum(
|
||||
1 for d in proposal.days if d.day_type == "vacation" and d.date.year == 2027
|
||||
)
|
||||
assert placed_vacation_2027 == 5
|
||||
|
||||
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
||||
assert expiry_warnings == []
|
||||
|
||||
def test_vacation_expiring_warning_unaffected_by_placements_in_other_year(self) -> None:
|
||||
# The plan places vacation only in 2027; the forfeiture forecast is
|
||||
# for 2028, a year this run places no vacation in at all, so the
|
||||
# figure must pass through unchanged.
|
||||
parent = _parent()
|
||||
wish = _wish(child_id=None, start=(2, 1), end=(2, 7), weeks=1, first_year=2027)
|
||||
alloc_input = _build_input(
|
||||
today=date(2027, 1, 1),
|
||||
horizon_end=date(2027, 12, 31),
|
||||
children=[],
|
||||
parents=[parent],
|
||||
pools={},
|
||||
vacation={
|
||||
(parent.uuid, 2027): VacationBudget(25, 0, 0),
|
||||
(parent.uuid, 2028): VacationBudget(25, 0, 0),
|
||||
},
|
||||
wishes=[wish],
|
||||
vacation_forfeiting={(parent.uuid, 2028): 4},
|
||||
)
|
||||
|
||||
proposal = Allocator().propose(alloc_input)
|
||||
|
||||
placed_vacation_2027 = sum(
|
||||
1 for d in proposal.days if d.day_type == "vacation" and d.date.year == 2027
|
||||
)
|
||||
assert placed_vacation_2027 == 5
|
||||
|
||||
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
|
||||
assert len(expiry_warnings) == 1
|
||||
assert expiry_warnings[0].data["expiring_days"] == 4
|
||||
assert expiry_warnings[0].data["year"] == 2028
|
||||
|
||||
|
||||
class TestVacationForfeitureBias:
|
||||
"""§Task 2 — `_assign_tier3` spends vacation before FP in a year where
|
||||
|
||||
Reference in New Issue
Block a user