diff --git a/ideas.md b/ideas.md index eba0b7b..008ad94 100644 --- a/ideas.md +++ b/ideas.md @@ -76,6 +76,169 @@ the app's premise is optimising the two *together*. assumption is that Swedish multiples rules add days at the sjukpenning tier, so the derivation still holds — worth confirming before anyone builds twins support. +## 7. Vacation forfeiture: the planner detects the loss and then does nothing about it (full plan) + +**The problem, quantified.** The seeded demo workspace shows 46 forfeiting vacation days for one +parent. This is not a demo artifact — the mechanism that would prevent it structurally cannot fire +for most of a typical year, for a reason explained below. `PLANNING_ENGINE_SPEC.md` §9.2 records +that the very first demo run had the same shape of bug: "finishing every year with 8–14 unused +semesterdagar" even after §Task 2's bias was designed. Whatever the exact number, the pattern is +structural, not incidental. + +**Root cause: the allocator only ever writes inside a wish's window.** `Allocator.propose` +(`backend/app/services/allocation_service.py:2773`) has exactly two passes: fixed wish windows +(`_expand_all_windows`, line 598) and flexible wish windows (`_expand_flexible_wish_years`, line +647). There is no third pass, and `sync_plan` (`backend/app/services/planning_service.py:814`) +calls nothing but `PlanningEngineService.propose` (line 883), which wraps this same `Allocator`. +Every `ProposedDay` the engine ever emits traces back to a date inside `[wish.window_start, +wish.window_end]`. A date with no wish covering it is never visited by `fund_window`, +`_assign_tier3`, or anything else — it simply never enters the loop. My suspicion going in (per the +task brief) was that this is the root cause, and having read `propose()` end to end, it is: there +is no code path capable of placing a day the user did not, in some form, ask a wish to cover. + +**Why §Task 2's bias doesn't save you.** The forfeiture-avoidance bias lives in `_assign_tier3` +(`allocation_service.py:1203`, decision at 1242-1250): when a parent's (parent, year) pair is +forecast to forfeit vacation (`AllocationInput.vacation_forfeiting`, computed by +`AllocationInputLoader` from `AnalyticsService._vacation_year_series`'s FIFO carry-forward bank — +same simulation the Analytics tab and the `use_vacation_before_expiry` tip both read, so the +figures agree), it reorders which resource pays for a day *that is already being funded* — vacation +first instead of FP first, even for an hourly parent who'd normally spend FP first. That is all it +does. It is a payment-method tiebreak for goal-dependent interior days (§3.3 tier 3) inside a window +a wish already created. It cannot manufacture a leave day where no wish asked for one. A parent +whose wishes cover 6-8 weeks a year (typical: summer + jullov + a few long weekends, per +`scripts/seed_demo.py`) has ~44-46 weeks of workdays every year where the allocator's placement +logic literally never runs, and that is exactly where unused vacation lives. + +**The other half of the existing machinery is purely advisory.** `WARNING_VACATION_EXPIRING` +(`allocation_service.py:2686`, `_vacation_expiry_warnings`) and the `use_vacation_before_expiry` +tip (`backend/app/services/tips_service.py:112`) both correctly compute the forecast loss and tell +the user a number and a year-end date. Neither places a day. `REMEDY_CARRY_VACATION_FORWARD` +(`allocation_service.py:1833`, `_carry_vacation_forward_remedy`) looks adjacent but solves the +opposite problem: it tells a *shortfall* ("this window is short 12 days") that spare vacation from +an earlier year *could* cover it — it's a funding-source suggestion for an under-filled wish, not a +forfeiture fix. `SURPLUS_USE_LESS_VACATION` (line ~2591) is the FP-surplus mirror, also advisory. So +today: the system knows the exact number, knows the exact deadline (year-end), and communicates +both — a nag, correctly computed, that nobody acts on before the days are gone. + +**Proposed mechanism: a third, non-wish placement pass that proposes rescue days.** After passes 1 +and 2 in `Allocator.propose()`, for every `(parent, year)` where `year == alloc_input.today.year` +(see below for why not future years) and the post-placement forfeiture is still > 0 — i.e. re-run +exactly the subtraction `_vacation_expiry_warnings` already does (line 2686-2744) rather than a +second forecast — generate up to that many candidate workdays between `today` and Dec 31 of that +year that are: not already a free day (`is_free_day`), not already occupied +(`_existing_lookup`/this-run's own placements), not in `excluded_days`. Rank candidates by adjacency +to a red-day cluster (`_red_day_cluster_dates`, line 510 — reuse it, don't reinvent it) so a rescue +day preferentially extends an existing weekend/holiday into a real day off instead of landing as an +isolated Tuesday with no value; among ties, prefer later dates (closer to the deadline leaves more +runway for the user's own wishes to claim the day first on a later sync). Emit these as +`ProposedDay`s with a **new** reason code (e.g. `REASON_VACATION_RESCUE`) and `wish_id = None` — do +not attach them to a synthetic wish, and do not silently fold them into an existing `WindowOutcome`. +They need to be visually and semantically distinct from a wish-funded day: the user didn't ask for +this date off, the planner is telling them "spend it here or lose it." + +**Do not auto-commit these to the plan.** Treat them exactly like `surplus_advice` today: a proposal +the user acts on, not a fact the sync silently writes. This matches how `_use_less_vacation_advice` +and `_carry_vacation_forward_remedy` already work (data, not `ProposedDay`s) — but rescue days are +concrete *dates*, not just a count, so they belong closer to `ProposedDay`/`WindowOutcome` shape than +to `SurplusAdvice`'s abstract `days_involved`. Suggest a new top-level `PlanProposal.vacation_rescue: +list[ProposedDay]` field, kept separate from `days` (line 460), so nothing downstream that iterates +`days` (balance netting, calendar rendering, exports) picks them up by accident before the user +accepts. Accepting one (or "accept all") writes it through the *same* ad-hoc write path a user +manually clicking a calendar day already uses today (`wish_id is None`, noted at +`planning_service.py:53` and the restructure notes' "promoting an ad-hoc band" at line 1050) — no +new persistence mechanism needed for acceptance, only new generation logic. + +**Why only `today.year`, not the whole horizon.** `vacation_forfeiting` is populated for every year +from `today.year` through `horizon_end.year` (`allocation_service.py:313-325`), and it would be easy +to "fix" all of them at once. Don't. A future year's forecast is provisional — it assumes the +household adds no more wishes between now and then, which is false by construction (that's the +whole point of `sync_plan` re-running on every edit). Suggesting concrete October-2028 rescue dates +in September 2026 is manufacturing false precision about a number that will legitimately keep +changing. Only the current year has a real, non-negotiable deadline (Dec 31 of *this* year) with no +more wish-adding time left after it passes. Keep `WARNING_VACATION_EXPIRING` as the informational +signal for future years — it already does that job correctly — and reserve the new mechanism for +the year where the deadline is actually imminent. + +**Interaction with the wish model.** This deliberately does not touch how wishes work. A wish is +"I want to be home for N weeks in this window" — a positive intent. A rescue day is the opposite +shape: "you have a day you're about to lose, spend it or don't." Bolting it onto the wish model (a +synthetic auto-generated wish) would force it through `LeaveWishService`'s materialisation, +priority ordering, and repeat-year machinery for something that is a one-shot, current-year-only, +non-negotiable nudge — that's a lot of borrowed complexity for a suggestion the user might reject +outright. Keeping it as its own small pass in the allocator, emitting its own output shape, is +smaller and doesn't risk `_expand_all_windows`/`_expand_flexible_wish_years` needing to know about +"wishes nobody made." + +**Interaction with restructure step 3 (`day_assertion`).** Genuinely easier after step 3, not +harder, but land it *after*, not concurrently. The accept-a-rescue-day write is, in both worlds, +"insert an ad-hoc leave commitment for this date" — today that's an ad-hoc `planned_leave_day` row +(`wish_id NULL`); after step 3 it's a `day_assertion` row with `wish_id NULL`, which is explicitly +called out as a first-class case in the restructure notes ("ad-hoc = assertion with `wish_id` +NULL"). The generation logic (the new pass inside `Allocator.propose()`) never touches persistence +at all — it's pure, like the rest of the allocator — so it is *not* coupled to step 3's schema +change and doesn't need to be redone once step 3 lands. The only coupling is the acceptance +endpoint, which is a few lines either way. Given the restructure's own rule — "Run STRICTLY +SERIALLY — all steps touch Calendar.tsx/planning_service.py/allocation_service.py" — and that this +feature touches exactly those same three files, sequence it after step 3 (or at minimum, not +overlapping any in-flight step) purely to avoid conflicting edits to `allocation_service.py`, not +because of any deeper technical dependency. + +**What's genuinely uncertain — needs a product-owner call, not a guess:** +- *Auto-apply vs. suggest-only.* Restructure step 2 turns eviction into "informational warning + + Undo" rather than a blocking choice. The same UX pattern — place the rescue days automatically and + let the user undo — is arguably more consistent with that direction than a separate accept/reject + UI, and would actually solve "vacation gets lost" instead of adding a fourth nag next to three + existing ones. But it also means the app puts dates on someone's calendar they didn't request, + which is a bigger behavioral commitment than anything else the planner does today (every other + auto-placement traces back to a wish the user created). I'd want this decided explicitly, not + inferred. +- *Ranking heuristic.* "Prefer extending a red-day cluster" is my judgment call, not a rule found in + the repo. A reasonable alternative is "spread evenly so no month gets clustered" or "prefer Fridays + to build long weekends without touching a holiday cluster at all." Not a legal question, a taste + question — flagging rather than deciding unilaterally. +- No new Swedish-law figure is needed. `statutory_vacation_days_per_year` (25), + `min_vacation_days_per_year` floor (20), and `vacation_carry_forward_years` (5) — all in + `backend/app/config.py:60-73`, all already cited to semesterlagen in the surrounding comments — + are exactly the inputs this mechanism needs and none are new. The one caveat already on record + (ideas.md §5): collectively-bargained extra days and non-statutory carry-over rules aren't modelled + at all, so a rescue suggestion inherits whatever fidelity gap already exists there. Not something + this plan should try to fix in passing. + +**Staged implementation, smallest-useful-first:** +1. Nothing in the allocator yet. Improve the existing `use_vacation_before_expiry` tip and + `WARNING_VACATION_EXPIRING` to name the actual last-safe-to-act date (year-end) more prominently + in the UI copy than they do today — cheap, ships this week, and makes the existing correct + forecast easier to act on manually. Almost certainly insufficient alone (it's the status quo's + mechanism, and it hasn't worked), but it's free and worth doing regardless of what follows. +2. Add the rescue-day generation pass to the pure `Allocator`, gated behind a feature flag/config + default-off, current year only, emitted on `PlanProposal.vacation_rescue`, not written anywhere. + Fully testable in isolation (same pure-function guarantee as the rest of the engine) before any + UI exists to show it — this is most of the actual engineering risk and can be validated with unit + tests against the existing `§Task 2` test fixtures before touching the frontend at all. +3. Surface it in the calendar/dashboard as a distinct visual affordance (not a normal band — the + user didn't ask for it) with one-click accept-one / accept-all / dismiss. Dismiss should behave + like the tip-dismissal system already does (§4 above) — expire when the underlying number + changes, not forever. +4. Only after 1-3 are live and observed for a season: revisit whether default behaviour should shift + toward auto-apply-with-undo, per the open question above. Do not decide that up front from a + description of the problem alone. + +**What I would NOT do:** +- Would not make the existing `_assign_tier3` bias more aggressive (e.g. spending vacation on + free-standing days by loosening what counts as "interior"). It's already correctly scoped to + "inside a window a wish created" — the bug isn't that the bias is weak, it's that most of the + calendar year never reaches it. Turning up a dial on a mechanism that structurally can't reach the + problem wastes effort and risks unrelated regressions in wish-funded placement, which today is + working correctly. +- Would not model employer-approval friction for a rescue day (whether the household could actually + get that specific date off work). Out of scope by the same abstraction line CLAUDE.md already + draws around money/SGI — this app doesn't model employer scheduling constraints anywhere else + either, and a rescue *suggestion* the user still has to act on doesn't need to. +- Would not extend this to future horizon years (see above) or to FP-surplus-at-age-4 (already has + its own, better-suited mirror mechanism in `SurplusAdvice` — no need to unify the two; they differ + in urgency shape: age-4 surplus is years away and gradual, vacation forfeiture is a hard Dec 31 + cliff). + --- ## Deliberately not proposed diff --git a/todo.md b/todo.md index 164cfb0..101fd8e 100644 --- a/todo.md +++ b/todo.md @@ -26,6 +26,8 @@ Following three have a plan (free-days-and-auto-planning-plan.md): - On every event that updates the days off (i.e. the calendar is making some changes), that should trigger the planner to recalculate the best kinds of time-off for all days. -> have a plan I think +- Use fabel to go over the frontend to analyse what can be extracted into proper components and reused that today might be implemented multiple times: "Component Extraction" + ## medium prio - Update all deprecated things in the backend code. -> fix-deprecations-backend-plan.md -> in progress