Restructures the handover so each task is a self-contained brief that can be lifted straight into a subagent prompt. The previous narrative format meant every dispatched agent read the whole document to use one paragraph of it. Shared preamble is read once by the orchestrator and never forwarded. Tasks are grouped into waves by file ownership so a wave can be dispatched in parallel without two agents touching the same file. Each brief carries its own goal, owned files, constraints and traps repeated verbatim, done criteria with numeric baselines, and any product decision that must be made before starting. Trap duplication across briefs is deliberate: an agent that has not been told about a trap reintroduces the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
27 KiB
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 checkclean,ruff format --checkclean (128 files) - Frontend: 710 tests passing / 56 files,
npm run typecheckclean,npm run lint0 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:
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:
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:
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.tsxUI, blocked on a product decision — see brief), C (frontend test files only, raises branch coverage), and D (backendanalytics_service.pydocs/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.pyregion + possiblyDashboard.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.tsxfrontend/src/context/WorkspaceContext.tsxbackend/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.tsxshow the right idiom.localStorageis unreliable in this test sandbox (Node's experimental webstorage shadows jsdom's). Usevi.stubGlobal('localStorage', mock).- Every user-facing string must be
message_key+data, never a hardcoded Swedish string.backend/tests/integration/test_user_journey.py::TestNoSwedishOnTheWirewalks responses for å/ä/ö and will fail violations.frontend/src/i18n/sv.tsis the source of truth;en.tsderives its type from it, so a key missing from either is a compile error. - No
any,@ts-ignoreor@ts-expect-error—no-explicit-anyis an ESLint error and CI runs--max-warnings 0. - Use
npm run typecheck, nevernpx tsc— npx resolves a newer TypeScript and emits a bogusTS5101 baseUrl is deprecatederror. git greponly searches tracked files — use plaingrepwhen 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.tsxhas an "Add partner" form (handleAddPartner, around line 230; the JSX section starts around line 397 withsettings.partnerSectionTitle) that POSTs viaapi.addPartner. It does not appear to callapi.listWorkspaceMembersanywhere, nor render any existing-member list — grep confirmsmembers/Membersdo not appear inSettings.tsx.frontend/src/services/api.tsaround line 868–872 haslistWorkspaceMembers(workspaceId)which callsGET /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.pyhasGET /api/workspace/{workspace_id}/members(returns allPersonalDataResponserows, i.e. both the logged-in user and any placeholder partner) andPOST /api/workspace/{workspace_id}/parents(add_placeholder_parent) which is how the seeded partner "Sam" was created — aPersonalDatarow with no login, perbackend/scripts/seed_demo.py(PARTNER_NAME = "Sam",add_placeholder_parent).frontend/src/context/WorkspaceContext.tsxexposesparentName(the current logged-in user's name) andpersonalDataUuid, 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
- 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 viamcp__claude-in-chrome__*browser tools). Confirm what is and isn't shown. - Read all of
Settings.tsxto 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). - Check the network tab / add a temporary log to see if
GET /workspace/{id}/membersis even called from Settings, and if so, what it returns for the demo workspace. - Check
Settings.test.tsxfor 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? - 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}/membersis 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
listWorkspaceMembersalready 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: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.cd frontend && npm run typecheck && npm run lint && npx vitest run && npm run coverage
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.tsxfrontend/src/components/planning/WishEditor.test.tsx(or wherever its existing test file is — confirm the exact name withgit ls-files | grep -i wisheditorbefore starting)frontend/src/i18n/sv.tsandfrontend/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 — usefrontend/src/i18n/sv.tsas the source of truth;en.tsderives its type from it, so a key missing from either is a compile error. - No
any,@ts-ignoreor@ts-expect-error—no-explicit-anyis an ESLint error and CI runs--max-warnings 0. - Use
npm run typecheck, nevernpx tsc— npx resolves a newer TypeScript and emits a bogusTS5101 baseUrl is deprecatederror. - Never delete, skip or weaken a test to make a suite pass.
localStorageis unreliable in this test sandbox — usevi.stubGlobal('localStorage', mock)if the component or its tests touch it.- The bounding-range fields already exist and are named
windowStartMonth/windowStartDay/windowEndMonth/windowEndDayin the wish object built byWishEditor.tsx(seeaddPreset/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
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
julpreset 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.tsxshow the right idiom.localStorageis unreliable in this test sandbox (Node's experimental webstorage shadows jsdom's). Usevi.stubGlobal('localStorage', mock).- No
any,@ts-ignoreor@ts-expect-error—no-explicit-anyis an ESLint error and CI runs--max-warnings 0. - Use
npm run typecheck, nevernpx tsc— npx resolves a newer TypeScript and emits a bogusTS5101 baseUrl is deprecatederror. - 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 greponly searches tracked files — use plaingrepwhen checking new or uncommitted work by other in-flight tasks so you don't collide with them.
Done criteria
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.tsxfrontend/src/pages/Dashboard.test.tsxfrontend/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 in760b312precisely 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.pyis 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.tsis the source of truth;en.tsderives its type from it, so a key missing from either is a compile error. - No
any,@ts-ignoreor@ts-expect-error—no-explicit-anyis an ESLint error and CI runs--max-warnings 0. - Use
npm run typecheck, nevernpx tsc— npx resolves a newer TypeScript and emits a bogusTS5101 baseUrl is deprecatederror. - Never delete, skip or weaken a test to make a suite pass.
- Reference facts already confirmed in
backend/app/services/analytics_service.py: it hasexpiring_at_b4per parent (a single-parent projection,max(projected_left - 96, 0)) and a combined view withcombined_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):
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:
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.
holidayslibrary combined names ("Påskdagen; Söndag") — exact-string match only._FULL_SCHEMAinbackend/app/db/connection.pymust track Alembic migrations.vi.mock('@/context')doesn't intercept direct submodule imports.backend/app/models/plan.pydataclass→response mapping is hand-written and has dropped fields twice.localStorageunreliable in the test sandbox —vi.stubGlobal.git greponly searches tracked files.- Literal
*/inside JSDoc breaks the file (bitWishEditor.tsxonce). npm run typecheck, nevernpx tsc(npx resolves a newer TS, bogusTS5101).- 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
Allocatorpure — no DB, nodate.today(). - Every user-facing string is
message_key+data;TestNoSwedishOnTheWireenforces 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=TrueraisesUncachedYearError→ 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_idonplanned_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.