Update todo
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user