The dashboard stated the loss and stopped there. With Jacob at 48/48 savable
and 24 days over the cap, and Amelie at 3/48 with 45 days of headroom, those
24 days are not lost at all — transferring them before the 4th birthday saves
every one. The app never said so.
`_rule_age4_pooling_loss` already computed exactly that advice, bounded by the
FK-reported `transferable_days_left`, in finished Swedish and English. It never
ran: `age4_pooling_tip_min_child_age_years` held it back until the child turned
2. The calendar IS the plan, so gate on the plan instead — the noise case is a
newborn with nothing planned, not a young child whose parents have planned in
detail. `age4_pooling_tip_min_planned_days` (20) replaces the age gate.
Both variants drop to `info`. A projection four years out should not read as a
verdict on the user's planning; the hero carries the alarm, this card carries
the advice. The copy now leads with "plans change, this is only a projection"
and closes by pointing at keeping balances up to date, since parents will come
back and record what they actually took.
- Suppress `preserve_for_age_4` whenever `age4_pooling_loss` fires for the same
child. The pooling tip states the same loss plus the remedy and the correct
per-parent cap, so emitting both gave two contradictory "you're losing N
days" figures.
- `preserve_for_age_4` now sends `parent_cap` and its copy interpolates it. It
claimed "only 96 days can be saved" — false for a two-parent family, where
the cap is 48 each, as the Age4Bars card two rows below already showed.
- The hero grows a "How to save them" line derived from the tip itself, so the
two can never disagree on the number. Dismissing the tip retires both.
- "Försäkringskassan" in that line links to FK's BankID login carrying a `goto`
to Min föräldrapenning — the e-service showing "Dagar ni kan föra över" —
so the transfer is one click away. `translate()` returns a plain string and
word order differs between locales, so the anchor's position travels inside
the template as `{fk}` and the rendered string is split around it.
Verified in the browser: hero, link target, tip severity and the absence of
`preserve_for_age_4`, in both languages and at 390px. Note the demo seed cannot
reach this shape — it gives the surplus parent no FK snapshot at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
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,Settingsrefuses to start unlessAPP_JWT_SECRETis 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— defaultHS256.APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES— default15.APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS— default7.APP_LOG_LEVEL— defaultINFO.APP_SCRAPER_ENABLED— defaults totrue. Legal/ToS review of FK scraping + BankID automation completed 2026-06; the scraper is cleared for use. Set tofalsein any environment without Playwright's Chromium installed (uv run playwright install chromium).APP_SCRAPER_HEADLESS— defaulttrue.APP_SCRAPER_SESSION_TTL_SECONDS— default180; auto-cancels a stalled scraper session.APP_SCRAPER_DEBUG_ENABLED— defaultfalse. When enabled, writes personal data to disk (an HTML dump + full-page screenshot of the logged-in FK page — name, balances, payout amounts). RequiresAPP_SCRAPER_DEBUG_DIRto 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— defaultfalse. 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 structureapp/scrapers/fk_scraper.pycurrently assumes). Same personal-data warning asAPP_SCRAPER_DEBUG_ENABLEDapplies; only takes effect when bothAPP_SCRAPER_DEBUG_ENABLEDandAPP_SCRAPER_DEBUG_DIRare also set.APP_SCRAPER_RESULT_RETENTION_SECONDS— default300; how long a finished scraper session's result stays in memory before being purged.APP_RULES__*— the Swedish parental-leave rule constants (LeaveRules, nested underrules), 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 — seeresolve_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.pyexists only so dev/tests are self-contained. Production schema is owned by Alembic migrations (a senior task).