76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
282 lines
16 KiB
Markdown
282 lines
16 KiB
Markdown
# Next Steps — handoff, 2026-09-13
|
||
|
||
Written at the end of a long session so a fresh session can pick up without re-deriving anything.
|
||
**Read `CLAUDE.md` and `ARCHITECTURE.md` first — both were brought up to date today and are
|
||
trustworthy again.** `PLANNING_ENGINE_SPEC.md` holds the approved design of the planning engine,
|
||
including decisions the product owner already made (don't relitigate them — see §6).
|
||
|
||
**Addendum:** the four product-owner todos from `todo_260912.txt` are now folded into §3 (marked
|
||
**NEW**), and the product owner's answers to the clarifying questions are recorded inline
|
||
(2026-09-13). Only two small sub-points remain open, in §3.11. `todo_260912.txt` can be deleted
|
||
once the todos land.
|
||
|
||
---
|
||
|
||
## 1. Where the app is
|
||
|
||
The core user journey works end to end:
|
||
|
||
> log in → set up child, vacation days per year, planning goal → describe breaks as
|
||
> *"6 weeks every summer, 4 weeks every Christmas"* → get a costed multi-year plan showing which
|
||
> days are already free, which cost a semesterdag, which cost a föräldrapenningdag → save it to
|
||
> the calendar.
|
||
|
||
Built today: the whole planning engine (`LeaveWish` → allocator → propose → apply), a Swedish
|
||
calendar cache with user overrides, the Planering tab, bilingual sv/en UI, and all five items from
|
||
the old `todo.txt` (which is now done and can be deleted).
|
||
|
||
**State at handoff**
|
||
- Backend: **361 tests passing**, `ruff check` clean, `ruff format --check` clean
|
||
- Frontend: **540 tests**, coverage exits 0 (89.47% stmts / 85.46% branches / 88.27% funcs)
|
||
- Three verification issues outstanding — see §4.1, none of them from feature work
|
||
|
||
---
|
||
|
||
## 2. Running it
|
||
|
||
```bash
|
||
cd frontend && npm run dev # starts BOTH vite (5173) and uvicorn (8000)
|
||
```
|
||
|
||
If the servers die (they did today), restart individually:
|
||
```bash
|
||
cd backend && uv run uvicorn app.main:app --reload --port 8000
|
||
cd frontend && npx vite
|
||
```
|
||
|
||
**Gotcha that cost an hour:** a stray `python -m http.server 8000` was squatting `*:8000` over
|
||
IPv6. macOS resolves `localhost` → `::1` first, so Vite's `/api` proxy hit the squatter and every
|
||
login returned `501`. `frontend/vite.config.ts` now targets `127.0.0.1` explicitly so this can't
|
||
recur. If the API behaves impossibly, check `lsof -nP -iTCP:8000 -sTCP:LISTEN` for extra listeners.
|
||
|
||
---
|
||
|
||
## 3. What's left — merged roadmap (original items + product-owner todos)
|
||
|
||
Phases, roughly in order:
|
||
|
||
- **Phase A (3.1–3.3, 3.5): finish what the backend already computes.** `surplus_advice` and
|
||
`is_assumed` already reach the wire; wiring them up is high-value, low-risk. 3.5 (the i18n
|
||
`message_key` fix) must land **before** 3.9, because flexible wishes will generate new
|
||
engine-produced Swedish strings — build on the `tips_service.py` pattern from day one instead
|
||
of creating more leaks to retrofit.
|
||
- **Phase B (3.4, 3.6–3.8): the calendar cluster.** All four touch `Calendar.tsx`, the holiday
|
||
cache or Settings — one pass over the holiday data, not four.
|
||
- **Phase C (3.9): flexible wishes** — the only item needing engine **and** spec changes. The
|
||
product-owner decisions are in (minimise paid days, re-pick yearly, propose like every other
|
||
plan; two small open points in 3.11); do it as a dedicated session after the UI foundations.
|
||
- **Phase D (3.10): page coverage** — interleaved whenever, don't batch it.
|
||
|
||
### 3.1 Show the surplus advice in the UI ← highest value
|
||
The backend computes it and `POST /api/plan/propose` now returns `surplus_advice`, but
|
||
**`PlanResults.tsx` doesn't render it**. Right now the app tells a family *"you'll lose 195 days"*
|
||
and stops. The advice that exists and isn't shown:
|
||
|
||
- `wish_more_leave` — "ni har råd med ytterligare N veckor — lägg till en vecka på sommaren eller ett höstlov"
|
||
- `shift_mix_to_fp` — spend föräldradagar rather than semester, since FP is what expires
|
||
- `front_load_early_years` — take more days while the child is small
|
||
- `use_less_vacation` — bounded by `min_vacation_days_per_year` (statutory 20)
|
||
|
||
Render it like the existing shortfall remedies (same shape, deliberately). This is the difference
|
||
between a calculator and a planner.
|
||
|
||
### 3.2 Surface `is_assumed` on vacation years
|
||
`VacationBalanceResponse.is_assumed` means *"no vacation_days row existed for this year, so we
|
||
assumed the statutory 25"*. It reaches the wire now but nothing displays it, so the app silently
|
||
plans around a guess. Prompt the user to confirm those years.
|
||
|
||
### 3.3 **NEW (todo):** show age-4 expiries *given the plan*
|
||
Request: the "Förfaller vid 4 års ålder" stat is nice, but the app should also show how many
|
||
days expire **assuming the plan actually gets taken** — while acknowledging that life may not
|
||
work out that way.
|
||
|
||
**Decision (product owner):** two surfaces, both plan-aware:
|
||
1. **Dashboard** — show how much is left given the *currently committed* plan. The existing
|
||
stat already does this: `analytics_service.py` computes `expiring_at_b4 = max(projected_left
|
||
− 96, 0)` with `projected_used = used + planned(date < B4)`, and the Dashboard renders it
|
||
(`dashboard.expiringAtB4`). Verify it, don't rebuild it.
|
||
2. **Plan tab, propose time** — before committing, show how much would be left *if this plan
|
||
were applied*. This is the actual work item: run the same projection for the proposed
|
||
allocation and render it in `PlanResults.tsx` next to 3.1's surplus advice. Same component
|
||
pass, so do it right after 3.1.
|
||
|
||
### 3.4 Shade röda dagar / klämdagar in the Kalender page
|
||
The data exists (`backend/app/data/swedish_calendar.json`, exposed via `app/utils/holidays.py`)
|
||
but `Calendar.tsx` doesn't use it. Users can't see which days are already free — arguably the most
|
||
obvious missing visual in the app.
|
||
|
||
### 3.5 Backend-generated Swedish leaks through in English mode
|
||
Four surfaces in `PlanResults.tsx` render Swedish regardless of locale, all produced by
|
||
`backend/app/services/`:
|
||
1. `outcome.explanation` (per-window explanation)
|
||
2. `remedy.description` (shortfall remedies)
|
||
3. `warning.message` (warning body; the *label* is translated)
|
||
4. `applyResult.rejections` (from `planning_service.py`)
|
||
|
||
**Do not fix this by string-matching client-side.** The right fix is the pattern
|
||
`tips_service.py` already uses: return a `message_key` plus a `data` dict and let the frontend
|
||
render it. Tips are already bilingual this way — copy that.
|
||
|
||
### 3.6 Day-override UI is settings-only
|
||
You can add/remove overrides in Inställningar, but clicking a day in the Kalender to mark it
|
||
worked/off would be far more natural. Pairs well with 3.4.
|
||
|
||
### 3.7 **NEW (todo):** month/year fast navigation in Kalender
|
||
Clicking the month/year should let you jump faster than the arrows. Interpreted as: click
|
||
month name = next month, click year = next year (backwards via the existing arrows or
|
||
modifier click). Small, independent change to `pages/Calendar.tsx` — do it in the Phase B
|
||
pass.
|
||
|
||
### 3.8 **NEW (todo):** red-dags list in Settings
|
||
A per-year list of all röda dagar (i.e. the non-Sunday holidays) so it's at a glance which
|
||
days the app treats as free. The data already exists — `backend/app/data/swedish_calendar.json`
|
||
exposed via `app/utils/holidays.py` — same source as 3.4, so batch the two. Expose it through
|
||
a proper API endpoint (not a file fetch from the frontend), and reuse the existing day
|
||
metadata (name, type, whether it's a klämdag relative to a weekend).
|
||
**Decision (product owner):** the list must show **both** the statutory red days **and** the
|
||
user's day overrides (worked/off), and it paginates **four years ahead at a time** (the full
|
||
10-year cache is available but not displayed at once).
|
||
|
||
### 3.9 **NEW (todo), big:** "när det passar bäst" wishes with red-day-dense proposals
|
||
A wish like "en vecka på våren" should be expressible as either **explicit dates** or
|
||
**"whenever it fits best"** — in which case the engine picks the window, preferring places
|
||
where there are the most röda dagar/klämdagar (e.g. around Easter) because that costs the
|
||
fewest semesterdagar.
|
||
|
||
Today `LeaveWish` is always a fixed month/day window (`backend/app/entities/leave_wish.py`).
|
||
One-off explicit windows are already expressible via `recurrence='once'`, but *engine picks
|
||
the window* doesn't exist.
|
||
|
||
What's involved:
|
||
- **Spec first** — extend `PLANNING_ENGINE_SPEC.md` with a new window mode (e.g.
|
||
`window_mode: 'fixed' | 'flexible'`) and its allocation semantics. §6's decisions stay
|
||
untouched; this is an addition. Don't start implementing before the spec addition is
|
||
agreed.
|
||
- `LeaveWish` entity + hand-written Alembic migration — and `_FULL_SCHEMA` in
|
||
`app/db/connection.py` (gotcha from §4.2: forgetting it = 500s with no error log).
|
||
- New allocation logic in `allocation_service.py` — keep the `Allocator` pure (no DB, no
|
||
`date.today()`, §6).
|
||
- UI in `WishEditor.tsx`: explicit window vs "när det passar bäst" toggle.
|
||
- Every new user-facing string must use the 3.5 `message_key` + `data` pattern from day one
|
||
— that's why 3.5 lands before this item.
|
||
- **Decision (product owner):** "best" = **fewest paid days to fulfil the wish** — i.e. the
|
||
engine minimises the semester- + parental-leave days that must be spent, using the red-day
|
||
map (already known to the planner) to find windows where most of the week is free. Easter
|
||
moves each year, so a yearly flexible wish re-picks **every year** (e.g. a spring week lands
|
||
on the Easter-adjacent window of that specific year). The engine's output is a **suggestion**
|
||
in the existing propose→apply flow — see the residual open points in 3.11.
|
||
|
||
### 3.10 Test coverage on pages
|
||
`Auth.tsx`, `Tips.tsx` and `DevFetch.tsx` were at 0% (Auth and Tips got tests during the i18n
|
||
work). Coverage thresholds are a **regression ratchet, not a target** — see §6.
|
||
|
||
### 3.11 Residual open points — small, confirm before 3.9 implementation
|
||
The main product-owner questions were answered on 2026-09-13 (recorded inline in 3.3, 3.8
|
||
and 3.9). Two sub-points of 3.9 remain unresolved — neither blocks starting the spec draft:
|
||
|
||
1. **Apply vs propose.** Interpreted as *propose*: the engine picks the window, the family
|
||
sees the concrete dates in the Plan tab and confirms, like every other plan. Flag if the
|
||
wish was auto-apply.
|
||
2. **Conflicts.** Interpreted as *fixed wishes always win*: a flexible wish fills whatever
|
||
space is left and can never displace a fixed one (displacing a user-chosen date would be
|
||
surprising). Priority still orders things among several flexible wishes. Flag if a
|
||
high-priority flexible wish should be allowed to push a low-priority fixed one.
|
||
|
||
Remember the Valborgsmässoafton precedent (§6): when in doubt about a default, ask.
|
||
|
||
---
|
||
|
||
## 4. Known issues
|
||
|
||
### 4.1 Three verification failures, none from feature work
|
||
1. **`npx tsc --noEmit` reports `tsconfig.json(19,5): error TS5101: Option 'baseUrl' is
|
||
deprecated`.** The project pins `typescript ^5.6.3`, and this check was clean earlier the same
|
||
day, so `npx` is very likely resolving a newer TypeScript than the local one. Check with
|
||
`./node_modules/.bin/tsc --version` and run the local binary directly before believing it.
|
||
Not caused by any source change.
|
||
2. **ESLint reports 3 warnings** — in `block-navigation.js`, `prettify.js`, `sorter.js`. These are
|
||
**generated artifacts** under `coverage/lcov-report/`, not source. They only appear after
|
||
`npm run coverage` has run. Either ignore `coverage/` in the ESLint config or clean it before
|
||
linting. CI uses `--max-warnings 0`, so this will fail CI once coverage has been generated.
|
||
3. **One flaky test:** `usePlanApply › handles replace parameter correctly`
|
||
(`src/hooks/usePlanApply.test.ts:117`) — failed once with `expected null to deeply equal {...}`
|
||
and passed in the very next run of the same suite. Almost certainly a missing `await`/`waitFor`
|
||
on async state. Worth fixing properly rather than re-running until green.
|
||
|
||
### 4.2 Traps that have already caused real bugs here
|
||
- **The `holidays` library returns combined names.** Easter Sunday comes back as the single string
|
||
`"Påskdagen; Söndag"`. Filtering with `"Söndag" in name` silently deletes **Påskdagen,
|
||
Pingstdagen, Nationaldagen and Annandag jul**. Match the exact string only.
|
||
- **`_FULL_SCHEMA` in `backend/app/db/connection.py` must stay in sync with Alembic migrations.**
|
||
Test fixtures apply that DDL string directly, so forgetting it means every test runs against a
|
||
stale schema. Migration 002 is a good worked example.
|
||
- **`vi.mock('@/context')` does NOT intercept direct submodule imports** like
|
||
`@/context/AuthContext`. This caused 14 test failures. Mock the specifier the component actually
|
||
imports. `Calendar.test.tsx` / `Settings.test.tsx` show the right idiom.
|
||
- **The dataclass → response mapping in `app/models/plan.py` is hand-written.** Anything added to
|
||
the engine is invisible to the UI until someone maps it. Two fields were silently dropped there
|
||
today (`surplus_advice`, `is_assumed`). When adding an engine output, check that boundary and
|
||
add an **API-level** test, not just a service test.
|
||
- **`localStorage` is unreliable in this test sandbox** (Node's experimental webstorage shadows
|
||
jsdom's). Existing tests work around it with `vi.stubGlobal('localStorage', mock)` — follow that.
|
||
- **`git grep` only searches tracked files.** Much of the new backend code is still untracked, so
|
||
`git grep` will silently miss it. Use plain `grep` when checking new work.
|
||
|
||
---
|
||
|
||
## 5. How to verify
|
||
|
||
```bash
|
||
cd backend && uv run ruff check . && uv run ruff format --check . && uv run pytest
|
||
cd frontend && npx tsc --noEmit && npm run lint && npx vitest run && npm run coverage
|
||
```
|
||
|
||
**Cost rule (in `CLAUDE.md`):** large models must NOT run these themselves — spawn a **Haiku**
|
||
subagent to run them and paste raw output. Haiku agents run them directly. Always give the verifier
|
||
the expected baseline and require verbatim output; several agents today reported gates as passing
|
||
that were in fact failing.
|
||
|
||
**And verify in a browser.** Every significant bug found today — the shortfall contradiction, the
|
||
dubbeldag rejection, the stale save banner, julafton being charged as a working day, the dropped
|
||
API fields — was found by clicking through the app while both test suites were green. The suites
|
||
confirmed the code did what it was written to do, not what it should do.
|
||
|
||
---
|
||
|
||
## 6. Decisions already made — don't relitigate
|
||
|
||
- **Planning horizon runs past the child's 4th birthday.** After B4 the FP pool is hard-capped at
|
||
`min(remaining, 96)`. The app plans it anyway and shows the squeeze rather than refusing.
|
||
- **Weekly intensity is derived from the planning goal**, not stored on a wish.
|
||
- **Preschool planning days are reserved off the top**, per-child configurable (default 6).
|
||
- **Vacation vs föräldrapenning is goal-driven, not a fixed order.** In a red-day-dense window the
|
||
goals *converge* — semester over klämdagar both protects pay and preserves FP days. See
|
||
`PLANNING_ENGINE_SPEC.md` §3.3 (three-tier resolution).
|
||
- **Valborgsmässoafton is NOT a default day off.** It was added, then removed — 30 April is an
|
||
ordinary working day for most Swedes. Users who get it off add it via a day override. A wrong
|
||
default day off is worse than a missing one.
|
||
- **No API calls at runtime in the planner.** The Swedish calendar is fetched at build time
|
||
(`backend/scripts/fetch_swedish_calendar.py`, 10 years cached) so planning stays offline and
|
||
deterministic.
|
||
- **The `Allocator` is pure** — no DB, no `date.today()`. Only `AllocationInputLoader` touches the
|
||
world. This exists because clock-reading inside `tips_service.py` made tests pass or fail
|
||
depending on the calendar date.
|
||
- **Coverage thresholds are a regression ratchet, not a target** (lines 80 / functions 85 /
|
||
branches 85 / statements 80). They were deliberately lowered from 90 when `src/pages/**` stopped
|
||
being excluded from measurement. Raise them as coverage grows; don't "fix" them back to 90.
|
||
- **i18n is hand-rolled on purpose** — no `react-i18next`. English derives its type from Swedish so
|
||
a missing key is a compile error.
|
||
|
||
---
|
||
|
||
## 7. Repo pointers
|
||
|
||
- `PLANNING_ENGINE_SPEC.md` — the engine design (wishes, three-tier allocation, shortfall remedies,
|
||
surplus advice, test matrix)
|
||
- `ARCHITECTURE.md` — system design; §13 covers the calendar cache and allocation service
|
||
- `backend/scripts/demo_plan.py` — runs the allocator standalone on Swedish defaults and prints a
|
||
readable plan. Best way to sanity-check engine changes without a browser.
|
||
- `backend/scripts/fetch_swedish_calendar.py` — regenerates the 10-year calendar cache
|
||
- The many `*_REPORT.md` / `*_TODO.md` files at the repo root are **historical handoff notes, not
|
||
spec**. `todo.txt` is fully done and can be deleted.
|