Files
F-r-ldraledigplannerare/PLANNING_ENGINE_SPEC.md
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

780 lines
46 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
**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**.
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.
```python
# 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).
```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`
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
```python
@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
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.
**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).
**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'`.
- **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
```
`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)
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:
```
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: 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:
```python
@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 `_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 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.
- **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:
```sql
-- 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`).