# Föräldraledigplannerare - Architecture & Implementation Plan > Parts of this document are superseded — search for **SUPERSEDED** for the current behaviour. **Date:** 2026-05-31 (updated 2026-09-18) **Status:** Shipped and in active development. The planning engine designed in **PLANNING_ENGINE_SPEC.md** is implemented; both documents now describe actual behavior, with **SUPERSEDED** notes marking design-time reasoning that changed. **Tech Stack:** TypeScript + React + Vite (frontend), FastAPI + Python 3.14 (backend), SQLite by default (Postgres is a partial stub — see §2.1) --- ## 0. Key Decisions (Resolved) These directional questions were resolved during review and drive the rest of the document: 1. **Försäkringskassan data source:** Headless-browser + BankID scraping is the **primary** mechanism, with a **manual-input fallback**. Legal/ToS/BankID-automation risks are documented in §2.3 — this is the highest-risk part of the project and the fallback must always work on its own. 2. **Money is abstract, not kronor:** The app does **not** compute exact compensation. It surfaces qualitative tradeoffs — taking fewer days per week (or none during the protected first year) stretches time at home and preserves days, at the cost of income now. SGI-protection rules drive these tips (§6). 3. **Leave-rule scope:** Model **reserved days** (90 non-transferable per parent) and **dubbeldagar** (max 60, only in the child's first 15 months). **Partial/fractional days** (1/8–3/4) are out of scope — full-day granularity only, expressed through weekly day-count patterns. --- ## 1. Project Overview A parental leave planning web app that helps Swedish parents plan and optimize their leave across: - Försäkringskassan parental-leave days (föräldrapenning) — fetched via headless-browser scraping with a manual fallback - Vacation days (semester) — manual input - Multiple children support (architecture ready, MVP single-child) - Partner collaboration in a shared workspace, or solo planning **Core Value:** Calendar-based planning + analytics + optimization recommendations that help parents balance *time at home* against *days/income preserved*, and ensure they don't lose days at the child's 4th birthday (only 96 days may be saved past age 4 for a single parent — 48 per parent, non-poolable, as soon as the child has two or more). **What the app deliberately does NOT do:** It does not compute exact monetary compensation (no SGI amount is stored). "Money" is represented qualitatively as the tradeoff between using more days now versus preserving days and protecting SGI for later (see §6). ### Swedish leave domain — reference facts the logic relies on - A child grants **480 föräldrapenning days** total: **390 at sjukpenningnivå** (income-based) + **90 at lägstanivå** (flat low rate). - With joint custody the default split is **240 days per parent**. - **90 of each parent's days are reserved** (non-transferable "reserverade dagar"); the remaining 150 can be transferred to the other parent. - **Dubbeldagar:** parents may take leave for the same child on the same day for up to **60 days**, only during the child's **first 15 months**. - **Age-4 rule:** from the child's 4th birthday, at most **96 days** remain usable for a genuine single parent — **48 per parent** (non-poolable) as soon as the child has two or more parents (`resolve_age4_savable_cap`, §6.5) — the rest expire. All days must be used by age 12. - **SGI protection:** SGI is protected automatically during the child's first year. After the child turns 1, a non-working parent generally needs to take out parental benefit **at least 5 days/week** (or be registered as a jobseeker) to keep full SGI. > These figures should live in a single `constants` module (backend `app/utils/constants.py`, frontend `src/utils/constants.ts`) so rules are not duplicated. Note rules change over time and differ for children born before 2014 / multiples (tvillingtillägg) — flag as future config. --- ## 2. Architecture Layers > **SUPERSEDED (2026-09-18).** §2.1/§2.2 originally hand-transcribed full directory trees. Those > drift from reality as soon as a file is added or renamed (and had, badly, by this date — missing > `src/i18n/`, `components/modals/`, `components/vacation-days/`, `pages/Onboarding.tsx`, > `frontend/e2e/`, several backend routers/services, and listing files that were never built). > Replaced with a short description of the layering plus a pointer to `git ls-files`, which never > goes stale. ### 2.1 Backend Architecture Layering: `routers/` (thin FastAPI handlers) → `services/` (business logic) → `repositories/` (abstract interfaces) → `repositories_impl/{sqlite,postgres}/` (concrete impls). `entities/` are plain dataclasses (persistence-layer row shapes); `models/` are Pydantic v2 API schemas — kept separate so the wire format can evolve independently of storage. `db/connection.py` owns the SQLite connection and a dev/test-only inline schema (`_FULL_SCHEMA`); production schema is owned by hand-written Alembic migrations in `backend/migrations/`. `scrapers/fk_scraper.py` isolates the Försäkringskassan headless-browser flow behind `FetchDataService`'s stable contract. **Postgres is a partial stub, not a drop-in swap.** Only `repositories_impl/postgres/` exists for `vacation_days_repository.py` — every other entity has no Postgres implementation, and every factory in `app/dependencies.py` (e.g. `get_user_repository`, `get_workspace_repository`) raises `ValueError` for any `database_type` other than `"sqlite"`. `DATABASE_TYPE=postgres` is not currently a usable deployment option; treat §12's "swap repositories" language as aspirational until the remaining repositories land. Run `git ls-files backend/app` for the current, authoritative file list; `graphify-out/wiki/index.md` (if present) gives a navigable overview. ### 2.2 Frontend Architecture `src/pages/` holds the three route-level screens (Dashboard/Översikt, Calendar/Kalender, Settings/Inställningar) plus `Onboarding.tsx` and `Auth.tsx`. `src/components/` groups presentational + composite components by feature (`calendar/`, `modals/`, `vacation-days/`, `inputs/`, `tips/`, `layout/`, `dashboard/`, `common/`), all plain CSS with BEM class names — no CSS framework, no CSS-in-JS. `src/hooks/` is the data layer (custom hooks calling `services/api.ts`; no TanStack Query, no Redux) — components never call `fetch` or the API client directly. `src/context/` holds `AuthContext` and `WorkspaceContext` (the latter exposes `planVersion`, a cross-hook invalidation counter — see §7.1). `src/i18n/` holds the Swedish/English translation namespaces and `LanguageContext` (see §7.2). `frontend/e2e/` holds Playwright end-to-end specs, run separately from Vitest unit tests. Run `git ls-files frontend/src` for the current, authoritative file list. --- ### 2.3 Data Acquisition: Scraping (primary) + Manual Fallback Försäkringskassan exposes **no public API**, so the primary mechanism is a headless-browser scraper that logs in via BankID and reads the parental-leave figures. This is the **single highest-risk component** of the project; the architecture treats it accordingly. **Risks to acknowledge (not ignore):** - **Terms of Service / legality:** automated access and BankID automation may violate Försäkringskassan's and BankID's terms. **Legal/ToS review completed (2026-06); the scraper is cleared for use** (`APP_SCRAPER_ENABLED` defaults to `true` — see `backend/app/config.py`). The scraper must only ever act on behalf of the authenticated user, on their own data, with their active consent each session — never store BankID credentials. - **Fragility:** any markup/flow change at FK breaks scraping. Isolate all selectors/flow in `scrapers/fk_scraper.py` behind the `FetchDataService` interface so the rest of the app depends on a stable contract (`ParentalLeaveSnapshot`), not on FK's DOM. - **Security:** the scraper handles a live authenticated session. Run it sandboxed (dedicated container), never persist the session/cookies beyond the fetch, and log no personal data. **Manual fallback (must always work standalone):** - A manual entry form lets the user type three numbers: sjukpenningnivå left, lägstanivå left, and dubbeldagar left. The entire snapshot (including reserved/transferable day breakdown and tier structure) is derived by `build_snapshot` (shared with the scraper), the same logic used to construct the scraped snapshot. These persist as a `ParentalLeaveFetch` with `source = 'manual'`. - The entire planning/analytics/tips feature set must function with manual data alone, so the app is useful even if scraping is unavailable, broken, or disabled for legal reasons. - The UI should show data provenance and freshness ("Manually entered on …" vs "Fetched from Försäkringskassan on …"). ``` POST /api/parental-leave/manual # Create/update a manual snapshot (source='manual') ``` --- ## 3. Data Models & Database Schema ### 3.0 Identity & Membership Model (important) The original draft conflated "parent" with "registered user." They are separated so that **solo planning works without the partner ever signing up**: - **PersonalData = a parent profile.** It carries a `workspace_id` and is what planned days, vacation, and leave snapshots reference. A workspace has 1–2 parent profiles. - **User = a login** attached to exactly one PersonalData. A parent profile may exist *without* a User (the partner you're planning for but who hasn't joined). - Membership in a workspace is defined by `PersonalData.workspace_id`, **not** by `User.workspace_id` — so `User.workspace_id` is dropped (derive it from the linked PersonalData) to avoid two sources of truth. **Entitlement (parent count as the single source):** Parent count determines day allocation: one member = sole parent (480 days total, 96 savable at age 4); two members = 240 days each, 48 savable at age 4 per parent (non-poolable). Workspaces are capped at 2 parents (`POST /workspace/{ws}/parents`). **Scraper family panel:** The scraper reads `[data-test='forDigOchAnnanForalder']` to expose `partner_deduction` (the other parent's usage deduced from family total minus logged-in parent's usage) on the poll response. Two ways a second parent enters a workspace: 1. **Solo planning:** parent A creates a second PersonalData (just a name) representing the partner. No login required. 2. **Collaborative planning:** parent A sends a `WorkspaceInvitation`; parent B registers and redeems it. If a placeholder PersonalData already exists for B, the redeem step links B's User to it instead of creating a duplicate. ### 3.1 Entities (Domain Layer) > **SUPERSEDED (2026-09-15, then 2026-09-18).** `goal_override` was removed entirely (an earlier migration, since squashed into the single initial migration — see `backend/migrations/versions/`). The allocator now applies a single policy based solely on `salary_type`. `PlannedLeaveDay` gained `wish_id` linking it back to the wish that produced it (nullable, for ad-hoc/legacy days), and `is_pinned` to mark days the user hand-edited so the allocator won't retype them. `LeaveWish` gained `window_mode`, `name_message_key`, `target_days` and `wants_dubbeldag` — none of which the code block below originally showed; it has been updated in place. `Workspace.planning_mode` was also removed entirely — see PLANNING_ENGINE_SPEC.md §2.2. ```typescript // backend/app/entities/personal_data.py from dataclasses import dataclass from datetime import datetime from uuid import UUID @dataclass class PersonalData: """A parent profile. May or may not have a linked User login. NOTE: storing the personnummer is discouraged (see §11). If kept, use a keyed HMAC / encryption, NOT a plain SHA-256 (the personnummer space is small enough to brute-force an unsalted hash). """ uuid: UUID workspace_id: UUID # parent belongs to exactly one workspace name: str # Categorical planning attributes (NOT kronor — keeps "money" abstract per §0.2). # Drive the salary-dependent tips in §6 (weekend/red-day wrapping needs monthly pay) # and the SGI tip (working/jobseeking protects SGI after age 1). salary_type: str = "monthly" # 'monthly' | 'hourly' employment_status: str = "employed" # 'employed' | 'jobseeking' | 'none' personnummer_enc: str | None = None # encrypted/HMAC, optional; prefer not storing created_at: datetime = None deleted_at: datetime | None = None # backend/app/entities/user.py @dataclass class User: """User account/login linked 1:1 to a PersonalData (parent profile).""" id: UUID personal_data_uuid: UUID # FK to PersonalData (workspace derived from it) email: str hashed_password: str # argon2/bcrypt, never plain SHA created_at: datetime # backend/app/entities/workspace.py @dataclass class Workspace: """Shared workspace for one or two parents.""" id: UUID name: str # Drives which tips are emphasized (§6). Not a hard constraint. planning_goal: str = "balanced" # 'max_time' | 'max_money' | 'balanced' # Whether the family plans solo or as a couple is derived from member # count (2+ parents = couple), not a stored field — see # PLANNING_ENGINE_SPEC.md §2.2 (superseded `planning_mode`, removed in an # earlier migration, since squashed into the single initial migration). created_at: datetime = None # backend/app/entities/workspace_invitation.py @dataclass class WorkspaceInvitation: """Pending invite that lets a partner join an existing workspace. Flow: parent A creates a workspace, generates an invite (single-use token), shares the link/code out-of-band; parent B registers/logs in and redeems it, which links parent B's User + PersonalData to the same workspace_id. """ id: UUID workspace_id: UUID token: str # random, single-use, hashed at rest invited_by_user_id: UUID status: str # 'pending' | 'accepted' | 'revoked' | 'expired' expires_at: datetime created_at: datetime accepted_at: datetime | None = None # backend/app/entities/leave_wish.py @dataclass class LeaveWish: """A family's recurring intent to be home for N weeks inside a yearly window (e.g. 'I want 6 weeks home each summer until the child turns 4'). Input to the planning engine (see PLANNING_ENGINE_SPEC.md §1). Replaces BreakPeriod.""" id: UUID workspace_id: UUID child_id: UUID | None # None = family-wide; set = tied to that child's pools name: str # 'Sommar', 'Jullov', ... # Recurring window anchored by month/day ints (SQLite-friendly). If # (end_month, end_day) < (start_month, start_day), wraps to next year # (e.g. Jullov 12-20 → 01-07, spanning two vacation-year budgets). window_start_month: int # 1-12 window_start_day: int # 1-31 window_end_month: int window_end_day: int target_weeks: int # desired weeks home together during this window recurrence: str # 'yearly' | 'once' first_year: int # anchor year (e.g. 2027) last_year: int | None # None = until child's 4th birthday (if child_id set) priority: int # 1 = highest; drives best-effort fill order parent_mode: str # 'either' | 'both' | 'specific' parent_personal_data_uuid: UUID | None # required when parent_mode == 'specific' # 'fixed' (default): window_start_*/window_end_* are the exact placement. 'flexible': # the same fields instead describe the bounding range the allocator searches inside # for the best-costing placement (PLANNING_ENGINE_SPEC.md §10.1). window_mode: str = "fixed" # 'fixed' | 'flexible' # Opt-in dubbeldagar for a parent_mode == 'both' window. Default False # means the co-leave funding path applies instead (PLANNING_ENGINE_SPEC.md §3.5). wants_dubbeldag: bool = False # i18n provenance of `name` when created from a preset (e.g. "wishEditor.presetSommar"); # cleared the moment the user edits `name` by hand. name_message_key: str | None = None # A calendar-drawn period's exact day count, overriding `target_weeks * 7` when set. target_days: int | None = None created_at: datetime | None = None updated_at: datetime | None = None # backend/app/entities/child.py @dataclass class Child: """Child entitlement (one per child, multi-child ready).""" id: UUID workspace_id: UUID name: str birth_date: date # use due date if not yet born; planning can start pre-birth preschool_start_date: date | None = None # drives the preschool-planning-day reserve tip # Per-child override for annual preschool planning-day ("studiedagar") closures. # Defaults to LeaveRules.preschool_planning_days_per_year from config. preschool_planning_days_per_year: int = 6 # typical Swedish preschool closures created_at: datetime = None # backend/app/entities/parental_leave.py @dataclass class ParentalLeaveFetch: """Cached data from Försäkringskassan (or manual entry — see `source`).""" id: UUID personal_data_uuid: UUID # Which parent workspace_id: UUID child_id: UUID source: str # 'scraped' | 'manual' | 'deduced' — provenance of these numbers # ('deduced' = other parent's balance from logged-in parent's FK fetch) fetched_at: datetime # Freshness indicator total_days: int # This parent's allocation (default 240) days_used: int days_left: int # Reserved (non-transferable) day accounting — required for accurate # partner planning. Of this parent's days, `reserved_days_total` (default 90) # cannot be transferred to the other parent. reserved_days_total: int # default 90 reserved_days_left: int transferable_days_left: int # days this parent COULD transfer to the partner # Dubbeldagar: shared-day quota for the workspace/child (max 60, first 15 months). # Tracked per parent as FK reports usage per parent. dubbeldagar_left: int tier_data: dict # JSON: sjukpenningnivå + lägstanivå breakdown (see TierData) created_at: datetime updated_at: datetime # backend/app/entities/vacation_days.py @dataclass class VacationDays: """Manual vacation (semester) entry per parent per year. Swedish semester: typically 25 statutory days/year; days above 20 may be saved for up to 5 years (sparad semester). `saved_days_in` lets the tips engine warn about days approaching the 5-year expiry — without it the 'use vacation before expiry' tip has no data to act on. """ id: UUID personal_data_uuid: UUID workspace_id: UUID year: int total_days: int # days available this year (entitlement) saved_days_in: int = 0 # carried over from prior years created_at: datetime = None updated_at: datetime = None # backend/app/entities/planned_leave.py @dataclass class PlannedLeaveDay: """Calendar event: which parent takes which day off (full-day granularity).""" id: UUID workspace_id: UUID child_id: UUID parent_personal_data_uuid: UUID date: date day_type: str # 'sjukpenning' | 'lagsta' | 'vacation' is_dubbeldag: bool = False # True if BOTH parents take this child-day together; # counts against the 60-day dubbeldagar quota and is # only valid within the child's first 15 months created_at: datetime = None updated_at: datetime = None ``` ### 3.2 Database Schema (SQLite/Postgres) > **SUPERSEDED (2026-09-15, then 2026-09-18).** This section used to re-transcribe the full DDL, > which drifted from reality (missing the `day_override` and `tip_dismissal` tables, stale columns > on others — e.g. `workspace.planning_goal` is real but `workspace.planning_mode` was removed, > `leave_wish.goal_override` was removed while `window_mode`/`wants_dubbeldag`/`name_message_key`/ > `target_days` were added). **`backend/app/db/connection.py`'s `_FULL_SCHEMA`** (used directly by > dev/tests) and **`backend/migrations/`** (hand-written Alembic revisions, the production schema > owner) are the authority — read those rather than a copy here that will go stale again. Eleven tables: `workspace`, `personal_data`, `"user"`, `workspace_invitation`, `child`, `leave_wish`, `parental_leave_fetch`, `vacation_days`, `planned_leave_day`, `day_override`, `tip_dismissal`. Foreign keys generally cascade on delete from `workspace`/`personal_data`/`child`; `personal_data` is isolated from other GDPR-sensitive data per §11. The general shape (UUID text primary keys, `created_at`/`updated_at` timestamps, `CHECK` constraints mirroring each entity's enum-like `str` fields) is unchanged from the original design — only individual columns moved. --- ## 4. Repository Pattern & Dependency Injection ### 4.1 Abstract Repository ```python # backend/app/repositories/base.py from abc import ABC, abstractmethod from typing import Generic, TypeVar, List, Any from uuid import UUID T = TypeVar('T') class Repository(ABC, Generic[T]): """Abstract repository - implement for each database backend.""" @abstractmethod async def create(self, item: T) -> T: """Create and return item with generated ID.""" pass @abstractmethod async def get_by_id(self, id: UUID) -> T | None: """Get item by ID, raise NotFoundError if not found.""" pass @abstractmethod async def update(self, id: UUID, item: T) -> T: """Update item, raise NotFoundError if not found.""" pass @abstractmethod async def delete(self, id: UUID) -> None: """Soft delete (set deleted_at timestamp).""" pass @abstractmethod async def hard_delete(self, id: UUID) -> None: """Permanent deletion (for GDPR compliance).""" pass @abstractmethod async def list(self, **filters) -> List[T]: """List items with optional filters.""" pass ``` ### 4.2 Dependency Injection Setup ```python # backend/app/dependencies.py from typing import Annotated from fastapi import Depends from app.config import settings from app.repositories import PersonalDataRepository, UserRepository, WorkspaceRepository from app.repositories_impl.sqlite import SQLitePersonalDataRepository, SQLiteUserRepository from app.repositories_impl.postgres import PostgresPersonalDataRepository, PostgresUserRepository def get_personal_data_repository() -> PersonalDataRepository: """Factory: returns appropriate repository based on config.""" if settings.DATABASE_TYPE == "sqlite": return SQLitePersonalDataRepository(db_connection=settings.db) elif settings.DATABASE_TYPE == "postgres": return PostgresPersonalDataRepository(db_connection=settings.db) else: raise ValueError(f"Unknown database type: {settings.DATABASE_TYPE}") def get_user_repository() -> UserRepository: """Similar pattern for all repositories.""" if settings.DATABASE_TYPE == "sqlite": return SQLiteUserRepository(db_connection=settings.db) return PostgresUserRepository(db_connection=settings.db) # Type hints for dependency injection PersonalDataRepositoryDep = Annotated[PersonalDataRepository, Depends(get_personal_data_repository)] UserRepositoryDep = Annotated[UserRepository, Depends(get_user_repository)] # Usage in routers: # @router.post("/user") # async def create_user(user: UserSchema, # repo: PersonalDataRepositoryDep) -> UserResponse: # return await repo.create(user) ``` ### 4.3 Service Layer ```python # backend/app/services/planning_service.py from datetime import date, datetime from uuid import UUID from app.entities.planned_leave import PlannedLeaveDay from app.repositories import PlannedLeaveDayRepository, ParentalLeaveRepository class PlanningService: """Business logic for leave planning.""" def __init__(self, planned_repo: PlannedLeaveDayRepository, leave_repo: ParentalLeaveRepository, child_repo: ChildRepository, vacation_repo: VacationDaysRepository): self.planned_repo = planned_repo self.leave_repo = leave_repo self.child_repo = child_repo self.vacation_repo = vacation_repo async def add_planned_day(self, workspace_id: UUID, child_id: UUID, parent_uuid: UUID, date: date, day_type: str, is_dubbeldag: bool = False) -> PlannedLeaveDay: """ Add a planned leave day with validation. Validation is against *planned* days, not just the fetched snapshot: remaining = fetched days_left - already-planned future days of that type. Raises: InsufficientDaysError: if parent doesn't have enough remaining days InvalidDayTypeError: if day_type is not recognized DubbeldagError: if dubbeldag is outside the first 15 months or over quota """ valid_types = ['sjukpenning', 'lagsta', 'vacation'] if day_type not in valid_types: raise InvalidDayTypeError(f"Invalid day_type: {day_type}") latest_fetch = await self.leave_repo.get_latest_by_parent(parent_uuid, child_id) already_planned = await self._planned_count(parent_uuid, child_id, day_type) # föräldrapenning day budget (sjukpenning + lagsta share the 480/parent pool) if day_type in ('sjukpenning', 'lagsta'): if latest_fetch.days_left - already_planned <= 0: raise InsufficientDaysError(f"No {day_type} days left to plan") # vacation budget is checked separately against VacationDays for that year if day_type == 'vacation': await self._assert_vacation_available(parent_uuid, date.year) # dubbeldagar rules: only valid in the child's first 15 months and within quota if is_dubbeldag: child = await self.child_repo.get_by_id(child_id) if date > _add_months(child.birth_date, 15): raise DubbeldagError("Dubbeldagar only allowed in the child's first 15 months") if latest_fetch.dubbeldagar_left - await self._planned_dubbeldagar(child_id) <= 0: raise DubbeldagError("Dubbeldagar quota (max 60) exhausted") planned_day = PlannedLeaveDay( id=uuid4(), workspace_id=workspace_id, child_id=child_id, parent_personal_data_uuid=parent_uuid, date=date, day_type=day_type, is_dubbeldag=is_dubbeldag, created_at=datetime.now(), updated_at=datetime.now() ) return await self.planned_repo.create(planned_day) async def get_days_used_by_type(self, workspace_id: UUID, child_id: UUID, parent_uuid: UUID, year: int) -> dict: """Calculate total days used by type for a specific year.""" # SQL query or iterate through planned days pass ``` --- ## 5. API Endpoints > **SUPERSEDED (2026-09-18).** The table below was regenerated from `backend/app/routers/*.py` > (route decorators + `main.py`'s `include_router` prefixes) — the original hand-written version had > nine wrong entries and several missing routes. There is **no account-deletion endpoint** (no user > router exists at all — `personal_data` rows are removed only as part of `remove_parent`/workspace > deletion, not a standalone `DELETE /api/user/...`), and **no `PUT /api/vacation-days/{id}`** — > `vacation-days` is create-or-update via a single `POST` (upsert by `(personal_data_uuid, year)`). ``` # Authentication POST /api/auth/register POST /api/auth/login POST /api/auth/logout POST /api/auth/refresh GET /api/auth/me GET /api/auth/context # Workspace POST /api/workspace # Create new GET /api/workspace/{workspace_id} PUT /api/workspace/{workspace_id} # Change name GET /api/workspace/{workspace_id}/members # List parent profiles (1–2) POST /api/workspace/{workspace_id}/parents # Add placeholder parent (solo planning) PATCH /api/workspace/{workspace_id}/parents/{personal_data_uuid} # Rename a parent DELETE /api/workspace/{workspace_id}/parents/{personal_data_uuid} # Remove a parent POST /api/workspace/{workspace_id}/invitations # Create single-use invite (returns token) # Partner invitations POST /api/invitations/redeem # Redeem token → join workspace DELETE /api/invitations/{invitation_id} # Revoke pending invite # Children POST /api/children GET /api/children/{child_id} GET /api/children/workspace/{workspace_id} PUT /api/settings/child/{child_id} # Set birth date + preschool start date DELETE /api/settings/{child_id} # Parent settings PUT /api/settings/parent/{personal_data_uuid} # Set salary_type, employment_status, min_vacation_days_per_year # Fetch Data from Försäkringskassan (scraper; 501 when APP_SCRAPER_ENABLED=false) POST /api/fetch-data/start # Initialize headless browser session GET /api/fetch-data/poll # Poll for QR scan + auth POST /api/fetch-data/cancel # Cancel in-progress fetch # Parental Leave Data (manual entry + scraped snapshots, keyed by parent+child) POST /api/parental-leave/manual GET /api/parental-leave/{personal_data_uuid}/{child_id} GET /api/parental-leave/{personal_data_uuid}/{child_id}/history # Planned Leave Days POST /api/planned-days GET /api/planned-days?child_id=... GET /api/planned-days/{planned_leave_day_id} GET /api/planned-days/workspace/{workspace_id} PUT /api/planned-days/{planned_leave_day_id} DELETE /api/planned-days/{planned_leave_day_id} # Vacation Days (no PUT — POST upserts by (parent, year)) POST /api/vacation-days GET /api/vacation-days/{vacation_days_id} GET /api/vacation-days/workspace/{workspace_id} # Day Overrides (workspace calendar corrections — §13.3) POST /api/day-overrides GET /api/day-overrides/{day_override_id} GET /api/day-overrides/workspace/{workspace_id} DELETE /api/day-overrides/{day_override_id} # Calendar (Swedish holidays & workspace overrides) GET /api/calendar/days # Calendar metadata (röda dagar, klämdagar, overrides) — §13.3 # Analytics/Dashboard GET /api/analytics/{workspace_id}/overview?child_id=... GET /api/analytics/{workspace_id}/projection?child_id=... # same response shape as overview GET /api/analytics/parent/{personal_data_uuid}/child/{child_id}/age4 # Tips GET /api/tips/{workspace_id} POST /api/tips/{workspace_id}/dismissals # Dismiss a tip (tip_dismissal table) # Leave Wishes (recurring planning windows; replaced BreakPeriod) GET /api/leave-wishes/{workspace_id} # List configured leave wishes POST /api/leave-wishes # Add a leave wish (summer/winter/...) PUT /api/leave-wishes/{leave_wish_id} # Edit name/dates/priority/recurrence DELETE /api/leave-wishes/{leave_wish_id} # Remove a leave wish # Plan (planning engine — see PLANNING_ENGINE_SPEC.md §4; /sync's shape is under active # development and deliberately not detailed here) POST /api/plan/propose POST /api/plan/apply POST /api/plan/sync ``` --- ## 6. Optimization Tips/Rules Engine The app provides contextual recommendations. Because the app does **not** compute kronor (decision §0.2), the "money" dimension is expressed qualitatively through two related levers: **Weekly intensity (days/week).** Föräldrapenning is taken in whole days, but the *number of days per week* a parent claims is the main planning knob. Fewer days/week = lower income now but more calendar weeks of being home and more days preserved for later. The planning UI should let a parent paint a date range with a weekly pattern (e.g. "5 days/week", "3 days/week", "weekends only") rather than clicking each day. The engine then explains the tradeoff instead of a number. **SGI protection.** This is the key constraint behind the "you can take fewer/no days early" advice: - During the child's **first year**, SGI is protected regardless of how few days are taken — so a parent can take little or no föräldrapenning that year and preserve days for later without losing SGI. - After the child turns **1**, a non-working parent generally must take out parental benefit **at least 5 days/week** (or be a registered jobseeker) to keep full SGI. > **SUPERSEDED (2026-09-15).** `planning_goal` was removed entirely. The engine now applies a single allocation policy based solely on `salary_type`, as described in PLANNING_ENGINE_SPEC.md §3.3. The engine derives these from the child's birth date and the planned weekly intensity; **no salary figure is needed** — only the categorical `salary_type` (monthly vs hourly) and `employment_status` on each parent. Tips are generated by `backend/app/services/tips_service.py`'s predicate rules (see `app/utils/tip_predicates.py`), not ranked by a workspace goal. The rules below reference helper predicates that operate on the combined plan (fetched snapshot + planned days). ### 6.1 Tip catalogue (incl. user-specified strategies) The rules below combine the generic tips with the concrete Swedish strategies requested for this app. Each is advisory, never a hard validation block (the user explicitly noted these are principles, not fixed rules). Two of them — wrapping weekends and wrapping red-day periods with vacation — only make sense for **monthly-salaried** employees and must be suppressed when `salary_type == 'hourly'`. The red-day and weekend tips depend on a Swedish public-holiday calendar (see §6.2). Rules to implement: ```python # backend/app/services/tips_service.py class OptimizationTips: """Generate tips based on current plan.""" RULES = [ { "id": "use_vacation_before_expiry", "description": "Use vacation days before they expire", "condition": lambda plan: has_unused_vacation_near_expiry(plan), "tip": "You have {days} vacation days expiring in {months} months. Consider using them now!" }, { "id": "balance_between_parents", "description": "Suggest balanced coverage for both parents", "condition": lambda plan: one_parent_takes_too_many(plan), "tip": "Parent {name} is taking {days} more days. Consider more balance for childcare coverage." }, { "id": "preserve_for_age_4", "description": "Remind about 96-day max at age 4", "condition": lambda plan: days_left_at_age_4(plan) < 50, "tip": "Only {days} days will be left after child's 4th birthday. Plan accordingly!" }, { "id": "use_dubbeldagar_strategically", "description": "Use double days when both parents want time off", "condition": lambda plan: has_overlapping_days(plan), "tip": "Both parents are taking {date}. Consider using dubbeldagar instead!" }, { "id": "lagsta_niva_coverage", "description": "Use lower tier days for flexibility", "condition": lambda plan: can_use_lagsta_more(plan), "tip": "You have lägsta nivå days available. Using them gives more flexibility." }, { "id": "stretch_time_at_home", "description": "Lower weekly intensity to extend time at home", "condition": lambda plan: weekly_intensity(plan) >= 7 and days_left_at_age_4(plan) < target, "tip": "Taking fewer days per week (e.g. 5 instead of 7) stretches your " "time at home and preserves more days for later — at the cost of " "less income during this period." }, { # User rule #1: maximize time at home "id": "skip_fp_first_12_months", "description": "For max time, take no föräldrapenning in the first 12 months", "goal": "max_time", "condition": lambda plan: child_under_age(plan, months=12) and has_planned_fp(plan, before_age_months=12), "tip": "If your goal is maximum time at home: your SGI is protected during the " "child's first 12 months even if you take NO parental-leave days. " "Skipping days now saves them for later and extends total time at home." }, { "id": "sgi_minimum_after_age_1", "description": "Warn when weekly intensity risks SGI after age 1", "condition": lambda plan: child_over_age(plan, years=1) and not working_or_jobseeking(plan) and weekly_intensity(plan) < 5, "tip": "After your child turns 1, take out at least 5 days/week (or register " "as a jobseeker / be working) to keep your full SGI." }, { # User rule #2: wrap weekends with vacation (monthly salary only) "id": "wrap_weekend_with_vacation", "description": "Bridge weekends with Friday+Monday vacation to keep weekend pay", "goal": "max_money", "condition": lambda plan: parent_salary_type(plan) == "monthly" and fp_spans_weekend_unbridged(plan), "tip": "You have a monthly salary: take VACATION on the Friday and Monday around " "a weekend instead of parental leave. This 'wraps' the weekend so you keep " "your weekend salary, which you'd otherwise lose on parental-leave days. " "(Does not apply to hourly pay.)" }, { # User rule #3: wrap red-day / holiday periods with vacation "id": "wrap_red_days_with_vacation", "description": "Use vacation around clusters of red days (Easter, Christmas, etc.)", "goal": "max_money", "condition": lambda plan: parent_salary_type(plan) == "monthly" and red_day_cluster_in_plan(plan), "tip": "There's a cluster of red days ({period}). With a monthly salary, taking " "vacation across this period (rather than parental leave) protects the pay " "you'd otherwise lose for holidays — same principle as bridging weekends." }, { # User rule #4: parent-swap strategy for any long break (summer, winter, ...) # Principle, not fixed length — each family sets the period and duration. "id": "long_break_parent_swap", "description": "Alternate FP and vacation between parents across a long break", "condition": lambda plan: has_long_shared_period_opportunity(plan), "tip": "To be home together for a long stretch ({period_name}): have one parent " "take parental leave while the other takes vacation, then swap. Two halves " "of length L overlap ~2×L weeks at home (e.g. 3+3 weeks ≈ 6 weeks together). " "You set the period and the length to fit your day budget." }, { # User rule #5: reserve days for preschool planning-day closures "id": "reserve_for_preschool_planning_days", "description": "Keep a buffer of days for preschool 'studiedagar' closures", "condition": lambda plan: child_in_preschool(plan) and reserved_buffer_days(plan) < PRESCHOOL_PLANNING_DAYS_PER_YEAR, "tip": "Preschools usually close ~6 days/year for planning days (studiedagar). " "Keep some days in reserve so you can stay home on those days instead of " "scrambling for childcare." } ] async def get_tips(self, workspace_id: UUID) -> List[Tip]: """Generate all applicable tips for workspace.""" pass ``` ### 6.2 Data dependencies for the tips engine - **Swedish public-holiday calendar (röda dagar).** The weekend-wrap and red-day tips need to know which dates are public holidays (and which weekday they fall on). Compute these (Easter is movable) via a library such as `holidays` (Sweden locale) or a vendored table, exposed through `utils/constants.py` / a `calendar_service`. Cache per year. This is also needed so the calendar UI can shade red days. - **Preschool planning-day buffer.** `PRESCHOOL_PLANNING_DAYS_PER_YEAR` defaults to 6 and should be user-overridable per child (preschools differ). The reserve tip compares planned-but-unallocated buffer days against this. - **Preschool start.** `child_in_preschool(plan)` needs an (estimated or entered) preschool start date on the `Child`; default assumption can be ~age 1–1.5 but let the user set it. Consider adding `preschool_start_date` to the `Child` entity. - **Leave Wishes (user-configurable).** The planning engine (see PLANNING_ENGINE_SPEC.md) takes recurring family intents to be home together — summer **and** winter holidays (and others) — as input. Model as a `LeaveWish` list per workspace, with recurring windows anchored by month/day ints, recurrence strategy, priority, and optional parent/child targeting. Each family defines these with their own target length and importance, since the right amount is entirely family-specific. See PLANNING_ENGINE_SPEC.md §2.1 for the entity; it replaced the single-year `BreakPeriod`. ### 6.3 Caveat on payroll rules The weekend/red-day "wrapping" tips reflect a common monthly-salary pattern, but exact salary deductions for parental leave depend on the employer's collective agreement (kollektivavtal) and deduction method (kalenderdags- vs arbetsdagsavdrag). Frame these as general guidance and suggest the user confirm with their employer — do not present them as guaranteed outcomes. --- ## 6.5 Core Calculation: Days Left at Age 4 (the headline feature) This is the app's reason to exist, but the original draft never defined it. It combines three inputs **per parent, per child**: > **SUPERSEDED (2026-09-15).** The age-4 savable cap was incorrectly applied as 96 per parent. The > formulas below have been corrected in place (not just this banner) — see `resolve_age4_savable_cap` > in `backend/app/utils/tip_predicates.py`, the single source of truth: it returns **48 per parent** > as soon as the child has **two or more parents** (non-poolable — 96 each would let a two-parent > family save 192 combined), and the full **96** only for a genuine single parent. This is purely a > function of parent count, not a workspace mode. 1. **Actual usage** from the latest `ParentalLeaveFetch` (or manual entry): `days_used`, `days_left`, tier breakdown. 2. **Planned future days** the workspace has laid out in the calendar (not yet reported to FK). 3. **Statutory caps**: the 480/parent pool, the age-4 ceiling (96 for a single parent, 48 per parent for two or more), and the age-12 hard expiry. **Definitions** (compute as of the child's 4th birthday `B4 = birth_date + 4 years`, with `cap` = `resolve_age4_savable_cap(parent_count)`): ``` projected_used_by_B4 = days_used + planned_days(date < B4) projected_left_at_B4 = total_days - projected_used_by_B4 # per parent savable_after_B4 = min(projected_left_at_B4, cap) # cap = 96 (solo) or 48 (2+ parents) expiring_at_B4 = max(projected_left_at_B4 - cap, 0) # LOST if not used before B4 ``` The dashboard's "projection" endpoint (`/api/analytics/{workspace_id}/projection`) returns, per parent and combined: `projected_left_at_B4`, `savable_after_B4`, and especially **`expiring_at_B4`** — the number of days the family will *lose* unless they plan them in before the 4th birthday. This is what the `preserve_for_age_4` tip keys off. **Reconciliation (planned vs. actual).** Planned days are a projection layered on top of the FK snapshot; they are never assumed to be reported to FK. When a fresh `ParentalLeaveFetch` arrives, the app should: - treat the new `days_used` as ground truth, - flag planned days in the past that the new snapshot does **not** reflect (the parent may not have actually claimed them), and - recompute all projections from the newest snapshot + remaining future planned days. Edge cases the calculation must handle: child not yet born (plan from due date), reserved days (the 90 non-transferable days are part of each parent's pool and also subject to the age-4 cap), dubbeldagar (each counts as a day against the using parent's pool), and leap-year / month-arithmetic for `birth_date + 4 years`. --- ## 7. Frontend Type Safety ```typescript // frontend/src/types/entities.ts export interface PersonalData { uuid: string; workspaceId: string; name: string; salaryType: 'monthly' | 'hourly'; employmentStatus: 'employed' | 'jobseeking' | 'none'; createdAt: Date; } export interface Workspace { id: string; name: string; planningGoal: 'max_time' | 'max_money' | 'balanced'; createdAt: Date; } export interface Child { id: string; workspaceId: string; name: string; birthDate: Date; preschoolStartDate: Date | null; preschoolPlanningDaysPerYear: number; createdAt: Date; } // SUPERSEDED (2026-09-18) — corrected to match frontend/src/types/entities.ts. `goalOverride` // never existed on the wire (the field was removed backend-side before this doc's first draft // shipped); `nameMessageKey`, `targetDays` and `windowMode` were missing. Note `wants_dubbeldag` // (backend/app/entities/leave_wish.py) has NO frontend type or UI yet — it is backend-only as of // this writing, set only by tests/seed data, not by any user-facing control. export interface LeaveWish { id: string; workspaceId: string; childId: string | null; // null = family-wide name: string; // 'Sommar', 'Jullov', ... nameMessageKey: string | null; // i18n provenance of a preset-derived `name` windowStartMonth: number; // 1-12 windowStartDay: number; // 1-31 windowEndMonth: number; windowEndDay: number; targetWeeks: number; targetDays?: number | null; // calendar-drawn period's exact day count, overrides targetWeeks*7 recurrence: 'yearly' | 'once'; firstYear: number; lastYear: number | null; priority: number; // 1 = highest parentMode: 'either' | 'both' | 'specific'; parentPersonalDataUuid: string | null; windowMode: 'fixed' | 'flexible'; // PLANNING_ENGINE_SPEC.md §10.1 createdAt: Date; updatedAt: Date; } export interface ParentalLeaveFetch { id: string; personalDataUuid: string; workspaceId: string; childId: string; source: 'scraped' | 'manual'; fetchedAt: Date; totalDays: number; daysUsed: number; daysLeft: number; reservedDaysTotal: number; // default 90, non-transferable reservedDaysLeft: number; transferableDaysLeft: number; dubbeldagarLeft: number; tierData: TierData; createdAt: Date; updatedAt: Date; } export interface TierData { sjukpenningsnivå: { totalDays: number; usedDays: number; leftDays: number; }; lägstaNivå: { totalDays: number; usedDays: number; leftDays: number; }; } export interface PlannedLeaveDay { id: string; workspaceId: string; childId: string; parentPersonalDataUuid: string; date: Date; dayType: 'sjukpenning' | 'lagsta' | 'vacation'; isDubbeldag: boolean; createdAt: Date; updatedAt: Date; } // frontend/src/types/api.ts export interface CreatePlannedDayRequest { childId: string; date: Date; dayType: PlannedLeaveDay['dayType']; isDubbeldag?: boolean; } export interface CreatePlannedDayResponse { success: boolean; data?: PlannedLeaveDay; error?: ApiError; } export interface ApiError { code: string; message: string; details?: Record; } ``` ### 7.1 Cross-hook invalidation (`WorkspaceContext.planVersion`) There is no shared query cache in this app (no TanStack Query, no Redux — see the CLAUDE.md conventions) — each data-fetching hook (`usePlannedDays`, `useAnalytics`, `useDayOverrides`, ...) holds its own independent `useState`, so two mounted instances (e.g. the Calendar's `usePlannedDays` and the Dashboard's `useAnalytics`) have no way to know about each other's writes. `WorkspaceContext` exposes a `planVersion: number` counter plus `bumpPlanVersion()`: any hook whose data can go stale from a planned-day/day-override mutation calls `bumpPlanVersion()` after a successful write and includes `planVersion` in its own fetch effect's dependency array. Bumping it gives every mounted instance — not just the one that made the change — a reason to refetch. This is the mechanism that makes the Översikt tab's analytics update immediately after an edit made on the Kalender tab. See `frontend/src/context/WorkspaceContext.tsx`. ### 7.2 i18n `frontend/src/i18n/` holds parallel `sv/` and `en/` namespace directories (`auth.ts`, `calendar.ts`, `common.ts`, `dashboard.ts`, `onboarding.ts`, `plan.ts`, `settings.ts`, `tips.ts`, `wishEditor.ts`) plus `LanguageContext.tsx`, which provides the active locale and a `t()` lookup (`useTranslation`). The UI defaults to Swedish. Backend-generated user-facing text follows the same `message_key: str` + `data: dict` pattern throughout — `Tip` (`tips_service.py`) established it first; `WindowOutcome`, `ShortfallRemedy`, `SurplusAdvice`, `PlanWarning` and domain errors (`NotFoundError`/`ValidationError`'s optional `message_key`/`data`, surfaced as structured HTTP `detail`) all carry an opaque key instead of a rendered sentence, so the frontend resolves it through the active locale rather than displaying backend-generated English/Swedish text verbatim. The frontend maps these opaque keys to real `TranslationKey`s via per-component lookup tables (e.g. `PlanResults.tsx`, `TipCard.tsx`'s `TIP_TRANSLATION_KEYS`). A wish's possibly-preset-derived `name` follows the same idea via `name_message_key`/`resolveWishName` (`src/utils/wishName.ts`), so a preset label re-translates on a language switch while a user-typed name does not. --- ## 8. Testing Strategy ### 8.1 Backend Testing (pytest) ```python # backend/tests/conftest.py import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @pytest.fixture def test_db(): """In-memory SQLite for testing.""" engine = create_engine("sqlite:///:memory:") # Create tables Base.metadata.create_all(engine) return engine @pytest.fixture async def personal_data_repo(test_db): """Mock repository for testing.""" return SQLitePersonalDataRepository(test_db) # backend/tests/unit/test_repositories/test_personal_data_repository.py class TestPersonalDataRepository: @pytest.mark.asyncio async def test_create_personal_data(self, personal_data_repo): """Test creating personal data.""" pd = PersonalData( uuid=uuid4(), workspace_id=uuid4(), name="Test User", personnummer_enc=None, created_at=datetime.now() ) result = await personal_data_repo.create(pd) assert result.uuid == pd.uuid @pytest.mark.asyncio async def test_get_by_id(self, personal_data_repo): """Test fetching by ID.""" # ... create then fetch pass @pytest.mark.asyncio async def test_hard_delete_cascades(self, personal_data_repo): """Test that hard delete removes all related data.""" # Create user + planned days # Delete personal data # Assert all related rows deleted pass # backend/tests/unit/test_services/test_planning_service.py class TestPlanningService: @pytest.mark.asyncio async def test_add_planned_day_validates_day_type(self, planning_service): """Test that invalid day types are rejected.""" with pytest.raises(InvalidDayTypeError): await planning_service.add_planned_day( workspace_id=uuid4(), child_id=uuid4(), parent_uuid=uuid4(), date=date.today(), day_type="invalid_type" ) @pytest.mark.asyncio async def test_insufficient_days_raises_error(self, planning_service): """Test that planning day without enough days fails.""" # Create scenario with 0 days left with pytest.raises(InsufficientDaysError): await planning_service.add_planned_day(...) # Actual CI gate: --cov-fail-under=80 (see backend/README.md and CLAUDE.md) pytest --cov=app --cov-report=html ``` ### 8.2 Frontend Testing (Vitest) ```typescript // frontend/tests/unit/hooks/usePlannedDays.test.ts import { renderHook, act, waitFor } from '@testing-library/react'; import { usePlannedDays } from '@/hooks/usePlannedDays'; import * as api from '@/services/api'; vi.mock('@/services/api'); describe('usePlannedDays', () => { it('should fetch planned days on mount', async () => { const mockDays = [ { id: '1', workspaceId: 'w1', childId: 'c1', parentPersonalDataUuid: 'p1', date: new Date('2026-06-01'), dayType: 'sjukpenning', isDubbeldag: false, createdAt: new Date(), updatedAt: new Date(), }, ]; vi.mocked(api.getPlannedDays).mockResolvedValue(mockDays); const { result } = renderHook(() => usePlannedDays('w1', 'c1') ); await waitFor(() => { expect(result.current.days).toEqual(mockDays); }); }); it('should handle fetch error gracefully', async () => { vi.mocked(api.getPlannedDays).mockRejectedValue( new Error('Network error') ); const { result } = renderHook(() => usePlannedDays('w1', 'c1') ); await waitFor(() => { expect(result.current.error).toBeTruthy(); }); }); }); // frontend/tests/unit/components/Calendar.test.tsx describe('CalendarView', () => { it('should render month view by default', () => { const { getByText } = render( ); expect(getByText(/June 2026/i)).toBeInTheDocument(); }); it('should allow adding day by clicking + dragging', async () => { const onAddDay = vi.fn(); const { getByTestId } = render( ); const dayCell = getByTestId('day-2026-06-15'); // Simulate drag event // Assert onAddDay called }); }); // Actual gates (frontend/vitest.config.ts): lines 96% / functions 91% / branches 92% / statements 96% vitest --coverage ``` --- ## 9. Implementation Roadmap > **SUPERSEDED (2026-09-18).** This was a week-by-week plan written before any code existed. All > five phases below are done — kept as a historical summary of build order, not an open task list. > Later, larger work (the planning engine, day overrides, tip dismissals, i18n) is tracked in > PLANNING_ENGINE_SPEC.md §8 and the `*_REPORT.md`/`*_TODO.md` handoff notes instead. - [x] **Foundation** — project structure, database schema + migrations, repository pattern + DI, auth (JWT access/refresh), workspace + parent profiles + partner invitations, manual parental-leave entry. - [x] **Data fetching** — Playwright headless-browser scraper for Försäkringskassan behind `FetchDataService`, BankID QR flow, scraped-snapshot storage, legal/ToS review (cleared 2026-06). - [x] **Planning interface** — calendar, planned-day CRUD, day-type selection, real-time validation; later superseded in shape by the planning engine (`LeaveWish` + allocator, PLANNING_ENGINE_SPEC.md). - [x] **Analytics & tips** — overview/projection endpoints, dashboard metrics, tips engine. - [x] **Testing & polish** — CI gates, ESLint, error handling. Coverage gates are 80% backend / 96-91-92-96 frontend (§8), not the 90% flat target originally written here. --- ## 10. Code Quality Standards All code must adhere to: ✅ **Backend (Python):** - Fully typed with type hints - pytest with coverage (CI gate: `--cov-fail-under=80`, currently ~84% — see CLAUDE.md) - No bare except clauses - UV for dependency management - Comments for complex logic - Repository pattern - Dependency injection - DRY principles ✅ **Frontend (TypeScript/React):** - All TS/TSX files (no .js) - Full type safety (no `any`, `@ts-ignore`, or `@ts-expect-error` — enforced, no exceptions) - Vitest with coverage gates: lines 96% / functions 91% / branches 92% / statements 96% - ESLint enforced, zero warnings (`--max-warnings 0`) - Comments for non-obvious logic - Custom hooks for state logic - Component composition - Error handling (no silent failures) - DRY principles ✅ **Error Handling:** - All errors explicitly caught - Never empty catch blocks - Custom error types for domain logic - Proper error propagation - User-friendly error messages --- ## 11. GDPR & Security - [x] Personal data isolated in separate table - [x] UUID mapping throughout app - [x] Soft delete (deleted_at) for audit trail - [x] Hard delete capability for data removal - [x] Cascading deletes via FK constraints - [x] Data minimization (only fetch what's needed) - [ ] **Reconsider storing personnummer at all** — `personnummer_enc` exists as a nullable, encrypted/HMAC field (never a bare hash), but the product decision to avoid storing it at all is still open. - [x] Passwords hashed with argon2 (`argon2-cffi`, never SHA) — `backend/app/services/auth_service.py` - [x] BankID credentials/session never persisted; scraper session discarded after each fetch (see §2.3) - [ ] Data export functionality (future) - [ ] Audit logging (future) ### 11.1 Cross-cutting concerns (previously unaddressed) - **Auth tokens:** resolved — short-lived JWT access token (`jwt_access_token_expire_minutes`, default 15 min) + refresh token (`jwt_refresh_token_expire_days`, default 7 days; `POST /api/auth/refresh`). Password reset and email verification flows are still not implemented. - **Localization (i18n):** resolved — `src/i18n/` (sv default + en), `LanguageContext`; see §7.2. - **Shared-workspace concurrency:** two parents may edit the same calendar at once. `updated_at` columns exist on the relevant tables but optimistic-concurrency rejection of stale writes is not implemented; real-time sync (WebSocket) remains a future enhancement. - **CORS / rate limiting / secrets:** resolved — `cors_origins` locks CORS to the frontend origin, `slowapi` rate-limits auth/fetch endpoints (`app/rate_limit.py`), secrets come from the environment (`app/config.py`). No `HMAC_PEPPER` setting exists despite `config.py`'s module docstring mentioning one — see backend/README.md's config section. --- ## 12. Deployment Notes **Development:** - SQLite local database - Headless browser runs locally - Frontend: `npm run dev` - Backend: `uv run uvicorn app.main:app --reload` **Production (aspirational — not yet built out):** - PostgreSQL database — **not currently a usable swap.** Only `vacation_days_repository.py` has a Postgres implementation; every other `app/dependencies.py` factory raises `ValueError` for `database_type != "sqlite"` (§2.1). "Swap repositories" describes the intended end state, not something you can do today by changing `DATABASE_TYPE`. - Headless browser: Docker container or hosted service - Frontend: Built + CDN hosted - Backend: Cloud function or container --- ## 13. Planning Engine & Supporting Infrastructure ### 13.1 Swedish Calendar Cache **File:** `backend/app/data/swedish_calendar.json` **Generated by:** `backend/scripts/fetch_swedish_calendar.py` (Dagsmart API) **Scope:** 10-year cache (2026–2035), validated against Python `holidays` library The planner never makes API calls at runtime — it uses a committed calendar cache to ensure planning is offline and deterministic. The cache carries three categories of days: - **public**: röda dagar (New Year, Easter, Midsummer, Christmas, etc.) - **de_facto**: days off in practice (Christmas Eve, New Year's Eve, Midsummer Eve, Easter Eve, Whit Eve) - **bridge_day** (klämdag): cheap vacation-buys when a red day falls mid-week **Trap:** The Python `holidays` library combines holiday names with weekdays. Filtering by `"Söndag" in name` deletes holidays that fall on Sunday (Påskdagen, Pingstdagen, Nationaldagen, Annandag jul). Always match the exact string `"Söndag"` — this is documented in `app/utils/holidays.py` and tested in `test_holidays.py`. ### 13.2 Planning Engine (Allocation Service) **Files:** - `backend/app/services/allocation_service.py` — the allocator and input loader - `PLANNING_ENGINE_SPEC.md` — full design specification **Architecture:** Strictly split into two parts: 1. **`AllocationInputLoader`** (async) — the ONLY part that touches repositories or the wall clock. Assembles a frozen `AllocationInput` from database state. > **SUPERSEDED (2026-09-15).** The allocator no longer uses `planning_goal`. It now applies a single policy based solely on `salary_type` (PLANNING_ENGINE_SPEC.md §3.3). 2. **`Allocator`** (pure) — no `await`, no repository access, no `date.today()`. Given the same `AllocationInput`, always produces identical output. This is the engine's heart: it turns `LeaveWish` intents into `ProposedDay` rows, choosing between föräldrapenning tiers and vacation based on parent `salary_type` alone, modeling parent-swap, opt-in dubbeldagar / default co-leave, and preschool day reserves along the way (PLANNING_ENGINE_SPEC.md §3.3, §3.5). The separation is critical: the pure allocator is cheap, re-runnable on every wish edit, and trivially unit-testable without a database. For the full design (window expansion, tier selection, shortfall remedies, warnings), see **PLANNING_ENGINE_SPEC.md**. ### 13.3 Calendar API Endpoint **Endpoint:** `GET /api/calendar/days` **Files:** - `backend/app/routers/calendar.py` — thin HTTP handler - `backend/app/services/calendar_service.py` — business logic - `backend/app/models/calendar.py` — Pydantic response schema **Purpose:** Expose Swedish calendar metadata (röda dagar, klämdagar, weekends, workspace overrides) to two UI surfaces: calendar shading (§3.4) and the per-year red-days list in Settings (§3.8). No runtime external API calls — everything reads the pre-built `swedish_calendar.json` cache (§13.1) or falls back to the local `holidays` library. **Request (Query Parameters):** - `workspace_id: UUID` (required) — fetch overrides for this workspace - `start_year: int` (required) — first year to include; must fall within the cached range (2026–2035 currently, else 422) - `years: int` (optional, default 4) — how many years to return; must be 1–4 (capped per product decision at `MAX_WINDOW_YEARS = 4` even though the cache holds 10) **Response (`CalendarDaysResponse`):** - `start_year: int` — echoes request - `end_year: int` — computed as `start_year + years - 1` - `min_year: int | null` — first year in the cache; null if cache file missing - `max_year: int | null` — last year in the cache; null if cache file missing (UI uses these to bound pagination controls) - `days: array[CalendarDay]` — one object per day in the window: - `date: Date` - `name: string | null` — exact holiday name (e.g. `"Påskdagen"`, `"Midsommardagen"`); **null unless `day_type == "red_day"`**. Combined names like `"Påskdagen; Söndag"` are preserved exactly as the `holidays` library returns them (see §13.3 caveat below). - `day_type: "red_day" | "klamdag" | "weekend" | "working_day"` — statutory classification (before overrides) - `is_override: bool` — true iff this day has a workspace override - `override_is_working_day: bool | null` — if `is_override`, the override value (true = marked as working day, false = marked as free day); null if no override - `is_free: bool` — **effective** free-day status after applying workspace overrides via `DayOverrideService` **Design Decisions:** 1. **Single source of truth for "free days":** The endpoint reuses `is_free_day()` from `allocation_service.py` (used by the planning engine itself) rather than implementing a parallel definition. This ensures the calendar UI and the planner agree on what days count as free. 2. **Overrides via DayOverrideService:** Workspace day overrides (e.g. marking Valborgsmässoafton as free for a specific family) are merged in via the existing `DayOverrideService`. No schema change or migration was needed — overrides live in the same table as other workspace settings. 3. **Pagination cap at four years:** Even though the cache spans 10 years (2026–2035), clients may request only 1–4 years per request (`MAX_WINDOW_YEARS = 4`). This is a product-owner decision to keep single requests bounded; UI should use pagination controls to browse the full decade. 4. **No runtime API calls:** The Swedish calendar is fetched at **build time** by `backend/scripts/fetch_swedish_calendar.py` (Dagsmart API) and committed to `backend/app/data/swedish_calendar.json`. This ensures planning stays offline and deterministic — no external dependencies at runtime. 5. **Combined-name trap (critical):** The Python `holidays` library combines holiday names with weekdays when a holiday falls on a particular day. For example, Påskdagen (Easter Sunday) is returned as `"Påskdagen; Söndag"` (one string). **Filtering by substring** (e.g. `if "Söndag" in name`) would silently delete Påskdagen, Pingstdagen, Nationaldagen, and Annandag jul when they fall on Sunday. Always use **exact-string matching** on `name`. This is documented and tested in `app/utils/holidays.py`. 6. **Valborgsmässoafton (30 April) is deliberately NOT a default day off:** Swedish custom treats this as a partial day off (evening bonfires), but statutory leave does not include it. Families who get it off or want to treat it as free should add a workspace day override. --- ## Status of the original open questions > **SUPERSEDED (2026-09-18).** The "Next Steps"/review-checklist framing below predates any code > and is no longer a to-do list — kept only as a record of what the original open questions were > and how each was actually resolved. **Resolved during review (see §0):** FK scraping is primary with a manual fallback; money is abstract (time-vs-days + SGI), not kronor; reserved days and dubbeldagar are modeled, partial/fractional days are out of scope. **Questions/Clarifications — resolution status:** 1. **Tip configurability:** partially — tips can be *dismissed* per workspace (`tip_dismissal` table, `POST /api/tips/{workspace_id}/dismissals`), not authored or edited. 2. **Audit logging of data access:** still not implemented. 3. **Preview before committing:** yes — `POST /api/plan/propose` backs a read-only shortfall/surplus preview on the Översikt tab; direct calendar edits persist immediately through `POST /api/plan/sync` (its request/response shape is under active development, not detailed in this doc — see §5). 4. **Export functionality (PDF/spreadsheet):** still not implemented. 5. **Legal stance on FK scraping + BankID automation:** resolved — legal/ToS review completed 2026-06; the scraper is cleared and enabled by default (§2.3). 6. **Special cases (multiples/tvillingtillägg, pre-2014 children, single custody):** still out of scope; the rule constants in `backend/app/config.py`'s `LeaveRules` remain the common-case figures only.