Manual entry now takes one parent's sjukpenningnivå, lägstanivå and dubbeldagar left (plus optional reserved days) and derives the rest in build_snapshot, shared with the scraper. The scraper also reads the family panel and exposes a partner deduction; with two parents it is saved as a 'deduced' snapshot (migration 002). Parent count is the single source of entitlement: one member gets 480 days and 96 savable at age 4, two get 240 and 48 each. Workspaces are capped at two parents. Also lands the in-flight squash of migrations 002–011 into the initial schema and the removal of planning_mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
56 KiB
Planning Engine — Design Spec
Parts of this document are superseded — search for SUPERSEDED for the current behaviour.
Status: Shipped. Phases 0–F (see §8) are implemented; this document now describes actual behavior, with SUPERSEDED notes marking design-time reasoning that later changed. Author: Senior (design) — implementation handed to Sonnet subagents, phase by phase. Supersedes: nothing. Extends: ARCHITECTURE.md §6 (tips engine) and §6.5 (age-4 projection).
0. Problem
Today PlanningService is CRUD over individual planned_leave_day rows: the user marks days one at a
time, in one calendar, for one year. There is nothing that turns intent ("I want 6 weeks off each
summer and 4 weeks each winter until the child is 4") into a concrete multi-year schedule, and
nothing that decides which resource to spend on which day.
That decision is the product. ARCHITECTURE.md §6 already states the principle:
Because the app does not compute kronor, the "money" dimension is expressed qualitatively through two related levers: weekly intensity and SGI protection.
SUPERSEDED (2026-09-15).
planning_goalwas removed entirely (an earlier migration, since squashed). The engine now applies a single policy based solely onsalary_type.
So the engine must not have a fixed spend order. Choosing semester vs föräldrapenning on a given day
is the optimisation — originally driven by planning_goal crossed with the parent's salary_type,
now (per the note above) by salary_type alone (monthly / hourly).
Decisions taken (from review)
| # | Decision | Consequence |
|---|---|---|
| 1 | Spend order is goal-driven, never fixed | Strategy selection step in the allocator (§3.3, superseded — now salary_type-driven, no goal) |
| 2 | Plan for the couple jointly, solo-safe | Gated purely on parent count (§2.2, superseded — was workspace.planning_mode) |
| 3 | Best-effort + explicit shortfall when wishes exceed budget | Per-window outcome records a shortfall; never refuses (§3.6) |
| 4 | Multiple children from the start | Pools and deadlines are keyed (parent, child) throughout |
1. Shape of the feature
Three steps, mirroring ARCHITECTURE.md open question #3 ("preview before committing" — answer: yes):
Wishes → Proposal → Apply
(persisted ) (computed, NOT ) (writes planned_leave_day
user intent ) persisted; a preview) rows; calendar + dashboard light up)
The proposal is a pure function of its inputs. That is the single most important design property here: it makes the engine deterministic, explainable, cheap to re-run on every edit, and trivially unit-testable without a database.
2. Data model changes
2.1 BreakPeriod → LeaveWish
BreakPeriod already models "N weeks home together in this window" — it is the right concept, but
it is single-year and has no notion of recurrence, priority, or which parent.
Recommendation: rename and extend it into LeaveWish rather than adding a parallel concept.
Rationale: two overlapping "period" entities would be permanently confusing, and the migration cost
is zero today — select count(*) from break_period returns 0. If we defer, that stops being true.
Alternative considered: keep BreakPeriod as the materialised per-year instance of a new recurring
LeaveWish. Rejected — the materialised rows are derivable, and persisting derived state invites
drift.
SUPERSEDED (2026-09-15).
goal_overridewas removed entirely (an earlier migration, since squashed) and never replaced — the allocator applies a single policy based onsalary_type(§3.3), so no per-window goal field exists. The entity has since gained fields this section never anticipated:window_mode(§10.1),wants_dubbeldag(§Decision A, see §3.5),name_message_key(i18n provenance of a preset-derivedname) andtarget_days(a calendar-drawn period's exact day count, overridingtarget_weeks * 7). Seebackend/app/entities/leave_wish.pyfor the current, authoritative field list rather than re-deriving it from this historical snippet.
# backend/app/entities/leave_wish.py (as originally proposed — see note above for what changed)
@dataclass
class LeaveWish:
"""A family's recurring intent to be home for N weeks inside a yearly window."""
id: UUID
workspace_id: UUID
child_id: UUID | None # None = family-wide; set = tied to that child's pools
name: str # "Sommar", "Jullov"
# Recurring anchor stored as month/day ints (SQLite-friendly, no custom type).
# If (end_month, end_day) < (start_month, start_day) the window wraps into the NEXT year —
# e.g. Jullov 12-20 → 01-07. See §3.2; this case also straddles two vacation-year budgets.
window_start_month: int # 1-12
window_start_day: int # 1-31
window_end_month: int
window_end_day: int
target_weeks: int # 6
recurrence: str # 'yearly' | 'once'
first_year: int # 2027
last_year: int | None # None = until the child's 4th birthday
priority: int # 1 = highest; drives best-effort fill order (§3.6)
parent_mode: str # 'either' | 'both' | 'specific'
parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific'
created_at: datetime | None = None
updated_at: datetime | None = None
Migration: rename table break_period → leave_wish, add the new columns, backfill
window_* from the old start_date/end_date, recurrence='once', priority=1,
parent_mode='either'. Hand-written Alembic revision (this repo does not autogenerate).
Blast radius to update: abstract repo, sqlite repo, service, both routers that currently expose
/api/break-periods (break_periods.py and settings.py:184), tips_service
(long_break_parent_swap), frontend useBreakPeriods, BreakPeriodsInput, Settings.tsx.
LeaveWish gains one more field beyond what's shown above — window_mode: 'fixed' | 'flexible'
— once "när det passar bäst" wishes land. See §10, which is additive and does not change anything
above.
2.2 workspace.planning_mode
SUPERSEDED (2026-09-18).
planning_modewas removed entirely (an earlier migration, since squashed). Product decision: the original rationale below — that the planner needs an explicit flag because member count is ambiguous (a solo parent planning ahead for a partner who hasn't joined yet; a two-member workspace that still wants solo plans) — did not hold up in practice. In every call site that read it,planning_modeand parent count moved together: nothing in the product ever let a two-parent workspace stay in "solo" mode or a one-parent workspace switch to "couple" mode on purpose. The flag was a second source of truth for a fact the workspace's membership already states unambiguously. It is gone; adding a partner to the workspace IS the signal.AllocationInputno longer carries aplanning_modefield —_Ledger._resolve_parents(§3.3'sparent_swap) now gates couple wish-assignment purely onlen(self._input.parents) >= 2. The age-4 savable-days cap (§9,resolve_age4_savable_cap) was never a function of this flag to begin with — it was always purely a function of parent count.
Original (removed) schema, kept for history:
ALTER TABLE workspace ADD COLUMN planning_mode TEXT NOT NULL DEFAULT 'solo'
CHECK (planning_mode IN ('solo','couple'));
2.3 No change to planned_leave_day
SUPERSEDED (2026-09-15).
planned_leave_daygainedwish_id(an earlier migration, since squashed) linking days back to the wish that produced them, andis_pinnedto mark hand-edited days the allocator should not retype.
The proposal writes ordinary planned_leave_day rows on apply (day_type stays
'sjukpenning' | 'lagsta' | 'vacation'). The existing calendar, dashboard and analytics then work
unchanged. This is deliberate: the engine produces nothing the user could not have entered by hand.
3. The allocator
Lives in backend/app/services/allocation_service.py. Split in two, and keep the split strict:
AllocationInputLoader— touches repositories, assembles a plainAllocationInputdataclass.Allocator— pure. Noawait, no repos, nodate.today(). Input in,PlanProposalout.
Every test in §5 targets the pure half.
Everything in this section (§3.1–§3.7) describes a fixed wish — one whose window is the exact placement. §10 adds a second, additive path for flexible wishes ("när det passar bäst"): the three-tier resolution (§3.3), the constraints (§3.5) and the shortfall vocabulary (§3.6) all apply completely unchanged once a flexible wish's window has been picked — §10 only adds a window-picking step that runs before funding, for that subset of wishes.
3.1 Input
SUPERSEDED (2026-09-15).
planning_goalwas removed entirely (an earlier migration, since squashed).SUPERSEDED (2026-09-18).
planning_modewas removed entirely (an earlier migration, since squashed) — see §2.2. Current fields, for reference (backend/app/services/allocation_service.pyis authoritative):
@dataclass(frozen=True)
class AllocationInput:
today: date # injected, never read from the clock
horizon_end: date # default: latest child's 4th birthday
# Resolved once per run via `resolve_age4_savable_cap(len(parents))` — 96 for a genuine
# single parent, 48 (non-poolable) as soon as the child has 2+ parents. Never re-derived
# inside the pure `_Ledger`.
age4_savable_cap: int
children: list[ChildFacts] # id, birth_date, fourth_birthday, preschool_start
parents: list[ParentFacts] # uuid, name, salary_type, employment_status
pools: dict[tuple[UUID, UUID], DayPool] # (parent, child) → sjukpenning_left, lagsta_left,
# dubbeldagar_left
vacation: dict[tuple[UUID, int], VacationBudget] # (parent, year) → total, saved_in, already_used
wishes: list[LeaveWish]
existing_planned: list[PlannedLeaveDay] # never double-book
holidays: dict[date, str] # per §3.4
# Workspace DayOverride corrections merged on top of `holidays` (see `is_free_day`).
day_overrides: dict[date, bool]
# Full, unfiltered snapshot of every persisted `planned_leave_day` row — lets a flexible
# wish re-find its own prior placement (matched by `wish_id`) even when a parent
# reassignment currently hides it from `existing_planned` (§10.3).
all_existing_planned: list[PlannedLeaveDay] | None
3.2 Expand wishes into concrete windows
For each wish, for each year in first_year … (last_year or child's 4th-birthday year), build a
concrete (start_date, end_date).
Two cases the implementation must get right:
- Year-wrapping windows. If
(end_month, end_day) < (start_month, start_day), the window ends inyear + 1(Jullov: 2027-12-20 → 2028-01-07). - Vacation-year straddling. A wrapping window spends against two different vacation budgets. Debit each year separately; do not pool them.
Drop windows entirely outside today … horizon_end; clip windows that partially overlap.
This expansion step assumes wish.window_mode == 'fixed' — the window is window_for_year(year).
A flexible wish uses a different expansion step (§10.2) where window_for_year(year) instead yields
a bounding range to search inside, but reuses applies_to_year, the horizon clip, and the
year-wrap/vacation-straddle rules above without any change.
3.3 Choose a strategy per window — dominance first, goal only for ties
SUPERSEDED (2026-09-15).
planning_goalwas removed entirely (an earlier migration, since squashed). The allocator now applies a single policy based solely onsalary_type, skipping the goal-driven tier-3 choice entirely.
A naive design makes planning_goal a hard switch between "spend semester" and "spend FP". That is
wrong, and the Jullov case shows why:
During jul there are many röda dagar. Spending semester on the klämdagar between them keeps your salary and preserves FP days for later. So it reads as
max_money, but it is equallymax_time— the goals converge.
So the allocator resolves each spendable day in three tiers, and only reaches for the goal when the choice is genuinely ambiguous:
Tier 1 — free days. Saturday or in the holiday set. Cost nothing. Never spend a resource here.
Tier 2 — dominant plays. Days where one resource wins on every axis, regardless of goal. The main case is a klämdag inside a red-day cluster → spend semester:
| Benefit | Monthly salary | Hourly salary |
|---|---|---|
| Preserves an FP day for later (time) | ✅ | ✅ |
| Protects pay you'd lose on an FP day (money) | ✅ | ❌ |
Note the asymmetry, which §6.3 hints at but does not spell out: the FP-preservation half is salary-type independent, so red-day-cluster vacation is favourable for hourly parents too. Only the pay-protection half is monthly-only. This is why the §6.1 suppression for hourly applies to weekend wrapping specifically — for an hourly parent there is no weekend salary to protect, so bracketing a bare Sat/Sun with Fri+Mon vacation buys nothing — but it does not justify suppressing red-day-cluster vacation.
SUPERSEDED (2026-09-15). Tier 3 no longer consults a goal at all — there is a single policy, branching only on
salary_type, that maximises value extracted from FP + vacation combined. The table below has been replaced to describe it; see_assign_tier3inallocation_service.py.
Tier 3 — salary-type interior. Ordinary working days with no red-day adjacency, where semester vs FP is a real tradeoff:
salary_type |
Interior choice | Why |
|---|---|---|
monthly |
vacation first; FP (sjukpenning preferred, then lägstanivå) fills what vacation can't | A monthly-salaried parent loses nothing by spending a vacation day, so spending it first preserves FP for later. |
hourly |
FP first (sjukpenning preferred, then lägstanivå); vacation only backstops what FP can't cover | An hourly parent loses pay on a vacation day (no salary to "keep" — §6.1/§6.3), so FP goes first. |
If neither resource is available (pool/budget exhausted) the day is left unfunded and counts toward the window's shortfall (§3.6).
parent_swap composes on top of all three tiers. When the workspace has 2+ parents (see
§2.2 — superseded planning_mode) and target_weeks >= 2, it first decides who is home in each
half of the window; each parent's days are then resolved through the tiers above. This remains the
single biggest lever in the app — per §6.1, two halves of length L yield ≈2×L weeks of family time
(3+3 weeks ≈ 6 weeks together).
SUPERSEDED (2026-09-15).
goal_overrideand the whole goal-driven framing were removed entirely — there is no longer amax_money/max_time/balancedchoice to make invariant. The corollary below is kept only as a historical note; test #14b no longer exists in the current suite.
Corollary (historical): the original design argued that, because tiers 1–2 were
goal-independent, goal_override = 'max_money' on a red-day-dense window like Jullov should
produce the same allocation as max_time would — proof that the goal only mattered for the
genuinely ambiguous tier-3 interior. With tier 3 now resolved purely by salary_type (no goal at
all), this invariant is moot: there is nothing left for a goal to disagree with.
Explanation copy must state both benefits when a tier-2 play fires, or the user will think the engine optimised for the wrong thing:
Jullov 2027 — semester över klämdagarna: du behåller lönen och sparar 4 FP-dagar.
3.4 Cost the window — free days are free
Classify every date in the window:
- Free (costs nothing): Saturday, or present in the Swedish holiday set.
- Spendable: everything else.
Gotcha, verified against the live data:
holidays.Sweden(years=2027, language="sv")returns 61 entries, because it lists every Sunday as"Söndag"alongside the real röda dagar. So the holiday set already covers Sundays —is_free = (weekday == SAT) or (d in holidays). Do not additionally special-case Sunday, and do not treat the set as "public holidays only" when counting red-day clusters forvacation_wrap(filter out"Söndag"there).
A 6-week summer window is ~42 calendar days but only ~30 spendable ones. Making this visible to the user ("helger gratis") is a large part of the perceived value.
3.5 Assign resources, under constraints
Per the chosen strategy, walk spendable days and assign vacation / sjukpenning / lagsta.
Constraints — all hard unless marked:
- Vacation budget: per
(parent, year),total_days + saved_days_in − already_used. Straddling windows debit both years (§3.2). - FP pool: per
(parent, child),sjukpenning_leftandlagsta_leftdrawn separately. - Reserved days are a transfer constraint, not a balance. 90 of each parent's 240 are
non-transferable, so the limit is
transferable_days_per_parent = 240 − 90 = 150— the maximum one parent may hand the other. The allocator must never decrement a "reserved days remaining" counter, and the UI must not show one. (This istodo.txtitem #5, and the engine depends on it being modelled correctly.) - Dubbeldagar: only within the child's first 15 months, ≤60 total, consumes both parents'
pools for that date. Reachable only inside a
parent_mode == 'both'window, and even there opt-in, never automatic — see the co-leave correction below.
SUPERSEDED (2026-09-18, §Decision A/C "co-leave" correction). A
parent_mode == 'both'window does not default to dubbeldagar. The default is co-leave: both parents home on the same date, funded as one PL day (sjukpenning/lagsta, to whichever parent has more FP left) plus one vacation day (the other parent) —strategy = 'co_leave'. Dubbeldagar (strategy = 'dubbeldag') fire only when the wish'swants_dubbeldagflag is explicitly set and the date is legally eligible (inside the child's first 15 months, quota left, both parents hold FP). A date neither co-leave nor dubbeldagar can cover falls back tostrategy = 'swap'— the same sequential per-parent-half fundingparent_swapuses. Seeallocation_service.py's_fund_bothand theWindowOutcome.strategy/requested_strategy/strategy_degraded/dubbeldagar_legally_availablefields (§10.4).
SUPERSEDED (2026-09-15). The age-4 savable cap was incorrectly applied as 96 per parent. It is now 48 per parent when the child has two (or more) parents, and 96 for a genuine single parent.
- Age-4 is an objective before B4 and a hard cap after it (§9.1). Before the 4th birthday,
days above 96 that remain will be lost, so the allocator mildly prefers spending before B4 and
warns when
projected_left_at_b4 > 96. From B4 onward the available pool is hard-capped atmin(remaining, 96). ReuseAnalyticsService.get_days_left_at_age_4(§6.5) — do not reimplement it. - Preschool planning-day reserve (§9.3):
preschool_planning_days_per_yearper child, default 6, user-configurable, deducted off the top before any wish is funded. When this reserve is what causes a shortfall, say so as a remedy (§9.1) rather than silently absorbing the days. - SGI (warning only): after the child turns 1, a parent who is not working or registered as a
jobseeker needs ≥5 FP days/week. The app cannot verify employment, so this is a warning keyed off
employment_status, never a block.
Every assigned day carries a machine-readable reason so the UI can explain itself:
free_weekend, free_red_day, vacation_wrap_weekend, vacation_wrap_red_day, fp_interior,
fp_lagsta_stretch, swap_first_half, swap_second_half, dubbeldag.
3.6 Shortfall, not failure
Windows are filled in (priority, chronological) order. When a window cannot be fully funded, the
allocator funds what it can and records the gap, then moves on to the next window:
Sommar 2029 — 6 veckor önskade, 4 veckor finansierade (12 FP-dagar saknas)
It never returns "no plan". The first run is exactly when the numbers are least tuned, so a usable plan plus a precise diagnosis beats a refusal.
§10.4 reuses this exact vocabulary for a flexible wish that cannot be placed at all — it is reported as a 0%-funded window with a full shortfall, not a new kind of failure.
3.7 Output
@dataclass(frozen=True)
class PlanProposal:
days: list[ProposedDay] # date, parent, child, day_type, is_dubbeldag, wish_id, reason
outcomes: list[WindowOutcome] # wish, year, weeks_wished, weeks_funded, shortfall_days,
# strategy, explanation, cost_breakdown,
# remedies: list[ShortfallRemedy] (§9.1)
balances_after: dict[...] # pools + vacation, post-plan
warnings: list[PlanWarning] # sgi_risk | days_lost_at_age_4 | vacation_expiring
4. API
GET /api/leave-wishes/{workspace_id}
POST /api/leave-wishes
PUT /api/leave-wishes/{id}
DELETE /api/leave-wishes/{id}
POST /api/plan/propose # → PlanProposal. Read-only, persists nothing. Safe to spam on edit.
POST /api/plan/apply # body: the accepted days → writes planned_leave_day rows
POST /api/plan/sync # the direct-manipulation calendar's one call (see note below)
apply must be idempotent per (workspace, child, parent, date) and must reject days that collide
with existing planned days unless replace: true is passed. Routers stay thin; all logic in services.
Note (2026-09-18).
/api/plan/syncis a newer, actively-changing endpoint — the direct-edit calendar's single call, distinct from the propose→apply pair above. Its request/response shape is not documented here yet; a follow-up pass will cover it once that work lands.proposetoday backs a read-only preview surfaced on the Översikt tab (PlanFold.tsx/usePlanProposal), not a calendar propose-then-apply flow —applyitself has no frontend caller at the time of writing.
5. Test matrix (the acceptance criteria)
SUPERSEDED (2026-09-15, then 2026-09-18). Several test cases are outdated: (1) Tests 1, 3–4 assume
planning_goalis operative (removed); (2) Test 14 refers togoal_override(removed); (3) Tests 8 and 12 use the incorrect 96-day-per-parent cap (now 48 per parent when the child has two parents, 96 otherwise); (4) Test 10 and the "test #10" cross-reference in test 22 describe dubbeldag-outside-15-months falling back to "sequential days" — the actual fallback chain for aparent_mode == 'both'window is co-leave first, then a sequential swap only if co-leave itself can't cover the date (§Decision A/C, see §3.5).
Against the pure Allocator, with a frozen today. Target ≥90% on new backend code.
| # | Scenario | Expected |
|---|---|---|
| 1 | solo, max_time, 6-week summer, child < 12 months |
zero FP days assigned (SGI protected) |
| 2 | couple, balanced, 6-week summer |
parent_swap, 3+3, ≈6 weeks together |
| 3 | max_money + monthly salary |
vacation lands on Fri/Mon around weekends |
| 4 | max_money + hourly salary |
wrapping suppressed; no vacation-wrap reasons emitted |
| 5 | wishes exceed budget | lower-priority window shows a shortfall; higher ones are full |
| 6 | Jullov 12-20 → 01-07 | window wraps years; vacation debited from both years |
| 7 | two children, overlapping windows | pools kept separate per (parent, child) |
| 8 | projected_left_at_b4 > 96 |
days_lost_at_age_4 warning raised |
| 9 | window containing Easter (movable) | red-day cluster detected in the correct year |
| 10 | dubbeldag outside 15 months | rejected; falls back to sequential days |
| 11 | re-run propose with identical input |
byte-identical output (determinism) |
| 12 | wish window after the 4th birthday | FP pool hard-capped at 96 (§9.1) |
| 13 | shortfall caused by the preschool reserve | reduce_preschool_reserve remedy, correct day count |
| 14 | wish carries goal_override |
that window uses the override, others use the workspace goal |
Rows 15+ target flexible ("när det passar bäst") wishes — see §10 for the mechanism under test:
| # | Scenario | Expected |
|---|---|---|
| 15 | flexible wish, bound = "vår" (mar–maj), 1 week, Påsk inside the bound | candidate touching the Påsk cluster wins; weeks_funded == weeks_wished at minimal cost |
| 16 | flexible wish, bound with two candidates of equal spendable-day cost | tie broken by earliest start, never by iteration order |
| 17 | two flexible wishes, identical bound, priorities 1 and 2 | the priority-1 wish gets the cheapest candidate; priority-2 is scored against what's left |
| 18 | fixed wish and flexible wish with overlapping bounds | fixed wish's dates are placed first and are never revisited; flexible wish's candidates exclude every fixed-wish date |
| 19 | flexible wish, bound shorter than target_weeks * 7 after horizon clipping |
single shrunk candidate = the clipped bound; reported as an ordinary partial shortfall (§3.6), not unplaced |
| 20 | flexible wish, bound long enough but every full-length run collides with a fixed wish | strategy == 'unplaced', weeks_funded == 0, start_date/end_date = the clipped bound (§10.4) |
| 21 | flexible wish, recurrence == 'yearly', horizon spans 3 Easters |
each year's picked window lands on that year's Påsk-adjacent dates independently (moving target) |
| 22 | flexible wish, parent_mode == 'both', bound outside the child's 15-month dubbeldagar window |
picked window still funds via the existing sequential-days fallback (test #10), unchanged by window_mode |
| 23 | re-run propose twice with identical input, at least one flexible wish present |
byte-identical output (determinism holds for flexible wishes too, §5 test #11's guarantee extended) |
| 24 | flexible wish, window_mode omitted / defaults |
behaves exactly as a 'fixed' wish (backward-compatible default, §10.1) |
6. Frontend
SUPERSEDED (2026-09-18). The standalone
/planpage below was never built. Wishes are authored directly on the Kalender tab instead: dragging a range opensPeriodPopover.tsx(notWishEditor.tsx), which edits a wish's name/dates/parentMode/recurrence in place — there is no separate multi-year timeline page or "Föreslå plan"/"Använd plan" two-step. A season-preset rail (PresetRail.tsx) helps place common windows. ThePOST /api/plan/proposepreview instead backs a shortfall/surplus fold on the Översikt (Dashboard) tab (PlanFold.tsx); direct calendar edits go throughPOST /api/plan/sync(see §4 note — HOLD, not detailed here). Data layer:useLeaveWishes,usePlanProposal, andusePlanSync(all infrontend/src/hooks/) — components still never call the API client directly. The description below is kept for the original design rationale (recurrence/priority/parent fields, per-window explanation, i18n viamessage_key+data), which mostly still holds even though the page shape changed.
New page src/pages/Plan.tsx, route /plan, nav entry "Plan".
- Wishes editor —
BreakPeriodsInputevolves intoLeaveWishesInput(recurrence, priority, parent). Moves out of Settings onto the Plan page; Settings keeps only true configuration. §10.7 adds the explicit-window vs "när det passar bäst" toggle to this editor (WishEditor.tsx). - "Föreslå plan" →
POST /api/plan/propose→ render without persisting. - Multi-year timeline — one row per year across the horizon, bars per funded window, coloured by day type. This is the view that answers "what does the next four years look like?".
- Per-window card — weeks wished vs funded, day breakdown, the
reason-derived explanation, shortfall in plain Swedish. - "Använd plan" →
POST /api/plan/apply→ calendar and dashboard update.
Data layer via a new useLeaveWishes / usePlanProposal hook pair — components never call the API
client directly. Plain CSS, BEM, no framework. All .ts/.tsx, no any.
7. Prerequisites from todo.txt
These are not separate chores — the engine reads all three as inputs, so they gate Phase B:
- #1 vacation days per year — the entity already has
year+saved_days_in; onlySettings.tsxis wrong (hardcodesnew Date().getFullYear(), single number). The allocator budgets per year, so it needs the real multi-year table, including the "fewer days the first year" case. - #2 planning-goal enum —
PlanningGoalSelectoremitsmaximize_contiguous/minimize_stress/balance; the backend requiresmax_time/max_money/balanced. This is the allocator's primary input, so the mismatch must go. - #5 reserved days — must become the transfer constraint described in §3.5, not a countdown.
todo.txt #4 (parent money field) is unrelated to the engine — money stays abstract per §0.2 of
ARCHITECTURE.md — and can be dropped independently.
8. Phasing (one Sonnet subagent per phase)
| Phase | Scope | Gate |
|---|---|---|
| 0 | todo.txt #1, #2, #5 + workspace.planning_mode (the flag itself was later dropped entirely — see §2.2) |
ruff, pytest, tsc, eslint, vitest |
| A | LeaveWish entity, migration, repo, service, CRUD router, tests |
backend suite |
| B | AllocationInput loader + pure Allocator + /plan/propose + the §5 matrix |
≥90% on new code |
| C | /plan/apply + idempotency/collision rules |
backend suite |
| D | Types, hooks, Plan.tsx, timeline, wishes editor |
tsc, eslint 0 warnings, ≥90% vitest |
| E | Retire BreakPeriod; point tips_service.long_break_parent_swap at wishes |
both suites |
| F | Flexible wishes (§10): window_mode + migration, candidate search in the allocator, WishEditor.tsx toggle |
both suites, §5 rows 15-24 |
Phase B is the one that must not be rushed — it is the whole product, it is pure, and it is where the
test matrix lives. Phases A–E each end with graphify update . per CLAUDE.md.
9. Resolved (was: open questions)
-
Horizon runs past age 4. Wishes may extend beyond the 4th birthday and the app must not block that. Consequence — the FP budget becomes time-dependent, which the allocator must model:
SUPERSEDED (2026-09-15). The 96-day cap is now 48 per parent when the child has two (or more) parents, 96 for a genuine single parent.
FP available for a day d: d < fourth_birthday → full remaining pool d >= fourth_birthday → min(remaining, 96)Default
horizon_endis therefore no longer the 4th birthday: it ismax(last wish year-end, latest fourth_birthday), capped at the age-12 hard expiry. A parent who maximises time before age 4 will arrive at B4 with ~96 days and find later wishes heavily constrained — the correct behaviour is to plan it anyway and show the squeeze, not to refuse. -
Weekly intensity is derived, not stored on the wish. The goal strategy (§3.3) picks it; the Plan UI may override it per window, which is passed as a proposal-time override rather than persisted on the wish.
-
Preschool planning days are reserved off the top, before any wish is funded. The per-child reserve is user-configurable (
preschool_planning_days_per_year, default 6 — preschools differ), so it needs a per-child settings field, not just the config default. -
Wish-level goal override: reversed. The original decision was to add
LeaveWish.goal_override: str | Noneso the allocator could resolvegoal = wish.goal_override or input.planning_goal.goal_overridewas instead removed entirely, along withplanning_goalitself (§3.3) — there is no per-window (or per-workspace) goal left to override.
9.2 Surplus advice — the mirror of a shortfall (added after reviewing demo output)
The first demo run funded all 44 wished weeks and then warned that the family would still forfeit 219 föräldradagar at the age-4 boundary (117 + 102), while finishing every year with 8–14 unused semesterdagar. The engine warned about the loss but did nothing about it — and ARCHITECTURE.md names avoiding exactly that loss as a core purpose of the app.
A surplus is as actionable as a shortfall and must produce ranked suggestions of its own:
wish_more_leave— "ni har råd med fler veckor: lägg till en vecka på sommaren, eller ett höstlov"shift_mix_to_fp— spend föräldradagar instead of semester in the goal-dependent interior (§3.3 tier 3), conserving semester. Correct whenever FP is the expiring resource and semester is not.front_load_early_years— take more days while the child is small and you are home anyway; with a large surplus themax_moneyreading is the rational one, because the days are otherwise lost.use_less_vacation— subject to the hard constraint below.
Employer/legal minimum on vacation (new constraint). Semesterdagar cannot be hoarded freely: Swedish semesterlagen requires a minimum number of days to be taken each year (statutory 20), and only days above that minimum may be saved, for up to 5 years. An employer may set a higher floor. So a parent with 30 days may be obliged to take 20 and can bank only 10 per year.
Model as min_vacation_days_per_year per parent (default 20, user-editable — entitlements and
agreements differ). Two consequences:
use_less_vacationadvice must never propose dropping a parent below their minimum.- Vacation carry-forward is capped at
total_days - min_vacation_days_per_yearper year, and the existing 5-year expiry still applies on top.
This also bounds carry_vacation_forward in §9.1 — that remedy was previously unconstrained.
9.1 Shortfall remedies (follows from #3)
A shortfall must not be a dead number. Each WindowOutcome carries an ordered list of concrete,
machine-generated remedies naming the resource that would close the gap:
Sommar 2029 — 6 veckor önskade, 4 veckor finansierade (12 dagar saknas)
→ Minska förskolans planeringsdagar (6 reserverade) frigör 6 dagar
→ Sänk Jullov 2029 från 4 till 2 veckor frigör 10 dagar
→ 8 semesterdagar från 2028 kan sparas till 2029
Remedy kinds: reduce_preschool_reserve, lower_priority_wish, carry_vacation_forward,
reduce_weekly_intensity, shift_window_into_free_days. Each states the days freed, so the UI can
render it as a one-click adjustment later.
10. "När det passar bäst" — flexible wishes
Addition, not a revision: nothing in §2, §3.1–§3.7, §6 (decisions) or §9 changes. This closes
NextSteps260913.md §3.9 — the request that a wish like "en vecka på våren" be expressible either
as an explicit window (today's only mode) or as "whenever it fits best", in which case the engine
itself picks the window, preferring the röda-dagar/klämdagar-densest placement (e.g. around Påsk)
because that placement costs the fewest paid days. The product-owner decisions in §3.9/§3.11 of that
handoff are treated as settled below and are not relitigated; §10.9 lists the two points they leave
open.
10.1 Data model: window_mode
One new field on LeaveWish, added to the trailing defaulted group so every existing construction
site (fixtures, the repo, the service) keeps compiling unchanged:
SUPERSEDED (2026-09-15).
goal_overridewas removed entirely before this field landed, so it never coexisted withwindow_modein the real entity — the snippet below has been corrected to drop it.
@dataclass
class LeaveWish:
...
# NEW. 'fixed' (default) = window_start_*/window_end_* are the exact placement, as today.
# 'flexible' = the SAME four fields instead describe the BOUNDING RANGE the engine searches
# inside for the best-costing placement (e.g. "vår" = 03-01..05-31) — see §10.2. target_weeks
# is still the wanted duration inside that range; recurrence/first_year/last_year are unchanged,
# so a 'yearly' flexible wish still re-resolves its bounding range every anchor year through the
# existing window_for_year()/applies_to_year() helpers below, completely unmodified.
window_mode: str = "fixed" # 'fixed' | 'flexible'
created_at: datetime | None = None
updated_at: datetime | None = None
No new fields for the bound itself. A named season ("vår", "sommar", "höst", "vinter") is just a
canonical (window_start_month, window_start_day, window_end_month, window_end_day) pair, exactly
like the existing Sommar/Jullov/Höstlov presets already are for fixed wishes (WishEditor.tsx
PRESETS) — reusing the field rather than adding a season enum keeps window_for_year,
window_wraps and applies_to_year correct for flexible wishes with zero code changes to those
three methods. This mirrors the reasoning in §2.1 for folding BreakPeriod into LeaveWish: two
ways to say "a date range" would be permanently confusing.
Alternative considered: a separate season: str | None enum ('spring' | 'summer' | ...) plus a
duration, with month/day left null for flexible rows. Rejected — it forks the schema by
window_mode (two different meanings of "the wish's dates" depending on a second field is worse
than one field, window_mode, changing the meaning of fields that already exist), and it cannot
express a custom (non-seasonal) bound like "the two weeks around midsommar" without inventing a
second enum value per case.
Service-level validation (per app/models/leave_wish.py's own note that business-rule validation
lives in the service, not the schema): when window_mode == 'flexible', reject create/update if the
bounding range's raw length (window_for_year(first_year)'s span, before any horizon clipping) is
shorter than target_weeks * 7 days. That is a user-input error ("find a 6-week window inside this
2-week range" is not a legitimate future shortfall) and must not be accepted only to report a
shortfall on every single occurrence forever.
10.2 Allocation semantics — two-pass ordering, candidate search, cost, tie-break
Fixed wishes are placed first, unconditionally. This is the direct implementation of "fixed
wishes always win" (§3.9/§3.11.2 of the handoff): Allocator.propose() runs in two passes, not one
priority-ordered pass over every wish:
- Pass 1 (unchanged).
_expand_all_windowsexpands everywindow_mode == 'fixed'wish into concrete_WishWindows exactly as §3.2 describes today; they are sorted by(wish.priority, w.start, wish.id.int)and funded throughledger.fund_window()exactly as today. - Pass 2 (new). Every
window_mode == 'flexible'wish is expanded into candidate windows (below), one winning candidate is picked per wish-year, and that_WishWindowis funded through the exact sameledger.fund_window()— no second funding path. Flexible wish-years are processed in(wish.priority, bound.start, wish.id.int)order, so priority still orders flexible wishes against each other (§3.9/§3.11.2's second half) — it just never lets a flexible wish jump ahead of any fixed wish, regardless of the two priority numbers.
This two-pass split is the one piece of real allocator surgery here; everything downstream of "a window has been chosen" (§3.3's tiers, §3.5's constraints, §3.6's shortfall handling) is reused as-is.
Occupied dates. A flexible candidate must never land on a date another wish has already claimed
for the same parent and child. _Ledger._existing_lookup (today: persisted existing_planned rows
only, keyed (parent_uuid, child_id, date)) is extended with one more source: after fund_window
returns its days for any window (fixed or already-resolved flexible), Allocator.propose()
folds those ProposedDays into the same lookup before moving to the next window. This is a three-line
addition to the existing loop at Allocator.propose(), not a redesign — the lookup's shape and its
one collision key are unchanged.
Per wish-year, resolve the bound, then search:
-
bound_start, bound_end = wish.window_for_year(year)— the bound, not the final window, for a flexible wish. Clip to[today, horizon_end]exactly as §3.2; if the clipped bound is empty, drop this wish-year silently (identical to a fixed window landing entirely outside the horizon). -
length_days = target_weeks * 7(whole weeks only — "no partial days" applies to the window's shape too, not just to individual days). -
Resolve
responsible = ledger._resolve_parents(wish)andresolved_child = ledger._resolve_wish_child(wish)— the exact functionsfund_windowalready calls. Both are pure functions ofwish/alloc_input, so calling them before a window is chosen is safe. -
If the clipped bound is shorter than
length_days(horizon clipping ate into it, e.g. the last partial year beforehorizon_end): there is only one candidate — the clipped bound itself, used as a shrunk window. This funds as an ordinary partial shortfall (§3.6), exactly like a fixed window clipped at the horizon edge today. This is not the "unplaced" case (§10.4) — that is reserved for when the bound is long enough but every full-length slot is already taken. -
Otherwise, enumerate every candidate start date
swithbound_start <= sands + length_days - 1 <= bound_end(sliding one day at a time — nothing about Swedish wishes requires a candidate to start on a Monday; the existing presets don't either). Discard any candidate whose[s, s + length_days - 1]range contains a datedwhere(p.uuid, resolved_child.id, d)is in the occupied set for somepinresponsible.parents. -
Cost each surviving candidate as the count of spendable days it contains:
cost(s) = sum(1 for d in [s, s+length_days-1] if not is_free_day(d, holidays, day_overrides)), reusing the exactis_free_dayhelper from §3.4 (so workspace day overrides are already respected). This calendar-only figure is the tie-break, not the primary ranking — see the §Decision C correction below.SUPERSEDED (2026-09-18, §Decision C "budget-aware candidate ranking"). The original reasoning here — that a calendar-only cost is always exactly "fewest paid days", so goal and affordability never need to enter candidate selection — held only as long as every candidate's resolved parent(s) could actually afford every spendable day. In practice a candidate that is cheap by the calendar can still leave half its days unfunded (vacation/FP already exhausted elsewhere), which is worse than a slightly costlier candidate the parent(s) can fully pay for. Candidates are now ranked by
(shortfall, calendar_cost, start, wish.id)—_candidate_shortfallsimulates the real_fund_solo/_fund_swap/_fund_bothfunding logic against a scratch copy of the live pool/vacation state (restored afterwards;propose()stays read-only and deterministic, §5 test #11) and counts what could not be funded; only when two candidates tie on shortfall does the calendar-onlycostfrom step 6 break the tie. Seeallocation_service.py's_candidate_shortfall,resolve_flexible_windowand_reuse_already_funded_window. -
If no candidate survives step 5's collision filter, this wish-year is unplaced — §10.4.
-
Otherwise, pick the winner deterministically:
min(candidates, key=lambda c: (shortfall(c), cost(c.start), c.start, wish.id.int))(§Decision C above). Ties on shortfall are broken by calendar cost, then by earliest start date (fills the bound from its beginning — the same chronological preferenceAllocator.propose()already uses to order windows), then by the wish's UUID integer value — never by set/dict iteration order and never by anything clock-dependent, per §6's purity requirement. This extends the tie-break shape already used atwindows.sort(key=lambda w: (w.wish.priority, w.start, w.wish.id.int)); §10 does not invent a new convention, it reuses and extends this one. -
The winning candidate becomes an ordinary
_WishWindow(itsanchor_year, resolvedstart/end) and is funded through the unchangedledger.fund_window()— tiers 1–3, the vacation/FP constraints, the B4 clamp and the dubbeldagar 15-month fallback all apply exactly as they do to a fixed window landing in the same place.
10.3 Interaction with existing mechanics
- Dominance tiers (§3.3). Unaffected. Window choice is resolved before tiers ever run; once chosen, a flexible window's days are classified free/dominant/goal-dependent exactly like a fixed window's.
- B4 cap (§3.5, §9.1). Unaffected.
_maybe_clamp_age4fires off the funded window's start date, regardless of how that window was chosen. A flexible window that happens to straddle B4 is clamped exactly as a fixed one would be. Window selection does not carry a separate, explicit B4 preference — but it is no longer B4-blind either: candidate ranking is budget-aware (§10.2 step 6/§Decision C), and_candidate_shortfall's funding simulation runs through the same B4 clamp every real window does, so a candidate that would run into the B4 wall is already disfavored via its simulated shortfall, not just via the clamp applied after the fact. - Dubbeldagar / 15-month window. No special-casing needed.
parent_mode == 'both'is resolved identically for flexible wishes (§10.2 step 3, same_resolve_parentscall); if the chosen window happens to fall outside the child's first 15 months,_fund_both's existing fallback to sequential per-parent days (§5 test #10) applies unchanged. The candidate search does not need to know about the 15-month rule at all. - Reserved days. No interaction — reserved days are a transfer constraint on
parent_swapcomposition (§3.5), orthogonal to which window was picked.
SUPERSEDED (2026-09-15, then 2026-09-18).
planned_leave_daygainedwish_id(an earlier migration, since squashed) linking days back to the wish that produced them, andis_pinnedto mark hand-edited days the allocator should not retype. The reasoning below — that no frozen linkage is needed because the propose→apply preview is the safeguard — did not survive contact with parent reassignment: a flexible wish's prior placement needs to be found again even when it is currently hidden fromexisting_planned(a reassigned parent has different occupied dates) or funded under a parent the wish no longer resolves to.wish_idis now the identity key that makes that possible.
- Applied flexible wishes participate in re-search, matched by
wish_id. On a subsequentpropose()call (after the family edits wishes, or reassigns a wish's parent), a previously-applied flexible wish's own prior placement is looked up viaAllocationInput.all_existing_planned— the full, unfiltered snapshot of every persistedplanned_leave_dayrow, keyed bywish_idrather than by parent — so_reuse_already_funded_windowcan prefer reusing that placement (via_persisted_by_wish/_persisted_dates_for_wish) even when it is invisible to the narrowerexisting_plannedcollision set. A wish may still land in a different window if its bound or the occupied set changed enough to make reuse impossible; the propose→apply preview (§1) keeps that movement visible before it is confirmed. - Preschool planning-day reserve (§9.3). No interaction — the reserve is deducted off the top before any wish (fixed or flexible) is funded, per wish-year window or not.
- Surplus advice (§9.2). No change to the mechanics. A fully-funded flexible wish participates in
the same age-4 projection as a fixed one. (Whether the engine should ever suggest a new flexible
wish as a
wish_more_leaveremedy — rather than only a fixed one — is out of scope here; today'swish_more_leavecopy already reads generically enough to cover either.) - Vacation-year straddling (§3.2). A flexible wish whose bound wraps a calendar year end (e.g.
a "vinter" bound spanning Dec–Feb) uses
window_wraps()/window_for_year()unmodified; a chosen candidate that itself straddles two vacation-year budgets debits both, exactly as §3.2 already specifies for fixed windows. The cost function does not weight cross-year splits specially.
10.4 When a flexible wish cannot be placed at all
Reuses §3.6's shortfall vocabulary rather than inventing a new failure mode, per the handoff's
instruction. The only addition is a new value for the existing WindowOutcome.strategy field (today:
'solo' | 'parent_swap' | 'dubbeldag') — 'unplaced' — for the case in §10.2 step 7: the bound is
long enough, but every full-length candidate collides with a date another wish already claimed.
WindowOutcome for an unplaced wish-year:
strategy = "unplaced".weeks_funded = 0.0,shortfall_days = length_days(target_weeks * 7). Unlike a partially-funded window, there is no chosen window to derive a free/spendable split from, so the full calendar-day duration is reported rather than an estimated spendable-day count. This is a deliberate, slightly pessimistic simplification, not a policy decision — the number still renders correctly through the existing per-window card (§6).cost_breakdown= all-zero (_new_cost()).start_date/end_date= the clipped bound for that year (not a fabricated window) — so the explanation can still say what range the engine searched.remedies: reuse_build_remedies's logic with the clipped bound standing in forw.start/w.endwherever a reference year or shift-probe date is needed.carry_vacation_forwardandlower_priority_wishboth still make sense (neither needs a specific winning window). Omitshift_window_into_free_days— there is no single window edge left to shift.
SUPERSEDED (2026-09-18). The claim that no new field is needed did not survive the §Decision A/C co-leave correction (§3.5): once
strategycould legitimately degrade from what aparent_mode == 'both'wish asked for (dubbeldag → co-leave → swap) down to something worse, a bare after-the-factstrategystring could no longer say whether that happened.WindowOutcomegained three fields to make that visible:requested_strategy(whatparent_mode+wants_dubbeldagasked for),strategy_degraded(bool, true wheneverstrategy != requested_strategy), anddubbeldagar_legally_available(whether dubbeldagar were even a legal option for this window, independent of whether the user opted in — the frontend uses this to decide whether to show the dubbeldag checkbox at all).strategy/requested_strategyremain a barestras originally described — adding a literal value ('co_leave','swap') is still a copy/behavior change, not a schema change; the three new fields are the actual schema change.
10.5 Persistence
LeaveWish gains exactly one column. Hand-written Alembic revision, following the pattern already
established by the existing migrations in backend/migrations/versions/ exactly:
ALTER TABLE leave_wish ADD COLUMN window_mode TEXT NOT NULL DEFAULT 'fixed'
CHECK (window_mode IN ('fixed','flexible'));
with the matching DROP COLUMN downgrade.
Do not forget
_FULL_SCHEMA.backend/app/db/connection.py's_FULL_SCHEMAis what test fixtures apply directly (ensure_schema) — it does not run Alembic. Add the same column + CHECK to theleave_wishtable definition there, or every test silently runs against a schema one migration behind reality — keep the two in sync by hand;tests/integration/test_schema_parity.pyasserts they agree.
Also needed, all mechanical and all outside backend/app/services/:
app/entities/leave_wish.py— thewindow_modefield (§10.1).app/models/leave_wish.py—window_mode: str = "fixed"onLeaveWishCreate,LeaveWishUpdateandLeaveWishResponse(+from_entity), following the exact pattern every other field on that model already uses.backend/app/repositories_impl/sqlite/leave_wish_repository.py— the row ⇄ entity mapping.
Not needed: any change to planned_leave_day (§2.3 still holds — apply still writes ordinary
rows) or to PlanProposal/WindowOutcome/ProposedDay (§10.4). The persistence surface of this
feature is deliberately just the one new column.
10.6 i18n
SUPERSEDED (2026-09-18). This section originally described the
message_key+datarollout as in-progress, citing aNextSteps260913.mdhandoff doc that is not (and never was) checked into this repo. The rollout is done: every UI-facing piece of text the allocator produces —WindowOutcome.data/message_key,ShortfallRemedy,SurplusAdvice,PlanWarning— follows the patterntips_service.py'sTipdataclass established first. The frontend resolves these opaque keys throughsrc/i18n/(sv/ennamespaces,LanguageContext) via per-component lookup tables (e.g.PlanResults.tsx). See ARCHITECTURE.md §7.2 for the full picture.
Every user-facing string this feature produces — the "engine picked this window" explanation, the
unplaced-wish explanation, remedy copy — uses the message_key + data pattern. No hardcoded
Swedish strings anywhere in the allocator's output.
Frontend copy needing new keys (final naming left to whoever implements the fixed/flexible UI, §10.7): the fixed/flexible toggle's two labels, the bounding-range field label (to make clear it is a search range, not the final dates, once "flexible" is selected), and any season quick-picks added to the preset list.
10.7 UI: WishEditor.tsx
- Toggle. Each wish (new or existing) gets an explicit-window vs "när det passar bäst" control —
a two-way toggle, not a dropdown, since there are exactly two modes and no plan to add a third.
Reflects/writes
LeaveWish.windowMode(Draft.windowMode: 'fixed' | 'flexible'alongside the existingDraftfields). - Bound picker. The existing "Justera datum" month/day picker (already built for fixed wishes) is reused unchanged for flexible wishes — same four inputs, different label, since it now edits a bound rather than the final window. No new date-picking UI component is needed.
- Season quick-picks (v1: presets only). v1 ships a fixed set of four season presets, each a
{startMonth, startDay, endMonth, endDay, defaultWeeks}literal:- vår (spring): 03-01 to 05-31, covers Easter and spring school holidays
- sommar (summer): 06-01 to 08-31, covers summer school holidays
- höst (autumn): 09-01 to 10-31, covers höstlov
- jul (Christmas): 12-01 to 01-31, covers Christmas and New Year
Each entry gains a
windowMode: 'flexible'field. A custom (non-seasonal) bounding-range picker is explicitly deferred, not rejected — future work may add it once early UX data informs the validation surface. The season date ranges above are the v1 defaults and can be tuned later without touching the architecture.
- Client-side validation. Mirror §10.1's service-level rule (bound length
>= target_weeks * 7) as an inline validation message before submit, the same way the existingtarget_weeks1–52 range is validated today — do not let the request round-trip to the backend just to learn the bound is too short. - Plan tab / per-window card. No new field is needed to show "this window was engine-picked" —
outcome.start_date/end_datealready carry the concrete dates (existing fields), and the Plan page already holds the wish list viauseLeaveWishes; joiningoutcome.wish_idback to that wish'swindowModeis enough to badge a card as engine-picked vs user-chosen. Anoutcome.strategy == 'unplaced'card (§10.4) renders like a 0%-funded shortfall card, withstart_date/end_dateshown as "sökte mellan X och Y" rather than a funded window.
10.8 Test matrix
See §5, rows 15–24 (appended there rather than duplicated here, since these are the same acceptance
criteria against the same pure Allocator).