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:
@@ -76,8 +76,24 @@ export function Dashboard(): ReactNode {
|
||||
// absent year, which is the correct fallback either way.
|
||||
const { data: preschoolPlanningDays } = usePreschoolPlanningDaysList(selectedChildId ?? null)
|
||||
const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null)
|
||||
// Unlike `usePlanProposal` below, `useOptimizationTips` still only fetches
|
||||
// once (no `planVersion` dependency) — a plan-changing write elsewhere
|
||||
// (Settings, a bumped partner add/remove, PlanSyncProvider's own
|
||||
// background sync) never refreshes the tips shown here. Same staleness
|
||||
// shape as the bug `usePlanProposal` was just fixed for; left alone here,
|
||||
// deliberately out of scope for this change.
|
||||
const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
|
||||
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null)
|
||||
// `proposal` (`data` from `usePlanProposal`) is already the freshest
|
||||
// proposal available — that hook itself prefers `PlanSyncProvider`'s
|
||||
// latest synced proposal over its own one-shot `propose()` result once a
|
||||
// sync has completed (see `usePlanProposal.ts`'s doc comment). No
|
||||
// `planVersion` dependency is needed *here* the way `useAnalytics.ts`
|
||||
// has one: `usePlanProposal` calls `usePlanSyncContextOptional()`
|
||||
// internally, a React Context read, so this component re-renders
|
||||
// whenever `PlanSyncProvider`'s state changes — the same plumbing
|
||||
// `planVersion` deps elsewhere rely on, just via Context instead of a
|
||||
// dependency array.
|
||||
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
|
||||
|
||||
const orderedMembers = orderSelfFirst(members, personalDataUuid)
|
||||
@@ -103,10 +119,12 @@ export function Dashboard(): ReactNode {
|
||||
wasAwaitingFirstChildRef.current = children.length === 0
|
||||
}, [children.length, refreshMembers])
|
||||
|
||||
// The plan proposal (`POST /api/plan/propose`, read-only — persists
|
||||
// nothing) is the only source for wished-vs-funded per year and the
|
||||
// surplus advice, so it is requested automatically once wishes exist.
|
||||
// Stops after one failure rather than retrying on every render.
|
||||
// Initial-load-only fetch: this is what supplies `proposal` before
|
||||
// `PlanSyncProvider`'s own background sync has completed even once (e.g.
|
||||
// right after login). Once that first sync lands, `usePlanProposal`
|
||||
// switches to its result and this effect's own `propose()` calls stop
|
||||
// mattering — see `usePlanProposal.ts`. `proposalError === null` still
|
||||
// guards it so a failed `propose()` never becomes a per-render retry loop.
|
||||
useEffect(() => {
|
||||
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing && proposalError === null) {
|
||||
void propose()
|
||||
@@ -231,6 +249,12 @@ export function Dashboard(): ReactNode {
|
||||
data: leaves[member.uuid] ?? null,
|
||||
}))
|
||||
|
||||
// `proposal` (and therefore these, and `outcomes` passed to `YearRows`
|
||||
// below) is always the freshest one available — see the comment by
|
||||
// `usePlanProposal(...)` above. A partner add/remove, a Settings save, or
|
||||
// the new manual recompute button (Settings.tsx) all eventually bump
|
||||
// `planVersion`, which `PlanSyncProvider` turns into a background sync,
|
||||
// which lands here without this component doing anything sync-specific.
|
||||
const surplusAdvice = proposal?.surplusAdvice ?? []
|
||||
const warnings = proposal?.warnings ?? []
|
||||
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
|
||||
|
||||
Reference in New Issue
Block a user