Separate user intent from planner output via day assertions

The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:20:14 +02:00
parent fc90cf9a5f
commit 782415122d
101 changed files with 9542 additions and 3930 deletions
+1 -164
View File
@@ -17,7 +17,7 @@ birthdays mean two cliffs.
**Suggestion:** decide whether multi-child is a supported scenario. If yes, the timeline on
Översikt is the natural place to show both; if no, say so in the UI rather than half-supporting it.
## 2. The planner cannot explain itself
## 2. The planner cannot explain itself --> in progress
Every funded day already carries a `reason` code (`vacation_wrap_red_day`, `fp_interior`,
`swap_first_half`, `dubbeldag`, …) and every window produces a `WindowOutcome` with a cost
@@ -76,169 +76,6 @@ 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