Files
F-r-ldraledigplannerare/ARCHITECTURE.md
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

67 KiB
Raw Blame History

Föräldraledigplannerare - Architecture & Implementation Plan

Date: 2026-05-31 (updated 2026-09-13)
Status: Planning phase - reviewed, decisions incorporated (see §0). Planning engine drafted in PLANNING_ENGINE_SPEC.md.
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
│   │   ├── leave_wish.py       # Recurring intent to be home (replaced BreakPeriod)
│   │   ├── child.py
│   │   ├── day_override.py     # Workspace calendar corrections
│   │   ├── 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
│   │   ├── allocation_service.py    # Pure planning-engine: Allocator + AllocationInputLoader
│   │   ├── 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
│   ├── data/
│   │   └── swedish_calendar.json  # 10-year cache (2026–2035), generated by scripts/
│   ├── 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)

// 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'
    # Explicit: whether to plan for one parent (solo) or two (couple).
    # Not inferred from member count; set by the workspace creator.
    planning_mode: str = "solo"  # 'solo' | 'couple'
    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'
    
    # Per-window goal override (nullable, no UI in v1).
    goal_override: str | None = None  # 'max_time' | 'max_money' | 'balanced' | 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' — 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)

-- 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')),
    planning_mode TEXT NOT NULL DEFAULT 'solo'
        CHECK (planning_mode IN ('solo','couple')),
    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
);

-- leave_wish: recurring family intent to be home (input to planning engine)
CREATE TABLE leave_wish (
    id TEXT PRIMARY KEY,
    workspace_id TEXT NOT NULL,
    child_id TEXT,                  -- NULL = family-wide, else tied to child's pools
    name TEXT NOT NULL,
    window_start_month INTEGER NOT NULL CHECK (window_start_month BETWEEN 1 AND 12),
    window_start_day INTEGER NOT NULL CHECK (window_start_day BETWEEN 1 AND 31),
    window_end_month INTEGER NOT NULL CHECK (window_end_month BETWEEN 1 AND 12),
    window_end_day INTEGER NOT NULL CHECK (window_end_day BETWEEN 1 AND 31),
    target_weeks INTEGER NOT NULL,
    recurrence TEXT NOT NULL CHECK (recurrence IN ('yearly', 'once')),
    first_year INTEGER NOT NULL,
    last_year INTEGER,              -- NULL = until child's 4th birthday (if child_id set)
    priority INTEGER NOT NULL,
    parent_mode TEXT NOT NULL CHECK (parent_mode IN ('either', 'both', 'specific')),
    parent_personal_data_uuid TEXT, -- required when parent_mode = 'specific'
    goal_override TEXT,             -- 'max_time' | 'max_money' | 'balanced' | NULL
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    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 SET NULL
);

-- 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,
    preschool_planning_days_per_year INTEGER NOT NULL DEFAULT 6,
    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

# 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

# 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

# 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}

# Calendar (Swedish holidays & workspace overrides)
GET    /api/calendar/days             # Calendar metadata (röda dagar, klämdagar, overrides)

# 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)

# 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

# User
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:

# 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.
  • 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:

  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

// 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';
  planningMode: 'solo' | 'couple';
  createdAt: Date;
}

export interface Child {
  id: string;
  workspaceId: string;
  name: string;
  birthDate: Date;
  preschoolStartDate: Date | null;
  preschoolPlanningDaysPerYear: number;
  createdAt: Date;
}

export interface LeaveWish {
  id: string;
  workspaceId: string;
  childId: string | null;            // null = family-wide
  name: string;                       // 'Sommar', 'Jullov', ...
  windowStartMonth: number;           // 1-12
  windowStartDay: number;             // 1-31
  windowEndMonth: number;
  windowEndDay: number;
  targetWeeks: number;
  recurrence: 'yearly' | 'once';
  firstYear: number;
  lastYear: number | null;
  priority: number;                   // 1 = highest
  parentMode: 'either' | 'both' | 'specific';
  parentPersonalDataUuid: string | null;
  goalOverride: 'max_time' | 'max_money' | 'balanced' | null;
  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<string, unknown>;
}

8. Testing Strategy

8.1 Backend Testing (pytest)

# 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)

// 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(
      <CalendarView 
        workspaceId="w1"
        childId="c1"
        plannedDays={[]}
      />
    );
    
    expect(getByText(/June 2026/i)).toBeInTheDocument();
  });
  
  it('should allow adding day by clicking + dragging', async () => {
    const onAddDay = vi.fn();
    const { getByTestId } = render(
      <CalendarView 
        workspaceId="w1"
        childId="c1"
        plannedDays={[]}
        onAddDay={onAddDay}
      />
    );
    
    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

  • Personal data isolated in separate table
  • UUID mapping throughout app
  • Soft delete (deleted_at) for audit trail
  • Hard delete capability for data removal
  • Cascading deletes via FK constraints
  • 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

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.

  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 the workspace planning_goal and parent salary_type, modeling parent-swap, dubbeldagar, and preschool day reserves along the way.

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.


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?