ARCHITECTURE.md and PLANNING_ENGINE_SPEC.md still described the planning goal, the per-wish goal override, and a per-parent age-4 ceiling of 96 days as current. Rather than rewrite two long documents that remain useful as history, each stale section now carries a SUPERSEDED banner naming what is true instead. The banners sit above the code fences they annotate, not inside them.
48 KiB
Planning Engine — Design Spec
Parts of this document are superseded — search for SUPERSEDED for the current behaviour.
Status: Draft for review. No code written yet. Author: Senior (design) — implementation to be 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 (migration 009). 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, and it is driven by planning_goal (max_time / max_money / balanced)
crossed with the parent's salary_type (monthly / hourly).
Decisions taken (from review)
| # | Decision | Consequence |
|---|---|---|
| 1 | Spend order is goal-driven, never fixed | Strategy selection step in the allocator (§3.3) |
| 2 | Plan for the couple jointly, solo-safe, with an explicit config flag | New workspace.planning_mode (§2.2) |
| 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 (migration 009).
# backend/app/entities/leave_wish.py
@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'
# Per-window override of workspace planning_goal (§9.4). Nullable, no UI in v1 —
# the field exists now so enabling it later needs no migration.
goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None
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
Explicitly requested: the planner must know whether it is planning for one parent or two, rather than inferring it from member count (a solo parent may be planning ahead for a partner who has not joined yet, and a two-member workspace may still want solo plans).
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(migration 008) 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 (migration 009).
@dataclass(frozen=True)
class AllocationInput:
today: date # injected, never read from the clock
horizon_end: date # default: latest child's 4th birthday
planning_goal: str # 'max_time' | 'max_money' | 'balanced'
planning_mode: str # 'solo' | 'couple'
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
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 (migration 009). 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 with goal-dependent choices was removed entirely. All tier-3 decisions now depend solely on
salary_type.
Tier 3 — goal-dependent interior. Ordinary working days with no red-day adjacency, where semester
vs FP is a real tradeoff. Here, and only here, consult the resolved goal
(wish.goal_override or planning_goal):
| Resolved goal | Interior choice | Source |
|---|---|---|
max_money + monthly |
semester (keeps full pay) | §6.1 rules #2, #3 |
max_money + hourly |
FP — wrapping buys nothing | §6.1, §6.3 |
max_time, child < 12 months |
no FP at all — SGI is protected anyway | §6.1 skip_fp_first_12_months |
max_time otherwise |
FP lägstanivå / lower weekly intensity to stretch | §6.1 stretch_time_at_home |
balanced |
alternate: semester early in the window, FP for the remainder | — |
parent_swap composes on top of all three tiers. When planning_mode == 'couple' 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_overridewas removed entirely (migration 009). This test is no longer applicable.
Corollary worth testing: because tiers 1–2 are goal-independent, setting goal_override = 'max_money' on a red-day-dense window like Jullov should produce the same allocation as
max_time would. The override is a UI convenience for the user's mental model, not a semantic
necessity — and the engine should be able to prove it (test #14b, §5).
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. Only reachable via
parent_mode == 'both'.
SUPERSEDED (2026-09-15). The age-4 savable cap was incorrectly applied as 96 per parent. It is now 48 per parent in couple mode with two parents, and 96 otherwise.
- 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
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.
5. Test matrix (the acceptance criteria)
SUPERSEDED (2026-09-15). Several test cases are outdated: (1) Tests 1, 3–4 assume
planning_goalis operative (removed migration 009); (2) Test 14 refers togoal_override(removed); (3) Tests 8 and 12 use the incorrect 96-day-per-parent cap (now 48 per parent in couple mode with two parents, 96 otherwise).
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
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 |
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 in couple mode with two parents, 96 otherwise.
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.
SUPERSEDED (2026-09-15).
goal_overridewas removed entirely (migration 009).
- Wish-level goal override: yes, as a nullable field now, no UI in v1.
LeaveWish.goal_override: str | None; the allocator resolvesgoal = wish.goal_override or input.planning_goal. Costs one field and one test, and avoids a second migration when the UI arrives.
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 (migration 009).
@dataclass
class LeaveWish:
...
goal_override: str | None = None
# 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 is deliberately calendar-only — it does not run the goal-dependent tier-3 logic or simulate which resource would be spent.Why a calendar-only cost is exactly "fewest paid days". For a fixed-length window, every spendable day consumes exactly one resource unit under best-effort funding — vacation, sjukpenning or lagsta, chosen by tiers 1–3, but always exactly one unit some way. So the candidate with the fewest spendable days is the candidate needing the fewest paid days, for every planning goal and every
parent_mode— includingdubbeldagmode, where a spendable day costs one unit from each parent's pool: that doubles the resource cost uniformly across every candidate, so it never changes which candidate is cheapest. This is the same principle as §3.3's tiers 1–2 being goal-independent "dominant plays" — window choice is dominant too, and the goal only ever governs which resource pays for a spendable day, never how many days must pay. - 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: (cost(c.start), c.start, wish.id.int)). Ties are broken first 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 is the identical 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 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 bias toward or away from B4 — the cost function is calendar-only (§10.2 step 6) and B4-blind by design, so it does not need a redundant preference on top of the clamp that already exists. - 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).
planned_leave_daygainedwish_id(migration 008) linking days back to the wish that produced them, andis_pinnedto mark hand-edited days the allocator should not retype.
- Applied flexible wishes participate in re-search. On a subsequent
propose()call (after the family edits wishes), a previously-applied flexible wish is re-searched from scratch within its bound and may land in a different window if the occupied set has changed (other wishes added, edited or removed). This is safe because the propose→apply flow always previews the new outcome before the family confirms (§1), so any movement is visible. Thusplanned_leave_dayneeds nowish_idcolumn — no frozen linkage is needed, because the safeguard is the preview, not schema-level persistence. - 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.
No new field is added to WindowOutcome, ProposedDay or PlanProposal for any of this — the
strategy field is already typed as a bare str on both sides (allocation_service.py's dataclass
and frontend/src/types/entities.ts:177, which already documents it as intentionally open-ended), so
adding a literal value is a copy/behavior change, not a schema change.
10.5 Persistence
LeaveWish gains exactly one column. Hand-written Alembic revision, following migration 002's
pattern (backend/migrations/versions/002_planning_mode_and_preschool_days.py) exactly:
-- 006_add_leave_wish_window_mode.py
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 (NextSteps260913.md §4.2; migration 002 is the worked example for keeping the two in sync).
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
Every new user-facing string this feature produces — the "engine picked this window" explanation, the
unplaced-wish explanation, any new remedy copy — must use the message_key + data pattern
tips_service.py's Tip dataclass already establishes (message_key: str, data: dict), which
NextSteps260913.md §3.5 is rolling out to outcome.explanation, remedy.description and
warning.message concurrently with this work. No hardcoded Swedish strings in anything new here
— that would recreate exactly the leak §3.5 exists to fix, on day one of a brand-new feature. The
exact key names/namespace are §3.5's call (it is landing in backend/app/services/ concurrently);
this section only fixes the pattern, not the vocabulary.
Frontend copy needing new keys (final naming left to whoever implements WishEditor.tsx, §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).