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