Files
F-r-ldraledigplannerare/NextSteps260913.md
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
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
2026-09-14 02:33:03 +02:00

16 KiB
Raw Blame History

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

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_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

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.