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
16 KiB
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 checkclean,ruff format --checkclean - 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
cd frontend && npm run dev # starts BOTH vite (5173) and uvicorn (8000)
If the servers die (they did today), restart individually:
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_adviceandis_assumedalready reach the wire; wiring them up is high-value, low-risk. 3.5 (the i18nmessage_keyfix) must land before 3.9, because flexible wishes will generate new engine-produced Swedish strings — build on thetips_service.pypattern 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 expiresfront_load_early_years— take more days while the child is smalluse_less_vacation— bounded bymin_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:
- Dashboard — show how much is left given the currently committed plan. The existing
stat already does this:
analytics_service.pycomputesexpiring_at_b4 = max(projected_left − 96, 0)withprojected_used = used + planned(date < B4), and the Dashboard renders it (dashboard.expiringAtB4). Verify it, don't rebuild it. - 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.tsxnext 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/:
outcome.explanation(per-window explanation)remedy.description(shortfall remedies)warning.message(warning body; the label is translated)applyResult.rejections(fromplanning_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.mdwith 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. LeaveWishentity + hand-written Alembic migration — and_FULL_SCHEMAinapp/db/connection.py(gotcha from §4.2: forgetting it = 500s with no error log).- New allocation logic in
allocation_service.py— keep theAllocatorpure (no DB, nodate.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+datapattern 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:
- 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.
- 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
npx tsc --noEmitreportstsconfig.json(19,5): error TS5101: Option 'baseUrl' is deprecated. The project pinstypescript ^5.6.3, and this check was clean earlier the same day, sonpxis very likely resolving a newer TypeScript than the local one. Check with./node_modules/.bin/tsc --versionand run the local binary directly before believing it. Not caused by any source change.- ESLint reports 3 warnings — in
block-navigation.js,prettify.js,sorter.js. These are generated artifacts undercoverage/lcov-report/, not source. They only appear afternpm run coveragehas run. Either ignorecoverage/in the ESLint config or clean it before linting. CI uses--max-warnings 0, so this will fail CI once coverage has been generated. - One flaky test:
usePlanApply › handles replace parameter correctly(src/hooks/usePlanApply.test.ts:117) — failed once withexpected null to deeply equal {...}and passed in the very next run of the same suite. Almost certainly a missingawait/waitForon async state. Worth fixing properly rather than re-running until green.
4.2 Traps that have already caused real bugs here
- The
holidayslibrary returns combined names. Easter Sunday comes back as the single string"Påskdagen; Söndag". Filtering with"Söndag" in namesilently deletes Påskdagen, Pingstdagen, Nationaldagen and Annandag jul. Match the exact string only. _FULL_SCHEMAinbackend/app/db/connection.pymust 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.tsxshow the right idiom.- The dataclass → response mapping in
app/models/plan.pyis 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. localStorageis unreliable in this test sandbox (Node's experimental webstorage shadows jsdom's). Existing tests work around it withvi.stubGlobal('localStorage', mock)— follow that.git greponly searches tracked files. Much of the new backend code is still untracked, sogit grepwill silently miss it. Use plaingrepwhen checking new work.
5. How to verify
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
Allocatoris pure — no DB, nodate.today(). OnlyAllocationInputLoadertouches the world. This exists because clock-reading insidetips_service.pymade 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 servicebackend/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.mdfiles at the repo root are historical handoff notes, not spec.todo.txtis fully done and can be deleted.