diff --git a/NextSteps260915.md b/NextSteps260915.md new file mode 100644 index 0000000..a8df39c --- /dev/null +++ b/NextSteps260915.md @@ -0,0 +1,438 @@ +# Next Steps — handoff, 2026-09-15 + +**Format note for whoever dispatches this:** each task below (§2 onward) is written as a +self-contained brief. Copy ONE brief verbatim into a subagent prompt — it does not need the rest +of this file, `NextSteps260914.md`, or `NextSteps260913.md`. Only the orchestrator needs §0/§1. +`NextSteps260913.md` and `NextSteps260914.md` are history — do not edit them. + +--- + +## 0. State as of now (all committed, working tree clean) + +Commits this session: `e3d19f2`, `42cebba`, `dd7f0ff`, `084ee91`, `760b312`. + +Gates, all currently green — **these are the baselines to paste into every verifier**: +- Backend: **439 tests passing**, `ruff check` clean, `ruff format --check` clean (128 files) +- Frontend: **710 tests passing / 56 files**, `npm run typecheck` clean, `npm run lint` 0 issues +- Coverage (frontend): 96.54% lines/statements, 91.08% functions, 86.84% branches. Thresholds are + a **regression ratchet — never lower them**: lines 95 / statements 95 / functions 89 / branches + 85. Backend CI gate `--cov-fail-under=75` (measured ~81), set in `.github/workflows/ci.yml`. + +Everything in `NextSteps260914.md`'s roadmap is DONE, including full browser verification. + +--- + +## 1. Shared preamble (orchestrator reads this once; do not forward to subagents) + +**Stack:** Python 3.14 / FastAPI / uv backend (no ORM, hand-rolled repositories, Alembic +migrations); TypeScript / React 18.3 / Vite 5 / Vitest frontend (no TanStack Query, no Redux, +hand-rolled i18n). Full conventions in `/Users/jhag/projects/Föräldraledigplannerare/CLAUDE.md`. + +**Run the app:** +```bash +cd frontend && npm run dev # starts BOTH vite (5173) and uvicorn (8000) +``` +Vite binds IPv6 `::1` — use `http://localhost:5173`, **not** `127.0.0.1`. If the API behaves +impossibly, check `lsof -nP -iTCP:8000 -sTCP:LISTEN` for a squatter (macOS resolves `localhost` to +`::1` first). + +**Seed demo data:** +```bash +cd backend && uv run python scripts/seed_demo.py --reset +``` +Login `demo@example.com` / `DemoPass123!`. Creates a couple workspace: Alex (has a login, a +near-drained pool) and Sam (no-login partner, created via `add_placeholder_parent`, large +surplus), child Nova, vacation rows for only some years (so `is_assumed` badges show), a worked +override and an off override, and four wishes including a flexible one and a deliberately +underfunded one (produces both shortfalls and surplus advice). + +**Sanity-check the engine without a browser:** `backend/scripts/demo_plan.py`. + +**Verification commands:** +```bash +cd backend && uv run ruff check . && uv run ruff format --check . && uv run pytest +cd frontend && npm run typecheck && npm run lint && npx vitest run && npm run coverage +``` + +**Cost rule (from `CLAUDE.md`):** a Haiku subagent runs these itself, immediately. A Sonnet-or-larger +subagent must NOT run full-suite verification itself — it finishes the code change, then spawns a +Haiku subagent with the exact commands, the directory to run them from, the numeric baseline above, +and an instruction to paste **raw verbatim output**, not a summary. Several agents have reported +gates as "all green" that were in fact failing — a report without pasted output is not accepted. +This rule does not apply to a single focused test run while mid-task. + +**Browser verification matters.** Every significant bug found this project — the calendar +overshoot, the flexible-wish double-booking, the doubled age-4 entitlement, two stale-data bugs — +was found by driving the running app while the entire suite was green. Suites confirm the code +does what it was written to do, not what it should do. `backend/tests/integration/test_user_journey.py` +guards the main journey but is not a substitute for clicking. + +--- + +## 2. Waves + +- **Wave 1 — investigation, no code guessed.** Task A only. It touches read paths across + frontend and backend but the brief explicitly forbids shipping a fix before the behaviour is + confirmed, so it cannot safely run in parallel with a wave that edits the same files for a + different reason. Not blocked on anything. +- **Wave 2 — independent file-owning tasks, can run fully in parallel.** Tasks B (frontend + `WishEditor.tsx` UI, blocked on a product decision — see brief), C (frontend test files only, + raises branch coverage), and D (backend `analytics_service.py` docs/decision framing, blocked on + a product decision). B, C, D touch disjoint files (B: `WishEditor.tsx` + its test + i18n; C: test + files across the frontend under existing components; D: `analytics_service.py` region + possibly + `Dashboard.tsx`) and none touches a file Task A investigates, so all three can be dispatched + together once Wave 1's finding is known (Task A might turn out to change what Task B/D need, but + neither B nor D depends on A's *outcome* to start — see each brief's decision-flag). + +If Task A concludes "this is a real bug and the fix is obvious and small," that fix is a **new** +task, not folded into A's brief — A is investigation-only per the instructions below. + +--- + +## Task A — Investigate the Settings partner section + +### Goal +Determine, with evidence (not assumption), whether the Inställningar (Settings) page's partner UI +is broken or working as designed. In the seeded couple workspace, partner "Sam" exists in the +workspace and is planned around (wishes, allocations, the surplus/shortfall picture all reference +Sam), but the Settings page currently shows only an empty "Add a partner" form with no visible +indication a partner is already linked. This was spotted in passing during a previous session and +was **not** dug into — it may be correct behaviour with unusual seed data, or it may be a real gap. +Done looks like: a clear, evidenced verdict — "this is a bug, here is exactly what's wrong and +where" or "this is correct, here is why the seed data made it look wrong" — with no code changed +unless the fix is trivial and you have explicit confirmation from whoever dispatched you that +shipping it is in scope. + +### Owned files +Read-only investigation. If (and only if) you get explicit go-ahead to fix something, the only +files in scope are: +- `frontend/src/pages/Settings.tsx` +- `frontend/src/context/WorkspaceContext.tsx` +- `backend/app/routers/workspace.py` + +Do not touch anything else without asking first. + +### Constraints and traps that apply to THIS task +- **`vi.mock('@/context')` does NOT intercept direct submodule imports** like + `@/context/AuthContext`. Mock the exact specifier the component imports — this caused 14 test + failures once. `Calendar.test.tsx` / `Settings.test.tsx` show the right idiom. +- **`localStorage` is unreliable in this test sandbox** (Node's experimental webstorage shadows + jsdom's). Use `vi.stubGlobal('localStorage', mock)`. +- **Every user-facing string must be `message_key` + `data`**, never a hardcoded Swedish string. + `backend/tests/integration/test_user_journey.py::TestNoSwedishOnTheWire` walks responses for + å/ä/ö and will fail violations. `frontend/src/i18n/sv.ts` is the source of truth; `en.ts` derives + its type from it, so a key missing from either is a compile error. +- **No `any`, `@ts-ignore` or `@ts-expect-error`** — `no-explicit-any` is an ESLint error and CI + runs `--max-warnings 0`. +- **Use `npm run typecheck`, never `npx tsc`** — npx resolves a newer TypeScript and emits a bogus + `TS5101 baseUrl is deprecated` error. +- **`git grep` only searches tracked files** — use plain `grep` when checking new or uncommitted + work. +- **Never delete, skip or weaken a test to make a suite pass.** + +### Facts already gathered (starting point — verify, don't re-derive from scratch) +- `frontend/src/pages/Settings.tsx` has an "Add partner" form (`handleAddPartner`, around line 230; + the JSX section starts around line 397 with `settings.partnerSectionTitle`) that POSTs via + `api.addPartner`. It does **not** appear to call `api.listWorkspaceMembers` anywhere, nor render + any existing-member list — grep confirms `members`/`Members` do not appear in `Settings.tsx`. +- `frontend/src/services/api.ts` around line 868–872 has `listWorkspaceMembers(workspaceId)` which + calls `GET /workspace/{id}/members` — this function exists and is used elsewhere (for picking + which parent a wish belongs to in wish editing) but is a candidate for what Settings is missing. +- `backend/app/routers/workspace.py` has `GET /api/workspace/{workspace_id}/members` (returns all + `PersonalDataResponse` rows, i.e. both the logged-in user and any placeholder partner) and + `POST /api/workspace/{workspace_id}/parents` (`add_placeholder_parent`) which is how the seeded + partner "Sam" was created — a `PersonalData` row with **no login**, per + `backend/scripts/seed_demo.py` (`PARTNER_NAME = "Sam"`, `add_placeholder_parent`). +- `frontend/src/context/WorkspaceContext.tsx` exposes `parentName` (the *current logged-in user's* + name) and `personalDataUuid`, but has no field for "other members" or "partner name" — check + whether this is intentional (Settings might be expected to fetch members itself) or a real gap. + +### What to actually check +1. Run the app with seeded demo data, log in as `demo@example.com`, open Inställningar, and look + at the partner section with your own eyes (or via `mcp__claude-in-chrome__*` browser tools). + Confirm what is and isn't shown. +2. Read all of `Settings.tsx` to see if there's member-fetching logic you missed (e.g. behind a + loading state, an error being swallowed, or a condition that's never true for this seed). +3. Check the network tab / add a temporary log to see if `GET /workspace/{id}/members` is even + called from Settings, and if so, what it returns for the demo workspace. +4. Check `Settings.test.tsx` for existing coverage of the partner section — does a test already + assert "no member list is shown," which would mean it's a deliberate v1 scope cut rather than a + bug that slipped through? +5. Decide: is the correct behaviour "show existing partner(s) with a way to manage/remove them" and + Settings simply never got that view built (a real gap), or is the current one-shot "add a + partner" form intentional because a workspace only ever gets one partner added once, after + which the UI doesn't need to display them here (they show up in the Plan/Calendar contexts + instead)? Look for the second parent's name surfacing anywhere else in Settings (e.g. a + per-parent vacation table) as evidence for the second reading. + +### Done criteria +No code changes are the default outcome. Report: +- The concrete behaviour observed (with a screenshot or copied DOM/text if using the browser + tools). +- Whether `GET /workspace/{id}/members` is called from Settings, and what it returns. +- A clear verdict: bug vs. by-design vs. "by-design but confusing enough to flag for a product + decision." +- If you conclude it's a bug and the fix is small and obvious (e.g. "just render the existing + members list that `listWorkspaceMembers` already returns"), you may implement it, but only after + making the verdict explicit in your own reasoning — do not silently patch it as a side effect of + investigating. If you do fix it, run: + ```bash + cd frontend && npm run typecheck && npm run lint && npx vitest run && npm run coverage + ``` + and confirm no regression against the frontend baseline (710 tests passing / 56 files, coverage + ≥ 96.54/91.08/86.84 for lines/functions/branches) before reporting done. Per the cost rule, if + you are a Sonnet-or-larger agent, delegate that run to a Haiku subagent and require verbatim + output. + +### Anything needing a product decision +If Task A finds the display is ambiguous-but-defensible (e.g. the partner does show up elsewhere, +just not in Settings), flag to the product owner: *should Settings show a "Partner: Sam" line with +a remove/replace action once a partner exists, instead of (or alongside) the add-partner form?* +Do not guess an answer and build it. + +--- + +## Task B — Custom bounding-range picker for flexible wishes + +### Goal +A flexible wish ("när det passar bäst") today can only be bounded by one of four season presets +(vår 03-01→05-31, sommar 06-01→08-31, höst 09-01→10-31, jul 12-01→01-31, which wraps the year +end). This was a deliberate v1 scope cut, not a rejection. Done looks like: a family can pick an +arbitrary date range (e.g. "sometime between March 15 and April 20") as the bounding range for a +flexible wish, in addition to keeping the four season presets as one-click shortcuts. The engine +side needs no changes — a flexible wish already reuses the existing `window_start_month/day` / +`window_end_month/day` fields as its search bound; this is a UI-only addition. + +### Owned files +- `frontend/src/components/planning/WishEditor.tsx` +- `frontend/src/components/planning/WishEditor.test.tsx` (or wherever its existing test file is — + confirm the exact name with `git ls-files | grep -i wisheditor` before starting) +- `frontend/src/i18n/sv.ts` and `frontend/src/i18n/en.ts` (new keys only, additive) + +Do not touch `backend/app/services/allocation_service.py`, `backend/app/entities/leave_wish.py`, +or `PLANNING_ENGINE_SPEC.md` — no engine or spec change is needed for this task. + +### Constraints and traps that apply to THIS task +- **A literal `*/` inside a JSDoc block comment prematurely closes it and breaks the whole file** — + this already bit this exact file (`WishEditor.tsx`) once. Avoid nested JSDoc strings containing + `*/`, or escape them carefully, in any comment you add or edit. +- **Every user-facing string must be `message_key` + `data`**, never a hardcoded Swedish string in + the frontend component — use `frontend/src/i18n/sv.ts` as the source of truth; `en.ts` derives + its type from it, so a key missing from either is a compile error. +- **No `any`, `@ts-ignore` or `@ts-expect-error`** — `no-explicit-any` is an ESLint error and CI + runs `--max-warnings 0`. +- **Use `npm run typecheck`, never `npx tsc`** — npx resolves a newer TypeScript and emits a bogus + `TS5101 baseUrl is deprecated` error. +- **Never delete, skip or weaken a test to make a suite pass.** +- **`localStorage` is unreliable in this test sandbox** — use `vi.stubGlobal('localStorage', mock)` + if the component or its tests touch it. +- The bounding-range fields already exist and are named `windowStartMonth` / `windowStartDay` / + `windowEndMonth` / `windowEndDay` in the wish object built by `WishEditor.tsx` (see + `addPreset`/`addSeasonPreset`, around lines 162–182) — a custom picker must populate exactly + these same fields, in the same month/day (not full-date) shape, since that's what the backend + entity and allocator expect. Do not invent a new field or a full ISO-date representation. +- Season presets fill the bounding range only, **not** the final placement (see the existing + `PLANNING_ENGINE_SPEC.md §10.1`/§10.7 comments already in the file around lines 58–65 and + 179–182) — a custom range picker must follow the same contract: it sets the search bound, the + engine still picks the actual placement inside it at propose time. + +### Done criteria +```bash +cd frontend && npm run typecheck && npm run lint && npx vitest run && npm run coverage +``` +Must show: typecheck clean, lint 0 issues, all tests passing (baseline 710 passing / 56 files, this +task adds tests so the count should only go up), and coverage not regressing below the current +ratchet (lines 95 / statements 95 / functions 89 / branches 85 — current measured 96.54 / 96.54 / +91.08 / 86.84). Per the cost rule, if you are a Sonnet-or-larger agent, delegate this run to a +Haiku subagent and require verbatim output, not a summary. + +Also do a manual browser pass (seed demo data per §1, log in, open a wish editor, add a flexible +wish, use the custom picker, propose a plan) — this project's history is that suites pass while the +feature is subtly broken in the browser (double-booking, overshoot, etc. were all browser-only +finds). + +### Anything needing a product decision +**This entire task is blocked on a product decision that has not been made:** how much freedom to +expose in the custom picker. Options include (not exhaustive, do not pick one yourself): +- Any two dates, unconstrained (family could pick a 300-day bounding range). +- A minimum/maximum span (e.g. must be between 1 and ~90 days) to keep the engine's + window-scanning inside sane limits. +- Whether the picker allows a range that wraps the calendar year end, the way the `jul` preset + does, or restricts custom ranges to a single calendar year for simplicity. +- Whether the four season presets stay as visible quick-picks alongside the custom picker, or the + custom picker replaces them once available. +Do not guess and build; get this decided (or explicitly ask the user) before writing UI, since it +changes the validation logic and the i18n copy. + +--- + +## Task C — Raise frontend branch coverage + +### Goal +Frontend branch coverage (86.84%) is the weakest of the four coverage metrics and has the least +headroom above its ratchet floor (85%). Done looks like: branch coverage measurably higher than +86.84% (ideally comfortably clear of 85, e.g. 88%+), achieved by adding real tests for genuinely +untested branches — not by deleting or weakening any existing test, and not by chasing the number +with low-value tests that don't exercise a real decision path. + +### Owned files +Test files only — do not modify any non-test source file to make coverage easier to hit. Before +starting, run `npm run coverage` and read the generated HTML/text report +(`frontend/coverage/`) to find the actual weakly-covered branches (this project's own history notes +that `Auth.tsx`, `Tips.tsx`, `DevFetch.tsx` were previously at or near 0% — check whether that is +still true or has since improved before assuming those are still the gaps). Pick specific files +based on the report, then add or extend the corresponding `*.test.tsx`/`*.test.ts` file for each. +Do not touch files owned by Task A (`Settings.tsx`) or Task B (`WishEditor.tsx`) — if the coverage +report points you at either of those, pick a different low-coverage file first and leave a note +about the overlap instead of racing another task's edit. + +### Constraints and traps that apply to THIS task +- **`vi.mock('@/context')` does NOT intercept direct submodule imports** like + `@/context/AuthContext`. Mock the exact specifier the component imports — this caused 14 test + failures once. `Calendar.test.tsx` / `Settings.test.tsx` show the right idiom. +- **`localStorage` is unreliable in this test sandbox** (Node's experimental webstorage shadows + jsdom's). Use `vi.stubGlobal('localStorage', mock)`. +- **No `any`, `@ts-ignore` or `@ts-expect-error`** — `no-explicit-any` is an ESLint error and CI + runs `--max-warnings 0`. +- **Use `npm run typecheck`, never `npx tsc`** — npx resolves a newer TypeScript and emits a bogus + `TS5101 baseUrl is deprecated` error. +- **Never delete, skip or weaken a test to make a suite pass.** This applies doubly here: the whole + point of this task is more real coverage, not a smaller denominator. +- **A literal `*/` inside a JSDoc block comment prematurely closes it** — be careful if any test + file you touch has JSDoc-style comments. +- **`git grep` only searches tracked files** — use plain `grep` when checking new or uncommitted + work by other in-flight tasks so you don't collide with them. + +### Done criteria +```bash +cd frontend && npm run typecheck && npm run lint && npx vitest run && npm run coverage +``` +Baseline to compare against: 710 tests / 56 files passing, coverage 96.54% lines/statements, +91.08% functions, 86.84% branches. After this task: test count should increase, all four coverage +numbers should be equal or higher (branches specifically should move up meaningfully, not by +0.01%), and none of the ratchet floors (lines 95 / statements 95 / functions 89 / branches 85) +should regress. Per the cost rule, if you are a Sonnet-or-larger agent, delegate this run to a +Haiku subagent and require verbatim output, not a summary. + +### Anything needing a product decision +None — this is pure test-quality work with no user-visible behaviour change. If while adding tests +you discover a branch that looks like a real bug (not just untested), stop and report it separately +rather than writing a test that locks in wrong behaviour. + +--- + +## Task D — Per-parent age-4 breakdown on the Dashboard + +### Goal +The Dashboard's age-4 stat ("Förfaller vid 4 års ålder") is workspace-wide — it sums both parents' +projected expiring days into one number and the label says "all parents". The Plan tab, by +contrast, already breaks this down per parent. Both numbers are currently correct (a prior bug that +doubled one parent's entitlement was fixed in commit `760b312`), so this is **not** a bug fix — it +is a product decision about whether the Dashboard should also show a per-parent breakdown for +clarity, the way the Plan tab does. Done looks like, IF the product owner confirms they want it: the +Dashboard shows each parent's own expiring-day count (not just the combined total) using the +per-parent projection the backend already computes, with the combined total kept or clearly +labelled as a sum, whichever the product owner picks. + +### Owned files +- `backend/app/services/analytics_service.py` (read-only reference; a per-parent breakdown is + likely already computed here — do not change its arithmetic without a specific reason) +- `frontend/src/pages/Dashboard.tsx` +- `frontend/src/pages/Dashboard.test.tsx` +- `frontend/src/i18n/sv.ts` / `frontend/src/i18n/en.ts` (new keys only, additive, if new labels are + needed) + +Do not touch `backend/app/models/plan.py`'s dataclass→response mapping unless the per-parent field +needed on the Dashboard genuinely isn't on the wire yet — check first (see traps below). + +### Constraints and traps that apply to THIS task +- **The 390 + 90 tier totals are the whole CHILD's entitlement across BOTH parents**, not one + parent's. One parent's no-snapshot default is `days_per_parent` (240 = 195 + 45). Getting this + wrong doubled a parent's entitlement and made two screens disagree — this was fixed in `760b312` + precisely in this area of the code (`analytics_service.py`), so be careful not to reintroduce it + while restructuring how the numbers are displayed. +- **The dataclass→response mapping in `backend/app/models/plan.py` is hand-written** and has + silently dropped new engine fields twice before. If you need a new field on the wire, add an + **API-level** test, not just a service test, to prove it actually reaches the response body. +- **Every user-facing string must be `message_key` + `data`**, never a hardcoded Swedish string. + `frontend/src/i18n/sv.ts` is the source of truth; `en.ts` derives its type from it, so a key + missing from either is a compile error. +- **No `any`, `@ts-ignore` or `@ts-expect-error`** — `no-explicit-any` is an ESLint error and CI + runs `--max-warnings 0`. +- **Use `npm run typecheck`, never `npx tsc`** — npx resolves a newer TypeScript and emits a bogus + `TS5101 baseUrl is deprecated` error. +- **Never delete, skip or weaken a test to make a suite pass.** +- Reference facts already confirmed in `backend/app/services/analytics_service.py`: it has + `expiring_at_b4` per parent (a single-parent projection, `max(projected_left - 96, 0)`) and a + combined view with `combined_days_used`, `combined_days_left`, `combined_expiring_at_b4` (sum + across all parents' projections) — the per-parent numbers this task needs already exist + server-side; check whether they already reach the Dashboard's API response or need adding to the + relevant response model. + +### Done criteria +Backend (only if you touch it): +```bash +cd backend && uv run ruff check . && uv run ruff format --check . && uv run pytest +``` +Baseline: 439 tests passing, ruff check clean, ruff format --check clean (128 files); count should +only increase. + +Frontend: +```bash +cd frontend && npm run typecheck && npm run lint && npx vitest run && npm run coverage +``` +Baseline: 710 tests / 56 files passing, coverage 96.54%/96.54%/91.08%/86.84% (lines/statements/ +functions/branches); none of the ratchet floors (95/95/89/85) may regress. + +Per the cost rule, if you are a Sonnet-or-larger agent, delegate both runs to a Haiku subagent and +require verbatim output, not a summary. Also do a manual browser pass with seeded demo data (see +§1) comparing the Dashboard and Plan tab numbers for Alex and Sam side by side — this is exactly +the kind of two-screens-disagree bug that has bitten this project before (`760b312`). + +### Anything needing a product decision +**This entire task is blocked on a product decision:** does the product owner actually want a +per-parent breakdown on the Dashboard, or is the current combined "all parents" framing intentional +(e.g. because the Dashboard is meant to be an at-a-glance household view and per-parent detail +belongs only in the Plan tab)? If yes, also decide: does the combined total stay visible alongside +the per-parent numbers, or does the per-parent breakdown replace it? Do not guess and build either +answer. + +--- + +## 3. Traps reference (for the orchestrator only — already duplicated into each brief above) + +Kept here once for the orchestrator's own awareness; do not point a subagent at this section +instead of the duplicated version in its brief — that defeats the point of self-contained briefs. + +- `holidays` library combined names (`"Påskdagen; Söndag"`) — exact-string match only. +- `_FULL_SCHEMA` in `backend/app/db/connection.py` must track Alembic migrations. +- `vi.mock('@/context')` doesn't intercept direct submodule imports. +- `backend/app/models/plan.py` dataclass→response mapping is hand-written and has dropped fields + twice. +- `localStorage` unreliable in the test sandbox — `vi.stubGlobal`. +- `git grep` only searches tracked files. +- Literal `*/` inside JSDoc breaks the file (bit `WishEditor.tsx` once). +- `npm run typecheck`, never `npx tsc` (npx resolves a newer TS, bogus `TS5101`). +- No `any` / `@ts-ignore` / `@ts-expect-error` — ESLint error, CI `--max-warnings 0`. +- Never delete/skip/weaken a test to make a suite pass. +- Keep the `Allocator` pure — no DB, no `date.today()`. +- Every user-facing string is `message_key` + `data`; `TestNoSwedishOnTheWire` enforces it. +- 390+90 tiers are the whole child's entitlement across both parents, not one parent's. + +## 4. Decisions already made — do not relitigate (for the orchestrator's awareness) + +- Planning horizon runs past B4; the FP pool is capped at `min(remaining, 96)` after — show the + squeeze, don't refuse. +- Valborgsmässoafton is NOT a default day off. +- No API calls at runtime in the planner; calendar cache covers 2026–2055, `strict=True` raises + `UncachedYearError` → 422 rather than silently degrading. +- 2049 and 2055 legitimately have 17 red days (Pingstdagen coincides with Sveriges nationaldag). +- Flexible wishes are propose-time suggestions, never auto-applied; fixed wishes always win. +- An applied flexible occurrence may move on a later re-propose (no `wish_id` on + `planned_leave_day`). +- WishEditor v1 ships season presets only — a custom range picker (Task B above) is deferred, not + rejected. +- Coverage thresholds are a regression ratchet, never lowered to make a gate pass. +- i18n is hand-rolled on purpose — no `react-i18next`. +- Verification cost rule: Haiku runs the suites itself; Sonnet+ delegates and requires verbatim + output.