The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
14 KiB
AGENTS.md
Operating guide for humans and AI agents in Föräldraledigplannerare — a web app for planning Swedish parental leave (solo or with a partner), folding in vacation days to optimize leave until the child turns 4 (after which only 96 days may be saved).
Full-stack monorepo: TypeScript/React/Vite frontend + Python/FastAPI backend.
🛑 DIRECTORY NAVIGATION & FILE DISCOVERY
Context: huge generated/dependency folders (node_modules, dist, build, .venv/venv, __pycache__, .mypy_cache, .ruff_cache, graphify-out, …). Unrestricted listings bloat context, crash sessions, waste tokens.
- Never
ls -R— forbidden here. - Never
findwithout-maxdepth(use-maxdepth 3or tighter); unboundedfindcrawls deps likels -R. - Discovery: default to
git ls-files— source code only. - Structure: only when you need a visual layout,
treewith mandatory ignores:tree -I 'node_modules|.git|dist|build|__pycache__|venv|.venv|env|.pytest_cache|.mypy_cache|.ruff_cache|graphify-out|*.egg-info' - Search:
git grep "term", nevergrep -r(crawls ignored/minified assets). - Never leave the current directory; use relative paths from "here".
graphify
Knowledge graph at graphify-out/: god nodes, community structure, cross-file relationships.
On /graphify, first invoke the skill tool with skill: "graphify".
- Codebase questions: run
graphify query "<question>"whengraphify-out/graph.jsonexists;graphify path "<A>" "<B>"for relationships,graphify explain "<concept>"for concepts. These return a scoped subgraph — usually far smaller thanGRAPH_REPORT.mdor raw grep. - Dirty
graphify-out/files are expected after hooks/incremental updates and are not a reason to skip graphify. Skip only if the task is about stale/incorrect graph output, or the user says not to use it. - Prefer
graphify-out/wiki/index.md(if present) over raw source browsing for broad navigation. - Read
graphify-out/GRAPH_REPORT.mdonly for broad architecture review or when query/path/explain lack context. - After code changes:
graphify update .(AST-only, no API cost).
QUICK START
Frontend package.json orchestrates both services. From frontend/:
npm install # frontend deps
npm run dev # BOTH: FastAPI (:8000) + Vite dev server, concurrently
Backend standalone, from backend/:
uv sync # deps incl. dev group
# --reload-exclude keeps the dev SQLite file out of the watched tree — see
# backend/README.md for why.
uv run uvicorn app.main:app --reload --reload-exclude '*.db' --reload-exclude '*.db-shm' --reload-exclude '*.db-wal' --reload-exclude '*.db-journal' --port 8000 # serve API
Scraper (FK login via Playwright) is off by default. Locally: npm run login from frontend/ (sets APP_SCRAPER_ENABLED=true APP_SCRAPER_HEADLESS=true). Chromium installs separately: uv run playwright install chromium.
REPOSITORY LAYOUT
backend/ Python 3.14 · FastAPI · uv (Hatchling build backend)
app/
entities/ Persistence-layer row shapes (DB-facing)
models/ Pydantic v2 request/response schemas (API-facing)
repositories/ Abstract repository interfaces
repositories_impl/
sqlite/ Default aiosqlite implementations
postgres/ Optional asyncpg implementations (extra: `postgres`)
routers/ FastAPI route handlers (thin)
services/ Business logic (planning, tips, analytics, auth, …)
scrapers/ Försäkringskassan (FK) scraper — Playwright
utils/ dates, holidays, errors, tip predicates
db/ Connection management
migrations/ Alembic migrations (NOT auto-generated from ORM models)
tests/ pytest + pytest-asyncio (asyncio_mode = auto)
frontend/ TypeScript · React 18.3 · Vite 5 · Vitest
src/
pages/ Route-level screens (Dashboard, Calendar, Settings, …)
components/ Presentational + composite components (BEM CSS classes)
hooks/ Data-fetching/state hooks (the data layer — no TanStack Query)
services/ api.ts (HTTP client) + errors.ts
context/ Auth + Workspace React context
types/ Shared TS types (api.ts, entities.ts)
utils/ dates, formatting
tests/ Vitest setup + fixtures; co-located *.test.tsx also used
.github/workflows/ CI (GitHub Actions) — backend + frontend gates
Backend layering: routers → services → repositories (abstract). Concrete DB code only in repositories_impl/. Routers stay thin, logic in services; never skip layers (e.g. router → repositories_impl).
Frontend data layer: components → src/hooks/* → src/services/api.ts. Components never call fetch/the API client directly.
Captures never tracked: captures/ (scraper page dumps, verification screenshots) is gitignored — real FK pages hold personal data. Parser tests use the synthetic fixture backend/tests/fixtures/fk_overview_synthetic.html.
CONVENTIONS
- Backend: FastAPI + Pydantic v2 models with a hand-rolled repository pattern (interfaces in
app/repositories/, impls inapp/repositories_impl/{sqlite,postgres}/). No ORM (no SQLModel/SQLAlchemy). Default: SQLite via asyncaiosqlite; Postgres viaasyncpgis an optional extra. - Migrations: Alembic (
backend/migrations/,backend/alembic.ini), hand-written against the schema, not generated from ORM metadata. Add a revision for every schema change. - Auth:
argon2-cffipassword hashing,PyJWTsessions/tokens,slowapirate limiting. - Python:
>=3.14, managed withuv+ Hatchling. Lint/format:ruff(line length 100, targetpy314). Tests:pytest+pytest-asynciowithasyncio_mode = "auto"(no@pytest.mark.asyncioneeded). - Frontend: React 18.3, react-router-dom v6 (
BrowserRouter/Routes/Route), TypeScript 5.6, Vite 5, Vitest + Testing Library. State/data fetching via custom hooks insrc/hooks/(no TanStack Query, no Redux). - Styling: plain CSS, BEM class names (e.g.
calendar-day__number,calendar-day--disabled). No CSS framework (no Tailwind), no CSS-in-JS. Follow existing components. - Linting: ESLint is configured (flat config
frontend/eslint.config.js, ESLint 9).@typescript-eslint/no-explicit-anyis an error. CI runsnpm run lint --max-warnings 0— zero warnings. - Scripts: backend helper scripts go in
backend/scripts/(create if missing); frontend scripts viapackage.json. - CI/CD: GitHub Actions (
.github/workflows/) only. Don't add.gitea/,.gitlab-ci.yml, orMakefiles — extend the existing workflow.
TESTS, LINT & CI GATES
Run CI's checks before pushing.
Backend (from backend/):
uv run ruff check . # lint
uv run ruff format --check . # format check
uv run pytest # tests + coverage (config in pyproject.toml)
Gate: --cov-fail-under=80, climbing toward 90. Never lower it; raise only as coverage grows.
Frontend (from frontend/):
npm run typecheck # never `npx tsc` — resolves a newer TS, emits bogus TS5101
npm run lint # ESLint, zero warnings
npm run coverage # Vitest with coverage
Gates (baseline measured after including src/pages/**): lines 96 | functions 91 | branches 92 | statements 96.
A regression ratchet, not a target. Verify current coverage is above these thresholds; never lower gates to pass.
WHO RUNS VERIFICATION (cost rule)
These checks are mechanical: fixed commands, judgement limited to reading pass/fail and failure output. Paying a large model to watch pytest, vitest, tsc, ruff or coverage is waste.
- Haiku subagent — run them yourself, immediately; no delegation (you're the cheap tier; spawning another agent only adds latency).
- Sonnet or larger subagent — don't run them. Finish the change, spawn a Haiku subagent to run them and report raw output, then act on it.
- Orchestrating/reviewing agent — same: delegate to Haiku.
Applies to full-suite runs: uv run pytest, uv run ruff check ., uv run ruff format --check ., npm run typecheck, npm run lint, npm run coverage, npx vitest run, uv run alembic upgrade head.
Does not apply to quick mid-task diagnostics (reading one file, a git grep, one focused test) — delegating those costs more than it saves. The rule targets long whole-suite passes.
Brief the Haiku verifier with: exact commands, directory to run from, and an instruction to report exact pass/fail counts and coverage percentages from their run, then compare them against the current branch state to verify no regression; require raw output verbatim, failures included, not a summary. "All green" without output is useless — several agents have reported gates passing that were failing.
MODEL TIERS
Binding for all work here, not just verification.
| Role | Model | Effort | Allowed to |
|---|---|---|---|
| Coordinator | Opus | medium | Dispatch tasks, review diffs, make product calls, maintain the plan. Writes no implementation code. |
| Implementer | Sonnet | — | Features and bugfixes. Only tier allowed to write non-trivial code. |
| Verifier / mechanical | Haiku | — | Run full suites and paste raw output; small mechanical edits (renames, i18n string moves, test-fixture churn). |
- No subagent runs on Opus — not for hard tasks, review, or browser verification. Too hard for Sonnet means too big: split it, don't escalate.
- The coordinator never runs verification suites itself (see cost rule).
- Browser verification is delegated too — Sonnet drives Chrome, never the coordinator.
ANTI-PATTERNS (CRITICAL GUARDRAILS)
- TS type safety: no
as any,@ts-ignore,@ts-expect-error.no-explicit-anyis an ESLint error and CI fails on warnings. Tests included — no exemption. Build mocks with the typed factories infrontend/tests/fixtures/(makeWorkspaceOverview,makePlanProposal,make*Result, …), which return complete, correctly-typed objects you override viaPartial<T>; mock functions withvi.mocked(fn), not casts. A cast in a test defeats the type check exactly where it guards the frontend/backend contract — this project's most common bug. Sole exception:as unknown as Tin a test deliberately exercising invalid input (missing field, bogus enum value) — keep it to that expression and comment why. - Python type safety: no bare
# type: ignore; fix the type or model. Prefer precise Pydantic models overdict[str, Any]at API boundaries. - Error handling: no empty
catch/except; handle or log explicitly. Backend errors flow throughapp/utils/errors.py, frontend throughsrc/services/errors.ts. - Testing integrity: never delete or comment out failing tests to make a suite "pass". Fix the code, or update the test properly with a clear reason.
- No JS slop: never create
.js/.jsxin the frontend — TypeScript (.ts,.tsx) only. - Respect layering: routers never touch
repositories_impl; components never callfetch. Go through services/hooks. - No ORM creep: no SQLModel/SQLAlchemy models — extend the repository pattern.
- No assumptions: never assume any file's contents. Always read it before modifying or referencing it.
MANDATORY EXECUTION PROTOCOLS
- Read before edit: read the whole target file first.
- Post-edit verification: check immediately for duplicated lines and intact imports.
- Stability check: after any change, run that side's lint + typecheck + tests. For cross-cutting changes (e.g. API contract edits in
app/modelsandsrc/types), run both suites — they must agree on the wire format. - Keep the graph current:
graphify update .after code changes.
DOMAIN RULES (Swedish parental leave)
- 390 + 90 model: of the parental-benefit days, 390 are at sickness-benefit level and 90 at minimum (lägstanivå) rate. Track day types, not one total.
- Reserved days: some days are reserved per parent and non-transferable — surface this in shared/partner planning.
- Dubbeldagar: both parents may take leave the same day for a limited number of days early in the child's life; model as consuming each parent's balance.
- Whole days only: no fractional/percentage days in the model.
- Age-4 cutoff: core purpose is optimizing leave over time. After the child turns 4, only 96 days may be saved; show how many days remain at that boundary.
- Money/SGI abstracted: benefit amounts/SGI stay abstract — no exact kronor payouts, no hardcoded rates; keep the agreed abstraction level.
Unsure about a rule? Ask before encoding a number — Swedish rules change, and wrong figures are worse than a TODO.
WHERE TO LOOK FIRST
ARCHITECTURE.md— system design and rationale.graphify-out/wiki/index.md— generated codebase navigation.backend/README.md— backend setup notes.- Root
*_REPORT.md/*_TODO.md/*_ONBOARDING.md— contributor handoff notes: historical context, not authoritative spec.
OUTPUTS
Be concise: no preamble, caveats, or restating the request. Sentence fragments and short bullets where clarity holds. Explain reasoning only when it aids debugging or isn't obvious from the code/diff.