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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user