Files
F-r-ldraledigplannerare/PLANNING_ENGINE_SPEC.md
T
jacobh2 ad0e3c0b33 Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 12:04:20 +02:00

910 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Planning Engine — Design Spec
> Parts of this document are superseded — search for **SUPERSEDED** for the current behaviour.
**Status:** Shipped. Phases 0–F (see §8) are implemented; this document now
describes actual behavior, with **SUPERSEDED** notes marking design-time
reasoning that later changed.
**Author:** Senior (design) — implementation handed to Sonnet subagents, phase by phase.
**Supersedes:** nothing. **Extends:** ARCHITECTURE.md §6 (tips engine) and §6.5 (age-4 projection).
---
## 0. Problem
Today `PlanningService` is CRUD over individual `planned_leave_day` rows: the user marks days one at a
time, in one calendar, for one year. There is nothing that turns *intent* ("I want 6 weeks off each
summer and 4 weeks each winter until the child is 4") into a concrete multi-year schedule, and
nothing that decides **which resource to spend on which day**.
That decision is the product. ARCHITECTURE.md §6 already states the principle:
> Because the app does not compute kronor, the "money" dimension is expressed qualitatively through
> two related levers: **weekly intensity** and **SGI protection**.
> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (an earlier migration, since squashed). 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 — originally driven by `planning_goal` crossed with the parent's `salary_type`,
now (per the note above) by `salary_type` alone (`monthly` / `hourly`).
### Decisions taken (from review)
| # | Decision | Consequence |
|---|---|---|
| 1 | Spend order is **goal-driven**, never fixed | Strategy selection step in the allocator (§3.3, superseded — now `salary_type`-driven, no goal) |
| 2 | Plan for the **couple jointly**, solo-safe | Gated purely on parent count (§2.2, superseded — was `workspace.planning_mode`) |
| 3 | **Best-effort + explicit shortfall** when wishes exceed budget | Per-window outcome records a shortfall; never refuses (§3.6) |
| 4 | **Multiple children from the start** | Pools and deadlines are keyed `(parent, child)` throughout |
---
## 1. Shape of the feature
Three steps, mirroring ARCHITECTURE.md open question #3 ("preview before committing" — answer: yes):
```
Wishes → Proposal → Apply
(persisted ) (computed, NOT ) (writes planned_leave_day
user intent ) persisted; a preview) rows; calendar + dashboard light up)
```
The proposal is a pure function of its inputs. That is the single most important design property
here: it makes the engine deterministic, explainable, cheap to re-run on every edit, and trivially
unit-testable without a database.
---
## 2. Data model changes
### 2.1 `BreakPeriod` → `LeaveWish`
`BreakPeriod` already models *"N weeks home together in this window"* — it is the right concept, but
it is single-year and has no notion of recurrence, priority, or which parent.
**Recommendation: rename and extend it into `LeaveWish` rather than adding a parallel concept.**
Rationale: two overlapping "period" entities would be permanently confusing, and the migration cost
is **zero today — `select count(*) from break_period` returns 0.** If we defer, that stops being true.
*Alternative considered:* keep `BreakPeriod` as the materialised per-year instance of a new recurring
`LeaveWish`. Rejected — the materialised rows are derivable, and persisting derived state invites
drift.
> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (an earlier migration, since squashed) and never
> replaced — the allocator applies a single policy based on `salary_type` (§3.3), so no per-window
> goal field exists. The entity has since gained fields this section never anticipated:
> `window_mode` (§10.1), `wants_dubbeldag` (§Decision A, see §3.5), `name_message_key` (i18n
> provenance of a preset-derived `name`) and `target_days` (a calendar-drawn period's exact day
> count, overriding `target_weeks * 7`). See `backend/app/entities/leave_wish.py` for the current,
> authoritative field list rather than re-deriving it from this historical snippet.
```python
# backend/app/entities/leave_wish.py (as originally proposed — see note above for what changed)
@dataclass
class LeaveWish:
"""A family's recurring intent to be home for N weeks inside a yearly window."""
id: UUID
workspace_id: UUID
child_id: UUID | None # None = family-wide; set = tied to that child's pools
name: str # "Sommar", "Jullov"
# Recurring anchor stored as month/day ints (SQLite-friendly, no custom type).
# If (end_month, end_day) < (start_month, start_day) the window wraps into the NEXT year —
# e.g. Jullov 12-20 → 01-07. See §3.2; this case also straddles two vacation-year budgets.
window_start_month: int # 1-12
window_start_day: int # 1-31
window_end_month: int
window_end_day: int
target_weeks: int # 6
recurrence: str # 'yearly' | 'once'
first_year: int # 2027
last_year: int | None # None = until the child's 4th birthday
priority: int # 1 = highest; drives best-effort fill order (§3.6)
parent_mode: str # 'either' | 'both' | 'specific'
parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific'
created_at: datetime | None = None
updated_at: datetime | None = None
```
Migration: rename table `break_period` → `leave_wish`, add the new columns, backfill
`window_*` from the old `start_date`/`end_date`, `recurrence='once'`, `priority=1`,
`parent_mode='either'`. Hand-written Alembic revision (this repo does not autogenerate).
Blast radius to update: abstract repo, sqlite repo, service, **both** routers that currently expose
`/api/break-periods` (`break_periods.py` and `settings.py:184`), `tips_service`
(`long_break_parent_swap`), frontend `useBreakPeriods`, `BreakPeriodsInput`, `Settings.tsx`.
`LeaveWish` gains one more field beyond what's shown above — `window_mode: 'fixed' | 'flexible'`
— once "när det passar bäst" wishes land. See §10, which is additive and does not change anything
above.
### 2.2 `workspace.planning_mode`
> **SUPERSEDED (2026-09-18).** `planning_mode` was removed entirely (an earlier migration, since squashed). Product
> decision: the original rationale below — that the planner needs an *explicit* flag because
> member count is ambiguous (a solo parent planning ahead for a partner who hasn't joined yet; a
> two-member workspace that still wants solo plans) — did not hold up in practice. In every call
> site that read it, `planning_mode` and parent count moved together: nothing in the product ever
> let a two-parent workspace stay in "solo" mode or a one-parent workspace switch to "couple" mode
> on purpose. The flag was a second source of truth for a fact the workspace's membership already
> states unambiguously. It is gone; **adding a partner to the workspace IS the signal.**
> `AllocationInput` no longer carries a `planning_mode` field — `_Ledger._resolve_parents`
> (§3.3's `parent_swap`) now gates couple wish-assignment purely on `len(self._input.parents) >=
> 2`. The age-4 savable-days cap (§9, `resolve_age4_savable_cap`) was never a function of this flag
> to begin with — it was always purely a function of parent count.
Original (removed) schema, kept for history:
```sql
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` (an earlier migration, since squashed) 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 (an earlier migration, since squashed).
>
> **SUPERSEDED (2026-09-18).** `planning_mode` was removed entirely (an earlier migration, since squashed) — see §2.2.
> Current fields, for reference (`backend/app/services/allocation_service.py` is authoritative):
```python
@dataclass(frozen=True)
class AllocationInput:
today: date # injected, never read from the clock
horizon_end: date # default: latest child's 4th birthday
# Resolved once per run via `resolve_age4_savable_cap(len(parents))` — 96 for a genuine
# single parent, 48 (non-poolable) as soon as the child has 2+ parents. Never re-derived
# inside the pure `_Ledger`.
age4_savable_cap: int
children: list[ChildFacts] # id, birth_date, fourth_birthday, preschool_start
parents: list[ParentFacts] # uuid, name, salary_type, employment_status
pools: dict[tuple[UUID, UUID], DayPool] # (parent, child) → sjukpenning_left, lagsta_left,
# dubbeldagar_left
vacation: dict[tuple[UUID, int], VacationBudget] # (parent, year) → total, saved_in, already_used
wishes: list[LeaveWish]
existing_planned: list[PlannedLeaveDay] # never double-book
holidays: dict[date, str] # per §3.4
# Workspace DayOverride corrections merged on top of `holidays` (see `is_free_day`).
day_overrides: dict[date, bool]
# Full, unfiltered snapshot of every persisted `planned_leave_day` row — lets a flexible
# wish re-find its own prior placement (matched by `wish_id`) even when a parent
# reassignment currently hides it from `existing_planned` (§10.3).
all_existing_planned: list[PlannedLeaveDay] | None
```
### 3.2 Expand wishes into concrete windows
For each wish, for each year in `first_year … (last_year or child's 4th-birthday year)`, build a
concrete `(start_date, end_date)`.
Two cases the implementation must get right:
- **Year-wrapping windows.** If `(end_month, end_day) < (start_month, start_day)`, the window ends 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 (an earlier migration, since squashed). 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 no longer consults a goal at all — there is a single policy,
> branching only on `salary_type`, that maximises value extracted from FP + vacation combined. The
> table below has been replaced to describe it; see `_assign_tier3` in `allocation_service.py`.
**Tier 3 — salary-type interior.** Ordinary working days with no red-day adjacency, where semester
vs FP is a real tradeoff:
| `salary_type` | Interior choice | Why |
|---|---|---|
| `monthly` | vacation first; FP (sjukpenning preferred, then lägstanivå) fills what vacation can't | A monthly-salaried parent loses nothing by spending a vacation day, so spending it first preserves FP for later. |
| `hourly` | FP first (sjukpenning preferred, then lägstanivå); vacation only backstops what FP can't cover | An hourly parent loses pay on a vacation day (no salary to "keep" — §6.1/§6.3), so FP goes first. |
If neither resource is available (pool/budget exhausted) the day is left unfunded and counts toward
the window's shortfall (§3.6).
**`parent_swap` composes on top of all three tiers.** When the workspace has **2+ parents** (see
§2.2 — superseded `planning_mode`) and `target_weeks >= 2`, it first decides *who* is home in each
half of the window; each parent's days are then resolved through the tiers above. This remains the
single biggest lever in the app — per §6.1, two halves of length L yield ≈2×L weeks of family time
(3+3 weeks ≈ 6 weeks together).
> **SUPERSEDED (2026-09-15).** `goal_override` and the whole goal-driven framing were removed
> entirely — there is no longer a `max_money`/`max_time`/`balanced` choice to make invariant. The
> corollary below is kept only as a historical note; test #14b no longer exists in the current suite.
**Corollary (historical):** the original design argued that, because tiers 1–2 were
goal-independent, `goal_override = 'max_money'` on a red-day-dense window like Jullov should
produce the same allocation as `max_time` would — proof that the goal only mattered for the
genuinely ambiguous tier-3 interior. With tier 3 now resolved purely by `salary_type` (no goal at
all), this invariant is moot: there is nothing left for a goal to disagree with.
**Explanation copy must state both benefits** when a tier-2 play fires, or the user will think the
engine optimised for the wrong thing:
```
Jullov 2027 — semester över klämdagarna: du behåller lönen och sparar 4 FP-dagar.
```
### 3.4 Cost the window — free days are free
Classify every date in the window:
- **Free** (costs nothing): Saturday, **or** present in the Swedish holiday set.
- **Spendable**: everything else.
> **Gotcha, verified against the live data:** `holidays.Sweden(years=2027, language="sv")` returns
> **61 entries**, because it lists every **Sunday** as `"Söndag"` alongside the real röda dagar.
> So the holiday set already covers Sundays — `is_free = (weekday == SAT) or (d in holidays)`.
> Do **not** additionally special-case Sunday, and do **not** treat the set as "public holidays only"
> when counting red-day clusters 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. Reachable only inside a `parent_mode == 'both'` window, and even there
**opt-in, never automatic** — see the co-leave correction below.
> **SUPERSEDED (2026-09-18, §Decision A/C "co-leave" correction).** A `parent_mode == 'both'`
> window does **not** default to dubbeldagar. The default is **co-leave**: both parents home on
> the same date, funded as one PL day (`sjukpenning`/`lagsta`, to whichever parent has more FP
> left) plus one vacation day (the other parent) — `strategy = 'co_leave'`. Dubbeldagar
> (`strategy = 'dubbeldag'`) fire only when the wish's `wants_dubbeldag` flag is explicitly set
> **and** the date is legally eligible (inside the child's first 15 months, quota
> left, both parents hold FP). A date neither co-leave nor dubbeldagar can cover falls back to
> `strategy = 'swap'` — the same sequential per-parent-half funding `parent_swap` uses. See
> `allocation_service.py`'s `_fund_both` and the `WindowOutcome.strategy` /
> `requested_strategy` / `strategy_degraded` / `dubbeldagar_legally_available` fields (§10.4).
> **SUPERSEDED (2026-09-15).** The age-4 savable cap was incorrectly applied as 96 per parent. It is now 48 per parent when the child has two (or more) parents, and 96 for a genuine single parent.
- **Age-4 is an objective *before* B4 and a hard cap *after* it** (§9.1). Before the 4th birthday,
days above 96 that remain will be *lost*, so the allocator mildly **prefers** spending before B4 and
warns when `projected_left_at_b4 > 96`. From B4 onward the available pool is hard-capped 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
```python
@dataclass(frozen=True)
class PlanProposal:
days: list[ProposedDay] # date, parent, child, day_type, is_dubbeldag, wish_id, reason
outcomes: list[WindowOutcome] # wish, year, weeks_wished, weeks_funded, shortfall_days,
# strategy, explanation, cost_breakdown,
# remedies: list[ShortfallRemedy] (§9.1)
balances_after: dict[...] # pools + vacation, post-plan
warnings: list[PlanWarning] # sgi_risk | days_lost_at_age_4 | vacation_expiring
```
---
## 4. API
```
GET /api/leave-wishes/{workspace_id}
POST /api/leave-wishes
PUT /api/leave-wishes/{id}
DELETE /api/leave-wishes/{id}
POST /api/plan/propose # → PlanProposal. Read-only, persists nothing. Safe to spam on edit.
POST /api/plan/apply # body: the accepted days → writes planned_leave_day rows
POST /api/plan/sync # the direct-manipulation calendar's one call (see note below)
```
`apply` must be idempotent per `(workspace, child, parent, date)` and must reject days that collide
with existing planned days unless `replace: true` is passed. Routers stay thin; all logic in services.
> **Note (2026-09-18).** `/api/plan/sync` is a newer, actively-changing endpoint — the direct-edit
> calendar's single call, distinct from the propose→apply pair above. Its request/response shape is
> not documented here yet; a follow-up pass will cover it once that work lands. `propose` today
> backs a read-only preview surfaced on the Översikt tab (`PlanFold.tsx`/`usePlanProposal`), not a
> calendar propose-then-apply flow — `apply` itself has no frontend caller at the time of writing.
---
## 5. Test matrix (the acceptance criteria)
> **SUPERSEDED (2026-09-15, then 2026-09-18).** Several test cases are outdated: (1) Tests 1, 3–4
> assume `planning_goal` is operative (removed); (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 when the
> child has two parents, 96 otherwise); (4) Test 10 and the "test #10" cross-reference in test 22
> describe dubbeldag-outside-15-months falling back to "sequential days" — the actual fallback
> chain for a `parent_mode == 'both'` window is co-leave first, then a sequential swap only if
> co-leave itself can't cover the date (§Decision A/C, see §3.5).
Against the **pure** `Allocator`, with a frozen `today`. Target ≥90% on new backend code.
| # | Scenario | Expected |
|---|---|---|
| 1 | solo, `max_time`, 6-week summer, child < 12 months | **zero** FP days assigned (SGI protected) |
| 2 | couple, `balanced`, 6-week summer | `parent_swap`, 3+3, ≈6 weeks together |
| 3 | `max_money` + monthly salary | vacation lands on Fri/Mon around weekends |
| 4 | `max_money` + **hourly** salary | wrapping **suppressed**; no vacation-wrap reasons emitted |
| 5 | wishes exceed budget | lower-priority window shows a shortfall; higher ones are full |
| 6 | Jullov 12-20 → 01-07 | window wraps years; vacation debited from **both** years |
| 7 | two children, overlapping windows | pools kept separate per `(parent, child)` |
| 8 | `projected_left_at_b4 > 96` | `days_lost_at_age_4` warning raised |
| 9 | window containing Easter (movable) | red-day cluster detected in the correct year |
| 10 | dubbeldag outside 15 months | rejected; falls back to sequential days |
| 11 | re-run `propose` with identical input | byte-identical output (determinism) |
| 12 | wish window **after** the 4th birthday | FP pool hard-capped at 96 (§9.1) |
| 13 | shortfall caused by the preschool reserve | `reduce_preschool_reserve` remedy, correct day count |
| 14 | wish carries `goal_override` | that window uses the override, others use the workspace goal |
Rows 15+ target flexible ("när det passar bäst") wishes — see §10 for the mechanism under test:
| # | Scenario | Expected |
|---|---|---|
| 15 | flexible wish, bound = "vår" (mar–maj), 1 week, Påsk inside the bound | candidate touching the Påsk cluster wins; `weeks_funded == weeks_wished` at minimal `cost` |
| 16 | flexible wish, bound with two candidates of equal spendable-day cost | tie broken by earliest `start`, never by iteration order |
| 17 | two flexible wishes, identical bound, priorities 1 and 2 | the priority-1 wish gets the cheapest candidate; priority-2 is scored against what's left |
| 18 | fixed wish and flexible wish with overlapping bounds | fixed wish's dates are placed first and are never revisited; flexible wish's candidates exclude every fixed-wish date |
| 19 | flexible wish, bound shorter than `target_weeks * 7` after horizon clipping | single shrunk candidate = the clipped bound; reported as an ordinary partial shortfall (§3.6), not `unplaced` |
| 20 | flexible wish, bound long enough but every full-length run collides with a fixed wish | `strategy == 'unplaced'`, `weeks_funded == 0`, `start_date`/`end_date` = the clipped bound (§10.4) |
| 21 | flexible wish, `recurrence == 'yearly'`, horizon spans 3 Easters | each year's picked window lands on that year's Påsk-adjacent dates independently (moving target) |
| 22 | flexible wish, `parent_mode == 'both'`, bound outside the child's 15-month dubbeldagar window | picked window still funds via the existing sequential-days fallback (test #10), unchanged by window_mode |
| 23 | re-run `propose` twice with identical input, at least one flexible wish present | byte-identical output (determinism holds for flexible wishes too, §5 test #11's guarantee extended) |
| 24 | flexible wish, `window_mode` omitted / defaults | behaves exactly as a `'fixed'` wish (backward-compatible default, §10.1) |
---
## 6. Frontend
> **SUPERSEDED (2026-09-18).** The standalone `/plan` page below was never built. Wishes are
> authored directly on the **Kalender** tab instead: dragging a range opens `PeriodPopover.tsx`
> (not `WishEditor.tsx`), which edits a wish's name/dates/`parentMode`/recurrence in place — there
> is no separate multi-year timeline page or "Föreslå plan"/"Använd plan" two-step. A season-preset
> rail (`PresetRail.tsx`) helps place common windows. The `POST /api/plan/propose` preview instead
> backs a shortfall/surplus fold on the **Översikt** (Dashboard) tab (`PlanFold.tsx`); direct
> calendar edits go through `POST /api/plan/sync` (see §4 note — HOLD, not detailed here). Data
> layer: `useLeaveWishes`, `usePlanProposal`, and `usePlanSync` (all in `frontend/src/hooks/`) —
> components still never call the API client directly. The description below is kept for the
> original design rationale (recurrence/priority/parent fields, per-window explanation, i18n via
> `message_key`+`data`), which mostly still holds even though the page shape changed.
New page `src/pages/Plan.tsx`, route `/plan`, nav entry "Plan".
- **Wishes editor** — `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` (the flag itself was later dropped entirely — see §2.2) | ruff, pytest, tsc, eslint, vitest |
| **A** | `LeaveWish` entity, migration, repo, service, CRUD router, tests | backend suite |
| **B** | `AllocationInput` loader + **pure `Allocator`** + `/plan/propose` + the §5 matrix | ≥90% on new code |
| **C** | `/plan/apply` + idempotency/collision rules | backend suite |
| **D** | Types, hooks, `Plan.tsx`, timeline, wishes editor | tsc, eslint 0 warnings, ≥90% vitest |
| **E** | Retire `BreakPeriod`; point `tips_service.long_break_parent_swap` at wishes | both suites |
| **F** | Flexible wishes (§10): `window_mode` + migration, candidate search in the allocator, `WishEditor.tsx` toggle | both suites, §5 rows 15-24 |
Phase B is the one that must not be rushed — it is the whole product, it is pure, and it is where the
test matrix lives. Phases A–E each end with `graphify update .` per CLAUDE.md.
---
## 9. Resolved (was: open questions)
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 when the child has two (or
> more) parents, 96 for a genuine single parent.
```
FP available for a day d: d < fourth_birthday → full remaining pool
d >= fourth_birthday → min(remaining, 96)
```
Default `horizon_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.
4. **Wish-level goal override: reversed.** The original decision was to add `LeaveWish.goal_override:
str | None` so the allocator could resolve `goal = wish.goal_override or input.planning_goal`.
`goal_override` was instead removed entirely, along with `planning_goal` itself (§3.3) — there is
no per-window (or per-workspace) goal left to override.
### 9.2 Surplus advice — the mirror of a shortfall (added after reviewing demo output)
The first demo run funded all 44 wished weeks and then warned that the family would still
**forfeit 219 föräldradagar** at the age-4 boundary (117 + 102), while finishing every year with
8–14 unused semesterdagar. The engine warned about the loss but did nothing about it — and
ARCHITECTURE.md names avoiding exactly that loss as a core purpose of the app.
A surplus is as actionable as a shortfall and must produce ranked suggestions of its own:
- `wish_more_leave` — "ni har råd med fler veckor: lägg till en vecka på sommaren, eller ett höstlov"
- `shift_mix_to_fp` — spend föräldradagar instead of semester in the goal-dependent interior (§3.3
tier 3), conserving semester. Correct whenever FP is the expiring resource and semester is not.
- `front_load_early_years` — take more days while the child is small and you are home anyway; with a
large surplus 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 before this field landed, so it
> never coexisted with `window_mode` in the real entity — the snippet below has been corrected to
> drop it.
```python
@dataclass
class LeaveWish:
...
# NEW. 'fixed' (default) = window_start_*/window_end_* are the exact placement, as today.
# 'flexible' = the SAME four fields instead describe the BOUNDING RANGE the engine searches
# inside for the best-costing placement (e.g. "vår" = 03-01..05-31) — see §10.2. target_weeks
# is still the wanted duration inside that range; recurrence/first_year/last_year are unchanged,
# so a 'yearly' flexible wish still re-resolves its bounding range every anchor year through the
# existing window_for_year()/applies_to_year() helpers below.
#
# SUPERSEDED (weekday-shift fix): "unmodified" held only for the helpers'
# signatures, not their behavior. `window_for_year` -> `project_window`
# now applies the same per-year weekday snap to BOTH modes (previously
# `fixed` only) — a flexible bound keeps its weekday alignment year over
# year (a week-shaped bound stays week-shaped) with its span, and so its
# search space, held exactly as wide as the anchor year's. See
# `app/entities/leave_wish.py::project_window`'s docstring.
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 `_WishWindow`s 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 `ProposedDay`s 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 calendar-only figure is the tie-break, not the primary ranking — see the
§Decision C correction below.
> **SUPERSEDED (2026-09-18, §Decision C "budget-aware candidate ranking").** The original
> reasoning here — that a calendar-only cost is *always* exactly "fewest paid days", so goal and
> affordability never need to enter candidate selection — held only as long as every candidate's
> resolved parent(s) could actually afford every spendable day. In practice a candidate that is
> cheap by the calendar can still leave half its days unfunded (vacation/FP already exhausted
> elsewhere), which is worse than a slightly costlier candidate the parent(s) can fully pay for.
> Candidates are now ranked by **`(shortfall, calendar_cost, start, wish.id)`** —
> `_candidate_shortfall` simulates the real `_fund_solo`/`_fund_swap`/`_fund_both` funding logic
> against a scratch copy of the live pool/vacation state (restored afterwards; `propose()` stays
> read-only and deterministic, §5 test #11) and counts what could **not** be funded; only when two
> candidates tie on shortfall does the calendar-only `cost` from step 6 break the tie. See
> `allocation_service.py`'s `_candidate_shortfall`, `resolve_flexible_window` and
> `_reuse_already_funded_window`.
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: (shortfall(c), cost(c.start), c.start, wish.id.int))` (§Decision C
above). Ties on shortfall are broken by calendar cost, then by earliest start date (fills the
bound from its beginning — the same chronological 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 extends the 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 and extends 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 carry a separate, explicit B4
preference — but it is no longer B4-blind either: candidate ranking is budget-aware
(§10.2 step 6/§Decision C), and `_candidate_shortfall`'s funding simulation runs through the same
B4 clamp every real window does, so a candidate that would run into the B4 wall is already
disfavored via its simulated shortfall, not just via the clamp applied after the fact.
- **Dubbeldagar / 15-month window.** No special-casing needed. `parent_mode == 'both'` is resolved
identically for flexible wishes (§10.2 step 3, same `_resolve_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, then 2026-09-18).** `planned_leave_day` gained `wish_id` (an earlier migration, since squashed)
> linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the
> allocator should not retype. The reasoning below — that no frozen linkage is needed because the
> propose→apply preview is the safeguard — did not survive contact with parent reassignment: a
> flexible wish's prior placement needs to be found again even when it is currently *hidden* from
> `existing_planned` (a reassigned parent has different occupied dates) or funded under a parent the
> wish no longer resolves to. `wish_id` is now the identity key that makes that possible.
- **Applied flexible wishes participate in re-search, matched by `wish_id`.** On a subsequent
`propose()` call (after the family edits wishes, or reassigns a wish's parent), a previously-applied
flexible wish's own prior placement is looked up via `AllocationInput.all_existing_planned` —
the full, unfiltered snapshot of every persisted `planned_leave_day` row, keyed by `wish_id` rather
than by parent — so `_reuse_already_funded_window` can prefer reusing that placement (via
`_persisted_by_wish`/`_persisted_dates_for_wish`) even when it is invisible to the narrower
`existing_planned` collision set. A wish may still land in a different window if its bound or the
occupied set changed enough to make reuse impossible; the propose→apply preview (§1) keeps that
movement visible before it is confirmed.
- **Preschool planning-day reserve (§9.3).** No interaction — the reserve is deducted off the top
before *any* wish (fixed or flexible) is funded, per wish-year window or not.
- **Surplus advice (§9.2).** No change to the mechanics. A fully-funded flexible wish participates in
the same age-4 projection as a fixed one. (Whether the engine should ever *suggest a new flexible
wish* as a `wish_more_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.
> **SUPERSEDED (2026-09-18).** The claim that no new field is needed did not survive the §Decision
> A/C co-leave correction (§3.5): once `strategy` could legitimately *degrade* from what a
> `parent_mode == 'both'` wish asked for (dubbeldag → co-leave → swap) down to something worse, a
> bare after-the-fact `strategy` string could no longer say whether that happened. `WindowOutcome`
> gained three fields to make that visible: `requested_strategy` (what `parent_mode` +
> `wants_dubbeldag` asked for), `strategy_degraded` (`bool`, true whenever `strategy !=
> requested_strategy`), and `dubbeldagar_legally_available` (whether dubbeldagar were even a legal
> option for this window, independent of whether the user opted in — the frontend uses this to
> decide whether to show the dubbeldag checkbox at all). `strategy`/`requested_strategy` remain a
> bare `str` as originally described — adding a literal value (`'co_leave'`, `'swap'`) is still a
> copy/behavior change, not a schema change; the three new fields are the actual schema change.
### 10.5 Persistence
`LeaveWish` gains exactly one column. Hand-written Alembic revision, following the pattern already
established by the existing migrations in `backend/migrations/versions/` exactly:
```sql
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 — keep the two in sync by hand; `tests/integration/test_schema_parity.py` asserts
> they agree.
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
> **SUPERSEDED (2026-09-18).** This section originally described the `message_key`+`data` rollout
> as in-progress, citing a `NextSteps260913.md` handoff doc that is not (and never was) checked into
> this repo. The rollout is done: every UI-facing piece of text the allocator produces —
> `WindowOutcome.data`/`message_key`, `ShortfallRemedy`, `SurplusAdvice`, `PlanWarning` — follows the
> pattern `tips_service.py`'s `Tip` dataclass established first. The frontend resolves these opaque
> keys through `src/i18n/` (`sv`/`en` namespaces, `LanguageContext`) via per-component lookup tables
> (e.g. `PlanResults.tsx`). See ARCHITECTURE.md §7.2 for the full picture.
Every user-facing string this feature produces — the "engine picked this window" explanation, the
unplaced-wish explanation, remedy copy — uses the `message_key` + `data` pattern. **No hardcoded
Swedish strings** anywhere in the allocator's output.
Frontend copy needing new keys (final naming left to whoever implements the fixed/flexible UI,
§10.7): the fixed/flexible toggle's two labels, the bounding-range field label (to make clear it is
a search range, not the final dates, once "flexible" is selected), and any season quick-picks added
to the preset list.
### 10.7 UI: `WishEditor.tsx`
- **Toggle.** Each wish (new or existing) gets an explicit-window vs "när det passar bäst" control —
a two-way toggle, not a dropdown, since there are exactly two modes and no plan to add a third.
Reflects/writes `LeaveWish.windowMode` (`Draft.windowMode: 'fixed' | 'flexible'` alongside the
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`).