Files
F-r-ldraledigplannerare/CLAUDE.md
T
jacobh2 97a7200c1a Parse transferable days from FK instead of deriving them
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only
visited ansok-och-planera-foraldrapenning#/valjdagar and derived the
reserved/transferable split in build_snapshot, assuming transferable days
are spent before reserved ones. On real data that gave 49 for the user and
0 for the partner where FK states 129 and 71.

The assumption cannot be repaired: reserved-first, transferable-first,
proportional and cap-based models were all checked against real figures and
none reproduces FK's accounting (reserved-first fits the partner but is 10
days off for the user). FK publishes the real numbers per parent per tier on
the min-foraldrapenning page, which the scraper had never visited.

- Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction,
  summing sjukpenningnivå + lägstanivå per parent. The totals are not
  literals on the page. Parse only direct-child <p> elements: the first
  group embeds a tooltip with decoy "45 dagar"/"90 dagar" text.
- build_snapshot accepts the reported value and derives reserved days from
  it; the old arithmetic remains only as a fallback. An implausible FK value
  degrades to that fallback rather than failing the fetch.
- Record provenance in transferable_days_reported (migration 006) and badge
  derived values as estimates in the dashboard breakdown. Drop the footnote's
  claim that at most 150 days are transferable — real data contradicts it as
  a per-family figure.
- Select the child's section by name when FK lists several; never guess.

Verified against a live BankID scrape: scraped 240/101/139 with 129
transferable, deduced partner 240/169/71 with 71, both flagged as reported.

Incidental fixes found along the way:

- The fetch router never forwarded the partner's transferable figure to
  persist_partner_deduction, so the partner's stored snapshot would have kept
  the flawed split even once the parse landed.
- 'deduced' snapshots were labelled "Manually entered" by a two-way ternary
  over a three-value union. Replaced with a compiler-checked exhaustive map.
- Alembic resolved its database from alembic.ini alone, ignoring
  APP_SQLITE_PATH, so migrations silently targeted the developer's real
  database even when pointed elsewhere. env.py now resolves it, preferring
  -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value.
- backend/captures/ was not ignored: the root pattern was anchored as
  /captures/ despite a comment saying scraper dumps must never be tracked.
- CLAUDE.md quoted test counts and coverage percentages that had gone stale,
  which made a correct verification report look suspect. Gate thresholds kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 09:11:53 +02:00

13 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 find without -maxdepth (use -maxdepth 3 or tighter); unbounded find crawls deps like ls -R.
  • Discovery: default to git ls-files — source code only.
  • Structure: only when you need a visual layout, tree with 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", never grep -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>" when graphify-out/graph.json exists; graphify path "<A>" "<B>" for relationships, graphify explain "<concept>" for concepts. These return a scoped subgraph — usually far smaller than GRAPH_REPORT.md or 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.md only 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
uv run uvicorn app.main:app --reload --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 in app/repositories_impl/{sqlite,postgres}/). No ORM (no SQLModel/SQLAlchemy). Default: SQLite via async aiosqlite; Postgres via asyncpg is 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-cffi password hashing, PyJWT sessions/tokens, slowapi rate limiting.
  • Python: >=3.14, managed with uv + Hatchling. Lint/format: ruff (line length 100, target py314). Tests: pytest + pytest-asyncio with asyncio_mode = "auto" (no @pytest.mark.asyncio needed).
  • 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 in src/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-any is an error. CI runs npm run lint --max-warnings 0 — zero warnings.
  • Scripts: backend helper scripts go in backend/scripts/ (create if missing); frontend scripts via package.json.
  • CI/CD: GitHub Actions (.github/workflows/) only. Don't add .gitea/, .gitlab-ci.yml, or Makefiles — 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-any is an ESLint error and CI fails on warnings. Tests included — no exemption. Build mocks with the typed factories in frontend/tests/fixtures/ (makeWorkspaceOverview, makePlanProposal, make*Result, …), which return complete, correctly-typed objects you override via Partial<T>; mock functions with vi.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 T in 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 over dict[str, Any] at API boundaries.
  • Error handling: no empty catch/except; handle or log explicitly. Backend errors flow through app/utils/errors.py, frontend through src/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/.jsx in the frontend — TypeScript (.ts, .tsx) only.
  • Respect layering: routers never touch repositories_impl; components never call fetch. 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/models and src/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.