Update todo

This commit is contained in:
2026-09-20 17:16:09 +02:00
parent 21f27b29a5
commit fc90cf9a5f
2 changed files with 165 additions and 0 deletions
+163
View File
@@ -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 assumption is that Swedish multiples rules add days at the sjukpenning tier, so the derivation
still holds — worth confirming before anyone builds twins support. 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 ## Deliberately not proposed
+2
View File
@@ -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. - 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 -> 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 ## medium prio
- Update all deprecated things in the backend code. - Update all deprecated things in the backend code.
-> fix-deprecations-backend-plan.md -> in progress -> fix-deprecations-backend-plan.md -> in progress