0e14b4dac8
ARCHITECTURE.md and PLANNING_ENGINE_SPEC.md still described the planning goal, the per-wish goal override, and a per-parent age-4 ceiling of 96 days as current. Rather than rewrite two long documents that remain useful as history, each stale section now carries a SUPERSEDED banner naming what is true instead. The banners sit above the code fences they annotate, not inside them.
808 lines
48 KiB
Markdown
808 lines
48 KiB
Markdown
# Planning Engine — Design Spec
|
||
|
||
> Parts of this document are superseded — search for **SUPERSEDED** for the current behaviour.
|
||
|
||
**Status:** Draft for review. No code written yet.
|
||
**Author:** Senior (design) — implementation to be handed to Sonnet subagents, phase by phase.
|
||
**Supersedes:** nothing. **Extends:** ARCHITECTURE.md §6 (tips engine) and §6.5 (age-4 projection).
|
||
|
||
---
|
||
|
||
## 0. Problem
|
||
|
||
Today `PlanningService` is CRUD over individual `planned_leave_day` rows: the user marks days one at a
|
||
time, in one calendar, for one year. There is nothing that turns *intent* ("I want 6 weeks off each
|
||
summer and 4 weeks each winter until the child is 4") into a concrete multi-year schedule, and
|
||
nothing that decides **which resource to spend on which day**.
|
||
|
||
That decision is the product. ARCHITECTURE.md §6 already states the principle:
|
||
|
||
> Because the app does not compute kronor, the "money" dimension is expressed qualitatively through
|
||
> two related levers: **weekly intensity** and **SGI protection**.
|
||
|
||
> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (migration 009). The engine now applies a single policy based solely on `salary_type`.
|
||
|
||
So the engine must not have a fixed spend order. Choosing semester vs föräldrapenning on a given day
|
||
*is* the optimisation, and it is driven by `planning_goal` (`max_time` / `max_money` / `balanced`)
|
||
crossed with the parent's `salary_type` (`monthly` / `hourly`).
|
||
|
||
### Decisions taken (from review)
|
||
|
||
| # | Decision | Consequence |
|
||
|---|---|---|
|
||
| 1 | Spend order is **goal-driven**, never fixed | Strategy selection step in the allocator (§3.3) |
|
||
| 2 | Plan for the **couple jointly**, solo-safe, with an **explicit config flag** | New `workspace.planning_mode` (§2.2) |
|
||
| 3 | **Best-effort + explicit shortfall** when wishes exceed budget | Per-window outcome records a shortfall; never refuses (§3.6) |
|
||
| 4 | **Multiple children from the start** | Pools and deadlines are keyed `(parent, child)` throughout |
|
||
|
||
---
|
||
|
||
## 1. Shape of the feature
|
||
|
||
Three steps, mirroring ARCHITECTURE.md open question #3 ("preview before committing" — answer: yes):
|
||
|
||
```
|
||
Wishes → Proposal → Apply
|
||
(persisted ) (computed, NOT ) (writes planned_leave_day
|
||
user intent ) persisted; a preview) rows; calendar + dashboard light up)
|
||
```
|
||
|
||
The proposal is a pure function of its inputs. That is the single most important design property
|
||
here: it makes the engine deterministic, explainable, cheap to re-run on every edit, and trivially
|
||
unit-testable without a database.
|
||
|
||
---
|
||
|
||
## 2. Data model changes
|
||
|
||
### 2.1 `BreakPeriod` → `LeaveWish`
|
||
|
||
`BreakPeriod` already models *"N weeks home together in this window"* — it is the right concept, but
|
||
it is single-year and has no notion of recurrence, priority, or which parent.
|
||
|
||
**Recommendation: rename and extend it into `LeaveWish` rather than adding a parallel concept.**
|
||
Rationale: two overlapping "period" entities would be permanently confusing, and the migration cost
|
||
is **zero today — `select count(*) from break_period` returns 0.** If we defer, that stops being true.
|
||
|
||
*Alternative considered:* keep `BreakPeriod` as the materialised per-year instance of a new recurring
|
||
`LeaveWish`. Rejected — the materialised rows are derivable, and persisting derived state invites
|
||
drift.
|
||
|
||
> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009).
|
||
|
||
```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`
|
||
|
||
> **SUPERSEDED (2026-09-15).** `planned_leave_day` gained `wish_id` (migration 008) linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the allocator should not retype.
|
||
|
||
The proposal writes ordinary `planned_leave_day` rows on apply (`day_type` stays
|
||
`'sjukpenning' | 'lagsta' | 'vacation'`). The existing calendar, dashboard and analytics then work
|
||
unchanged. This is deliberate: the engine produces *nothing the user could not have entered by hand*.
|
||
|
||
---
|
||
|
||
## 3. The allocator
|
||
|
||
Lives in `backend/app/services/allocation_service.py`. Split in two, and keep the split strict:
|
||
|
||
- **`AllocationInputLoader`** — touches repositories, assembles a plain `AllocationInput` dataclass.
|
||
- **`Allocator`** — **pure**. No `await`, no repos, no `date.today()`. Input in, `PlanProposal` out.
|
||
|
||
Every test in §5 targets the pure half.
|
||
|
||
Everything in this section (§3.1–§3.7) describes a **fixed** wish — one whose window is the exact
|
||
placement. §10 adds a second, additive path for **flexible** wishes ("när det passar bäst"): the
|
||
three-tier resolution (§3.3), the constraints (§3.5) and the shortfall vocabulary (§3.6) all apply
|
||
completely unchanged once a flexible wish's window has been picked — §10 only adds a window-*picking*
|
||
step that runs before funding, for that subset of wishes.
|
||
|
||
### 3.1 Input
|
||
|
||
> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (migration 009).
|
||
|
||
```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
|
||
|
||
> **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely (migration 009). The allocator now applies a single policy based solely on `salary_type`, skipping the goal-driven tier-3 choice entirely.
|
||
|
||
A naive design makes `planning_goal` a hard switch between "spend semester" and "spend FP". That is
|
||
wrong, and the Jullov case shows why:
|
||
|
||
> During jul there are many röda dagar. Spending semester on the *klämdagar* between them keeps your
|
||
> salary **and** preserves FP days for later. So it reads as `max_money`, but it is equally
|
||
> `max_time` — the goals **converge**.
|
||
|
||
So the allocator resolves each spendable day in three tiers, and only reaches for the goal when the
|
||
choice is genuinely ambiguous:
|
||
|
||
**Tier 1 — free days.** Saturday or in the holiday set. Cost nothing. Never spend a resource here.
|
||
|
||
**Tier 2 — dominant plays.** Days where one resource wins on *every* axis, regardless of goal. The
|
||
main case is a **klämdag inside a red-day cluster** → spend **semester**:
|
||
|
||
| Benefit | Monthly salary | Hourly salary |
|
||
|---|---|---|
|
||
| Preserves an FP day for later (time) | ✅ | ✅ |
|
||
| Protects pay you'd lose on an FP day (money) | ✅ | ❌ |
|
||
|
||
Note the asymmetry, which §6.3 hints at but does not spell out: the **FP-preservation** half is
|
||
salary-type independent, so red-day-cluster vacation is favourable for hourly parents too. Only the
|
||
**pay-protection** half is monthly-only. This is why the §6.1 suppression for hourly applies to
|
||
*weekend wrapping* specifically — for an hourly parent there is no weekend salary to protect, so
|
||
bracketing a bare Sat/Sun with Fri+Mon vacation buys nothing — but it does **not** justify suppressing
|
||
red-day-cluster vacation.
|
||
|
||
> **SUPERSEDED (2026-09-15).** Tier 3 with goal-dependent choices was removed entirely. All tier-3 decisions now depend solely on `salary_type`.
|
||
|
||
**Tier 3 — goal-dependent interior.** Ordinary working days with no red-day adjacency, where semester
|
||
vs FP is a real tradeoff. Here, and only here, consult the resolved goal
|
||
(`wish.goal_override or planning_goal`):
|
||
|
||
| Resolved goal | Interior choice | Source |
|
||
|---|---|---|
|
||
| `max_money` + monthly | semester (keeps full pay) | §6.1 rules #2, #3 |
|
||
| `max_money` + hourly | FP — wrapping buys nothing | §6.1, §6.3 |
|
||
| `max_time`, child < 12 months | **no FP at all** — SGI is protected anyway | §6.1 `skip_fp_first_12_months` |
|
||
| `max_time` otherwise | FP lägstanivå / lower weekly intensity to stretch | §6.1 `stretch_time_at_home` |
|
||
| `balanced` | alternate: semester early in the window, FP for the remainder | — |
|
||
|
||
**`parent_swap` composes on top of all three tiers.** When `planning_mode == 'couple'` and
|
||
`target_weeks >= 2`, it first decides *who* is home in each half of the window; each parent's days are
|
||
then resolved through the tiers above. This remains the single biggest lever in the app — per §6.1,
|
||
two halves of length L yield ≈2×L weeks of family time (3+3 weeks ≈ 6 weeks together).
|
||
|
||
> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009). This test is no longer applicable.
|
||
|
||
**Corollary worth testing:** because tiers 1–2 are goal-independent, setting `goal_override =
|
||
'max_money'` on a red-day-dense window like Jullov should produce **the same allocation** as
|
||
`max_time` would. The override is a UI convenience for the user's mental model, not a semantic
|
||
necessity — and the engine should be able to prove it (test #14b, §5).
|
||
|
||
**Explanation copy must state both benefits** when a tier-2 play fires, or the user will think the
|
||
engine optimised for the wrong thing:
|
||
|
||
```
|
||
Jullov 2027 — semester över klämdagarna: du behåller lönen och sparar 4 FP-dagar.
|
||
```
|
||
|
||
### 3.4 Cost the window — free days are free
|
||
|
||
Classify every date in the window:
|
||
|
||
- **Free** (costs nothing): Saturday, **or** present in the Swedish holiday set.
|
||
- **Spendable**: everything else.
|
||
|
||
> **Gotcha, verified against the live data:** `holidays.Sweden(years=2027, language="sv")` returns
|
||
> **61 entries**, because it lists every **Sunday** as `"Söndag"` alongside the real röda dagar.
|
||
> So the holiday set already covers Sundays — `is_free = (weekday == SAT) or (d in holidays)`.
|
||
> Do **not** additionally special-case Sunday, and do **not** treat the set as "public holidays only"
|
||
> when counting red-day clusters for `vacation_wrap` (filter out `"Söndag"` there).
|
||
|
||
A 6-week summer window is ~42 calendar days but only ~30 spendable ones. Making this visible to the
|
||
user ("helger gratis") is a large part of the perceived value.
|
||
|
||
### 3.5 Assign resources, under constraints
|
||
|
||
Per the chosen strategy, walk spendable days and assign `vacation` / `sjukpenning` / `lagsta`.
|
||
|
||
Constraints — all hard unless marked:
|
||
|
||
- **Vacation budget**: per `(parent, year)`, `total_days + saved_days_in − already_used`. Straddling
|
||
windows debit both years (§3.2).
|
||
- **FP pool**: per `(parent, child)`, `sjukpenning_left` and `lagsta_left` drawn separately.
|
||
- **Reserved days are a *transfer* constraint, not a balance.** 90 of each parent's 240 are
|
||
non-transferable, so the limit is `transferable_days_per_parent = 240 − 90 = 150` — the maximum one
|
||
parent may hand the other. The allocator must **never** decrement a "reserved days remaining"
|
||
counter, and the UI must not show one. *(This is `todo.txt` item #5, and the engine depends on it
|
||
being modelled correctly.)*
|
||
- **Dubbeldagar**: only within the child's first 15 months, ≤60 total, consumes **both** parents'
|
||
pools for that date. Only reachable via `parent_mode == 'both'`.
|
||
> **SUPERSEDED (2026-09-15).** The age-4 savable cap was incorrectly applied as 96 per parent. It is now 48 per parent in couple mode with two parents, and 96 otherwise.
|
||
|
||
- **Age-4 is an objective *before* B4 and a hard cap *after* it** (§9.1). Before the 4th birthday,
|
||
days above 96 that remain will be *lost*, so the allocator mildly **prefers** spending before B4 and
|
||
warns when `projected_left_at_b4 > 96`. From B4 onward the available pool is hard-capped at
|
||
`min(remaining, 96)`. Reuse `AnalyticsService.get_days_left_at_age_4` (§6.5) — do not reimplement it.
|
||
- **Preschool planning-day reserve** (§9.3): `preschool_planning_days_per_year` per child, default 6,
|
||
**user-configurable**, deducted off the top before any wish is funded. When this reserve is what
|
||
causes a shortfall, say so as a remedy (§9.1) rather than silently absorbing the days.
|
||
- **SGI (warning only)**: after the child turns 1, a parent who is not working or registered as a
|
||
jobseeker needs ≥5 FP days/week. The app cannot verify employment, so this is a warning keyed off
|
||
`employment_status`, never a block.
|
||
|
||
Every assigned day carries a machine-readable `reason` so the UI can explain itself:
|
||
`free_weekend`, `free_red_day`, `vacation_wrap_weekend`, `vacation_wrap_red_day`, `fp_interior`,
|
||
`fp_lagsta_stretch`, `swap_first_half`, `swap_second_half`, `dubbeldag`.
|
||
|
||
### 3.6 Shortfall, not failure
|
||
|
||
Windows are filled in `(priority, chronological)` order. When a window cannot be fully funded, the
|
||
allocator funds what it can and records the gap, then **moves on to the next window**:
|
||
|
||
```
|
||
Sommar 2029 — 6 veckor önskade, 4 veckor finansierade (12 FP-dagar saknas)
|
||
```
|
||
|
||
It never returns "no plan". The first run is exactly when the numbers are least tuned, so a usable
|
||
plan plus a precise diagnosis beats a refusal.
|
||
|
||
§10.4 reuses this exact vocabulary for a flexible wish that cannot be placed at all — it is reported
|
||
as a 0%-funded window with a full shortfall, not a new kind of failure.
|
||
|
||
### 3.7 Output
|
||
|
||
```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)
|
||
|
||
> **SUPERSEDED (2026-09-15).** Several test cases are outdated: (1) Tests 1, 3–4 assume `planning_goal` is operative (removed migration 009); (2) Test 14 refers to `goal_override` (removed); (3) Tests 8 and 12 use the incorrect 96-day-per-parent cap (now 48 per parent in couple mode with two parents, 96 otherwise).
|
||
|
||
Against the **pure** `Allocator`, with a frozen `today`. Target ≥90% on new backend code.
|
||
|
||
| # | Scenario | Expected |
|
||
|---|---|---|
|
||
| 1 | solo, `max_time`, 6-week summer, child < 12 months | **zero** FP days assigned (SGI protected) |
|
||
| 2 | couple, `balanced`, 6-week summer | `parent_swap`, 3+3, ≈6 weeks together |
|
||
| 3 | `max_money` + monthly salary | vacation lands on Fri/Mon around weekends |
|
||
| 4 | `max_money` + **hourly** salary | wrapping **suppressed**; no vacation-wrap reasons emitted |
|
||
| 5 | wishes exceed budget | lower-priority window shows a shortfall; higher ones are full |
|
||
| 6 | Jullov 12-20 → 01-07 | window wraps years; vacation debited from **both** years |
|
||
| 7 | two children, overlapping windows | pools kept separate per `(parent, child)` |
|
||
| 8 | `projected_left_at_b4 > 96` | `days_lost_at_age_4` warning raised |
|
||
| 9 | window containing Easter (movable) | red-day cluster detected in the correct year |
|
||
| 10 | dubbeldag outside 15 months | rejected; falls back to sequential days |
|
||
| 11 | re-run `propose` with identical input | byte-identical output (determinism) |
|
||
| 12 | wish window **after** the 4th birthday | FP pool hard-capped at 96 (§9.1) |
|
||
| 13 | shortfall caused by the preschool reserve | `reduce_preschool_reserve` remedy, correct day count |
|
||
| 14 | wish carries `goal_override` | that window uses the override, others use the workspace goal |
|
||
|
||
Rows 15+ target flexible ("när det passar bäst") wishes — see §10 for the mechanism under test:
|
||
|
||
| # | Scenario | Expected |
|
||
|---|---|---|
|
||
| 15 | flexible wish, bound = "vår" (mar–maj), 1 week, Påsk inside the bound | candidate touching the Påsk cluster wins; `weeks_funded == weeks_wished` at minimal `cost` |
|
||
| 16 | flexible wish, bound with two candidates of equal spendable-day cost | tie broken by earliest `start`, never by iteration order |
|
||
| 17 | two flexible wishes, identical bound, priorities 1 and 2 | the priority-1 wish gets the cheapest candidate; priority-2 is scored against what's left |
|
||
| 18 | fixed wish and flexible wish with overlapping bounds | fixed wish's dates are placed first and are never revisited; flexible wish's candidates exclude every fixed-wish date |
|
||
| 19 | flexible wish, bound shorter than `target_weeks * 7` after horizon clipping | single shrunk candidate = the clipped bound; reported as an ordinary partial shortfall (§3.6), not `unplaced` |
|
||
| 20 | flexible wish, bound long enough but every full-length run collides with a fixed wish | `strategy == 'unplaced'`, `weeks_funded == 0`, `start_date`/`end_date` = the clipped bound (§10.4) |
|
||
| 21 | flexible wish, `recurrence == 'yearly'`, horizon spans 3 Easters | each year's picked window lands on that year's Påsk-adjacent dates independently (moving target) |
|
||
| 22 | flexible wish, `parent_mode == 'both'`, bound outside the child's 15-month dubbeldagar window | picked window still funds via the existing sequential-days fallback (test #10), unchanged by window_mode |
|
||
| 23 | re-run `propose` twice with identical input, at least one flexible wish present | byte-identical output (determinism holds for flexible wishes too, §5 test #11's guarantee extended) |
|
||
| 24 | flexible wish, `window_mode` omitted / defaults | behaves exactly as a `'fixed'` wish (backward-compatible default, §10.1) |
|
||
|
||
---
|
||
|
||
## 6. Frontend
|
||
|
||
New page `src/pages/Plan.tsx`, route `/plan`, nav entry "Plan".
|
||
|
||
- **Wishes editor** — `BreakPeriodsInput` evolves into `LeaveWishesInput` (recurrence, priority,
|
||
parent). Moves out of Settings onto the Plan page; Settings keeps only true configuration.
|
||
§10.7 adds the explicit-window vs "när det passar bäst" toggle to this editor (`WishEditor.tsx`).
|
||
- **"Föreslå plan"** → `POST /api/plan/propose` → render without persisting.
|
||
- **Multi-year timeline** — one row per year across the horizon, bars per funded window, coloured by
|
||
day type. This is the view that answers "what does the next four years look like?".
|
||
- **Per-window card** — weeks wished vs funded, day breakdown, the `reason`-derived explanation,
|
||
shortfall in plain Swedish.
|
||
- **"Använd plan"** → `POST /api/plan/apply` → calendar and dashboard update.
|
||
|
||
Data layer via a new `useLeaveWishes` / `usePlanProposal` hook pair — components never call the API
|
||
client directly. Plain CSS, BEM, no framework. All `.ts`/`.tsx`, no `any`.
|
||
|
||
---
|
||
|
||
## 7. Prerequisites from `todo.txt`
|
||
|
||
These are not separate chores — the engine reads all three as inputs, so they gate Phase B:
|
||
|
||
- **#1 vacation days per year** — the entity already has `year` + `saved_days_in`; only `Settings.tsx`
|
||
is wrong (hardcodes `new Date().getFullYear()`, single number). The allocator budgets per year, so
|
||
it needs the real multi-year table, including the "fewer days the first year" case.
|
||
- **#2 planning-goal enum** — `PlanningGoalSelector` emits `maximize_contiguous` / `minimize_stress` /
|
||
`balance`; the backend requires `max_time` / `max_money` / `balanced`. This is the allocator's
|
||
primary input, so the mismatch must go.
|
||
- **#5 reserved days** — must become the transfer constraint described in §3.5, not a countdown.
|
||
|
||
`todo.txt` #4 (parent money field) is unrelated to the engine — money stays abstract per §0.2 of
|
||
ARCHITECTURE.md — and can be dropped independently.
|
||
|
||
---
|
||
|
||
## 8. Phasing (one Sonnet subagent per phase)
|
||
|
||
| Phase | Scope | Gate |
|
||
|---|---|---|
|
||
| **0** | `todo.txt` #1, #2, #5 + `workspace.planning_mode` | ruff, pytest, tsc, eslint, vitest |
|
||
| **A** | `LeaveWish` entity, migration, repo, service, CRUD router, tests | backend suite |
|
||
| **B** | `AllocationInput` loader + **pure `Allocator`** + `/plan/propose` + the §5 matrix | ≥90% on new code |
|
||
| **C** | `/plan/apply` + idempotency/collision rules | backend suite |
|
||
| **D** | Types, hooks, `Plan.tsx`, timeline, wishes editor | tsc, eslint 0 warnings, ≥90% vitest |
|
||
| **E** | Retire `BreakPeriod`; point `tips_service.long_break_parent_swap` at wishes | both suites |
|
||
| **F** | Flexible wishes (§10): `window_mode` + migration, candidate search in the allocator, `WishEditor.tsx` toggle | both suites, §5 rows 15-24 |
|
||
|
||
Phase B is the one that must not be rushed — it is the whole product, it is pure, and it is where the
|
||
test matrix lives. Phases A–E each end with `graphify update .` per CLAUDE.md.
|
||
|
||
---
|
||
|
||
## 9. Resolved (was: open questions)
|
||
|
||
1. **Horizon runs past age 4.** Wishes may extend beyond the 4th birthday and the app must not block
|
||
that. Consequence — the FP budget becomes **time-dependent**, which the allocator must model:
|
||
|
||
> **SUPERSEDED (2026-09-15).** The 96-day cap is now 48 per parent in couple mode with two parents, 96 otherwise.
|
||
|
||
```
|
||
FP available for a day d: d < fourth_birthday → full remaining pool
|
||
d >= fourth_birthday → min(remaining, 96)
|
||
```
|
||
|
||
Default `horizon_end` is therefore no longer the 4th birthday: it is
|
||
`max(last wish year-end, latest fourth_birthday)`, capped at the age-12 hard expiry. A parent who
|
||
maximises time before age 4 will arrive at B4 with ~96 days and find later wishes heavily
|
||
constrained — the correct behaviour is to **plan it anyway and show the squeeze**, not to refuse.
|
||
|
||
2. **Weekly intensity is derived, not stored on the wish.** The goal strategy (§3.3) picks it;
|
||
the Plan UI may override it per window, which is passed as a proposal-time override rather than
|
||
persisted on the wish.
|
||
|
||
3. **Preschool planning days are reserved off the top**, before any wish is funded. The per-child
|
||
reserve is **user-configurable** (`preschool_planning_days_per_year`, default 6 — preschools
|
||
differ), so it needs a per-child settings field, not just the config default.
|
||
|
||
> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009).
|
||
|
||
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:
|
||
|
||
> **SUPERSEDED (2026-09-15).** `goal_override` was removed entirely (migration 009).
|
||
|
||
```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.
|
||
> **SUPERSEDED (2026-09-15).** `planned_leave_day` gained `wish_id` (migration 008) linking days back to the wish that produced them, and `is_pinned` to mark hand-edited days the allocator should not retype.
|
||
|
||
- **Applied flexible wishes participate in re-search.** On a subsequent `propose()` call (after the
|
||
family edits wishes), a previously-applied flexible wish is re-searched from scratch within its bound
|
||
and may land in a different window if the occupied set has changed (other wishes added, edited or
|
||
removed). This is safe because the propose→apply flow always previews the new outcome before the
|
||
family confirms (§1), so any movement is visible. Thus `planned_leave_day` needs no `wish_id` column
|
||
— no frozen linkage is needed, because the safeguard is the preview, not schema-level persistence.
|
||
- **Preschool planning-day reserve (§9.3).** No interaction — the reserve is deducted off the top
|
||
before *any* wish (fixed or flexible) is funded, per wish-year window or not.
|
||
- **Surplus advice (§9.2).** No change to the mechanics. A fully-funded flexible wish participates in
|
||
the same age-4 projection as a fixed one. (Whether the engine should ever *suggest a new flexible
|
||
wish* as a `wish_more_leave` remedy — rather than only a fixed one — is out of scope here; today's
|
||
`wish_more_leave` copy already reads generically enough to cover either.)
|
||
- **Vacation-year straddling (§3.2).** A flexible wish whose *bound* wraps a calendar year end (e.g.
|
||
a "vinter" bound spanning Dec–Feb) uses `window_wraps()`/`window_for_year()` unmodified; a chosen
|
||
candidate that itself straddles two vacation-year budgets debits both, exactly as §3.2 already
|
||
specifies for fixed windows. The cost function does not weight cross-year splits specially.
|
||
|
||
### 10.4 When a flexible wish cannot be placed at all
|
||
|
||
Reuses §3.6's shortfall vocabulary rather than inventing a new failure mode, per the handoff's
|
||
instruction. The only addition is a new value for the existing `WindowOutcome.strategy` field (today:
|
||
`'solo' | 'parent_swap' | 'dubbeldag'`) — `'unplaced'` — for the case in §10.2 step 7: the bound is
|
||
long enough, but every full-length candidate collides with a date another wish already claimed.
|
||
|
||
`WindowOutcome` for an unplaced wish-year:
|
||
|
||
- `strategy = "unplaced"`.
|
||
- `weeks_funded = 0.0`, `shortfall_days = length_days` (`target_weeks * 7`). Unlike a partially-funded
|
||
window, there is no chosen window to derive a free/spendable split from, so the full calendar-day
|
||
duration is reported rather than an estimated spendable-day count. This is a deliberate, slightly
|
||
pessimistic simplification, not a policy decision — the number still renders correctly through the
|
||
existing per-window card (§6).
|
||
- `cost_breakdown` = all-zero (`_new_cost()`).
|
||
- `start_date`/`end_date` = the **clipped bound** for that year (not a fabricated window) — so the
|
||
explanation can still say what range the engine searched.
|
||
- `remedies`: reuse `_build_remedies`'s logic with the clipped bound standing in for `w.start`/`w.end`
|
||
wherever a reference year or shift-probe date is needed. `carry_vacation_forward` and
|
||
`lower_priority_wish` both still make sense (neither needs a *specific* winning window). Omit
|
||
`shift_window_into_free_days` — there is no single window edge left to shift.
|
||
|
||
No new field is added to `WindowOutcome`, `ProposedDay` or `PlanProposal` for any of this — the
|
||
`strategy` field is already typed as a bare `str` on both sides (`allocation_service.py`'s dataclass
|
||
and `frontend/src/types/entities.ts:177`, which already documents it as intentionally open-ended), so
|
||
adding a literal value is a copy/behavior change, not a schema change.
|
||
|
||
### 10.5 Persistence
|
||
|
||
`LeaveWish` gains exactly one column. Hand-written Alembic revision, following migration 002's
|
||
pattern (`backend/migrations/versions/002_planning_mode_and_preschool_days.py`) exactly:
|
||
|
||
```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`).
|
||
|