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:
@@ -2392,7 +2392,7 @@ class _Ledger:
|
||||
warnings: list[PlanWarning] = []
|
||||
warnings.extend(self._age4_warnings(all_days))
|
||||
warnings.extend(self._sgi_warnings(all_days))
|
||||
warnings.extend(self._vacation_expiry_warnings())
|
||||
warnings.extend(self._vacation_expiry_warnings(all_days))
|
||||
return warnings
|
||||
|
||||
def _age4_projections(
|
||||
@@ -2683,7 +2683,7 @@ class _Ledger:
|
||||
)
|
||||
return warnings
|
||||
|
||||
def _vacation_expiry_warnings(self) -> list[PlanWarning]:
|
||||
def _vacation_expiry_warnings(self, all_days: list[ProposedDay]) -> list[PlanWarning]:
|
||||
"""§Task 2 — the real forfeiture figure (`AllocationInput.
|
||||
vacation_forfeiting`, sourced from the same FIFO carry-forward
|
||||
simulation the Analytics tab uses) replaces the old
|
||||
@@ -2691,20 +2691,43 @@ class _Ledger:
|
||||
total + large carry-in" and could both under- and over-warn relative
|
||||
to what actually gets lost.
|
||||
|
||||
This is a forecast, not a post-plan recomputation: `vacation_
|
||||
forfeiting` is computed by the loader from already-committed usage,
|
||||
before this run's own placements (including `_assign_tier3`'s
|
||||
forfeiture bias above) are added. A (parent, year) this run funds
|
||||
with extra vacation days over a forecast forfeiture may end up
|
||||
losing fewer days than warned here — re-simulating the bank with
|
||||
this run's own placements would mean re-deriving `AnalyticsService`'s
|
||||
full carry-forward algorithm a second time inside the pure `_Ledger`,
|
||||
which is deliberately not duplicated here.
|
||||
`vacation_forfeiting` itself is a forecast: the loader computes it
|
||||
from already-committed usage, before this run's own placements
|
||||
(including `_assign_tier3`'s forfeiture-avoidance bias — see its
|
||||
docstring above) are added. Left unadjusted, that forecast double-
|
||||
counts — `_assign_tier3` deliberately spends at-risk vacation days
|
||||
first specifically to avoid this forfeiture, so the naive figure
|
||||
warns about days the plan is actively saving.
|
||||
|
||||
This method corrects for that here, at the one call site that sees
|
||||
every placed day, rather than re-deriving `AnalyticsService`'s full
|
||||
carry-forward/FIFO bank algorithm inside this pure `_Ledger` (still
|
||||
deliberately not duplicated — see `_assign_tier3`'s docstring): for
|
||||
each (parent, year) forecast to forfeit, it subtracts the number of
|
||||
`all_days` entries with `day_type == "vacation"` that this run
|
||||
placed for that parent with a `date.year` matching that year, and
|
||||
clamps the result at 0. This is sound *because* the bank is FIFO —
|
||||
a vacation day spent in year Y is always drawn from the oldest
|
||||
entry in that parent's bank, which is exactly the entry about to
|
||||
expire. So each vacation day placed in year Y retires one
|
||||
forecast-expiring day for that year one-for-one, up to the
|
||||
forecast itself; the clamp stops the count going negative once
|
||||
placements cover (or exceed) the forecast, and any placements
|
||||
beyond that draw from newer, non-expiring entries and are ignored.
|
||||
"""
|
||||
placed_vacation_by_parent_year: dict[tuple[UUID, int], int] = {}
|
||||
for d in all_days:
|
||||
if d.day_type != "vacation":
|
||||
continue
|
||||
key = (d.parent_personal_data_uuid, d.date.year)
|
||||
placed_vacation_by_parent_year[key] = placed_vacation_by_parent_year.get(key, 0) + 1
|
||||
|
||||
warnings: list[PlanWarning] = []
|
||||
for (parent_uuid, year), expiring in sorted(
|
||||
for (parent_uuid, year), forecast in sorted(
|
||||
self._input.vacation_forfeiting.items(), key=lambda kv: (str(kv[0][0]), kv[0][1])
|
||||
):
|
||||
placed = placed_vacation_by_parent_year.get((parent_uuid, year), 0)
|
||||
expiring = max(0, forecast - placed)
|
||||
if expiring <= 0:
|
||||
continue
|
||||
warnings.append(
|
||||
|
||||
Reference in New Issue
Block a user