57 KiB
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:
- 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.
- 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).
- 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
constantsmodule (backendapp/utils/constants.py, frontendsrc/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.pybehind theFetchDataServiceinterface 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 aParentalLeaveFetchwithsource = '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_idand 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 byUser.workspace_id— soUser.workspace_idis dropped (derive it from the linked PersonalData) to avoid two sources of truth.
Two ways a second parent enters a workspace:
- Solo planning: parent A creates a second PersonalData (just a name) representing the partner. No login required.
- 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'
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)
-- 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
# 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}
# 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:
# 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 throughutils/constants.py/ acalendar_service. Cache per year. This is also needed so the calendar UI can shade red days. - Preschool planning-day buffer.
PRESCHOOL_PLANNING_DAYS_PER_YEARdefaults 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 theChild; default assumption can be ~age 1–1.5 but let the user set it. Consider addingpreschool_start_dateto theChildentity. - 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
BreakPeriodlist 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:
- Actual usage from the latest
ParentalLeaveFetch(or manual entry):days_used,days_left, tier breakdown. - Planned future days the workspace has laid out in the calendar (not yet reported to FK).
- 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_usedas 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';
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<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
anywithout 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_atcolumn 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
- Review this plan with another model
- Refine any unclear sections
- Begin Phase 1 implementation
- Maintain test coverage >= 90% throughout
- 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:
- Should tips be configurable/editable by users later?
- Do you want audit logging of data access?
- Should there be a "preview" of what days would look like before committing?
- Export functionality (PDF calendar, spreadsheet) in MVP or later?
- 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?
- 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?