# Föräldraledigplannerare - Architecture & Implementation Plan **Date:** 2026-05-31 **Status:** Planning phase - reviewed, decisions incorporated (see §0) **Tech Stack:** TypeScript + React + Vite (frontend), FastAPI + Python 3.14 (backend), SQLite → Postgres --- ## 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). **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 (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 ### 2.1 Backend Architecture ``` backend/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI app initialization │ ├── config.py # Environment config │ ├── dependencies.py # Dependency injection setup │ ├── models/ # Pydantic schemas for API │ │ ├── __init__.py │ │ ├── personal_data.py │ │ ├── user.py │ │ ├── workspace.py │ │ ├── child.py │ │ ├── parental_leave.py │ │ ├── vacation_days.py │ │ └── planned_leave.py │ ├── entities/ # Domain entities (business logic) │ │ ├── __init__.py │ │ ├── personal_data.py │ │ ├── user.py │ │ ├── workspace.py │ │ ├── workspace_invitation.py │ │ ├── break_period.py │ │ ├── child.py │ │ ├── parental_leave.py │ │ ├── vacation_days.py │ │ └── planned_leave.py │ ├── repositories/ │ │ ├── __init__.py │ │ ├── base.py # Abstract Repository[T] │ │ ├── personal_data_repository.py │ │ ├── user_repository.py │ │ ├── workspace_repository.py │ │ ├── child_repository.py │ │ ├── parental_leave_repository.py │ │ ├── vacation_days_repository.py │ │ └── planned_leave_repository.py │ ├── repositories_impl/ │ │ ├── __init__.py │ │ ├── sqlite/ │ │ │ ├── __init__.py │ │ │ ├── personal_data_repository.py │ │ │ ├── user_repository.py │ │ │ └── ... (one per entity) │ │ └── postgres/ │ │ ├── __init__.py │ │ ├── personal_data_repository.py │ │ └── ... (identical interface, different impl) │ ├── services/ │ │ ├── __init__.py │ │ ├── user_service.py │ │ ├── workspace_service.py # incl. invitations │ │ ├── fetch_data_service.py # FK scraping + manual entry (stable contract) │ │ ├── planning_service.py # Calendar planning logic │ │ ├── analytics_service.py # Metrics + days-left-at-age-4 calculation │ │ └── tips_service.py # Optimization tips / SGI rules engine │ ├── routers/ │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── workspace.py │ │ ├── fetch_data.py │ │ ├── planned_days.py │ │ ├── vacation_days.py │ │ ├── analytics.py │ │ └── settings.py │ ├── scrapers/ │ │ ├── __init__.py │ │ └── fk_scraper.py # Försäkringskassan headless browser │ ├── db/ │ │ ├── __init__.py │ │ ├── connection.py # DB factory (SQLite/Postgres) │ │ └── migrations/ # Alembic migrations │ └── utils/ │ ├── __init__.py │ ├── logger.py │ ├── errors.py │ ├── constants.py # 480/390/90/96/60-day rules in one place │ └── validators.py ├── tests/ │ ├── __init__.py │ ├── conftest.py # Pytest fixtures │ ├── unit/ │ │ ├── test_repositories/ │ │ ├── test_services/ │ │ └── test_entities/ │ ├── integration/ │ │ ├── test_user_flow.py │ │ ├── test_fetch_data_flow.py │ │ └── test_planning_flow.py │ └── fixtures/ │ └── mock_data.py ├── pyproject.toml # UV package management ├── uv.lock └── README.md ``` ### 2.2 Frontend Architecture ``` frontend/ ├── src/ │ ├── main.tsx │ ├── App.tsx │ ├── vite-env.d.ts │ ├── types/ # Shared TypeScript interfaces │ │ ├── index.ts │ │ ├── entities.ts # PersonalData, User, Workspace, etc. │ │ ├── api.ts # API request/response types │ │ └── ui.ts # Component prop types │ ├── services/ │ │ ├── api.ts # API client (typed) │ │ ├── storage.ts # Local storage abstraction │ │ └── errors.ts # Error handling │ ├── hooks/ │ │ ├── useAuth.ts │ │ ├── useWorkspace.ts │ │ ├── usePlannedDays.ts │ │ ├── useParentalLeave.ts │ │ ├── useVacationDays.ts │ │ ├── useAnalytics.ts │ │ └── useOptimizationTips.ts │ ├── context/ │ │ ├── AuthContext.tsx │ │ └── WorkspaceContext.tsx │ ├── pages/ │ │ ├── Dashboard.tsx │ │ ├── Calendar.tsx │ │ ├── Settings.tsx │ │ ├── Tips.tsx │ │ └── Auth.tsx │ ├── components/ │ │ ├── common/ │ │ │ ├── Header.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Layout.tsx │ │ │ └── LoadingSpinner.tsx │ │ ├── calendar/ │ │ │ ├── CalendarView.tsx # Google Calendar-like │ │ │ ├── CalendarDay.tsx │ │ │ ├── CalendarEvent.tsx │ │ │ └── DayPlanner.tsx │ │ ├── analytics/ │ │ │ ├── OverviewMetrics.tsx │ │ │ ├── DaysBreakdown.tsx │ │ │ └── ProgressChart.tsx │ │ ├── settings/ │ │ │ ├── VacationDaysInput.tsx │ │ │ ├── ChildBirthdayInput.tsx # incl. preschool start date │ │ │ ├── ParentProfileInput.tsx # salary_type, employment_status │ │ │ ├── PlanningGoalSelector.tsx # max_time / max_money / balanced │ │ │ ├── BreakPeriodsInput.tsx # editable summer/winter/... periods │ │ │ └── WorkspaceSettings.tsx │ │ ├── fetch-data/ │ │ │ ├── FetchDataButton.tsx │ │ │ ├── QRCodeModal.tsx │ │ │ ├── FetchingProgress.tsx │ │ │ └── FetchDataStatus.tsx │ │ └── tips/ │ │ ├── TipsPanel.tsx │ │ ├── TipCard.tsx │ │ └── OptimizationEngine.tsx │ ├── utils/ │ │ ├── dates.ts │ │ ├── calculations.ts │ │ ├── validators.ts │ │ └── formatting.ts │ ├── stores/ # State management (if needed) │ │ └── index.ts │ └── styles/ │ ├── global.css │ └── components.module.css ├── tests/ │ ├── unit/ │ │ ├── hooks/ │ │ ├── utils/ │ │ ├── services/ │ │ └── components/ │ ├── integration/ │ │ └── flows/ │ ├── fixtures/ │ │ └── mockData.ts │ └── setup.ts ├── .eslintrc.json ├── tsconfig.json ├── vite.config.ts ├── vitest.config.ts └── package.json ``` --- ### 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. This needs a deliberate decision (and ideally legal review) before shipping publicly. 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 the same numbers they see on FK (`total_days`, `days_used`, reserved/transferable split, dubbeldagar left, tier breakdown). 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. 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) ```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' 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/break_period.py @dataclass class BreakPeriod: """A user-defined window the family wants to be home together for (e.g. summer, winter holidays). Drives the parent-swap tip. Lengths and dates are entirely family-specific; seeded with editable defaults.""" id: UUID workspace_id: UUID name: str # 'Summer', 'Winter holidays', ... start_date: date end_date: date target_weeks: int # desired weeks home together for this break created_at: datetime = None updated_at: datetime = 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 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' — provenance of these numbers 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) ```sql -- personal_data: a parent profile (login optional). Isolated for GDPR. CREATE TABLE personal_data ( uuid TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, salary_type TEXT NOT NULL DEFAULT 'monthly' CHECK (salary_type IN ('monthly','hourly')), employment_status TEXT NOT NULL DEFAULT 'employed' CHECK (employment_status IN ('employed','jobseeking','none')), personnummer_enc TEXT, -- encrypted/HMAC, nullable; prefer NULL (see §11) created_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); -- user: a login linked 1:1 to a parent profile (workspace derived via personal_data) CREATE TABLE "user" ( id TEXT PRIMARY KEY, personal_data_uuid TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, hashed_password TEXT NOT NULL, created_at TIMESTAMP NOT NULL, FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); -- workspace: Shared planning space (1–2 parent profiles) CREATE TABLE workspace ( id TEXT PRIMARY KEY, name TEXT NOT NULL, planning_goal TEXT NOT NULL DEFAULT 'balanced' CHECK (planning_goal IN ('max_time','max_money','balanced')), created_at TIMESTAMP NOT NULL ); -- workspace_invitation: lets a partner join an existing workspace CREATE TABLE workspace_invitation ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, -- store only a hash of the single-use token invited_by_user_id TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending','accepted','revoked','expired')), expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL, accepted_at TIMESTAMP, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (invited_by_user_id) REFERENCES "user"(id) ON DELETE CASCADE ); -- break_period: user-defined windows to be home together (summer, winter, ...) CREATE TABLE break_period ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, target_weeks INTEGER NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); -- child: One per child, multi-child ready CREATE TABLE child ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, birth_date DATE NOT NULL, preschool_start_date DATE, created_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); -- parental_leave_fetch: Cached Försäkringskassan data (or manual entry) CREATE TABLE parental_leave_fetch ( id TEXT PRIMARY KEY, personal_data_uuid TEXT NOT NULL, workspace_id TEXT NOT NULL, child_id TEXT NOT NULL, source TEXT NOT NULL CHECK (source IN ('scraped', 'manual')), fetched_at TIMESTAMP NOT NULL, total_days INTEGER NOT NULL, days_used INTEGER NOT NULL, days_left INTEGER NOT NULL, reserved_days_total INTEGER NOT NULL DEFAULT 90, reserved_days_left INTEGER NOT NULL, transferable_days_left INTEGER NOT NULL, dubbeldagar_left INTEGER NOT NULL, tier_data TEXT NOT NULL, -- JSON string (sjukpenningnivå + lägstanivå) created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE ); -- vacation_days: Manual input CREATE TABLE vacation_days ( id TEXT PRIMARY KEY, personal_data_uuid TEXT NOT NULL, workspace_id TEXT NOT NULL, year INTEGER NOT NULL, total_days INTEGER NOT NULL, saved_days_in INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(personal_data_uuid, year), FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); -- planned_leave_day: Calendar events CREATE TABLE planned_leave_day ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, child_id TEXT NOT NULL, parent_personal_data_uuid TEXT NOT NULL, date DATE NOT NULL, day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning', 'lagsta', 'vacation')), is_dubbeldag INTEGER NOT NULL DEFAULT 0, -- 0/1; both parents on same child-day created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid), FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); ``` --- ## 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 ``` # Authentication POST /api/auth/register POST /api/auth/login POST /api/auth/logout GET /api/auth/me # Workspace GET /api/workspace/{workspace_id} PUT /api/workspace/{workspace_id} # Change name POST /api/workspace # Create new GET /api/workspace/{workspace_id}/members # List parent profiles (1–2) POST /api/workspace/{workspace_id}/parents # Add placeholder parent (solo planning) # Partner invitations POST /api/workspace/{workspace_id}/invitations # Create single-use invite (returns token) POST /api/invitations/redeem # Redeem token → join workspace DELETE /api/invitations/{invitation_id} # Revoke pending invite # Fetch Data from Försäkringskassan POST /api/fetch-data/start # Initialize headless browser GET /api/fetch-data/poll # Poll for QR scan + auth POST /api/fetch-data/cancel # Cancel in-progress fetch # Parental Leave Data GET /api/parental-leave/{workspace_id}/{child_id} GET /api/parental-leave/{workspace_id}/{child_id}/history # Fetch history # Planned Leave Days POST /api/planned-days # Add planned day GET /api/planned-days/{workspace_id}/{child_id} # Get month/year DELETE /api/planned-days/{planned_day_id} PUT /api/planned-days/{planned_day_id} # Vacation Days POST /api/vacation-days GET /api/vacation-days/{workspace_id}/{personal_data_uuid}/{year} PUT /api/vacation-days/{vacation_days_id} # Analytics/Dashboard GET /api/analytics/{workspace_id}/overview GET /api/analytics/{workspace_id}/breakdown # Days by type/parent GET /api/analytics/{workspace_id}/projection # Days left at age 4 # Tips/Optimization GET /api/tips/{workspace_id} # Settings PUT /api/settings/child/{child_id} # Set birth date + preschool start date PUT /api/settings/parent/{personal_data_uuid} # Set salary_type, employment_status PUT /api/settings/workspace/{workspace_id}/goal # Set planning_goal (max_time/max_money/balanced) GET /api/break-periods/{workspace_id} # List configured break periods POST /api/break-periods # Add a break period (summer/winter/...) PUT /api/break-periods/{break_period_id} # Edit name/dates/target_weeks DELETE /api/break-periods/{break_period_id} # Remove a break period DELETE /api/user/{personal_data_uuid} # Delete account + data ``` --- ## 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. 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, plus the workspace `planning_goal`. Tips are ranked by the active goal (`max_time` surfaces day-preserving tips; `max_money` surfaces salary-protecting tips). 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, ranked by planning_goal.""" 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. - **Shared-break periods (user-configurable).** The parent-swap tip applies to *any* long break the family wants to be home together for — summer **and** winter holidays (and others). Each family defines these as named periods with their own target length, since the right amount is entirely family-specific (e.g. ~6 weeks summer, ~4 weeks winter). Model as a `BreakPeriod` list per workspace `{ name, start_date, end_date, target_weeks }` with seeded defaults the user can edit or delete. The tip references the matching period by `{period_name}`. ### 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**: 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 96-day age-4 ceiling, and the age-12 hard expiry. **Definitions** (compute as of the child's 4th birthday `B4 = birth_date + 4 years`): ``` 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, 96) # the 96-day cap expiring_at_B4 = max(projected_left_at_B4 - 96, 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; createdAt: Date; } export interface BreakPeriod { id: string; workspaceId: string; name: string; // 'Summer', 'Winter holidays', ... startDate: Date; endDate: Date; targetWeeks: number; 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; } ``` --- ## 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(...) # Target: >=90% coverage 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 }); }); // Target: >=90% coverage vitest --coverage ``` --- ## 9. Implementation Roadmap ### Phase 1: Foundation (Week 1) - [ ] Setup project structure (backend + frontend) - [ ] Database schema + migrations - [ ] Repository pattern + DI setup - [ ] Basic auth (signup/login) + token strategy - [ ] Workspace + parent profiles + partner invitations - [ ] **Manual parental-leave entry** (the always-available data path) ### Phase 2: Data Fetching (Week 2) - [ ] Headless browser scraper for Försäkringskassan (behind FetchDataService contract) - [ ] BankID QR code capture + display - [ ] Parental leave data scraping + storage (source='scraped') - [ ] Fetch data endpoints + frontend flow - [ ] Legal/ToS review of scraping + BankID automation (gate before public launch) ### Phase 3: Planning Interface (Week 3) - [ ] Calendar component (month/week view) - [ ] Add/edit/delete planned days - [ ] Day type selection (sjukpenning/lägsta/vacation) - [ ] Real-time validation against remaining days ### Phase 4: Analytics & Tips (Week 4) - [ ] Analytics endpoints (overview, breakdown, projection) - [ ] Dashboard UI with metrics - [ ] Optimization tips engine - [ ] Tips display component ### Phase 5: Polish & Testing (Week 5) - [ ] Comprehensive test coverage (>=90%) - [ ] Error handling + logging - [ ] ESLint + code quality - [ ] Documentation --- ## 10. Code Quality Standards All code must adhere to: ✅ **Backend (Python):** - Fully typed with type hints - pytest with >=90% coverage - 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` without justification) - Vitest with >=90% coverage - ESLint enforced - 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** — the original `personnummer_hash` (plain SHA-256) is *not* protection: the personnummer keyspace is small enough to brute-force an unsalted hash. Prefer **not storing it**; if a stable identifier is truly required, use a keyed HMAC or authenticated encryption with a server-side secret (`personnummer_enc`), never a bare hash. - [ ] Passwords hashed with argon2/bcrypt (never SHA) - [ ] 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:** decide the session strategy explicitly — short-lived JWT access token + refresh token, or server-side sessions. Include password reset and email verification flows. None of these were in the original draft. - **Localization (i18n):** the domain is Swedish (sjukpenningnivå, lägstanivå, dubbeldagar, semester). Default the UI to Swedish with copy externalized for translation; keep dates/numbers locale-aware (sv-SE). - **Shared-workspace concurrency:** two parents may edit the same calendar at once. Use the `updated_at` column for optimistic concurrency (reject stale writes) and refresh the calendar on focus/poll; real-time sync (WebSocket) is a future enhancement. - **CORS / rate limiting / secrets:** lock CORS to the frontend origin, rate-limit auth and fetch endpoints, and manage the scraper/HMAC secrets via environment/secret store, not source. --- ## 12. Deployment Notes **Development:** - SQLite local database - Headless browser runs locally - Frontend: `npm run dev` - Backend: `uv run uvicorn app.main:app --reload` **Production:** - PostgreSQL database (swap repositories) - Headless browser: Docker container or hosted service - Frontend: Built + CDN hosted - Backend: Cloud function or container --- ## Next Steps 1. **Review this plan** with another model 2. **Refine** any unclear sections 3. **Begin Phase 1** implementation 4. **Maintain test coverage** >= 90% throughout 5. **Weekly sync** on progress --- **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 Still Open:** 1. Should tips be configurable/editable by users later? 2. Do you want audit logging of data access? 3. Should there be a "preview" of what days would look like before committing? 4. Export functionality (PDF calendar, spreadsheet) in MVP or later? 5. Legal stance on FK scraping + BankID automation — who signs off before public launch, and is the manual-only path acceptable as a v1 if scraping is blocked? 6. Should the app handle special cases (multiples/tvillingtillägg, children born before 2014, single-custody where one parent gets all 480 days) in v1 or later?