014503e89b
The planner knew exactly how many semesterdagar a parent was about to forfeit and never placed a single day to prevent it. It also had no weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on every tier-3 interior vacation day, including a lone Tuesday in February. Two changes. Spend better. A workday adjacent to a free stretch, or in the middle of a gap of three or fewer fenced by free days, now takes vacation -- _vacation_bridge_reason, consulted for every parent type. This departs from spec 6.1's "wrapping suppressed for hourly": the FP-preservation argument is salary-type independent, only the pay argument was hourly-moot. In a both-parents window, both now take vacation on such a date rather than one paying with FP. After funding, reallocate_vacation_by_value swaps vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold vacation improves while how many never drops -- greedy first, re-timed after, never withheld. An invariant test asserts the count can only rise. Name the losses. A new pass proposes concrete dates for vacation that would otherwise expire, current year only, ranked red-day bridge then weekend bridge then interior, later dates first. They ride on PlanProposal.rescue_days, kept off `days` so nothing downstream commits them, and surface on the Dashboard with accept-one, accept-all (one batched /api/plan/apply, not N) and dismiss. Dismissal reuses the tip_dismissal table, keyed on (parent, year) identity rather than the group's dates -- those are recomputed from the whole plan's residual on every propose, so date-hashing made dismissal self-defeating. Dismiss does not bump the plan version; it changes no data and must not trigger a write. Measured on the seeded demo: 12 forfeiting days for one parent now yield 12 proposed dates, all Fridays or Mondays. Total vacation placed is unchanged at 203 days, confirming the reallocation only re-times. Three defects here were found by driving a browser, while 2273 unit tests passed -- all three lived in the seam between layers that unit tests mock away. Adds API-level tests over the real request sequence. Rate limiting is now off in tests (one file consumed the whole registration budget) with the suite's first real 429 assertion. PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along the way: Dashboard figures swing on the first sync with no user action. Backend 946 tests, frontend 1341, coverage above all gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
922 lines
58 KiB
Markdown
922 lines
58 KiB
Markdown
# 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 (§weekend-wrap-bridge, revised 2026-09-20).** Days where vacation is
|
||
spent to **bridge or extend a free stretch**, regardless of goal *or* `salary_type`. This tier used
|
||
to fire only for a **klämdag inside a real red-day cluster**; it now also covers the far more common
|
||
case of a bare weekend wrap (Friday before / Monday after a plain Sat+Sun) and the middle day of any
|
||
short (≤3-day) non-working gap, whether or not a real red day is involved. See
|
||
`_vacation_bridge_reason` in `allocation_service.py` for the exact rule (adjacency first, then
|
||
short-run-middle) and its reason codes (`vacation_wrap_red_day` when the adjacent/fencing free run
|
||
contains a genuine holiday, `vacation_wrap_weekend` otherwise):
|
||
|
||
| Benefit | Monthly salary | Hourly salary |
|
||
|---|---|---|
|
||
| Preserves an FP day for later (time) | ✅ | ✅ |
|
||
| Protects pay you'd lose on an FP day (money) | ✅ | ❌ |
|
||
|
||
**Superseded reasoning (kept for history):** this tier used to be suppressed for weekend-only
|
||
wraps on an hourly parent, on the theory that "there is no weekend salary to protect, so bracketing
|
||
a bare Sat/Sun with Fri+Mon vacation buys nothing." That argument only weighed the pay-protection
|
||
half of the benefit table and ignored the FP-preservation half, which — as the row above already
|
||
shows — is salary-type independent. **Decision (2026-09-20):** the tier now fires ungated, for every
|
||
`salary_type` and regardless of `vacation_forfeiting`. For an hourly parent whose vacation is not
|
||
otherwise expiring, this is a genuine behaviour change: vacation now pays the Friday/Monday instead
|
||
of an FP day (or, previously, instead of nothing at all — a hollow "buys nothing" framing that
|
||
missed the FP saved for later), trading a day that costs the household nothing extra for a day kept
|
||
in the bank. The old `REASON_VACATION_WRAP_WEEKEND` reason code, retired when this suppression was
|
||
introduced, is reinstated for exactly this case.
|
||
|
||
> **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 that tier 2 does not claim as a bridge
|
||
(see `_vacation_bridge_reason`: not adjacent to a free day, and not the middle day of a ≤3-day
|
||
gap) — genuinely interior weekdays 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_interior`, `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 | weekend/red-day **wrapping still fires** (vacation on Fri/Mon and short fenced gaps, `vacation_wrap_*` reasons emitted, §weekend-wrap-bridge below) — only genuinely **interior** mid-week days still prefer FP |
|
||
| 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`).
|
||
|