Files
F-r-ldraledigplannerare/PLANNING_ENGINE_SPEC.md
T
jacobh2 0e14b4dac8 Flag the superseded sections of the design docs
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.
2026-09-15 21:18:38 +02:00

48 KiB
Raw Blame History

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_goal was removed entirely (migration 009). The engine now applies a single policy based solely on salary_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_override was 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_day gained wish_id (migration 008) linking days back to the wish that produced them, and is_pinned to 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 plain AllocationInput dataclass.
  • Allocator — pure. No await, no repos, no date.today(). Input in, PlanProposal out.

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_goal was 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 in year + 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_goal was removed entirely (migration 009). The allocator now applies a single policy based solely on salary_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 equally max_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_override was 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 for vacation_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_left and lagsta_left drawn 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 is todo.txt item #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 at min(remaining, 96). Reuse AnalyticsService.get_days_left_at_age_4 (§6.5) — do not reimplement it.
  • Preschool planning-day reserve (§9.3): preschool_planning_days_per_year per 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_goal is operative (removed migration 009); (2) Test 14 refers to goal_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 — BreakPeriodsInput evolves into LeaveWishesInput (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; only Settings.tsx is wrong (hardcodes new 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 — PlanningGoalSelector emits maximize_contiguous / minimize_stress / balance; the backend requires max_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)

  1. 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_end is therefore no longer the 4th birthday: it is max(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.

  2. 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.

  3. 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_override was removed entirely (migration 009).

  1. Wish-level goal override: yes, as a nullable field now, no UI in v1. LeaveWish.goal_override: str | None; the allocator resolves goal = 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 the max_money reading 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:

  1. use_less_vacation advice must never propose dropping a parent below their minimum.
  2. Vacation carry-forward is capped at total_days - min_vacation_days_per_year per 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_override was 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:

  1. Pass 1 (unchanged). _expand_all_windows expands every window_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 through ledger.fund_window() exactly as today.
  2. Pass 2 (new). Every window_mode == 'flexible' wish is expanded into candidate windows (below), one winning candidate is picked per wish-year, and that _WishWindow is funded through the exact same ledger.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:

  1. 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).
  2. length_days = target_weeks * 7 (whole weeks only — "no partial days" applies to the window's shape too, not just to individual days).
  3. Resolve responsible = ledger._resolve_parents(wish) and resolved_child = ledger._resolve_wish_child(wish) — the exact functions fund_window already calls. Both are pure functions of wish/alloc_input, so calling them before a window is chosen is safe.
  4. If the clipped bound is shorter than length_days (horizon clipping ate into it, e.g. the last partial year before horizon_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.
  5. Otherwise, enumerate every candidate start date s with bound_start <= s and s + 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 date d where (p.uuid, resolved_child.id, d) is in the occupied set for some p in responsible.parents.
  6. 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 exact is_free_day helper 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 — including dubbeldag mode, 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.

  7. If no candidate survives step 5's collision filter, this wish-year is unplaced — §10.4.
  8. 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 preference Allocator.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 at windows.sort(key=lambda w: (w.wish.priority, w.start, w.wish.id.int)); §10 does not invent a new convention, it reuses this one.
  9. The winning candidate becomes an ordinary _WishWindow (its anchor_year, resolved start/end) and is funded through the unchanged ledger.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_age4 fires 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_parents call); 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_swap composition (§3.5), orthogonal to which window was picked.

SUPERSEDED (2026-09-15). planned_leave_day gained wish_id (migration 008) linking days back to the wish that produced them, and is_pinned to 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. Thus planned_leave_day needs no wish_id column — 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_leave remedy — rather than only a fixed one — is out of scope here; today's wish_more_leave copy 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 for w.start/w.end wherever a reference year or shift-probe date is needed. carry_vacation_forward and lower_priority_wish both still make sense (neither needs a specific winning window). Omit shift_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_SCHEMA is what test fixtures apply directly (ensure_schema) — it does not run Alembic. Add the same column + CHECK to the leave_wish table 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 — the window_mode field (§10.1).
  • app/models/leave_wish.py — window_mode: str = "fixed" on LeaveWishCreate, LeaveWishUpdate and LeaveWishResponse (+ 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 existing Draft fields).
  • 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 existing target_weeks 1–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_date already carry the concrete dates (existing fields), and the Plan page already holds the wish list via useLeaveWishes; joining outcome.wish_id back to that wish's windowMode is enough to badge a card as engine-picked vs user-chosen. An outcome.strategy == 'unplaced' card (§10.4) renders like a 0%-funded shortfall card, with start_date/end_date shown 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).