Files
F-r-ldraledigplannerare/backend
jacobh2 b76fb7417e Keep plan warnings in step with the plan they describe
Warnings such as "vacation days at risk of being lost" kept showing after
the planner had already resolved them. The planner itself was firing
correctly -- PlanSyncProvider syncs on every plan-input change -- so the
fault was in the warnings, in two independent places.

The vacation-expiry warning was computed before allocation. It read
`vacation_forfeiting`, which the loader derives from already-committed
usage, while `_assign_tier3` deliberately spends at-risk vacation days
first precisely to avoid that forfeiture. The figure therefore counted
days the plan was in the middle of saving. `_vacation_expiry_warnings`
now subtracts the vacation days this run actually placed for that parent
and year, clamped at zero -- sound because the bank is FIFO, so a day
spent in year Y retires one expiring day one-for-one. The full
carry-forward simulation is still not duplicated inside the ledger.

The Dashboard then never refreshed them. Its proposal came from a
one-shot effect guarded by `proposal === null`, with no planVersion
dependency, so the card kept rendering the pre-replan warning until
remount -- while `syncPlan` was already returning a fresh proposal that
both call sites discarded. PlanSyncProvider now exposes that proposal
behind a monotonic counter, and usePlanProposal prefers it once any sync
has completed, keeping `propose()` for the first load.

Also adds a manual recompute button to Settings, which only bumps
planVersion and reports what the resulting sync did, and fixes partner
add/remove never bumping planVersion at all -- that gap meant gaining or
losing a parent never re-ran the allocator, though it changes who days
can be assigned to. Rename stays cosmetic.

Verified in the browser: a 2031 warning of 15 days dropped to 12 after
three vacation days were planned, without a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
2026-09-20 14:26:50 +02:00
..
2026-06-14 09:58:30 +02:00
2026-06-01 16:48:21 +02:00
2026-06-14 09:58:30 +02:00

Backend — Föräldraledigplannerare

FastAPI + Python 3.14, packages managed with uv. Layered architecture (entities → repositories → services → routers) with dependency injection. Postgres is a partial stub, not a working swap today: only repositories_impl/postgres/vacation_days_repository.py exists; every other app/dependencies.py repository factory raises ValueError for any database_type other than "sqlite". SQLite is the only usable backend until the remaining Postgres repositories land.

Setup

uv sync                 # install deps (incl. dev group)
uv run uvicorn app.main:app --reload   # run dev server on :8000
uv run pytest           # run tests with coverage
uv run ruff check .     # lint

uv run pytest alone does not enforce a coverage floor (pyproject.toml's addopts only requests a report). CI additionally passes --cov-fail-under=80 (see the root CLAUDE.md) — a locally green run can still fail in CI if coverage dropped.

Configuration

Env vars (prefix APP_, nested via __; see .env.example). Settings lives in app/config.py — that file's module docstring mentions a HMAC_PEPPER setting; no such setting exists, so do not look for it here or set it expecting an effect.

  • APP_ENV — development (default) | test | production. In production, Settings refuses to start unless APP_JWT_SECRET is a random value of at least 32 characters (the shipped dev default is rejected). Production also disables /docs, /redoc, and /openapi.json.
  • APP_DATABASE_TYPE — sqlite (default) | postgres (not usable yet — see above).
  • APP_SQLITE_PATH — default ./foraldraledig.db.
  • APP_CORS_ORIGINS — JSON list; default ["http://localhost:5173"].
  • APP_JWT_SECRET — required to be a strong, random secret in production.
  • APP_JWT_ALGORITHM — default HS256.
  • APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES — default 15.
  • APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS — default 7.
  • APP_LOG_LEVEL — default INFO.
  • APP_SCRAPER_ENABLED — defaults to true. Legal/ToS review of FK scraping + BankID automation completed 2026-06; the scraper is cleared for use. Set to false in any environment without Playwright's Chromium installed (uv run playwright install chromium).
  • APP_SCRAPER_HEADLESS — default true.
  • APP_SCRAPER_SESSION_TTL_SECONDS — default 180; auto-cancels a stalled scraper session.
  • APP_SCRAPER_DEBUG_ENABLED — default false. When enabled, writes personal data to disk (an HTML dump + full-page screenshot of the logged-in FK page — name, balances, payout amounts). Requires APP_SCRAPER_DEBUG_DIR to also be set. Local debugging only; never enable in a shared or production environment.
  • APP_SCRAPER_DEBUG_DIR — where debug artifacts are written when the above is enabled.
  • APP_SCRAPER_DEBUG_DUMP_ON_SUCCESS — default false. Also writes the same debug artifacts (HTML + full-page screenshot) after a successful extraction, not just on error — a capture aid for confirming FK's real DOM (e.g. the family panel structure app/scrapers/fk_scraper.py currently assumes). Same personal-data warning as APP_SCRAPER_DEBUG_ENABLED applies; only takes effect when both APP_SCRAPER_DEBUG_ENABLED and APP_SCRAPER_DEBUG_DIR are also set.
  • APP_SCRAPER_RESULT_RETENTION_SECONDS — default 300; how long a finished scraper session's result stays in memory before being purged.
  • APP_RULES__* — the Swedish parental-leave rule constants (LeaveRules, nested under rules), overridable per field, e.g. APP_RULES__MAX_SAVABLE_DAYS_AT_AGE_4=96. Defaults: 390 sjukpenning days, 90 lägstanivå days, 90 reserved days/parent, 60 max dubbeldagar over the first 15 months, 96 max savable at age 4 (48 per parent when the child has two or more parents — see resolve_age4_savable_cap), age-12 hard expiry, 6 preschool planning days/year, 25 statutory vacation days/year, 20 minimum vacation days/year that must be taken. These are common-case figures only — children born before 2014, multiples, and single custody differ and are not modeled.

Layout

app/
  config.py            # pydantic-settings (app config + frozen LeaveRules)
  dependencies.py      # DI factories (pick repo impl by settings.database_type)
  entities/            # plain dataclasses (domain)
  models/              # pydantic API schemas
  repositories/        # abstract interfaces
  repositories_impl/   # concrete impls per backend (sqlite/, postgres/)
  services/            # business logic
  routers/             # FastAPI routes
  db/connection.py     # connection + (dev) schema bootstrap
  utils/               # errors, logger

Demo data (manual/browser testing)

scripts/seed_demo.py seeds a realistic, ready-to-explore workspace through the normal service/repository layers (no raw SQL) — a couple workspace with a partner, a child born ~6 months ago, vacation rows for only some years (so the is_assumed badge is visible), day overrides, and a spread of leave wishes (fixed, flexible, and one deliberately underfunded to show a shortfall + remedies + surplus advice). Login credentials are fixed and printed at the end.

uv run python scripts/seed_demo.py            # create once; re-running is a no-op
uv run python scripts/seed_demo.py --reset     # wipe the demo workspace and rebuild

Then open http://localhost:5173 and log in with the printed demo@example.com credentials. Safe to re-run: without --reset it detects the existing demo user and changes nothing; --reset only ever deletes the one demo workspace.

Reference vertical slice

VacationDays is implemented end-to-end (entity → schema → repo interface → SQLite impl → service → router → tests). Copy this slice when adding a new entity.

The inline schema in db/connection.py exists only so dev/tests are self-contained. Production schema is owned by Alembic migrations (a senior task).