A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.
project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.
_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.
_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.
Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.
Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.
Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
72 KiB
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:
- 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 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
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
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 togit 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.2.1 Styling — the Almanacka design system
All design tokens (colour, spacing, type scale, radii, shadow) live as CSS custom properties in
src/styles/global.css only — a style guard (src/styles/styles.test.ts) fails the build on any
hex/rgba, px font-size, or @media query found outside global.css/responsive.css. Everything
else composes those tokens: src/styles/primitives.css holds cross-page primitives (.btn,
.field, .card, .modal__*, .segmented, .disclosure, .skeleton, .alert, .badge), and
each component/page owns a co-located *.css file for its own layout.
One accent (ink-blue) drives CTAs, focus rings, the active nav item, "today" and selection — there
is no second "success" colour. Type is self-hosted Schibsted Grotesk (public/fonts/, woff2, no
Google Fonts request — GDPR), scale 12/14/16/20/28/40 with nothing below 12px. The calendar ("the
Almanacka") tells day states apart by numeral ink and a small badge letter rather than cell fill —
red day → danger ink + "R", klämdag → warning ink + "K", today → filled accent circle — and tells
leave types apart by fill/ink/outline (vacation = accent tint, sjukpenning = dark fill, lägsta =
white + hatched outline), never by hue alone. Icons come from a single Icon component (inline SVG
symbols) — no emoji, no icon font. See captures/redesign/design-system.md for the full token
table and invariants (gitignored — a design scratch doc, not part of the shipped app).
Two recurring structural primitives replace what used to be ad hoc boxes: .disclosure (a
<details>/<summary> row with a hairline, not a bordered card — used by the dashboard's "Fler
detaljer", each dashboard year row, and Settings' per-year red-days list) and .segmented (a
pill-style two-or-more-option toggle — the SV/EN LanguageToggle, the auth page's Log in/Create
account tabs). Loading states go through .skeleton shimmer blocks rendered by a shared
PageSkeleton component (src/components/common/), which each page (Dashboard, Calendar,
Settings) renders inside its own <Layout> while its first fetch is in flight, so every route
gets the same loading treatment for free instead of each page inventing its own spinner.
Parental-leave balance entry has exactly one home: BalanceFlow (src/components/balance/), used
from Settings' "Saldon" panel — the dashboard no longer has its own balance-entry UI, only a read
of the latest saved figures. BalanceFlow wraps ParentBalanceForm (the modal that actually asks
for sjukpenningnivå/lägstanivå/dubbeldagar left) with the per-member status row (source +
freshness) that used to be duplicated per page.
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_ENABLEDdefaults totrue— seebackend/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.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 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 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.
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:
- 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)
SUPERSEDED (2026-09-15, then 2026-09-18).
goal_overridewas removed entirely (an earlier migration, since squashed into the single initial migration — seebackend/migrations/versions/). The allocator now applies a single policy based solely onsalary_type.PlannedLeaveDaygainedwish_idlinking it back to the wish that produced it (nullable, for ad-hoc/legacy days), andis_pinnedto mark days the user hand-edited so the allocator won't retype them.LeaveWishgainedwindow_mode,name_message_key,target_daysandwants_dubbeldag— none of which the code block below originally showed; it has been updated in place.Workspace.planning_modewas also removed entirely — see PLANNING_ENGINE_SPEC.md §2.2.
// 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). Both modes get the
# same per-year weekday snap at materialisation: a flexible bound keeps its weekday
# alignment year over year (not a literal, unshifted month/day repeat) while its span
# stays fixed — see `project_window` in app/entities/leave_wish.py.
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_overrideandtip_dismissaltables, stale columns on others — e.g.workspace.planning_goalis real butworkspace.planning_modewas removed,leave_wish.goal_overridewas removed whilewindow_mode/wants_dubbeldag/name_message_key/target_dayswere added).backend/app/db/connection.py's_FULL_SCHEMA(used directly by dev/tests) andbackend/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
# 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
SUPERSEDED (2026-09-18). The table below was regenerated from
backend/app/routers/*.py(route decorators +main.py'sinclude_routerprefixes) — 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_datarows are removed only as part ofremove_parent/workspace deletion, not a standaloneDELETE /api/user/...), and noPUT /api/vacation-days/{id}—vacation-daysis create-or-update via a singlePOST(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_goalwas removed entirely. The engine now applies a single allocation policy based solely onsalary_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:
# 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 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. - 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
LeaveWishlist 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-yearBreakPeriod.
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_capinbackend/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.
- 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 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_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.
6.6 Per-year balance series (the analytics overview's year-by-year answer)
Age4Projection.year_balances (ParentYearBalance, backend/app/services/analytics_service.py)
gives the overview a vacation + parental-leave balance per parent, per calendar year, birth
year through the 4th-birthday year, instead of only the single age-4 total. The vacation half
(AnalyticsService._vacation_year_series) models Semesterlagen's carry-forward rule as a FIFO bank
of (year_earned, count) entries:
- Each year, planned use is drawn oldest-entry-first, then from that year's own entitlement (never
itself banked); at least
min_vacation_days_per_year(the floor, default 20) must be taken from the current year's own days, not the bank. - At year end, only days earned above the floor (plus the surviving bank balance) carry forward,
and each carried-forward day expires after 5 years (
LeaveRules.vacation_carry_forward_years) — an entry still unconsumed in its final valid year drops out and is reported asexpiringfor that year instead of carrying on. saved_days_inon avacation_daysrow normally only seeds the bank for the series' first year (no earlier history to derive it from). On any later year, a non-zerosaved_days_inis instead a Settings-UI override: it replaces the computed carry-in outright (resetting the bank to one entry dated year − 1) rather than adding to it. A zero/absent value never overrides.
The frontend renders this as one row per year (YearRows.tsx, a .disclosure per year) on the
Dashboard, each showing carried-in/earned/used/carried-out per parent.
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;
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 TranslationKeys 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)
# 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)
// 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.mdhandoff notes instead.
- Foundation — project structure, database schema + migrations, repository pattern + DI, auth (JWT access/refresh), workspace + parent profiles + partner invitations, manual parental-leave entry.
- Data fetching — Playwright headless-browser scraper for Försäkringskassan behind
FetchDataService, BankID QR flow, scraped-snapshot storage, legal/ToS review (cleared 2026-06). - 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). - Analytics & tips — overview/projection endpoints, dashboard metrics, tips engine.
- 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
- 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 —
personnummer_encexists as a nullable, encrypted/HMAC field (never a bare hash), but the product decision to avoid storing it at all is still open. - Passwords hashed with argon2 (
argon2-cffi, never SHA) —backend/app/services/auth_service.py - 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_atcolumns 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_originslocks CORS to the frontend origin,slowapirate-limits auth/fetch endpoints (app/rate_limit.py), secrets come from the environment (app/config.py). NoHMAC_PEPPERsetting exists despiteconfig.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.pyhas a Postgres implementation; every otherapp/dependencies.pyfactory raisesValueErrorfordatabase_type != "sqlite"(§2.1). "Swap repositories" describes the intended end state, not something you can do today by changingDATABASE_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 loaderPLANNING_ENGINE_SPEC.md— full design specification
Architecture: Strictly split into two parts:
AllocationInputLoader(async) — the ONLY part that touches repositories or the wall clock. Assembles a frozenAllocationInputfrom database state.
SUPERSEDED (2026-09-15). The allocator no longer uses
planning_goal. It now applies a single policy based solely onsalary_type(PLANNING_ENGINE_SPEC.md §3.3).
Allocator(pure) — noawait, no repository access, nodate.today(). Given the sameAllocationInput, always produces identical output. This is the engine's heart: it turnsLeaveWishintents intoProposedDayrows, choosing between föräldrapenning tiers and vacation based on parentsalary_typealone, 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 handlerbackend/app/services/calendar_service.py— business logicbackend/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 workspacestart_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 atMAX_WINDOW_YEARS = 4even though the cache holds 10)
Response (CalendarDaysResponse):
start_year: int— echoes requestend_year: int— computed asstart_year + years - 1min_year: int | null— first year in the cache; null if cache file missingmax_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: Datename: string | null— exact holiday name (e.g."Påskdagen","Midsommardagen"); null unlessday_type == "red_day". Combined names like"Påskdagen; Söndag"are preserved exactly as theholidayslibrary 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 overrideoverride_is_working_day: bool | null— ifis_override, the override value (true = marked as working day, false = marked as free day); null if no overrideis_free: bool— effective free-day status after applying workspace overrides viaDayOverrideService
Design Decisions:
-
Single source of truth for "free days": The endpoint reuses
is_free_day()fromallocation_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. -
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. -
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. -
No runtime API calls: The Swedish calendar is fetched at build time by
backend/scripts/fetch_swedish_calendar.py(Dagsmart API) and committed tobackend/app/data/swedish_calendar.json. This ensures planning stays offline and deterministic — no external dependencies at runtime. -
Combined-name trap (critical): The Python
holidayslibrary 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 onname. This is documented and tested inapp/utils/holidays.py. -
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:
- Tip configurability: partially — tips can be dismissed per workspace (
tip_dismissaltable,POST /api/tips/{workspace_id}/dismissals), not authored or edited. - Audit logging of data access: still not implemented.
- Preview before committing: yes —
POST /api/plan/proposebacks a read-only shortfall/surplus preview on the Översikt tab; direct calendar edits persist immediately throughPOST /api/plan/sync(its request/response shape is under active development, not detailed in this doc — see §5). - Export functionality (PDF/spreadsheet): still not implemented.
- Legal stance on FK scraping + BankID automation: resolved — legal/ToS review completed 2026-06; the scraper is cleared and enabled by default (§2.3).
- Special cases (multiples/tvillingtillägg, pre-2014 children, single custody): still out of
scope; the rule constants in
backend/app/config.py'sLeaveRulesremain the common-case figures only.